diff --git a/src/course-outline/data/api.ts b/src/course-outline/data/api.ts index b8aebc718a..4f178c7c4a 100644 --- a/src/course-outline/data/api.ts +++ b/src/course-outline/data/api.ts @@ -1,5 +1,6 @@ import { camelCaseObject, getConfig } from '@edx/frontend-platform'; import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; +import { PUBLISH_TYPES } from '@src/course-unit/constants'; import { XBlock } from '@src/data/types'; import { CourseOutline, @@ -13,6 +14,10 @@ import { const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL; +const pickDefined = >(obj: T) => Object.fromEntries( + Object.entries(obj).filter(([, value]) => value !== undefined), +); + export const getCourseOutlineIndexApiUrl = ( courseId: string, ) => `${getApiBaseUrl()}/api/contentstore/v1/course_index/${courseId}`; @@ -238,30 +243,65 @@ export async function configureCourseSection(variables: ConfigureSectionData): P /** * Configure course subsection */ -export async function configureCourseSubsection(variables: ConfigureSubsectionData): Promise { +export async function configureCourseSubsection( + variables: Partial & Pick, +): Promise { + const { + itemId, + isVisibleToStaffOnly, + dueDate, + hideAfterDue, + showCorrectness, + isPracticeExam, + isTimeLimited, + isProctoredExam, + isOnboardingExam, + examReviewRules, + defaultTimeLimitMinutes, + releaseDate, + graderType, + isPrereq, + prereqUsageKey, + prereqMinScore, + prereqMinCompletion, + } = variables; + + const metadata = pickDefined({ + visible_to_staff_only: (() => { + if (isVisibleToStaffOnly === undefined) { + return undefined; + } + return isVisibleToStaffOnly ? true : null; + })(), + due: dueDate, + hide_after_due: hideAfterDue, + show_correctness: showCorrectness, + is_practice_exam: isPracticeExam, + is_time_limited: isTimeLimited, + is_proctored_enabled: ( + isProctoredExam !== undefined || isPracticeExam !== undefined || isOnboardingExam !== undefined + ) + ? (isProctoredExam || isPracticeExam || isOnboardingExam) + : undefined, + exam_review_rules: examReviewRules, + default_time_limit_minutes: defaultTimeLimitMinutes, + is_onboarding_exam: isOnboardingExam, + start: releaseDate, + }); + + const body = pickDefined({ + publish: 'republish', + graderType, + isPrereq, + prereqUsageKey, + prereqMinScore, + prereqMinCompletion, + metadata, + }); + const { data } = await getAuthenticatedHttpClient() - .post(getCourseItemApiUrl(variables.itemId), { - publish: 'republish', - graderType: variables.graderType, - isPrereq: variables.isPrereq, - prereqUsageKey: variables.prereqUsageKey, - prereqMinScore: variables.prereqMinScore, - prereqMinCompletion: variables.prereqMinCompletion, - metadata: { - // The backend expects metadata.visible_to_staff_only to either true or null - visible_to_staff_only: variables.isVisibleToStaffOnly ? true : null, - due: variables.dueDate, - hide_after_due: variables.hideAfterDue, - show_correctness: variables.showCorrectness, - is_practice_exam: variables.isPracticeExam, - is_time_limited: variables.isTimeLimited, - is_proctored_enabled: variables.isProctoredExam || variables.isPracticeExam || variables.isOnboardingExam, - exam_review_rules: variables.examReviewRules, - default_time_limit_minutes: variables.defaultTimeLimitMin, - is_onboarding_exam: variables.isOnboardingExam, - start: variables.releaseDate, - }, - }); + .post(getCourseItemApiUrl(itemId), body); + return data; } @@ -269,18 +309,23 @@ export async function configureCourseSubsection(variables: ConfigureSubsectionDa * Configure course unit */ export async function configureCourseUnit(variables: ConfigureUnitData): Promise { - const { data } = await getAuthenticatedHttpClient() - .post(getCourseItemApiUrl(variables.unitId), { - publish: 'republish', + const body = { + publish: variables.groupAccess ? null : variables.type, + ...(variables.type === PUBLISH_TYPES.republish ? { metadata: { - // The backend expects metadata.visible_to_staff_only to either true or null visible_to_staff_only: variables.isVisibleToStaffOnly ? true : null, - group_access: variables.groupAccess, - discussion_enabled: variables.discussionEnabled, + ...(variables.discussionEnabled !== undefined && { + discussion_enabled: variables.discussionEnabled, + }), + ...(variables.groupAccess != null && { group_access: variables.groupAccess }), }, - }); + } : {}), + }; + const url = getCourseItemApiUrl(variables.unitId); + const { data } = await getAuthenticatedHttpClient() + .post(url, body); - return data; + return camelCaseObject(data); } /** diff --git a/src/course-outline/data/apiHooks.ts b/src/course-outline/data/apiHooks.ts index 13d175e512..4a413b5f3b 100644 --- a/src/course-outline/data/apiHooks.ts +++ b/src/course-outline/data/apiHooks.ts @@ -6,12 +6,15 @@ import { ConfigureUnitData, StaticFileNotices, } from '@src/course-outline/data/types'; -import { useToastContext } from '@src/generic/toast-context'; -import { NOTIFICATION_MESSAGES } from '@src/constants'; +import { getNotificationMessage } from '@src/course-unit/data/utils'; import { createGlobalState } from '@src/data/apiHooks'; import type { XBlockBase, XblockChildInfo } from '@src/data/types'; -import { getBlockType, getCourseKey } from '@src/generic/key-utils'; +import { + ContainerType, getBlockType, getCourseKey, normalizeContainerType, +} from '@src/generic/key-utils'; +import { useMutationWithProcessingNotification } from '@src/generic/processing-notification/data/apiHooks'; import { handleResponseErrors } from '@src/generic/saving-error-alert'; +import { useToastContext } from '@src/generic/toast-context'; import { ParentIds } from '@src/generic/types'; import { QueryClient, @@ -85,7 +88,7 @@ export const useScrollState = createGlobalState(courseOutlineQueryK * 1. If sectionId exists, invalidate section data which also updates all children block data * 2. Else If subsectionId exists, invalidate subsection data */ -const invalidateParentQueries = async (queryClient: QueryClient, variables: ParentIds) => { +export const invalidateParentQueries = async (queryClient: QueryClient, variables: ParentIds) => { if (variables.sectionId) { await queryClient.invalidateQueries({ queryKey: courseOutlineQueryKeys.courseItemId(variables.sectionId) }); } else if (variables.subsectionId) { @@ -108,20 +111,12 @@ export const useCreateCourseBlock = ( courseKey: string, callback?: ((locator: string, parentLocator: string) => Promise), ) => { - const { - showToast, - closeToast, - } = useToastContext(); const queryClient = useQueryClient(); const { setData } = useScrollState(courseKey); const dispatch = useDispatch(); - return useMutation({ + return useMutationWithProcessingNotification({ mutationFn: (variables: CreateCourseXBlockMutationProps) => createCourseXblock(variables), - onMutate: () => { - showToast(NOTIFICATION_MESSAGES.saving, undefined, 15000); - }, onSuccess: async (data: { locator: string; }, variables) => { - closeToast(); await callback?.(data.locator, variables.parentLocator); queryClient.invalidateQueries({ queryKey: courseOutlineQueryKeys.courseDetails(getCourseKey(data.locator)), @@ -136,9 +131,6 @@ export const useCreateCourseBlock = ( dispatch(addSection(newBlock)); } }, - onError: () => { - closeToast(); - }, }); }; @@ -198,7 +190,7 @@ export const useCourseDetails = (courseId?: string, enabled: boolean = true) => */ export const useUpdateCourseBlockName = (courseId: string) => { const queryClient = useQueryClient(); - return useMutation({ + return useMutationWithProcessingNotification({ mutationFn: (variables:{ itemId: string; displayName: string; @@ -213,7 +205,7 @@ export const useUpdateCourseBlockName = (courseId: string) => { export const usePublishCourseItem = () => { const queryClient = useQueryClient(); - return useMutation({ + return useMutationWithProcessingNotification({ mutationFn: (variables:{ itemId: string; } & ParentIds) => publishCourseItem(variables.itemId), @@ -226,7 +218,7 @@ export const usePublishCourseItem = () => { export const useDeleteCourseItem = () => { const queryClient = useQueryClient(); - return useMutation({ + return useMutationWithProcessingNotification({ mutationFn: (variables:{ itemId: string; } & ParentIds) => deleteCourseItem(variables.itemId), @@ -239,17 +231,9 @@ export const useDeleteCourseItem = () => { export const useConfigureSection = () => { const queryClient = useQueryClient(); - const { - showToast, - closeToast, - } = useToastContext(); - return useMutation({ + return useMutationWithProcessingNotification({ mutationFn: (variables: ConfigureSectionData & ParentIds) => configureCourseSection(variables), - onMutate: () => { - showToast(NOTIFICATION_MESSAGES.saving, undefined, 15000); - }, onSettled: (_data, _err, variables) => { - closeToast(); queryClient.invalidateQueries({ queryKey: courseOutlineQueryKeys.courseDetails(getCourseKey(variables.sectionId)), }); @@ -260,58 +244,61 @@ export const useConfigureSection = () => { export const useConfigureSubsection = () => { const queryClient = useQueryClient(); - const { - showToast, - closeToast, - } = useToastContext(); - return useMutation({ - mutationFn: (variables: ConfigureSubsectionData & ParentIds) => configureCourseSubsection(variables), - onMutate: () => { - showToast(NOTIFICATION_MESSAGES.saving, undefined, 15000); - }, - onSettled: (_data, _err, variables) => { - closeToast(); - queryClient.invalidateQueries({ queryKey: courseOutlineQueryKeys.courseDetails(getCourseKey(variables.itemId)) }); + return useMutationWithProcessingNotification({ + mutationFn: ( + variables: Partial & Pick & ParentIds, + ) => configureCourseSubsection(variables), + onSettled: async (_data, _err, variables) => { + const courseKey = getCourseKey(variables.itemId); + queryClient.invalidateQueries({ queryKey: courseOutlineQueryKeys.courseDetails(courseKey) }); invalidateParentQueries(queryClient, variables).catch((e) => handleResponseErrors(e)); + if (variables.isPrereq !== undefined) { + const subsectionItemQueries = queryClient.getQueryCache().findAll({ + predicate: (query) => { + const { queryKey } = query; + return Array.isArray(queryKey) + && queryKey.length >= 3 + && queryKey[0] === courseOutlineQueryKeys.all[0] + && queryKey[1] === courseKey + && typeof queryKey[2] === 'string' + && normalizeContainerType(getBlockType(queryKey[2], 'empty')) === ContainerType.Subsection; + }, + }); + await Promise.all(subsectionItemQueries.map((query) => queryClient.invalidateQueries({ + queryKey: query.queryKey, + }))); + } }, }); }; export const useConfigureUnit = () => { const queryClient = useQueryClient(); - const { - showToast, - closeToast, - } = useToastContext(); + const { showToast, closeToast } = useToastContext(); + // We are not using useMutationWithProcessingNotification to set custom processing notification message return useMutation({ mutationFn: (variables: ConfigureUnitData & ParentIds) => configureCourseUnit(variables), - onMutate: () => { - showToast(NOTIFICATION_MESSAGES.saving, undefined, 15000); + onMutate: (variables) => { + const msg = getNotificationMessage(variables.type, variables.isVisibleToStaffOnly, true); + // Show processing notification + showToast(msg, undefined, 15000); }, onSettled: (_data, _err, variables) => { - closeToast(); queryClient.invalidateQueries({ queryKey: courseOutlineQueryKeys.courseDetails(getCourseKey(variables.unitId)) }); invalidateParentQueries(queryClient, variables).catch((e) => handleResponseErrors(e)); + closeToast(); }, }); }; export const useUpdateCourseSectionHighlights = () => { - const { - showToast, - closeToast, - } = useToastContext(); const queryClient = useQueryClient(); - return useMutation({ + return useMutationWithProcessingNotification({ mutationFn: (variables: { sectionId: string; highlights: string[]; } & ParentIds) => updateCourseSectionHighlights(variables.sectionId, variables.highlights), - onMutate: () => { - showToast(NOTIFICATION_MESSAGES.saving, undefined, 15000); - }, onSettled: (_data, _err, variables) => { - closeToast(); queryClient.invalidateQueries({ queryKey: courseOutlineQueryKeys.courseDetails(getCourseKey(variables.sectionId)), }); @@ -321,21 +308,14 @@ export const useUpdateCourseSectionHighlights = () => { }; export const useDuplicateItem = (courseKey: string) => { - const { - showToast, - closeToast, - } = useToastContext(); const queryClient = useQueryClient(); const dispatch = useDispatch(); const { setData } = useScrollState(courseKey); - return useMutation({ + return useMutationWithProcessingNotification({ mutationFn: (variables: { itemId: string; parentId: string; } & ParentIds) => duplicateCourseItem(variables.itemId, variables.parentId), - onMutate: () => { - showToast(NOTIFICATION_MESSAGES.saving, undefined, 15000); - }, onSuccess: async (data, variables) => { await invalidateParentQueries(queryClient, variables); // add duplicated section to store, subsection and unit are handled by invalidateParentQueries @@ -346,9 +326,6 @@ export const useDuplicateItem = (courseKey: string) => { // scroll to newly added block setData({ id: data.locator }); }, - onSettled: () => { - closeToast(); - }, }); }; @@ -362,20 +339,13 @@ export const usePasteFileNotices = createGlobalState( ); export const usePasteItem = (courseId?: string) => { - const { - showToast, - closeToast, - } = useToastContext(); const queryClient = useQueryClient(); const { setData: setScrollState } = useScrollState(courseId); const { setData } = usePasteFileNotices(courseId); - return useMutation({ + return useMutationWithProcessingNotification({ mutationFn: (variables: { parentLocator: string; } & ParentIds) => pasteBlock(variables.parentLocator), - onMutate: () => { - showToast(NOTIFICATION_MESSAGES.saving, undefined, 15000); - }, onSuccess: async (data, variables) => { await invalidateParentQueries(queryClient, variables); // set pasteFileNotices @@ -383,8 +353,5 @@ export const usePasteItem = (courseId?: string) => { // scroll to pasted block setScrollState({ id: data.locator }); }, - onSettled: () => { - closeToast(); - }, }); }; diff --git a/src/course-outline/data/types.ts b/src/course-outline/data/types.ts index 4e927216dc..698c5f9347 100644 --- a/src/course-outline/data/types.ts +++ b/src/course-outline/data/types.ts @@ -1,4 +1,5 @@ import { XBlock, XBlockActions } from '@src/data/types'; +import { PUBLISH_TYPES } from '@src/course-unit/constants'; export interface CourseStructure { highlightsEnabledForMessaging: boolean, @@ -34,6 +35,7 @@ export interface CourseDetails { org: string; description?: string; hasChanges: boolean; + selfPaced: boolean; } export interface ChecklistType { @@ -98,29 +100,30 @@ export interface ConfigureSectionData { export interface ConfigureSubsectionData { itemId: string, - isVisibleToStaffOnly: boolean, - releaseDate: string, - graderType: string, - dueDate: string, - isTimeLimited: boolean, - isProctoredExam: boolean, - isOnboardingExam: boolean, - isPracticeExam: boolean, - examReviewRules: string, - defaultTimeLimitMin: number, - hideAfterDue: string, - showCorrectness: string, - isPrereq: boolean, - prereqUsageKey: string, - prereqMinScore: number, - prereqMinCompletion: number, + isVisibleToStaffOnly?: boolean, + releaseDate?: string, + graderType?: string, + dueDate?: string, + isTimeLimited?: boolean, + isProctoredExam?: boolean, + isOnboardingExam?: boolean, + isPracticeExam?: boolean, + examReviewRules?: string, + defaultTimeLimitMinutes?: number, + hideAfterDue?: boolean, + showCorrectness?: 'always' | 'never' | 'past_due' | 'never_but_include_grade', + isPrereq?: boolean, + prereqUsageKey?: string, + prereqMinScore?: number, + prereqMinCompletion?: number, } export interface ConfigureUnitData { - unitId: string, - isVisibleToStaffOnly: boolean, - groupAccess: object, - discussionEnabled: boolean, + unitId: string; + isVisibleToStaffOnly: boolean; + type: typeof PUBLISH_TYPES[keyof typeof PUBLISH_TYPES]; + groupAccess: Record | null, + discussionEnabled?: boolean; } export type StaticFileNotices = { diff --git a/src/course-outline/highlights-modal/HighlightsModal.jsx b/src/course-outline/highlights-modal/HighlightsModal.jsx deleted file mode 100644 index 1fd254cfe0..0000000000 --- a/src/course-outline/highlights-modal/HighlightsModal.jsx +++ /dev/null @@ -1,98 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import { useIntl } from '@edx/frontend-platform/i18n'; -import { - ModalDialog, - Button, - ActionRow, - Hyperlink, -} from '@openedx/paragon'; -import { Formik } from 'formik'; - -import { useCourseAuthoringContext } from '@src/CourseAuthoringContext'; -import { useCourseItemData } from '@src/course-outline/data/apiHooks'; -import { useHelpUrls } from '../../help-urls/hooks'; -import FormikControl from '../../generic/FormikControl'; -import { HIGHLIGHTS_FIELD_MAX_LENGTH } from '../constants'; -import { getHighlightsFormValues } from '../utils'; -import messages from './messages'; - -const HighlightsModal = ({ - isOpen, - onClose, - onSubmit, -}) => { - const intl = useIntl(); - const { currentSelection } = useCourseAuthoringContext(); - const { data: currentItemData } = useCourseItemData(currentSelection?.currentId); - const { highlights = [], displayName } = currentItemData || {}; - const initialFormValues = getHighlightsFormValues(highlights); - - const { - contentHighlights: contentHighlightsUrl, - } = useHelpUrls(['contentHighlights']); - - return ( - - - - {intl.formatMessage(messages.title, { - title: displayName, - })} - - - - {({ values, dirty, handleSubmit }) => ( - <> - -

- {intl.formatMessage(messages.description, { - documentation: ( - - {intl.formatMessage(messages.documentationLink)} - ), - })} -

- {Object.entries(initialFormValues).map(([key], index) => ( - - ))} -
- - - - {intl.formatMessage(messages.cancelButton)} - - - - - - )} -
-
- ); -}; - -HighlightsModal.propTypes = { - isOpen: PropTypes.bool.isRequired, - onClose: PropTypes.func.isRequired, - onSubmit: PropTypes.func.isRequired, -}; - -export default HighlightsModal; diff --git a/src/course-outline/highlights-modal/HighlightsModal.test.tsx b/src/course-outline/highlights-modal/HighlightsModal.test.tsx index cc9fecff9b..e44ceda7b3 100644 --- a/src/course-outline/highlights-modal/HighlightsModal.test.tsx +++ b/src/course-outline/highlights-modal/HighlightsModal.test.tsx @@ -1,111 +1,324 @@ import { - initializeMocks, render, fireEvent, act, waitFor, + initializeMocks, render, fireEvent, screen, waitFor, } from '@src/testUtils'; +import { userEvent } from '@testing-library/user-event'; -import HighlightsModal from './HighlightsModal'; +import * as apiHooks from '@src/course-outline/data/apiHooks'; +import * as routerDom from 'react-router-dom'; +import type { UseQueryResult } from '@tanstack/react-query'; +import { XBlockBase } from '@src/data/types'; import messages from './messages'; - -const mockPathname = '/foo-bar'; +import HighlightsModal, { HighlightsCard, HighlightsForm } from './HighlightsModal'; const currentItemMock = { highlights: ['Highlight 1', 'Highlight 2'], displayName: 'Test Section', -}; +} as XBlockBase; jest.mock('@src/CourseAuthoringContext', () => ({ useCourseAuthoringContext: () => ({ - courseId: 5, - courseUsageKey: 'course-usage-key', - courseDetails: { name: 'Test course' }, currentSelection: { currentId: 1 }, }), })); jest.mock('@src/course-outline/data/apiHooks', () => ({ - useCourseItemData: () => ({ + useCourseItemData: jest.fn(() => ({ data: currentItemMock, - }), + } as unknown as UseQueryResult)), })); -jest.mock('react-router-dom', () => ({ - ...jest.requireActual('react-router-dom'), - useLocation: () => ({ - pathname: mockPathname, - }), +jest.mock('react-router', () => ({ + ...jest.requireActual('react-router'), + useBlocker: jest.fn(() => ({ + state: 'unblocked', + proceed: jest.fn(), + reset: jest.fn(), + })), })); jest.mock('../../help-urls/hooks', () => ({ - useHelpUrls: () => ({ - contentHighlights: 'some', - }), + useHelpUrls: () => ({ contentHighlights: 'https://example.com' }), +})); + +jest.mock('@src/generic/prompt-if-dirty/PromptIfDirty', () => ({ + __esModule: true, + default: () => null, })); const onCloseMock = jest.fn(); const onSubmitMock = jest.fn(); -const renderComponent = () => render( - , -); - describe('', () => { beforeEach(() => { initializeMocks(); + jest.clearAllMocks(); + }); + + it('renders modal with highlights and form', async () => { + render( + , + ); + + expect(await screen.findByText(/Highlights for/)).toBeInTheDocument(); + expect(await screen.findByLabelText(/Highlight 1/)).toBeInTheDocument(); + expect(await screen.findByRole('button', { name: messages.saveButton.defaultMessage })).toBeInTheDocument(); + }); + + it('calls onClose when cancel button is clicked', async () => { + const user = userEvent.setup(); + render( + , + ); + + await user.click(await screen.findByRole('button', { name: messages.cancelButton.defaultMessage })); + expect(onCloseMock).toHaveBeenCalled(); + }); + + it('calls onSubmit with form values on save', async () => { + const user = userEvent.setup(); + render( + , + ); + + fireEvent.change(await screen.findByLabelText(/Highlight 1/), { target: { value: 'New value' } }); + + await user.click(await screen.findByRole('button', { name: messages.saveButton.defaultMessage })); + + await waitFor(() => expect(onSubmitMock).toHaveBeenCalled()); + }); +}); + +describe('', () => { + beforeEach(() => { + initializeMocks(); + jest.clearAllMocks(); + }); + + const defaultProps = { + initialValues: { + highlight_1: 'Test 1', + highlight_2: '', + highlight_3: '', + highlight_4: '', + highlight_5: '', + }, + onSubmit: onSubmitMock, + onCancel: jest.fn(), + }; + + it('renders 5 highlight fields and buttons', async () => { + render(); + + const queries = Array.from({ length: 5 }, (_, i) => screen.findByLabelText(new RegExp(`Highlight ${i + 1}`))); + const fields = await Promise.all(queries); + fields.forEach((field) => expect(field).toBeInTheDocument()); + + expect(await screen.findByRole('button', { name: messages.saveButton.defaultMessage })).toBeInTheDocument(); + }); + + it('disables save button when pristine', async () => { + render(); + + const saveBtn = await screen.findByRole('button', { name: messages.saveButton.defaultMessage }) as HTMLButtonElement; + expect(saveBtn.disabled).toBe(true); + }); + + it('enables save button when form is dirty', async () => { + render(); + + fireEvent.change(await screen.findByLabelText(/Highlight 1/), { target: { value: 'Modified' } }); + + const saveBtn = await screen.findByRole('button', { name: messages.saveButton.defaultMessage }); + expect((saveBtn as HTMLButtonElement).disabled).toBe(false); + }); + + it('calls onDirtyChange when form changes', async () => { + const onDirtyChange = jest.fn(); + render( + , + ); + + fireEvent.change(await screen.findByLabelText(/Highlight 1/), { target: { value: 'Modified' } }); + + await waitFor(() => expect(onDirtyChange).toHaveBeenCalledWith(true)); + }); + + it('calls onCancel when cancel button is clicked', async () => { + const user = userEvent.setup(); + const onCancel = jest.fn(); + render( + , + ); + + await user.click(await screen.findByRole('button', { name: messages.cancelButton.defaultMessage })); + expect(onCancel).toHaveBeenCalled(); + }); + + it('submits form with correct values', async () => { + const user = userEvent.setup(); + render(); + + fireEvent.change(await screen.findByLabelText(/Highlight 1/), { target: { value: 'Updated 1' } }); + fireEvent.change(await screen.findByLabelText(/Highlight 3/), { target: { value: 'Updated 3' } }); + + await user.click(await screen.findByRole('button', { name: messages.saveButton.defaultMessage })); + + await waitFor(() => expect(onSubmitMock).toHaveBeenCalled()); + }); +}); + +describe('', () => { + beforeEach(() => { + initializeMocks(); + jest.mocked(apiHooks.useCourseItemData).mockReturnValue({ + data: currentItemMock, + } as unknown as UseQueryResult); }); - it('renders HighlightsModal component correctly', () => { - const { getByText, getByRole, getByLabelText } = renderComponent(); - - expect(getByText(`Highlights for ${currentItemMock.displayName}`)).toBeInTheDocument(); - expect(getByText(/Enter 3-5 highlights to include in the email message that learners receive for this section/i)).toBeInTheDocument(); - expect(getByText(/For more information and an example of the email template, read our/i)).toBeInTheDocument(); - expect(getByText(messages.documentationLink.defaultMessage)).toBeInTheDocument(); - expect(getByLabelText(messages.highlight.defaultMessage.replace('{index}', '1'))).toBeInTheDocument(); - expect(getByLabelText(messages.highlight.defaultMessage.replace('{index}', '2'))).toBeInTheDocument(); - expect(getByLabelText(messages.highlight.defaultMessage.replace('{index}', '3'))).toBeInTheDocument(); - expect(getByLabelText(messages.highlight.defaultMessage.replace('{index}', '4'))).toBeInTheDocument(); - expect(getByLabelText(messages.highlight.defaultMessage.replace('{index}', '5'))).toBeInTheDocument(); - expect(getByRole('button', { name: messages.cancelButton.defaultMessage })).toBeInTheDocument(); - expect(getByRole('button', { name: messages.saveButton.defaultMessage })).toBeInTheDocument(); - }); - - it('calls the onClose function when the cancel button is clicked', () => { - const { getByRole } = renderComponent(); - - const cancelButton = getByRole('button', { name: messages.cancelButton.defaultMessage }); - fireEvent.click(cancelButton); - expect(onCloseMock).toHaveBeenCalledTimes(1); - }); - - it('calls the onSubmit function with correct values when the save button is clicked', async () => { - const { getByRole, getByLabelText } = renderComponent(); - - const field1 = getByLabelText(messages.highlight.defaultMessage.replace('{index}', '1')); - const field2 = getByLabelText(messages.highlight.defaultMessage.replace('{index}', '2')); - fireEvent.change(field1, { target: { value: 'New highlight 1' } }); - fireEvent.change(field2, { target: { value: 'New highlight 2' } }); - - const saveButton = getByRole('button', { name: messages.saveButton.defaultMessage }); - - await act(async () => { - fireEvent.click(saveButton); - }); - - await waitFor(() => { - expect(onSubmitMock).toHaveBeenCalledTimes(1); - expect(onSubmitMock).toHaveBeenCalledWith( - { - highlight_1: 'New highlight 1', - highlight_2: 'New highlight 2', - highlight_3: '', - highlight_4: '', - highlight_5: '', - }, - expect.objectContaining({ submitForm: expect.any(Function) }), - ); - }); + it('renders viewing mode with highlights', async () => { + render( + , + ); + + expect(await screen.findByText('Highlight 1')).toBeInTheDocument(); + expect(await screen.findByLabelText(messages.editButton.defaultMessage)).toBeInTheDocument(); + }); + + it('renders empty state when no highlights exist', async () => { + jest.mocked(apiHooks.useCourseItemData).mockReturnValue({ + data: { highlights: [], displayName: 'Test' }, + } as unknown as UseQueryResult); + + render( + , + ); + + expect(await screen.findByRole('button', { name: messages.addHighlightsButton.defaultMessage })).toBeInTheDocument(); + }); + + it('transitions to editing mode on edit button click', async () => { + const user = userEvent.setup(); + render( + , + ); + + await user.click(await screen.findByLabelText(messages.editButton.defaultMessage)); + + const field = await screen.findByLabelText(/Highlight 1/); + expect(field).toBeInTheDocument(); + }); + + it('transitions to editing mode on add button click', async () => { + const user = userEvent.setup(); + jest.mocked(apiHooks.useCourseItemData).mockReturnValue({ + data: { highlights: [], displayName: 'Test' }, + } as unknown as UseQueryResult); + + render( + , + ); + + await user.click(await screen.findByRole('button', { name: messages.addHighlightsButton.defaultMessage })); + + const field = await screen.findByLabelText(/Highlight 1/); + expect(field).toBeInTheDocument(); + }); + + it('returns to viewing mode on cancel', async () => { + const user = userEvent.setup(); + render( + , + ); + + await user.click(await screen.findByLabelText(messages.editButton.defaultMessage)); + + const cancelBtn = await screen.findByRole('button', { name: messages.cancelButton.defaultMessage }); + await user.click(cancelBtn); + + await waitFor(() => expect(screen.queryByLabelText(/Highlight 1/)).not.toBeInTheDocument()); + }); + + it('submits form and calls onSubmit', async () => { + const user = userEvent.setup(); + render( + , + ); + + await user.click(await screen.findByLabelText(messages.editButton.defaultMessage)); + + const field = await screen.findByLabelText(/Highlight 1/); + fireEvent.change(field, { target: { value: 'Updated' } }); + + const saveBtn = await screen.findByRole('button', { name: messages.saveButton.defaultMessage }); + await user.click(saveBtn); + + await waitFor(() => expect(onSubmitMock).toHaveBeenCalled()); + }); + + it('returns to viewing mode after successful submit', async () => { + const user = userEvent.setup(); + render( + , + ); + + await user.click(await screen.findByLabelText(messages.editButton.defaultMessage)); + + const field = await screen.findByLabelText(/Highlight 1/); + fireEvent.change(field, { target: { value: 'Updated' } }); + + const saveBtn = await screen.findByRole('button', { name: messages.saveButton.defaultMessage }); + await user.click(saveBtn); + + const editBtn = await screen.findByLabelText(messages.editButton.defaultMessage); + expect(editBtn).toBeInTheDocument(); + }); + + it('displays only non-empty highlights in view mode', async () => { + jest.mocked(apiHooks.useCourseItemData).mockReturnValue({ + data: { highlights: ['H1', '', 'H3', '', ''], displayName: 'Test' }, + } as unknown as UseQueryResult); + + render( + , + ); + + expect(await screen.findByText('H1')).toBeInTheDocument(); + expect(await screen.findByText('H3')).toBeInTheDocument(); + }); + + it('shows empty state after clearing all highlights', async () => { + const user = userEvent.setup(); + render( + , + ); + + await user.click(await screen.findByLabelText(messages.editButton.defaultMessage)); + + const field1 = await screen.findByLabelText(/Highlight 1/); + const field2 = await screen.findByLabelText(/Highlight 2/); + + fireEvent.change(field1, { target: { value: '' } }); + fireEvent.change(field2, { target: { value: '' } }); + + const saveBtn = await screen.findByRole('button', { name: messages.saveButton.defaultMessage }); + await user.click(saveBtn); + + const addBtn = await screen.findByRole('button', { name: messages.addHighlightsButton.defaultMessage }); + expect(addBtn).toBeInTheDocument(); + }); + + it('handles navigation blocking when form is dirty', () => { + const blockerMock = { + state: 'blocked', + proceed: jest.fn(), + reset: jest.fn(), + }; + (routerDom.useBlocker as jest.Mock).mockReturnValue(blockerMock); + + render( + , + ); + + expect(blockerMock.state).toBe('blocked'); }); }); diff --git a/src/course-outline/highlights-modal/HighlightsModal.tsx b/src/course-outline/highlights-modal/HighlightsModal.tsx new file mode 100644 index 0000000000..2d06e43bd1 --- /dev/null +++ b/src/course-outline/highlights-modal/HighlightsModal.tsx @@ -0,0 +1,342 @@ +import { useIntl } from '@edx/frontend-platform/i18n'; +import { + ModalDialog, + Button, + ActionRow, + Hyperlink, + Form, + Card, + IconButton, +} from '@openedx/paragon'; +import { Edit as EditIcon } from '@openedx/paragon/icons'; +import { Formik, useFormikContext } from 'formik'; +import { useEffect, useState } from 'react'; + +import { useCourseAuthoringContext } from '@src/CourseAuthoringContext'; +import { useCourseItemData } from '@src/course-outline/data/apiHooks'; +import { ExpandableCard } from '@src/generic/expandable-card/ExpandableCard'; +import { useBlocker } from 'react-router'; +import PromptIfDirty from '@src/generic/prompt-if-dirty/PromptIfDirty'; +import { useHelpUrls } from '../../help-urls/hooks'; +import FormikControl from '../../generic/FormikControl'; +import { HIGHLIGHTS_FIELD_MAX_LENGTH } from '../constants'; +import { getHighlightsFormValues } from '../utils'; +import messages from './messages'; + +export interface HighlightData { + highlight_1: string; + highlight_2: string; + highlight_3: string; + highlight_4: string; + highlight_5: string; +} + +type DisplayMode = 'empty' | 'viewing' | 'editing'; + +interface HighlightsFormProps { + onSubmit: (highlights: HighlightData) => void; + onCancel?: () => void; + initialValues: HighlightData; + onDirtyChange?: (dirty: boolean) => void; +} + +interface HighlightsCardProps { + sectionId: string; + onSubmit: (highlights: HighlightData) => void; +} + +const ConfirmNavigationModal = ({ + isOpen, + onConfirm, + onCancel, +}: { + isOpen: boolean; + onConfirm: () => void; + onCancel: () => void; +}) => { + const intl = useIntl(); + + return ( + + + + {intl.formatMessage(messages.unsavedChangesTitle)} + + + +

{intl.formatMessage(messages.unsavedChangesMessage)}

+
+ + + + + + +
+ ); +}; + +// Separate component so hooks can be used at the top level of a component +const HighlightsFormInner = ({ + initialValues, + onCancel, + onDirtyChange, +}: Pick) => { + const intl = useIntl(); + const { contentHighlights: contentHighlightsUrl } = useHelpUrls([ + 'contentHighlights', + ]); + + const { + values, dirty, handleSubmit, resetForm, + } = useFormikContext(); + + // Notify parent of dirty state changes + useEffect(() => onDirtyChange?.(dirty), [dirty, onDirtyChange]); + + return ( +
+
+

+ {intl.formatMessage(messages.description, { + documentation: ( + + {intl.formatMessage(messages.documentationLink)} + + ), + })} +

+
+ {Object.entries(initialValues).map(([key], index) => ( + + ))} +
+
+ + +
+
+
+ ); +}; + +export const HighlightsForm = ({ + onSubmit, + onCancel, + initialValues, + onDirtyChange, +}: HighlightsFormProps) => ( + + + +); + +const HighlightsViewCard = ({ + highlights, + onEdit, +}: { + highlights: string[]; + onEdit: () => void; +}) => { + const intl = useIntl(); + const nonEmptyHighlights = highlights.filter((h) => h?.trim()); + + return ( + + + + + )} + /> + + + {nonEmptyHighlights.map((highlight) => ( +

{highlight}

+ ))} +
+
+
+ ); +}; + +const HighlightsEmptyState = ({ onAdd }: { onAdd: () => void }) => { + const intl = useIntl(); + + return ( + + ); +}; + +export const HighlightsCard = ({ sectionId, onSubmit }: HighlightsCardProps) => { + const { data: currentItemData } = useCourseItemData(sectionId); + const { highlights = [] } = currentItemData || {}; + + const [mode, setMode] = useState( + highlights.some((h) => h?.trim()) ? 'viewing' : 'empty', + ); + + const [formDirty, setFormDirty] = useState(false); + + const initialFormValues = getHighlightsFormValues(highlights); + const blocker = useBlocker(formDirty); + + const handleAddClick = () => { + setMode('editing'); + setFormDirty(false); + }; + + const handleEditClick = () => { + setMode('editing'); + setFormDirty(false); + }; + + const handleFormSubmit = async (values: HighlightData) => { + // Call parent onSubmit + onSubmit(values); + setFormDirty(false); + setMode( + Object.values(values).some((v) => v?.trim()) ? 'viewing' : 'empty', + ); + }; + + const handleFormCancel = () => { + setFormDirty(false); + setMode( + highlights.some((h) => h?.trim()) ? 'viewing' : 'empty', + ); + }; + + /* istanbul ignore next */ + const handleConfirmNavigation = () => { + setFormDirty(false); + blocker.proceed?.(); + }; + + return ( + <> + { + blocker.reset?.(); + }} + /> + + {mode === 'empty' && } + + {mode === 'viewing' && ( + + )} + + {mode === 'editing' && ( + + )} + + + ); +}; + +// Keep the modal version for backward compatibility +const HighlightsModal = ({ + isOpen, + onClose, + onSubmit, +}: { + isOpen: boolean; + onClose: () => void; + onSubmit: (highlights: HighlightData) => void; +}) => { + const intl = useIntl(); + const { currentSelection } = useCourseAuthoringContext(); + const { data: currentItemData } = useCourseItemData( + currentSelection?.currentId, + ); + const { displayName } = currentItemData || {}; + const { highlights = [] } = currentItemData || {}; + const initialFormValues = getHighlightsFormValues(highlights); + + return ( + + + + {intl.formatMessage(messages.title, { + title: displayName, + })} + + + + + + + ); +}; + +export default HighlightsModal; diff --git a/src/course-outline/highlights-modal/messages.ts b/src/course-outline/highlights-modal/messages.ts index e8c083a9dc..74a07ef008 100644 --- a/src/course-outline/highlights-modal/messages.ts +++ b/src/course-outline/highlights-modal/messages.ts @@ -25,6 +25,51 @@ const messages = defineMessages({ id: 'course-authoring.course-outline.highlights-modal.button.save', defaultMessage: 'Save', }, + highlightsTitle: { + id: 'course.authoring.highlights.card.title', + defaultMessage: 'Highlights', + description: 'Title of the highlights card', + }, + editButton: { + id: 'course.authoring.highlights.card.edit.button', + defaultMessage: 'Edit', + description: 'Edit button text in viewing mode', + }, + addHighlightsButton: { + id: 'course.authoring.highlights.empty.add.button', + defaultMessage: 'Add Highlights', + description: 'Add highlights button in empty state', + }, + showMoreButton: { + id: 'course.authoring.highlights.card.show.more.button', + defaultMessage: 'Show More', + description: 'Show more button to expand highlights list', + }, + noHighlightsMessage: { + id: 'course.authoring.highlights.empty.message', + defaultMessage: 'No highlights added yet. Add highlights to help learners focus on key points.', + description: 'Message shown when no highlights exist', + }, + unsavedChangesTitle: { + id: 'course.authoring.highlights.unsaved.changes.title', + defaultMessage: 'Leaving without saving?', + description: 'Title of unsaved changes confirmation dialog', + }, + unsavedChangesMessage: { + id: 'course.authoring.highlights.unsaved.changes.message', + defaultMessage: 'Changes you made to highlights will not be saved', + description: 'Message in unsaved changes confirmation dialog', + }, + keepEditingButton: { + id: 'course.authoring.highlights.unsaved.changes.keep.editing.button', + defaultMessage: 'Cancel', + description: 'Button to keep editing and close confirmation dialog', + }, + discardChangesButton: { + id: 'course.authoring.highlights.unsaved.changes.discard.button', + defaultMessage: 'Leave', + description: 'Button to discard changes and navigate away', + }, }); export default messages; diff --git a/src/course-outline/hooks.jsx b/src/course-outline/hooks.jsx index dcf859f061..1a48d12243 100644 --- a/src/course-outline/hooks.jsx +++ b/src/course-outline/hooks.jsx @@ -22,6 +22,7 @@ import { usePasteItem, useUpdateCourseSectionHighlights, } from '@src/course-outline/data/apiHooks'; +import { PUBLISH_TYPES } from '@src/course-unit/constants'; import { COURSE_BLOCK_NAMES } from './constants'; import { deleteSection, @@ -187,15 +188,9 @@ const useCourseOutline = ({ courseId }) => { }); }, [currentUnlinkModalData, unlinkDownstream, closeUnlinkModal]); - const { - mutate: configureCourseSection, - } = useConfigureSection(); - const { - mutate: configureCourseSubsection, - } = useConfigureSubsection(); - const { - mutate: configureCourseUnit, - } = useConfigureUnit(); + const { mutate: configureCourseSection } = useConfigureSection(); + const { mutate: configureCourseSubsection } = useConfigureSubsection(); + const { mutate: configureCourseUnit } = useConfigureUnit(); const handleConfigureItemSubmit = (variables) => { const category = getBlockType(currentSelection.currentId); switch (category) { @@ -216,6 +211,7 @@ const useCourseOutline = ({ courseId }) => { configureCourseUnit({ unitId: currentSelection?.currentId, sectionId: currentSelection?.sectionId, + type: PUBLISH_TYPES.republish, ...variables, }); break; @@ -291,9 +287,7 @@ const useCourseOutline = ({ courseId }) => { deleteSubsection, ]); - const { - mutate: duplicateItem, - } = useDuplicateItem(courseId); + const { mutate: duplicateItem } = useDuplicateItem(courseId); const handleDuplicateSectionSubmit = () => { duplicateItem({ itemId: currentSelection?.currentId, diff --git a/src/course-outline/outline-sidebar/info-sidebar/CourseInfoSidebar.tsx b/src/course-outline/outline-sidebar/info-sidebar/CourseInfoSidebar.tsx index a7344952ea..26b008704c 100644 --- a/src/course-outline/outline-sidebar/info-sidebar/CourseInfoSidebar.tsx +++ b/src/course-outline/outline-sidebar/info-sidebar/CourseInfoSidebar.tsx @@ -1,4 +1,3 @@ -import { getConfig } from '@edx/frontend-platform'; import { useIntl } from '@edx/frontend-platform/i18n'; import { Tab, diff --git a/src/course-outline/outline-sidebar/info-sidebar/SectionInfoSidebar.tsx b/src/course-outline/outline-sidebar/info-sidebar/SectionInfoSidebar.tsx index 82f684c5da..f5448c6712 100644 --- a/src/course-outline/outline-sidebar/info-sidebar/SectionInfoSidebar.tsx +++ b/src/course-outline/outline-sidebar/info-sidebar/SectionInfoSidebar.tsx @@ -10,6 +10,7 @@ import { useCourseItemData } from '@src/course-outline/data/apiHooks'; import Loading from '@src/generic/Loading'; import { useCourseAuthoringContext } from '@src/CourseAuthoringContext'; import { useOutlineSidebarContext } from '@src/course-outline/outline-sidebar/OutlineSidebarContext'; +import { SectionSettings } from '@src/course-outline/outline-sidebar/info-sidebar/SectionSettings'; import { InfoSection } from './InfoSection'; import messages from '../messages'; import { PublishButon } from './PublishButon'; @@ -57,8 +58,11 @@ export const SectionSidebar = ({ sectionId }: Props) => { - -
Settings
+ + diff --git a/src/course-outline/outline-sidebar/info-sidebar/SectionSettings.test.tsx b/src/course-outline/outline-sidebar/info-sidebar/SectionSettings.test.tsx new file mode 100644 index 0000000000..977dafd1ef --- /dev/null +++ b/src/course-outline/outline-sidebar/info-sidebar/SectionSettings.test.tsx @@ -0,0 +1,111 @@ +import { initializeMocks, render, screen } from '@src/testUtils'; +import userEvent from '@testing-library/user-event'; + +import { SectionSettings } from './SectionSettings'; + +const sectionId = 'section-1'; + +// Mock the HighlightsCard to expose a button that triggers onSubmit with sample data +jest.mock('@src/course-outline/highlights-modal/HighlightsModal', () => ({ + __esModule: true, + HighlightsCard: ({ onSubmit }: any) => ( +
+ +
+ ), +})); + +// Mock ReleaseSection and VisibilitySection to provide simple buttons that call onChange +jest.mock('./sharedSettings/ReleaseSection', () => ({ + __esModule: true, + ReleaseSection: ({ onChange }: any) => ( + + ), +})); + +jest.mock('./sharedSettings/VisibilitySection', () => ({ + __esModule: true, + VisibilitySection: ({ onChange }: any) => ( + + ), +})); + +// Mock hooks from apiHooks +const configureMutate = jest.fn(); +const highlightsMutate = jest.fn(); + +jest.mock('@src/course-outline/data/apiHooks', () => ({ + useConfigureSection: () => ({ mutate: configureMutate }), + useUpdateCourseSectionHighlights: () => ({ mutate: highlightsMutate }), + useCourseItemData: jest.fn(), + useCourseDetails: jest.fn(), +})); + +// Mock CourseAuthoringContext +jest.mock('@src/CourseAuthoringContext', () => ({ + useCourseAuthoringContext: () => ({ courseId: '5' }), +})); + +const apiHooks = jest.requireMock('@src/course-outline/data/apiHooks') as any; + +describe('SectionSettings', () => { + beforeEach(() => { + initializeMocks(); + configureMutate.mockReset(); + highlightsMutate.mockReset(); + }); + + it('renders highlights, release and visibility and calls mutates with expected payloads', async () => { + // course not self paced -> ReleaseSection should render + apiHooks.useCourseDetails.mockReturnValue({ data: { selfPaced: false } }); + apiHooks.useCourseItemData.mockReturnValue({ data: { visibilityState: 'staff_only', start: '2020-01-01' }, isPending: false }); + + const user = userEvent.setup(); + render(); + + // Highlights submit should call highlightsMutate with filtered values + await user.click(await screen.findByRole('button', { name: 'Submit Highlights' })); + expect(highlightsMutate).toHaveBeenCalledWith({ sectionId, highlights: ['one', 'two'] }); + + // Release button should be present and calling it should call configure mutate + await user.click(await screen.findByRole('button', { name: 'Release' })); + expect(configureMutate).toHaveBeenCalledWith(expect.objectContaining({ sectionId, isVisibleToStaffOnly: true, startDatetime: '2025-01-01' })); + + // Visibility button should also call configure mutate + await user.click(await screen.findByRole('button', { name: 'Visibility' })); + expect(configureMutate).toHaveBeenCalledWith(expect.objectContaining({ sectionId, isVisibleToStaffOnly: true, visibility: 'changed' })); + }); + + it('does not render ReleaseSection when course is self paced', () => { + apiHooks.useCourseDetails.mockReturnValue({ data: { selfPaced: true } }); + apiHooks.useCourseItemData.mockReturnValue({ data: { visibilityState: 'gated', start: null }, isPending: false }); + + render(); + + expect(screen.queryByRole('button', { name: 'Release' })).not.toBeInTheDocument(); + // Visibility should still be present + expect(screen.getByRole('button', { name: 'Visibility' })).toBeInTheDocument(); + }); + + it('does not call configure mutate when item data is pending', async () => { + apiHooks.useCourseDetails.mockReturnValue({ data: { selfPaced: false } }); + apiHooks.useCourseItemData.mockReturnValue({ data: { visibilityState: 'gated', start: null }, isPending: true }); + + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('button', { name: 'Visibility' })); + expect(configureMutate).not.toHaveBeenCalled(); + }); +}); diff --git a/src/course-outline/outline-sidebar/info-sidebar/SectionSettings.tsx b/src/course-outline/outline-sidebar/info-sidebar/SectionSettings.tsx new file mode 100644 index 0000000000..27c049b126 --- /dev/null +++ b/src/course-outline/outline-sidebar/info-sidebar/SectionSettings.tsx @@ -0,0 +1,63 @@ +import { + useConfigureSection, useCourseDetails, useCourseItemData, useUpdateCourseSectionHighlights, +} from '@src/course-outline/data/apiHooks'; +import { useCourseAuthoringContext } from '@src/CourseAuthoringContext'; +import { SidebarContent } from '@src/generic/sidebar'; +import { ConfigureSectionData } from '@src/course-outline/data/types'; +import { VisibilityTypes } from '@src/data/constants'; +import { HighlightData, HighlightsCard } from '@src/course-outline/highlights-modal/HighlightsModal'; +import { VisibilitySection } from './sharedSettings/VisibilitySection'; +import { ReleaseSection } from './sharedSettings/ReleaseSection'; + +interface Props { + sectionId: string; +} + +const Highlights = ({ sectionId }: Props) => { + const { mutate } = useUpdateCourseSectionHighlights(); + const onSubmit = (highlights: HighlightData) => { + const dataToSend = Object.values(highlights).filter(Boolean); + mutate({ + sectionId, + highlights: dataToSend, + }); + }; + return ( + + ); +}; + +export const SectionSettings = ({ sectionId }: Props) => { + const { courseId } = useCourseAuthoringContext(); + const { data: courseDetails } = useCourseDetails(courseId); + const { data: itemData, isPending } = useCourseItemData(sectionId); + const { mutate } = useConfigureSection(); + + const onChange = (variables: Partial) => { + if (isPending || !itemData) { + return; + } + mutate({ + sectionId, + isVisibleToStaffOnly: itemData.visibilityState === VisibilityTypes.STAFF_ONLY, + startDatetime: itemData.start, + ...variables, + }); + }; + + return ( + + + { !courseDetails?.selfPaced && ( + onChange({ startDatetime: val })} + /> + ) } + + + ); +}; diff --git a/src/course-outline/outline-sidebar/info-sidebar/SubsectionInfoSidebar.tsx b/src/course-outline/outline-sidebar/info-sidebar/SubsectionInfoSidebar.tsx index 540c2effe1..5f2577556e 100644 --- a/src/course-outline/outline-sidebar/info-sidebar/SubsectionInfoSidebar.tsx +++ b/src/course-outline/outline-sidebar/info-sidebar/SubsectionInfoSidebar.tsx @@ -13,6 +13,7 @@ import { useOutlineSidebarContext } from '@src/course-outline/outline-sidebar/Ou import { InfoSection } from './InfoSection'; import { PublishButon } from './PublishButon'; import messages from '../messages'; +import { SubsectionSettings } from './SubsectionSettings'; interface Props { subsectionId: string; @@ -59,7 +60,8 @@ export const SubsectionSidebar = ({ subsectionId }: Props) => {
-
Settings
+ {/* key is required to reset local state of tab */} +
diff --git a/src/course-outline/outline-sidebar/info-sidebar/SubsectionSettings.test.tsx b/src/course-outline/outline-sidebar/info-sidebar/SubsectionSettings.test.tsx new file mode 100644 index 0000000000..3cd14e194b --- /dev/null +++ b/src/course-outline/outline-sidebar/info-sidebar/SubsectionSettings.test.tsx @@ -0,0 +1,243 @@ +import { initializeMocks, render, screen } from '@src/testUtils'; +import userEvent from '@testing-library/user-event'; +import { useState } from 'react'; + +import { SubsectionSettings } from './SubsectionSettings'; + +const subsectionId = 'sub-1'; + +// Make useStateWithCallback synchronous so callbacks call mutate immediately +jest.mock('@src/hooks', () => ({ + useStateWithCallback: (defaultValue: any, cb?: any) => { + const [state, setState] = useState(defaultValue); + const wrappedSetState = (val: any) => { + const newVal = typeof val === 'function' ? val(state) : val; + setState(newVal); + if (cb) { cb(newVal); } + }; + return [state, wrappedSetState]; + }, +})); + +// Mock DatepickerControl used in GradingSection so we can trigger onChange +jest.mock('@src/generic/datepicker-control', () => ({ + DATEPICKER_TYPES: { date: 'date', time: 'time' }, + DatepickerControl: ({ onChange, type, ...props }: any) => ( + + ), +})); + +// Mock nested components: ReleaseSection, VisibilitySection, AdvancedTab +jest.mock('./sharedSettings/ReleaseSection', () => ({ + __esModule: true, + ReleaseSection: ({ onChange }: any) => ( + + ), +})); + +jest.mock('./sharedSettings/VisibilitySection', () => ({ + __esModule: true, + VisibilitySection: ({ onChange }: any) => ( + + ), +})); + +jest.mock('@src/generic/configure-modal/AdvancedTab', () => ({ + __esModule: true, + default: ({ setFieldValue }: any) => ( +
+ +
+ ), +})); + +// Mock hooks +const mutate = jest.fn(); +jest.mock('@src/course-outline/data/apiHooks', () => ({ + useConfigureSubsection: () => ({ mutate }), + useCourseDetails: jest.fn(), + useCourseItemData: jest.fn(), +})); + +// Mock contexts +jest.mock('@src/CourseAuthoringContext', () => ({ + useCourseAuthoringContext: () => ({ courseId: '5' }), +})); + +jest.mock('@src/course-outline/outline-sidebar/OutlineSidebarContext', () => ({ + useOutlineSidebarContext: () => ({ selectedContainerState: { sectionId: 'section-abc' } }), +})); + +const apiHooks = jest.requireMock('@src/course-outline/data/apiHooks') as any; + +const baseItemData = { + visibilityState: 'staff_only', + start: '2022-01-01', + format: null, + due: null, + isTimeLimited: false, + isProctoredExam: false, + isOnboardingExam: false, + isPracticeExam: false, + examReviewRules: null, + defaultTimeLimitMinutes: null, + hideAfterDue: undefined, + showCorrectness: undefined, + isPrereq: false, + prereq: null, + prereqMinScore: null, + prereqMinCompletion: null, + courseGraders: ['g1', 'g2'], + graded: true, +}; + +describe('SubsectionSettings', () => { + beforeEach(() => { + initializeMocks(); + mutate.mockReset(); + }); + + it('renders core sections and calls mutate for release, visibility, grading, and special exam', async () => { + apiHooks.useCourseDetails.mockReturnValue({ data: { selfPaced: false } }); + apiHooks.useCourseItemData.mockReturnValue({ data: baseItemData, isPending: false }); + + const user = userEvent.setup(); + render(); + + // Release + await user.click(await screen.findByRole('button', { name: 'Release' })); + expect(mutate).toHaveBeenCalledWith(expect.objectContaining({ itemId: subsectionId, sectionId: 'section-abc', releaseDate: '2030-01-01' })); + + // Visibility + await user.click(await screen.findByRole('button', { name: 'Visibility' })); + expect(mutate).toHaveBeenCalledWith(expect.objectContaining({ itemId: subsectionId, sectionId: 'section-abc', visibility: 'v' })); + + // Grading -> Ungraded + await user.click(await screen.findByRole('button', { name: 'Ungraded' })); + expect(mutate).toHaveBeenCalledWith(expect.objectContaining({ itemId: subsectionId, graderType: 'notgraded' })); + + // Special exam + await user.click(await screen.findByRole('button', { name: 'Set Proctored' })); + expect(mutate).toHaveBeenCalledWith(expect.objectContaining({ itemId: subsectionId, isProctoredExam: true })); + }); + + it('handles grading select and due date/time changes', async () => { + apiHooks.useCourseDetails.mockReturnValue({ data: { selfPaced: false } }); + apiHooks.useCourseItemData.mockReturnValue({ + data: { + ...baseItemData, graded: false, prereqMinScore: '50', prereqMinCompletion: '75', + }, + isPending: false, + }); + + const user = userEvent.setup(); + render(); + + await user.click(await screen.findByRole('button', { name: 'Graded' })); + const select = await screen.findByTestId('grader-type-select'); + await user.selectOptions(select as HTMLSelectElement, 'g1'); + expect(mutate).toHaveBeenCalledWith(expect.objectContaining({ itemId: subsectionId, graderType: 'g1' })); + + await user.click(await screen.findByTestId('due-date-picker')); + expect(mutate).toHaveBeenCalledWith(expect.objectContaining({ itemId: subsectionId, dueDate: '2025-12-31' })); + await user.click(await screen.findByTestId('time')); + expect(mutate).toHaveBeenCalledWith(expect.objectContaining({ itemId: subsectionId, dueDate: '12:00' })); + }); + + it('toggles assessment result visibility', async () => { + apiHooks.useCourseDetails.mockReturnValue({ data: { selfPaced: false } }); + apiHooks.useCourseItemData.mockReturnValue({ data: { ...baseItemData, graded: false }, isPending: false }); + + const user = userEvent.setup(); + render(); + + await user.click(await screen.findByRole('button', { name: 'Show' })); + expect(mutate).toHaveBeenCalledWith(expect.objectContaining({ itemId: subsectionId, showCorrectness: 'always' })); + + await user.click(await screen.findByRole('button', { name: 'Hide' })); + expect(mutate).toHaveBeenCalledWith(expect.objectContaining({ itemId: subsectionId, showCorrectness: 'never' })); + + const checkbox = await screen.findByRole('checkbox'); + await user.click(checkbox); + expect(mutate).toHaveBeenCalledWith(expect.objectContaining({ itemId: subsectionId, showCorrectness: 'past_due' })); + }); + + it('does not render ReleaseSection when course is self paced', () => { + apiHooks.useCourseDetails.mockReturnValue({ data: { selfPaced: true } }); + apiHooks.useCourseItemData.mockReturnValue({ data: { ...baseItemData, start: null }, isPending: false }); + + render(); + + expect(screen.queryByRole('button', { name: 'Release' })).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Visibility' })).toBeInTheDocument(); + }); + + it('does not call mutate when item data is pending', async () => { + apiHooks.useCourseDetails.mockReturnValue({ data: { selfPaced: false } }); + apiHooks.useCourseItemData.mockReturnValue({ + data: { + ...baseItemData, + start: null, + graded: false, + }, + isPending: true, + }); + + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('button', { name: 'Visibility' })); + expect(mutate).not.toHaveBeenCalled(); + }); + + it('resets grading local state when itemData changes', async () => { + apiHooks.useCourseDetails.mockReturnValue({ data: { selfPaced: false } }); + const firstItemData = { + ...baseItemData, format: 'g1', due: '2024-01-01', graded: true, + }; + const secondItemData = { ...firstItemData, format: 'g2', due: '2024-02-02' }; + + apiHooks.useCourseItemData.mockReturnValue({ data: firstItemData, isPending: false }); + + const { rerender } = render(); + + mutate.mockClear(); + apiHooks.useCourseItemData.mockReturnValue({ data: secondItemData, isPending: false }); + rerender(); + + expect(mutate).toHaveBeenCalledWith(expect.objectContaining({ graderType: 'g2', dueDate: '2024-02-02' })); + }); + + it('resets assessment visibility local state when itemData changes', async () => { + apiHooks.useCourseDetails.mockReturnValue({ data: { selfPaced: false } }); + const firstItemData = { ...baseItemData, graded: false, showCorrectness: 'always' }; + const secondItemData = { ...firstItemData, showCorrectness: 'never' }; + + apiHooks.useCourseItemData.mockReturnValue({ data: firstItemData, isPending: false }); + + const { rerender } = render(); + + mutate.mockClear(); + apiHooks.useCourseItemData.mockReturnValue({ data: secondItemData, isPending: false }); + rerender(); + + expect(mutate).toHaveBeenCalledWith(expect.objectContaining({ showCorrectness: 'never' })); + }); + + it('does not call mutate when item data is absent', async () => { + apiHooks.useCourseDetails.mockReturnValue({ data: { selfPaced: false } }); + apiHooks.useCourseItemData.mockReturnValue({ data: undefined, isPending: false }); + + const user = userEvent.setup(); + render(); + + await user.click(screen.getByRole('button', { name: 'Visibility' })); + expect(mutate).not.toHaveBeenCalled(); + }); +}); diff --git a/src/course-outline/outline-sidebar/info-sidebar/SubsectionSettings.tsx b/src/course-outline/outline-sidebar/info-sidebar/SubsectionSettings.tsx new file mode 100644 index 0000000000..766e6fd47f --- /dev/null +++ b/src/course-outline/outline-sidebar/info-sidebar/SubsectionSettings.tsx @@ -0,0 +1,310 @@ +import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n'; +import { + Button, ButtonGroup, Form, Stack, +} from '@openedx/paragon'; +import { useConfigureSubsection, useCourseDetails, useCourseItemData } from '@src/course-outline/data/apiHooks'; +import { getProctoredExamsFlag, getTimedExamsFlag } from '@src/course-outline/data/selectors'; +import { ConfigureSubsectionData } from '@src/course-outline/data/types'; +import { useOutlineSidebarContext } from '@src/course-outline/outline-sidebar/OutlineSidebarContext'; +import { useCourseAuthoringContext } from '@src/CourseAuthoringContext'; +import AdvancedTab from '@src/generic/configure-modal/AdvancedTab'; +import { DatepickerControl, DATEPICKER_TYPES } from '@src/generic/datepicker-control'; +import { SidebarContent, SidebarSection } from '@src/generic/sidebar'; +import { useStateWithCallback } from '@src/hooks'; +import { + useCallback, useEffect, useRef, useState, +} from 'react'; +import { useSelector } from 'react-redux'; +import { ReleaseSection } from './sharedSettings/ReleaseSection'; +import messages from './messages'; +import { VisibilitySection } from './sharedSettings/VisibilitySection'; + +interface Props { + subsectionId: string; +} + +const defaultPrereqScore = (val: string | number | null | undefined) => { + if (val === null || val === undefined) { + return 100; + } + const parsed = parseFloat(val.toString()); + return Number.isNaN(parsed) ? 100 : parsed; +}; + +interface SubProps extends Props { + onChange: (variables: Partial) => void; +} + +const GradingSection = ({ subsectionId, onChange }: SubProps) => { + const intl = useIntl(); + const { data: itemData } = useCourseItemData(subsectionId); + const [graded, setGraded] = useState(itemData?.graded); + const { courseId } = useCourseAuthoringContext(); + const { data: courseDetails } = useCourseDetails(courseId); + const [localState, setLocalState] = useStateWithCallback>( + { + graderType: itemData?.format, + dueDate: itemData?.due || '', + }, + (val) => onChange(val || {}), + ); + const didMountRef = useRef(false); + + useEffect(() => { + const nextState = { + graderType: itemData?.format, + dueDate: itemData?.due || '', + }; + + if (!didMountRef.current) { + didMountRef.current = true; + return; + } + + if (localState?.graderType !== nextState.graderType || localState?.dueDate !== nextState.dueDate) { + setLocalState(nextState); + } + }, [itemData?.format, itemData?.due]); + + const setUngraded = () => { + setGraded(false); + onChange({ graderType: 'notgraded' }); + }; + + const createOptions = () => itemData?.courseGraders?.map((option) => ( + + )); + + return ( + + + + + + {graded + && ( + + + + + setLocalState((prev) => ({ ...prev, graderType: e.target.value }))} + data-testid="grader-type-select" + > + + {createOptions()} + + + )} + {!courseDetails?.selfPaced && graded + && ( + + setLocalState((prev) => ({ ...prev, dueDate: val }))} + data-testid="due-date-picker" + /> + setLocalState((prev) => ({ ...prev, dueDate: val }))} + /> + + )} + + ); +}; + +const AssessmentResultVisibilitySection = ({ subsectionId, onChange }: SubProps) => { + const intl = useIntl(); + const { data: itemData } = useCourseItemData(subsectionId); + const [localState, setLocalState] = useStateWithCallback>( + { + showCorrectness: itemData?.showCorrectness, + }, + (val) => onChange(val || {}), + ); + const didMountRef = useRef(false); + + useEffect(() => { + if (!didMountRef.current) { + didMountRef.current = true; + return; + } + + if (localState?.showCorrectness !== itemData?.showCorrectness) { + setLocalState({ showCorrectness: itemData?.showCorrectness }); + } + }, [itemData?.showCorrectness]); + + return ( + + + + + + ) => setLocalState({ + showCorrectness: e.target.checked ? 'past_due' : 'never', + })} + > + + + + ); +}; + +const SpecialExamSection = ({ subsectionId, onChange }: SubProps) => { + const intl = useIntl(); + const { data: itemData } = useCourseItemData(subsectionId); + const enableTimedExams = useSelector(getTimedExamsFlag); + const enableProctoredExams = useSelector(getProctoredExamsFlag); + const getLatestLocalState = useCallback(() => ({ + isProctoredExam: itemData?.isProctoredExam, + isTimeLimited: itemData?.isTimeLimited, + isOnboardingExam: itemData?.isOnboardingExam, + isPracticeExam: itemData?.isPracticeExam, + defaultTimeLimitMinutes: itemData?.defaultTimeLimitMinutes, + examReviewRules: itemData?.examReviewRules, + isPrereq: itemData?.isPrereq, + prereqMinScore: defaultPrereqScore(itemData?.prereqMinScore), + prereqMinCompletion: defaultPrereqScore(itemData?.prereqMinCompletion), + prereqUsageKey: itemData?.prereq, + }), [itemData]); + + const [localState, setLocalState] = useStateWithCallback>( + getLatestLocalState, + (val) => onChange(val || {}), + ); + const didMountRef = useRef(false); + + useEffect(() => { + if (!didMountRef.current) { + didMountRef.current = true; + return; + } + + const nextState = getLatestLocalState(); + const hasChanges = Object.keys(nextState).some((key) => (localState as any)?.[key] !== (nextState as any)[key]); + + if (hasChanges) { + setLocalState({ value: nextState, skipCallback: true }); + } + }, [getLatestLocalState]); + + const setFieldValue = (key: keyof ConfigureSubsectionData, value: any) => { + setLocalState((prev) => ({ + ...prev, + [key]: value, + })); + }; + + return ( + + + + ); +}; + +export const SubsectionSettings = ({ subsectionId }: Props) => { + const { courseId } = useCourseAuthoringContext(); + const { data: courseDetails } = useCourseDetails(courseId); + const { data: itemData, isPending } = useCourseItemData(subsectionId); + const { mutate } = useConfigureSubsection(); + const { selectedContainerState } = useOutlineSidebarContext(); + + const onChange = (variables: Partial) => { + if (isPending || !itemData) { + return; + } + + mutate({ + itemId: subsectionId, + sectionId: selectedContainerState?.sectionId, + ...variables, + }); + }; + + return ( + + { !courseDetails?.selfPaced && ( + onChange({ releaseDate: val })} + /> + ) } + + + + + + ); +}; diff --git a/src/course-outline/outline-sidebar/info-sidebar/UnitInfoSidebar.test.tsx b/src/course-outline/outline-sidebar/info-sidebar/UnitInfoSidebar.test.tsx new file mode 100644 index 0000000000..c22bca474b --- /dev/null +++ b/src/course-outline/outline-sidebar/info-sidebar/UnitInfoSidebar.test.tsx @@ -0,0 +1,93 @@ +import { initializeMocks, render, screen } from '@src/testUtils'; +import userEvent from '@testing-library/user-event'; +import { UnitSidebar } from './UnitInfoSidebar'; + +// Mocks +jest.mock('@src/course-outline/data/apiHooks', () => ({ + useCourseItemData: jest.fn(), + courseOutlineQueryKeys: { courseItemId: (id: string) => ['courseItem', id] }, +})); + +jest.mock('../OutlineSidebarContext', () => ({ + useOutlineSidebarContext: jest.fn(), +})); + +jest.mock('@src/CourseAuthoringContext', () => ({ + useCourseAuthoringContext: jest.fn(), +})); + +jest.mock('./PublishButon', () => ({ PublishButon: ({ onClick }: any) => })); +jest.mock('./InfoSection', () => ({ InfoSection: ({ itemId }: any) =>
InfoSection:{itemId}
})); +jest.mock('@src/course-unit/unit-sidebar/unit-info/GenericUnitInfoSettings', () => ({ GenericUnitInfoSettings: () =>
GenericUnitInfoSettings
})); +jest.mock('@src/generic/block-type-utils', () => ({ getItemIcon: () => () => null })); +jest.mock('@src/course-unit/xblock-container-iframe', () => function XBlockIframe() { + return
XBlockIframe
; +}); +jest.mock('@src/generic/hooks/context/iFrameContext', () => ({ IframeProvider: ({ children }: any) =>
{children}
})); + +const apiHooks = jest.requireMock('@src/course-outline/data/apiHooks') as any; +const outlineContext = jest.requireMock('../OutlineSidebarContext') as any; +const authoring = jest.requireMock('@src/CourseAuthoringContext') as any; + +describe('UnitSidebar', () => { + beforeEach(() => { + initializeMocks(); + outlineContext.useOutlineSidebarContext.mockReturnValue({ selectedContainerState: { sectionId: 's1', subsectionId: 'ss1' }, clearSelection: jest.fn() }); + authoring.useCourseAuthoringContext.mockReturnValue({ openPublishModal: jest.fn(), getUnitUrl: (id: string) => `/unit/${id}`, courseId: '5' }); + }); + + it('renders title and info tab by default', () => { + apiHooks.useCourseItemData.mockReturnValue({ + data: { + displayName: 'Unit 1', hasChanges: false, category: 'vertical', id: 'unit-1', + }, + isPending: false, + }); + render(); + expect(screen.getByText('Unit 1')).toBeInTheDocument(); + expect(screen.getByText('InfoSection:unit-1')).toBeInTheDocument(); + }); + + it('shows publish button and triggers openPublishModal when unit has changes', async () => { + const user = userEvent.setup(); + const openPublishModal = jest.fn(); + authoring.useCourseAuthoringContext.mockReturnValue({ openPublishModal, getUnitUrl: (id: string) => `/unit/${id}`, courseId: '5' }); + apiHooks.useCourseItemData.mockReturnValue({ + data: { + displayName: 'Unit 2', hasChanges: true, category: 'vertical', id: 'unit-2', + }, + isPending: false, + }); + + render(); + expect(screen.getByText('Publish')).toBeInTheDocument(); + await user.click(screen.getByText('Publish')); + expect(openPublishModal).toHaveBeenCalledWith({ value: expect.any(Object), sectionId: 's1', subsectionId: 'ss1' }); + }); + + it('switches to preview tab and shows iframe', async () => { + const user = userEvent.setup(); + apiHooks.useCourseItemData.mockReturnValue({ + data: { + displayName: 'Unit 3', hasChanges: false, category: 'vertical', id: 'unit-3', + }, + isPending: false, + }); + render(); + await user.click(screen.getByRole('tab', { name: /Preview/i })); + expect(screen.getByText('XBlockIframe')).toBeInTheDocument(); + }); + + it('shows settings tab content when selected', async () => { + const user = userEvent.setup(); + apiHooks.useCourseItemData.mockReturnValue({ + data: { + displayName: 'Unit 4', hasChanges: false, category: 'vertical', id: 'unit-4', visibilityState: undefined, discussionEnabled: false, userPartitionInfo: null, + }, + isPending: false, + }); + render(); + await user.click(screen.getByRole('tab', { name: /Settings/i })); + expect(screen.getByText('GenericUnitInfoSettings')).toBeInTheDocument(); + }); +}); diff --git a/src/course-outline/outline-sidebar/info-sidebar/UnitInfoSidebar.tsx b/src/course-outline/outline-sidebar/info-sidebar/UnitInfoSidebar.tsx index 2c4a97b1f8..50b9e9ee37 100644 --- a/src/course-outline/outline-sidebar/info-sidebar/UnitInfoSidebar.tsx +++ b/src/course-outline/outline-sidebar/info-sidebar/UnitInfoSidebar.tsx @@ -11,12 +11,14 @@ import { getItemIcon } from '@src/generic/block-type-utils'; import { SidebarTitle } from '@src/generic/sidebar'; -import { useCourseItemData } from '@src/course-outline/data/apiHooks'; +import { courseOutlineQueryKeys, useCourseItemData } from '@src/course-outline/data/apiHooks'; import Loading from '@src/generic/Loading'; import { useCourseAuthoringContext } from '@src/CourseAuthoringContext'; import XBlockContainerIframe from '@src/course-unit/xblock-container-iframe'; import { IframeProvider } from '@src/generic/hooks/context/iFrameContext'; import { Link } from 'react-router-dom'; +import { GenericUnitInfoSettings } from '@src/course-unit/unit-sidebar/unit-info/GenericUnitInfoSettings'; +import { useQueryClient } from '@tanstack/react-query'; import { useOutlineSidebarContext } from '../OutlineSidebarContext'; import { PublishButon } from './PublishButon'; import messages from '../messages'; @@ -26,10 +28,37 @@ interface Props { unitId: string; } +const UnitSettingsTab = ({ unitId }: Props) => { + const queryClient = useQueryClient(); + const { data: unitData, isPending } = useCourseItemData(unitId); + const { selectedContainerState } = useOutlineSidebarContext(); + + if (isPending || !unitData) { + return ; + } + + const onUpdate = () => { + queryClient.invalidateQueries({ queryKey: courseOutlineQueryKeys.courseItemId(unitId) }); + }; + + return ( + + ); +}; + export const UnitSidebar = ({ unitId }: Props) => { const intl = useIntl(); const [tab, setTab] = useState<'preview' | 'info' | 'settings'>('info'); - const { data: unitData, isLoading } = useCourseItemData(unitId); + const { data: unitData, isPending } = useCourseItemData(unitId); const { selectedContainerState, clearSelection } = useOutlineSidebarContext(); const { openPublishModal, getUnitUrl, courseId } = useCourseAuthoringContext(); @@ -43,7 +72,7 @@ export const UnitSidebar = ({ unitId }: Props) => { } }; - if (isLoading) { + if (isPending) { return ; } @@ -71,7 +100,7 @@ export const UnitSidebar = ({ unitId }: Props) => { { isUnitVerticalType={false} unitXBlockActions={{ handleDelete: () => {}, handleDuplicate: () => {}, handleUnlink: () => {} }} courseVerticalChildren={[]} - handleConfigureSubmit={() => {}} readonly /> @@ -98,7 +126,7 @@ export const UnitSidebar = ({ unitId }: Props) => { -
Settings
+
diff --git a/src/course-outline/outline-sidebar/info-sidebar/messages.ts b/src/course-outline/outline-sidebar/info-sidebar/messages.ts new file mode 100644 index 0000000000..273896e2db --- /dev/null +++ b/src/course-outline/outline-sidebar/info-sidebar/messages.ts @@ -0,0 +1,116 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + subsectionReleaseTitle: { + id: 'course-authoring.course-outline.sidebar.library.subsection-settings.release.title', + defaultMessage: 'Release Date and Time', + description: 'Release data time section title in subsection settings sidebar.', + }, + releaseDateLabel: { + id: 'course-authoring.course-outline.sidebar.library.subsection-settings.release.date-label', + defaultMessage: 'Release Date', + description: 'Release data time section label in subsection settings sidebar.', + }, + releaseTimeLabel: { + id: 'course-authoring.course-outline.sidebar.library.subsection-settings.release.time-label', + defaultMessage: 'Release Time (UTC)', + description: 'Release data time section label in subsection settings sidebar.', + }, + subsectionGradingTitle: { + id: 'course-authoring.course-outline.sidebar.library.subsection-settings.grading.title', + defaultMessage: 'Subsection Grading', + description: 'Subsection Grading section title in subsection settings sidebar', + }, + subsectionGradingUngradedBtn: { + id: 'course-authoring.course-outline.sidebar.library.subsection-settings.grading.ungraded-btn', + defaultMessage: 'Ungraded', + description: 'Subsection Grading section Ungraded button text in subsection settings sidebar', + }, + subsectionGradingGradedBtn: { + id: 'course-authoring.course-outline.sidebar.library.subsection-settings.grading.graded-btn', + defaultMessage: 'Graded', + description: 'Subsection Grading section Graded button text in subsection settings sidebar', + }, + subsectionGradingDropdownLabel: { + id: 'course-authoring.course-outline.sidebar.library.subsection-settings.grading.dropdown-label', + defaultMessage: 'Grade as:', + description: 'Dropdown label for selecting assignment type in subsection settings sidebar', + }, + subsectionGradingDropdownPlaceholder: { + id: 'course-authoring.course-outline.sidebar.library.subsection-settings.grading.dropdown-placeholder', + defaultMessage: 'Select Assignment Type', + description: 'Dropdown placeholder for selecting assignment type in subsection settings sidebar', + }, + subsectionGradingDueDateLabel: { + id: 'course-authoring.course-outline.sidebar.library.subsection-settings.grading.due-date-label', + defaultMessage: 'Due Date', + description: 'Label for Due Date field in subsection settings sidebar', + }, + subsectionGradingDueTimeLabel: { + id: 'course-authoring.course-outline.sidebar.library.subsection-settings.grading.due-time-label', + defaultMessage: 'Due Time (UTC)', + description: 'Label for Due Time field in subsection settings sidebar', + }, + subsectionVisibilityTitle: { + id: 'course-authoring.course-outline.sidebar.library.subsection-settings.visibility.title', + defaultMessage: 'Visibility', + description: 'Subsection visibility section title in subsection settings sidebar', + }, + subsectionVisibilityStudentVisible: { + id: 'course-authoring.course-outline.sidebar.library.subsection-settings.visibility.student-visible', + defaultMessage: 'Student Visible', + description: 'Visibility option for student visibility in subsection settings sidebar', + }, + subsectionVisibilityStaffOnly: { + id: 'course-authoring.course-outline.sidebar.library.subsection-settings.visibility.staff-only', + defaultMessage: 'Staff Only', + description: 'Visibility option for staff only in subsection settings sidebar', + }, + subsectionVisibilityHideAfterDueLabel: { + id: 'course-authoring.course-outline.sidebar.library.subsection-settings.visibility.hideAfterDue', + defaultMessage: 'Hide content after due date', + description: 'Hide content after due date Checkbox label', + }, + subsectionAssessmentResultsTitle: { + id: 'course-authoring.course-outline.sidebar.library.subsection-settings.assessment-results.title', + defaultMessage: 'Assessment Results Visibility', + description: 'Subsection Assessment Results Visibility section title in subsection settings sidebar', + }, + subsectionAssessmentResultsShowBtn: { + id: 'course-authoring.course-outline.sidebar.library.subsection-settings.assessment-results.show-btn', + defaultMessage: 'Show', + description: 'Subsection Assessment Results Visibility section show button text in subsection settings sidebar', + }, + subsectionAssessmentResultsHideBtn: { + id: 'course-authoring.course-outline.sidebar.library.subsection-settings.assessment-results.hide-btn', + defaultMessage: 'Hide', + description: 'Subsection Assessment Results Visibility section hide button text in subsection settings sidebar', + }, + subsectionAssessmentResultsCheckbox: { + id: 'course-authoring.course-outline.sidebar.library.subsection-settings.assessment-results.checkbox', + defaultMessage: 'Only show results after due date', + description: 'Subsection Assessment Results Visibility section checkbox text in subsection settings sidebar', + }, + subsectionSpecialExamTitle: { + id: 'course-authoring.course-outline.sidebar.library.subsection-settings.special-exam.title', + defaultMessage: 'Set as Special Exam', + description: 'Subsection Set as Special Exam section title in subsection settings sidebar', + }, + accessRestrictionsTitle: { + id: 'course-authoring.course-outline.sidebar.library.subsection-settings.access-restrictions.title', + defaultMessage: 'Access Restrictions', + description: 'Title for the Access Restrictions section in subsection settings sidebar', + }, + accessRestrictionsContentGroups: { + id: 'course-authoring.course-outline.sidebar.library.subsection-settings.access-restrictions.content-groups', + defaultMessage: 'Content Groups', + description: 'Label for the Content Groups dropdown in access restrictions section', + }, + accessRestrictionsSelectGroupsLabel: { + id: 'course-authoring.course-outline.sidebar.library.subsection-settings.access-restrictions.select-groups-label', + defaultMessage: 'Select one or more groups:', + description: 'Label for selecting groups in the access restrictions section', + }, +}); + +export default messages; diff --git a/src/course-outline/outline-sidebar/info-sidebar/sharedSettings/ReleaseSection.test.tsx b/src/course-outline/outline-sidebar/info-sidebar/sharedSettings/ReleaseSection.test.tsx new file mode 100644 index 0000000000..9fcc207a50 --- /dev/null +++ b/src/course-outline/outline-sidebar/info-sidebar/sharedSettings/ReleaseSection.test.tsx @@ -0,0 +1,60 @@ +import { initializeMocks, render, screen } from '@src/testUtils'; +import userEvent from '@testing-library/user-event'; + +import { useCourseItemData } from '@src/course-outline/data/apiHooks'; +import { ReleaseSection } from './ReleaseSection'; + +// Make useStateWithCallback synchronous so callbacks call onChange immediately +jest.mock('@src/hooks', () => ({ + useStateWithCallback: (defaultValue: any, cb?: any) => { + const { useState } = jest.requireActual('react'); + const [state, setState] = useState(defaultValue); + const wrappedSetState = (val: any) => { + const newVal = typeof val === 'function' ? val(state) : val; + setState(newVal); + if (cb) { cb(newVal); } + }; + return [state, wrappedSetState]; + }, +})); + +// Mock DatepickerControl so we can trigger onChange easily +jest.mock('@src/generic/datepicker-control', () => ({ + DATEPICKER_TYPES: { date: 'date', time: 'time' }, + DatepickerControl: ({ onChange, type }: any) => ( + + ), +})); + +jest.mock('@src/course-outline/data/apiHooks', () => ({ + useCourseItemData: jest.fn(), +})); + +const mockUseCourseItemData = useCourseItemData as jest.Mock; + +describe('ReleaseSection', () => { + beforeEach(() => { + initializeMocks(); + mockUseCourseItemData.mockReturnValue({ data: { start: null } }); + }); + + it('renders date and time pickers', () => { + render(); + expect(screen.getByRole('button', { name: 'date' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'time' })).toBeInTheDocument(); + }); + + it('calls onChange when pickers change', async () => { + const user = userEvent.setup(); + const onChange = jest.fn(); + render(); + + await user.click(screen.getByRole('button', { name: 'date' })); + expect(onChange).toHaveBeenCalledWith('2025-12-31'); + + await user.click(screen.getByRole('button', { name: 'time' })); + expect(onChange).toHaveBeenCalledWith('12:00'); + }); +}); diff --git a/src/course-outline/outline-sidebar/info-sidebar/sharedSettings/ReleaseSection.tsx b/src/course-outline/outline-sidebar/info-sidebar/sharedSettings/ReleaseSection.tsx new file mode 100644 index 0000000000..45c48d45d7 --- /dev/null +++ b/src/course-outline/outline-sidebar/info-sidebar/sharedSettings/ReleaseSection.tsx @@ -0,0 +1,45 @@ +import { useIntl } from '@edx/frontend-platform/i18n'; +import { Stack } from '@openedx/paragon'; +import { useCourseItemData } from '@src/course-outline/data/apiHooks'; +import { DatepickerControl, DATEPICKER_TYPES } from '@src/generic/datepicker-control'; +import { SidebarSection } from '@src/generic/sidebar'; +import { useStateWithCallback } from '@src/hooks'; +import messages from '../messages'; + +interface Props { + itemId: string; + onChange: (val?: string) => void; +} + +export const ReleaseSection = ({ itemId, onChange }: Props) => { + const intl = useIntl(); + const { data: itemData } = useCourseItemData(itemId); + const [localState, setLocalState] = useStateWithCallback( + itemData?.start, + (val) => onChange(val), + ); + + return ( + + + + + + + + ); +}; diff --git a/src/course-outline/outline-sidebar/info-sidebar/sharedSettings/VisibilitySection.test.tsx b/src/course-outline/outline-sidebar/info-sidebar/sharedSettings/VisibilitySection.test.tsx new file mode 100644 index 0000000000..23d226f205 --- /dev/null +++ b/src/course-outline/outline-sidebar/info-sidebar/sharedSettings/VisibilitySection.test.tsx @@ -0,0 +1,79 @@ +import { + initializeMocks, render, screen, waitFor, +} from '@src/testUtils'; +import userEvent from '@testing-library/user-event'; +import { useCourseItemData } from '@src/course-outline/data/apiHooks'; +import { VisibilityTypes } from '@src/data/constants'; +import { VisibilitySection } from './VisibilitySection'; + +jest.mock('@src/course-outline/data/apiHooks', () => ({ + ...jest.requireActual('@src/course-outline/data/apiHooks'), + useCourseItemData: jest.fn(), +})); + +const mockUseCourseItemData = useCourseItemData as jest.Mock; + +const defaultProps = { + itemId: 'block-v1:course+type@sequential+block@test', + isSubsection: true, + onChange: jest.fn(), +}; + +describe('VisibilitySection component', () => { + beforeEach(() => { + initializeMocks(); + mockUseCourseItemData.mockReturnValue({ data: undefined }); + }); + + it('renders title and buttons', async () => { + render(); + expect(await screen.findByText('Visibility')).toBeInTheDocument(); + expect(await screen.findByRole('button', { name: 'Student Visible' })).toBeInTheDocument(); + expect(await screen.findByRole('button', { name: 'Staff Only' })).toBeInTheDocument(); + }); + + it('clicking staff only calls onChange with staff and hideAfterDue false', async () => { + const user = userEvent.setup(); + const onChange = jest.fn(); + render(); + + await user.click(await screen.findByRole('button', { name: 'Staff Only' })); + await waitFor(async () => { + expect(onChange).toHaveBeenCalledWith({ isVisibleToStaffOnly: true, hideAfterDue: false }); + }); + }); + + it('clicking student visible calls onChange with isVisibleToStaffOnly false', async () => { + const user = userEvent.setup(); + const onChange = jest.fn(); + mockUseCourseItemData.mockReturnValue({ data: { visibilityState: VisibilityTypes.STAFF_ONLY } }); + render(); + + await user.click(await screen.findByRole('button', { name: 'Student Visible' })); + await waitFor(async () => { + expect(onChange).toHaveBeenCalledWith({ isVisibleToStaffOnly: false }); + }); + }); + + it('shows checkbox when subsection and not staff only, and toggling it calls onChange', async () => { + const user = userEvent.setup(); + const onChange = jest.fn(); + // initial data not staff only + mockUseCourseItemData.mockReturnValue({ data: { visibilityState: undefined, hideAfterDue: false } }); + render(); + + const checkbox = await screen.findByRole('checkbox'); + await user.click(checkbox); + await waitFor(async () => { + expect(onChange).toHaveBeenCalledWith({ hideAfterDue: true, isVisibleToStaffOnly: false }); + }); + }); + + it('hides checkbox when staff visible', async () => { + const onChange = jest.fn(); + // when item is staff only, checkbox should not be present + mockUseCourseItemData.mockReturnValue({ data: { visibilityState: VisibilityTypes.STAFF_ONLY } }); + render(); + expect(screen.queryByRole('checkbox')).not.toBeInTheDocument(); + }); +}); diff --git a/src/course-outline/outline-sidebar/info-sidebar/sharedSettings/VisibilitySection.tsx b/src/course-outline/outline-sidebar/info-sidebar/sharedSettings/VisibilitySection.tsx new file mode 100644 index 0000000000..d8c1807fad --- /dev/null +++ b/src/course-outline/outline-sidebar/info-sidebar/sharedSettings/VisibilitySection.tsx @@ -0,0 +1,75 @@ +import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n'; +import { Button, ButtonGroup, Form } from '@openedx/paragon'; +import { useCourseItemData } from '@src/course-outline/data/apiHooks'; +import { ConfigureSubsectionData } from '@src/course-outline/data/types'; +import { VisibilityTypes } from '@src/data/constants'; +import { SidebarSection } from '@src/generic/sidebar'; +import { useStateWithCallback } from '@src/hooks'; +import messages from '../messages'; + +interface Props> { + itemId: string; + isSubsection?: boolean; + onChange: (variables: T) => void; +} + +interface State { + isVisibleToStaffOnly?: boolean; + hideAfterDue?: boolean; +} + +export const VisibilitySection = ({ itemId, isSubsection, onChange }: Props) => { + const intl = useIntl(); + const { data: itemData } = useCourseItemData(itemId); + const [localState, setLocalState] = useStateWithCallback( + { + isVisibleToStaffOnly: itemData?.visibilityState === VisibilityTypes.STAFF_ONLY, + hideAfterDue: itemData?.hideAfterDue, + }, + (val) => { + if (val && !isSubsection) { + // eslint-disable-next-line no-param-reassign + val.hideAfterDue = undefined; + } + return onChange(val || {}); + }, + ); + + return ( + + + + + + {isSubsection && !localState?.isVisibleToStaffOnly && ( + ) => setLocalState((prev) => ({ + ...prev, + hideAfterDue: e.target.checked, + isVisibleToStaffOnly: false, + }))} + > + + + )} + + ); +}; diff --git a/src/course-unit/CourseUnit.test.tsx b/src/course-unit/CourseUnit.test.tsx index cc84dc4e0d..d7ff9fac1e 100644 --- a/src/course-unit/CourseUnit.test.tsx +++ b/src/course-unit/CourseUnit.test.tsx @@ -33,6 +33,7 @@ import { } from '@src/library-authoring/data/api.mocks'; import { mockContentSearchConfig } from '@src/search-manager/data/api.mock'; +import { getCourseItemApiUrl } from '@src/course-outline/data/api'; import { getCourseSectionVerticalApiUrl, getCourseVerticalChildrenApiUrl, @@ -43,7 +44,6 @@ import { import { createNewCourseXBlock, deleteUnitItemQuery, - editCourseUnitVisibilityAndData, fetchCourseSectionVerticalData, fetchCourseVerticalChildrenData, getCourseOutlineInfoQuery, @@ -82,7 +82,7 @@ let store; let mockShowToast; let mockCloseToast; const courseId = '123'; -const blockId = '567890'; +const blockId = courseSectionVerticalMock.xblock_info.id; const sequenceId = 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@19a30717eff543078a5d94ae9d6c18a5'; const unitDisplayName = courseSectionVerticalMock.xblock_info.display_name; const mockedUsedNavigate = jest.fn(); @@ -105,8 +105,8 @@ const postXBlockBody = { staged_content: 'clipboard', }; -jest.mock('react-router-dom', () => ({ - ...jest.requireActual('react-router-dom'), +jest.mock('react-router', () => ({ + ...jest.requireActual('react-router'), useParams: () => ({ blockId, sequenceId }), useNavigate: () => mockedUsedNavigate, })); @@ -177,13 +177,13 @@ describe('', () => { const currentSubSectionName = courseSectionVerticalMock.xblock_info.ancestor_info.ancestors[1].display_name; const unitHeaderTitle = await screen.findByTestId('unit-header-title'); - expect(screen.getByText(unitDisplayName)).toBeInTheDocument(); + expect(await screen.findByText(unitDisplayName)).toBeInTheDocument(); expect(within(unitHeaderTitle).getByRole('button', { name: headerTitleMessages.altButtonEdit.defaultMessage })).toBeInTheDocument(); expect(within(unitHeaderTitle).getByRole('button', { name: headerTitleMessages.altButtonSettings.defaultMessage })).toBeInTheDocument(); - expect(screen.getByRole('button', { name: headerNavigationsMessages.viewLiveButton.defaultMessage })).toBeInTheDocument(); - expect(screen.getByRole('button', { name: headerNavigationsMessages.previewButton.defaultMessage })).toBeInTheDocument(); - expect(screen.getByRole('button', { name: currentSectionName })).toBeInTheDocument(); - expect(screen.getByRole('button', { name: currentSubSectionName })).toBeInTheDocument(); + expect(await screen.findByRole('button', { name: headerNavigationsMessages.viewLiveButton.defaultMessage })).toBeInTheDocument(); + expect(await screen.findByRole('button', { name: headerNavigationsMessages.previewButton.defaultMessage })).toBeInTheDocument(); + expect(await screen.findByRole('button', { name: currentSectionName })).toBeInTheDocument(); + expect(await screen.findByRole('button', { name: currentSubSectionName })).toBeInTheDocument(); }); it('renders the course unit iframe with correct attributes', async () => { @@ -377,7 +377,8 @@ describe('', () => { published_by: userName, }, }); - await executeThunk(editCourseUnitVisibilityAndData(blockId, PUBLISH_TYPES.makePublic, true), store.dispatch); + const publishBtn = await screen.findByRole('button', { name: /Publish/ }); + await user.click(publishBtn); // check if the sidebar status is Published and Live expect(await screen.findByText( @@ -388,7 +389,9 @@ describe('', () => { .replace('{publishedOn}', courseSectionVerticalMock.xblock_info.published_on) .replace('{publishedBy}', userName), )).toBeInTheDocument(); - expect(screen.queryByRole('button', { name: legacySidebarMessages.actionButtonPublishTitle.defaultMessage })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { + name: legacySidebarMessages.actionButtonPublishTitle.defaultMessage, + })).not.toBeInTheDocument(); expect(await screen.findByText(unitDisplayName)).toBeInTheDocument(); axiosMock @@ -419,7 +422,7 @@ describe('', () => { axiosMock .onGet(getCourseSectionVerticalApiUrl(blockId)) .reply(200, courseSectionVerticalMock); - await executeThunk(editCourseUnitVisibilityAndData(blockId, PUBLISH_TYPES.makePublic, true), store.dispatch); + await user.click(publishBtn); expect(await screen.findByTitle( xblockContainerIframeMessages.xblockIframeTitle.defaultMessage, @@ -481,17 +484,49 @@ describe('', () => { }); it('checks if xblock is a duplicate when the corresponding duplicate button is clicked and if the sidebar status is updated', async () => { - const user = userEvent.setup(); - render(); + axiosMock + .onGet(getCourseSectionVerticalApiUrl(blockId)) + .reply(200, { + ...courseSectionVerticalMock, + xblock_info: { + ...courseSectionVerticalMock.xblock_info, + visibility_state: UNIT_VISIBILITY_STATES.live, + has_changes: false, + published_by: userName, + }, + }); - simulatePostMessageEvent(messageTypes.duplicateXBlock, { - id: courseVerticalChildrenMock.children[0].block_id, - }); + render(); axiosMock .onPost(postXBlockBaseApiUrl()) .replyOnce(200, { locator: '1234567890' }); + axiosMock + .onPost(getCourseItemApiUrl(blockId), { + publish: PUBLISH_TYPES.makePublic, + }) + .reply(200, { dummy: 'value' }); + + const iframe = await screen.findByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage); + expect(iframe).toHaveAttribute( + 'aria-label', + xblockContainerIframeMessages.xblockIframeLabel.defaultMessage + .replace('{xblockCount}', courseVerticalChildrenMock.children.length.toString()), + ); + + // check if the sidebar status is Published and Live + expect( + await screen.findByText(legacySidebarMessages.sidebarTitlePublishedAndLive.defaultMessage), + ).toBeInTheDocument(); + expect(await screen.findByText( + unitInfoMessages.publishLastPublished.defaultMessage + .replace('{publishedOn}', courseSectionVerticalMock.xblock_info.published_on) + .replace('{publishedBy}', userName), + )).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: legacySidebarMessages.actionButtonPublishTitle.defaultMessage })).not.toBeInTheDocument(); + expect(await screen.findByText(unitDisplayName)).toBeInTheDocument(); + const updatedCourseVerticalChildren = [ ...courseVerticalChildrenMock.children, { @@ -505,7 +540,6 @@ describe('', () => { }, }, ]; - axiosMock .onGet(getCourseVerticalChildrenApiUrl(blockId)) .reply(200, { @@ -513,55 +547,13 @@ describe('', () => { children: updatedCourseVerticalChildren, }); - await user.click( - await screen.findByRole('button', { name: legacySidebarMessages.actionButtonPublishTitle.defaultMessage }), - ); - - const iframe = screen.getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage); - expect(iframe).toHaveAttribute( - 'aria-label', - xblockContainerIframeMessages.xblockIframeLabel.defaultMessage - .replace('{xblockCount}', courseVerticalChildrenMock.children.length.toString()), - ); - simulatePostMessageEvent(messageTypes.duplicateXBlock, { id: courseVerticalChildrenMock.children[0].block_id, }); - axiosMock - .onPost(getXBlockBaseApiUrl(blockId), { - publish: PUBLISH_TYPES.makePublic, - }) - .reply(200, { dummy: 'value' }); - axiosMock - .onGet(getCourseSectionVerticalApiUrl(blockId)) - .reply(200, { - ...courseSectionVerticalMock, - xblock_info: { - ...courseSectionVerticalMock.xblock_info, - visibility_state: UNIT_VISIBILITY_STATES.live, - has_changes: false, - published_by: userName, - }, - }); - await executeThunk(editCourseUnitVisibilityAndData(blockId, PUBLISH_TYPES.makePublic, true), store.dispatch); - - await waitFor(() => { - // check if the sidebar status is Published and Live - expect(screen.getByText(legacySidebarMessages.sidebarTitlePublishedAndLive.defaultMessage)).toBeInTheDocument(); - expect(screen.getByText( - unitInfoMessages.publishLastPublished.defaultMessage - .replace('{publishedOn}', courseSectionVerticalMock.xblock_info.published_on) - .replace('{publishedBy}', userName), - )).toBeInTheDocument(); - expect(screen.queryByRole('button', { name: legacySidebarMessages.actionButtonPublishTitle.defaultMessage })).not.toBeInTheDocument(); - expect(screen.getByText(unitDisplayName)).toBeInTheDocument(); - }); - axiosMock .onGet(getCourseSectionVerticalApiUrl(blockId)) .reply(200, courseSectionVerticalMock); - await executeThunk(editCourseUnitVisibilityAndData(blockId, PUBLISH_TYPES.makePublic, true), store.dispatch); const xblockIframe = await screen.findByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage); expect(xblockIframe).toHaveAttribute( @@ -571,21 +563,23 @@ describe('', () => { ); // after duplicate the xblock, the sidebar status changes to Draft (unpublished changes) - expect(screen.getByText( + expect(await screen.findByText( legacySidebarMessages.sidebarTitleDraftUnpublishedChanges.defaultMessage, )).toBeInTheDocument(); - expect(screen.getByText(legacySidebarMessages.releaseStatusTitle.defaultMessage)).toBeInTheDocument(); - expect(screen.getByText(unitInfoMessages.visibilityVisibleToTitle.defaultMessage)).toBeInTheDocument(); - expect(screen.getByText(unitInfoMessages.visibilityCheckboxTitle.defaultMessage)).toBeInTheDocument(); - expect(screen.getByText(legacySidebarMessages.actionButtonPublishTitle.defaultMessage)).toBeInTheDocument(); - expect(screen.getByText(legacySidebarMessages.actionButtonDiscardChangesTitle.defaultMessage)).toBeInTheDocument(); - expect(screen.getByText(courseSectionVerticalMock.xblock_info.release_date)).toBeInTheDocument(); - expect(screen.getByText( + expect(await screen.findByText(legacySidebarMessages.releaseStatusTitle.defaultMessage)).toBeInTheDocument(); + expect(await screen.findByText(unitInfoMessages.visibilityVisibleToTitle.defaultMessage)).toBeInTheDocument(); + expect(await screen.findByText(unitInfoMessages.visibilityCheckboxTitle.defaultMessage)).toBeInTheDocument(); + expect(await screen.findByText(legacySidebarMessages.actionButtonPublishTitle.defaultMessage)).toBeInTheDocument(); + expect( + await screen.findByText(legacySidebarMessages.actionButtonDiscardChangesTitle.defaultMessage), + ).toBeInTheDocument(); + expect(await screen.findByText(courseSectionVerticalMock.xblock_info.release_date)).toBeInTheDocument(); + expect(await screen.findByText( unitInfoMessages.publishInfoDraftSaved.defaultMessage .replace('{editedOn}', courseSectionVerticalMock.xblock_info.edited_on) .replace('{editedBy}', courseSectionVerticalMock.xblock_info.edited_by), )).toBeInTheDocument(); - expect(screen.getByText( + expect(await screen.findByText( legacySidebarMessages.releaseInfoWithSection.defaultMessage .replace('{sectionName}', courseSectionVerticalMock.xblock_info.release_date_from), )).toBeInTheDocument(); @@ -696,8 +690,6 @@ describe('', () => { .reply(200, courseCreateXblockMock); render(); - await user.click(await screen.findByRole('button', { name: legacySidebarMessages.actionButtonPublishTitle.defaultMessage })); - axiosMock .onPost(getXBlockBaseApiUrl(blockId), { publish: PUBLISH_TYPES.makePublic, @@ -714,8 +706,33 @@ describe('', () => { published_by: userName, }, }); + const updatedCourseVerticalChildren = [ + ...courseVerticalChildrenMock.children, + { + name: 'Copy XBlock', + block_id: '1234567890', + block_type: 'drag-and-drop-v2', + user_partition_info: { + selectable_partitions: [], + selected_partition_index: -1, + selected_groups_label: '', + }, + }, + ]; + + axiosMock + .onGet(getCourseVerticalChildrenApiUrl(blockId)) + .reply(200, { + ...courseVerticalChildrenMock, + children: updatedCourseVerticalChildren, + }); - await executeThunk(editCourseUnitVisibilityAndData(blockId, PUBLISH_TYPES.makePublic, true), store.dispatch); + const publishBtn = await screen.findByRole('button', { name: /Publish/ }); + await user.click(publishBtn); + + axiosMock + .onGet(getCourseSectionVerticalApiUrl(blockId)) + .reply(200, courseSectionVerticalMock); const problemButton = await screen.findByRole('button', { name: new RegExp(`problem ${addComponentMessages.buttonText.defaultMessage} Problem`, 'i'), @@ -728,24 +745,24 @@ describe('', () => { .onGet(getCourseSectionVerticalApiUrl(blockId)) .reply(200, courseSectionVerticalMock); - await executeThunk(editCourseUnitVisibilityAndData(blockId, PUBLISH_TYPES.makePublic, true), store.dispatch); - // after creating problem xblock, the sidebar status changes to Draft (unpublished changes) - expect(screen.getByText( + expect(await screen.findByText( legacySidebarMessages.sidebarTitleDraftUnpublishedChanges.defaultMessage, )).toBeInTheDocument(); - expect(screen.getByText(legacySidebarMessages.releaseStatusTitle.defaultMessage)).toBeInTheDocument(); - expect(screen.getByText(unitInfoMessages.visibilityVisibleToTitle.defaultMessage)).toBeInTheDocument(); - expect(screen.getByText(unitInfoMessages.visibilityCheckboxTitle.defaultMessage)).toBeInTheDocument(); - expect(screen.getByText(legacySidebarMessages.actionButtonPublishTitle.defaultMessage)).toBeInTheDocument(); - expect(screen.getByText(legacySidebarMessages.actionButtonDiscardChangesTitle.defaultMessage)).toBeInTheDocument(); - expect(screen.getByText(courseSectionVerticalMock.xblock_info.release_date)).toBeInTheDocument(); - expect(screen.getByText( + expect(await screen.findByText(legacySidebarMessages.releaseStatusTitle.defaultMessage)).toBeInTheDocument(); + expect(await screen.findByText(unitInfoMessages.visibilityVisibleToTitle.defaultMessage)).toBeInTheDocument(); + expect(await screen.findByText(unitInfoMessages.visibilityCheckboxTitle.defaultMessage)).toBeInTheDocument(); + expect(await screen.findByText(legacySidebarMessages.actionButtonPublishTitle.defaultMessage)).toBeInTheDocument(); + expect( + await screen.findByText(legacySidebarMessages.actionButtonDiscardChangesTitle.defaultMessage), + ).toBeInTheDocument(); + expect(await screen.findByText(courseSectionVerticalMock.xblock_info.release_date)).toBeInTheDocument(); + expect(await screen.findByText( unitInfoMessages.publishInfoDraftSaved.defaultMessage .replace('{editedOn}', courseSectionVerticalMock.xblock_info.edited_on) .replace('{editedBy}', courseSectionVerticalMock.xblock_info.edited_by), )).toBeInTheDocument(); - expect(screen.getByText( + expect(await screen.findByText( legacySidebarMessages.releaseInfoWithSection.defaultMessage .replace('{sectionName}', courseSectionVerticalMock.xblock_info.release_date_from), )).toBeInTheDocument(); @@ -897,7 +914,7 @@ describe('', () => { }, }); - await executeThunk(editCourseUnitVisibilityAndData(blockId, PUBLISH_TYPES.makePublic, true), store.dispatch); + await user.click(publishButton); await waitFor(() => { // check if the sidebar status is Published and Live @@ -911,6 +928,10 @@ describe('', () => { )).toBeInTheDocument(); expect(screen.queryByRole('button', { name: legacySidebarMessages.actionButtonPublishTitle.defaultMessage })).not.toBeInTheDocument(); + axiosMock + .onGet(getCourseSectionVerticalApiUrl(blockId)) + .reply(200, courseSectionVerticalMock); + const videoButton = screen.getByRole('button', { name: new RegExp(`${addComponentMessages.buttonText.defaultMessage} Video`, 'i'), hidden: true, @@ -918,32 +939,31 @@ describe('', () => { await user.click(videoButton); - axiosMock - .onGet(getCourseSectionVerticalApiUrl(blockId)) - .reply(200, courseSectionVerticalMock); - - await executeThunk(editCourseUnitVisibilityAndData(blockId, PUBLISH_TYPES.makePublic, true), store.dispatch); - // after creating video xblock, the sidebar status changes to Draft (unpublished changes) - expect(screen.getByText( + expect(await screen.findByText( legacySidebarMessages.sidebarTitleDraftUnpublishedChanges.defaultMessage, )).toBeInTheDocument(); - expect(screen.getByText(legacySidebarMessages.releaseStatusTitle.defaultMessage)).toBeInTheDocument(); - expect(screen.getByText(unitInfoMessages.visibilityVisibleToTitle.defaultMessage)).toBeInTheDocument(); - expect(screen.getByText(unitInfoMessages.visibilityCheckboxTitle.defaultMessage)).toBeInTheDocument(); - expect(screen.getByText(legacySidebarMessages.actionButtonPublishTitle.defaultMessage)).toBeInTheDocument(); - expect(screen.getByText(legacySidebarMessages.actionButtonDiscardChangesTitle.defaultMessage)).toBeInTheDocument(); - expect(screen.getByText(courseSectionVerticalMock.xblock_info.release_date)).toBeInTheDocument(); - expect(screen.getByText( + expect(await screen.findByText(legacySidebarMessages.releaseStatusTitle.defaultMessage)).toBeInTheDocument(); + expect(await screen.findByText(unitInfoMessages.visibilityVisibleToTitle.defaultMessage)).toBeInTheDocument(); + expect(await screen.findByText(unitInfoMessages.visibilityCheckboxTitle.defaultMessage)).toBeInTheDocument(); + expect(await screen.findByText(legacySidebarMessages.actionButtonPublishTitle.defaultMessage)).toBeInTheDocument(); + expect( + await screen.findByText(legacySidebarMessages.actionButtonDiscardChangesTitle.defaultMessage), + ).toBeInTheDocument(); + expect(await screen.findByText(courseSectionVerticalMock.xblock_info.release_date)).toBeInTheDocument(); + expect(await screen.findByText( unitInfoMessages.publishInfoDraftSaved.defaultMessage .replace('{editedOn}', courseSectionVerticalMock.xblock_info.edited_on) .replace('{editedBy}', courseSectionVerticalMock.xblock_info.edited_by), )).toBeInTheDocument(); - expect(screen.getByText( + expect(await screen.findByText( legacySidebarMessages.releaseInfoWithSection.defaultMessage .replace('{sectionName}', courseSectionVerticalMock.xblock_info.release_date_from), )).toBeInTheDocument(); - expect(screen.getByRole('heading', { name: /add video to your course/i, hidden: true })).toBeInTheDocument(); + expect(await screen.findByRole('heading', { + name: /add video to your course/i, + hidden: true, + })).toBeInTheDocument(); waffleSpy.mockRestore(); }); @@ -976,26 +996,31 @@ describe('', () => { }, }); - await executeThunk(editCourseUnitVisibilityAndData(blockId, PUBLISH_TYPES.makePublic, true), store.dispatch); + const publishButton = await screen.findByRole('button', { name: legacySidebarMessages.actionButtonPublishTitle.defaultMessage }); + await user.click(publishButton); - await waitFor(async () => { - // check if the sidebar status is Published and Live - expect(screen.getByText(legacySidebarMessages.sidebarTitlePublishedAndLive.defaultMessage)).toBeInTheDocument(); - expect(screen.getByText( - unitInfoMessages.publishLastPublished.defaultMessage - .replace('{publishedOn}', courseSectionVerticalMock.xblock_info.published_on) - .replace('{publishedBy}', userName), - )).toBeInTheDocument(); - expect(screen.queryByRole('button', { name: legacySidebarMessages.actionButtonPublishTitle.defaultMessage })).not.toBeInTheDocument(); + // check if the sidebar status is Published and Live + expect( + await screen.findByText(legacySidebarMessages.sidebarTitlePublishedAndLive.defaultMessage), + ).toBeInTheDocument(); + expect(await screen.findByText( + unitInfoMessages.publishLastPublished.defaultMessage + .replace('{publishedOn}', courseSectionVerticalMock.xblock_info.published_on) + .replace('{publishedBy}', userName), + )).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: legacySidebarMessages.actionButtonPublishTitle.defaultMessage })).not.toBeInTheDocument(); - const videoButton = screen.getByRole('button', { - name: new RegExp(`${addComponentMessages.buttonText.defaultMessage} Video`, 'i'), - hidden: true, - }); + axiosMock + .onGet(getCourseSectionVerticalApiUrl(blockId)) + .reply(200, courseSectionVerticalMock); - await user.click(videoButton); + const videoButton = await screen.findByRole('button', { + name: new RegExp(`${addComponentMessages.buttonText.defaultMessage} Video`, 'i'), + hidden: true, }); + await user.click(videoButton); + /** TODO -- fix this test. await waitFor(() => { expect(getByRole('textbox', { name: /paste your video id or url/i })).toBeInTheDocument(); @@ -1006,24 +1031,24 @@ describe('', () => { .onGet(getCourseSectionVerticalApiUrl(blockId)) .reply(200, courseSectionVerticalMock); - await executeThunk(editCourseUnitVisibilityAndData(blockId, PUBLISH_TYPES.makePublic, true), store.dispatch); - // after creating video xblock, the sidebar status changes to Draft (unpublished changes) - expect(screen.getByText( + expect(await screen.findByText( legacySidebarMessages.sidebarTitleDraftUnpublishedChanges.defaultMessage, )).toBeInTheDocument(); - expect(screen.getByText(legacySidebarMessages.releaseStatusTitle.defaultMessage)).toBeInTheDocument(); - expect(screen.getByText(unitInfoMessages.visibilityVisibleToTitle.defaultMessage)).toBeInTheDocument(); - expect(screen.getByText(unitInfoMessages.visibilityCheckboxTitle.defaultMessage)).toBeInTheDocument(); - expect(screen.getByText(legacySidebarMessages.actionButtonPublishTitle.defaultMessage)).toBeInTheDocument(); - expect(screen.getByText(legacySidebarMessages.actionButtonDiscardChangesTitle.defaultMessage)).toBeInTheDocument(); - expect(screen.getByText(courseSectionVerticalMock.xblock_info.release_date)).toBeInTheDocument(); - expect(screen.getByText( + expect(await screen.findByText(legacySidebarMessages.releaseStatusTitle.defaultMessage)).toBeInTheDocument(); + expect(await screen.findByText(unitInfoMessages.visibilityVisibleToTitle.defaultMessage)).toBeInTheDocument(); + expect(await screen.findByText(unitInfoMessages.visibilityCheckboxTitle.defaultMessage)).toBeInTheDocument(); + expect(await screen.findByText(legacySidebarMessages.actionButtonPublishTitle.defaultMessage)).toBeInTheDocument(); + expect( + await screen.findByText(legacySidebarMessages.actionButtonDiscardChangesTitle.defaultMessage), + ).toBeInTheDocument(); + expect(await screen.findByText(courseSectionVerticalMock.xblock_info.release_date)).toBeInTheDocument(); + expect(await screen.findByText( unitInfoMessages.publishInfoDraftSaved.defaultMessage .replace('{editedOn}', courseSectionVerticalMock.xblock_info.edited_on) .replace('{editedBy}', courseSectionVerticalMock.xblock_info.edited_by), )).toBeInTheDocument(); - expect(screen.getByText( + expect(await screen.findByText( legacySidebarMessages.releaseInfoWithSection.defaultMessage .replace('{sectionName}', courseSectionVerticalMock.xblock_info.release_date_from), )).toBeInTheDocument(); @@ -1107,21 +1132,15 @@ describe('', () => { it('should toggle visibility from sidebar and update course unit state accordingly', async () => { const user = userEvent.setup(); render(); - let courseUnitSidebar; - let draftUnpublishedChangesHeading; - let visibilityCheckbox; - - await waitFor(async () => { - courseUnitSidebar = screen.getByTestId('course-unit-sidebar'); + const courseUnitSidebar = await screen.findByTestId('course-unit-sidebar'); - draftUnpublishedChangesHeading = within(courseUnitSidebar) - .getByText(legacySidebarMessages.sidebarTitleDraftUnpublishedChanges.defaultMessage); - expect(draftUnpublishedChangesHeading).toBeInTheDocument(); + const draftUnpublishedChangesHeading = await within(courseUnitSidebar) + .findByText(legacySidebarMessages.sidebarTitleDraftUnpublishedChanges.defaultMessage); + expect(draftUnpublishedChangesHeading).toBeInTheDocument(); - visibilityCheckbox = within(courseUnitSidebar) - .getByLabelText(unitInfoMessages.visibilityCheckboxTitle.defaultMessage); - expect(visibilityCheckbox).not.toBeChecked(); - }); + const visibilityCheckbox = await within(courseUnitSidebar) + .findByLabelText(unitInfoMessages.visibilityCheckboxTitle.defaultMessage); + expect(visibilityCheckbox).not.toBeChecked(); axiosMock .onPost(getXBlockBaseApiUrl(blockId), { @@ -1140,7 +1159,7 @@ describe('', () => { }, }); - await executeThunk(editCourseUnitVisibilityAndData(blockId, PUBLISH_TYPES.republish, true), store.dispatch); + await user.click(visibilityCheckbox); await waitFor(async () => { expect(visibilityCheckbox).toBeChecked(); @@ -1176,9 +1195,15 @@ describe('', () => { .onGet(getCourseSectionVerticalApiUrl(blockId)) .reply(200, courseSectionVerticalMock); - await executeThunk(editCourseUnitVisibilityAndData(blockId, PUBLISH_TYPES.republish, null), store.dispatch); + await user.click(visibilityCheckbox); - expect(visibilityCheckbox).not.toBeChecked(); + await user.click(await within(await screen.findByRole('dialog')).findByRole('button', { + name: unitInfoMessages.modalMakeVisibilityActionButtonText.defaultMessage, + })); + + await waitFor(async () => { + expect(visibilityCheckbox).not.toBeChecked(); + }); expect(draftUnpublishedChangesHeading).toBeInTheDocument(); }); @@ -1213,7 +1238,8 @@ describe('', () => { }, }); - await executeThunk(editCourseUnitVisibilityAndData(blockId, PUBLISH_TYPES.makePublic, true), store.dispatch); + const publishButton = await screen.findByRole('button', { name: legacySidebarMessages.actionButtonPublishTitle.defaultMessage }); + await user.click(publishButton); expect(within(courseUnitSidebar) .getByText(legacySidebarMessages.sidebarTitlePublishedAndLive.defaultMessage)).toBeInTheDocument(); @@ -1225,7 +1251,7 @@ describe('', () => { expect(publishBtn).not.toBeInTheDocument(); }); - it('should discard changes after click on the "Discard changes" button', async () => { + it('should discard changes after click on the Discard changes button', async () => { const user = userEvent.setup(); render(); let courseUnitSidebar; @@ -1273,59 +1299,50 @@ describe('', () => { }, }); - await executeThunk(editCourseUnitVisibilityAndData( - blockId, - PUBLISH_TYPES.discardChanges, - true, - ), store.dispatch); + await user.click(await screen.findByRole('button', { + name: legacySidebarMessages.actionButtonDiscardChangesTitle.defaultMessage, + })); + await user.click(await within(await screen.findByRole('dialog')).findByRole('button', { + name: legacySidebarMessages.actionButtonDiscardChangesTitle.defaultMessage, + })); - expect(within(courseUnitSidebar) - .getByText(legacySidebarMessages.sidebarTitlePublishedNotYetReleased.defaultMessage)).toBeInTheDocument(); + expect(await within(courseUnitSidebar) + .findByText(legacySidebarMessages.sidebarTitlePublishedNotYetReleased.defaultMessage)).toBeInTheDocument(); expect(discardChangesBtn).not.toBeInTheDocument(); }); it('should toggle visibility from header configure modal and update course unit state accordingly', async () => { const user = userEvent.setup(); render(); - let courseUnitSidebar; - let sidebarVisibilityCheckbox; - let modalVisibilityCheckbox; - let configureModal; - let restrictAccessSelect; + expect(await within(await screen.findByTestId('course-unit-sidebar')) + .findByLabelText(unitInfoMessages.visibilityCheckboxTitle.defaultMessage)).not.toBeChecked(); - await waitFor(async () => { - courseUnitSidebar = screen.getByTestId('course-unit-sidebar'); - sidebarVisibilityCheckbox = within(courseUnitSidebar) - .getByLabelText(unitInfoMessages.visibilityCheckboxTitle.defaultMessage); - expect(sidebarVisibilityCheckbox).not.toBeChecked(); + const headerConfigureBtn = await screen.findByRole('button', { name: /settings/i }); + expect(headerConfigureBtn).toBeInTheDocument(); - const headerConfigureBtn = screen.getByRole('button', { name: /settings/i }); - expect(headerConfigureBtn).toBeInTheDocument(); + await user.click(headerConfigureBtn); + const configureModal = await screen.findByTestId('configure-modal'); + const restrictAccessSelect = await within(configureModal) + .findByRole('combobox', { name: configureModalMessages.restrictAccessTo.defaultMessage }); + expect(await within(configureModal) + .findByText(configureModalMessages.unitVisibility.defaultMessage)).toBeInTheDocument(); + expect(await within(configureModal) + .findByText(configureModalMessages.restrictAccessTo.defaultMessage)).toBeInTheDocument(); + expect(restrictAccessSelect).toBeInTheDocument(); + expect(restrictAccessSelect).toHaveValue('-1'); - await user.click(headerConfigureBtn); - configureModal = screen.getByTestId('configure-modal'); - restrictAccessSelect = within(configureModal) - .getByRole('combobox', { name: configureModalMessages.restrictAccessTo.defaultMessage }); - expect(within(configureModal) - .getByText(configureModalMessages.unitVisibility.defaultMessage)).toBeInTheDocument(); - expect(within(configureModal) - .getByText(configureModalMessages.restrictAccessTo.defaultMessage)).toBeInTheDocument(); - expect(restrictAccessSelect).toBeInTheDocument(); - expect(restrictAccessSelect).toHaveValue('-1'); + const modalVisibilityCheckbox = await within(configureModal) + .findByRole('checkbox', { name: configureModalMessages.hideFromLearners.defaultMessage }); + expect(modalVisibilityCheckbox).not.toBeChecked(); - modalVisibilityCheckbox = within(configureModal) - .getByRole('checkbox', { name: configureModalMessages.hideFromLearners.defaultMessage }); - expect(modalVisibilityCheckbox).not.toBeChecked(); + await user.click(modalVisibilityCheckbox); + expect(modalVisibilityCheckbox).toBeChecked(); - await user.click(modalVisibilityCheckbox); - expect(modalVisibilityCheckbox).toBeChecked(); + await user.selectOptions(restrictAccessSelect, '0'); + const [, group1Checkbox] = await within(configureModal).findAllByRole('checkbox'); - await user.selectOptions(restrictAccessSelect, '0'); - const [, group1Checkbox] = within(configureModal).getAllByRole('checkbox'); - - await user.click(group1Checkbox); - expect(group1Checkbox).toBeChecked(); - }); + await user.click(group1Checkbox); + expect(group1Checkbox).toBeChecked(); axiosMock .onPost(getXBlockBaseApiUrl(courseSectionVerticalMock.xblock_info.id), { @@ -1334,7 +1351,10 @@ describe('', () => { }) .reply(200, { dummy: 'value' }); axiosMock - .onGet(getCourseSectionVerticalApiUrl(blockId)) + .onGet(getCourseVerticalChildrenApiUrl(courseSectionVerticalMock.xblock_info.id)) + .reply(200, courseVerticalChildrenMock); + axiosMock + .onGet(getCourseSectionVerticalApiUrl(courseSectionVerticalMock.xblock_info.id)) .reply(200, { ...courseSectionVerticalMock, xblock_info: { @@ -1344,17 +1364,16 @@ describe('', () => { }, }); - const modalSaveBtn = within(configureModal) - .getByRole('button', { name: configureModalMessages.saveButton.defaultMessage }); + const modalSaveBtn = await within(configureModal) + .findByRole('button', { name: configureModalMessages.saveButton.defaultMessage }); await user.click(modalSaveBtn); - await waitFor(() => { - expect(sidebarVisibilityCheckbox).toBeChecked(); - expect(within(courseUnitSidebar) - .getByText(legacySidebarMessages.sidebarTitleVisibleToStaffOnly.defaultMessage)).toBeInTheDocument(); - expect(within(courseUnitSidebar) - .getByText(unitInfoMessages.visibilityStaffOnlyTitle.defaultMessage)).toBeInTheDocument(); - }); + expect(await within(await screen.findByTestId('course-unit-sidebar')) + .findByLabelText(unitInfoMessages.visibilityCheckboxTitle.defaultMessage)).toBeChecked(); + expect(await within(await screen.findByTestId('course-unit-sidebar')) + .findByText(legacySidebarMessages.sidebarTitleVisibleToStaffOnly.defaultMessage)).toBeInTheDocument(); + expect(await within(await screen.findByTestId('course-unit-sidebar')) + .findByText(unitInfoMessages.visibilityStaffOnlyTitle.defaultMessage)).toBeInTheDocument(); }); it('shows the Tags sidebar when enabled', async () => { @@ -2392,6 +2411,10 @@ describe('', () => { }); it('should change the visibility of the unit in the settings sidebar', async () => { + setConfig({ + ...getConfig(), + ENABLE_UNIT_PAGE_NEW_DESIGN: 'true', + }); const user = userEvent.setup(); render(); @@ -2404,23 +2427,20 @@ describe('', () => { // Move to settings expect(await screen.findByRole('heading', { name: /draft \(unpublished changes\)/i })).toBeInTheDocument(); - const settingsTab = screen.getByRole('tab', { name: /settings/i }); + const settingsTab = await screen.findByRole('tab', { name: /settings/i }); expect(settingsTab).toBeInTheDocument(); await user.click(settingsTab); - // Change Visibility to Staff Only - expect(screen.getByRole('heading', { name: /visibility/i })).toBeInTheDocument(); - const staffOnlyButton = screen.getByRole('button', { name: /staff only/i }); - expect(staffOnlyButton).toBeInTheDocument(); - await user.click(staffOnlyButton); - axiosMock .onPost(getXBlockBaseApiUrl(blockId), { publish: PUBLISH_TYPES.republish, - metadata: { visible_to_staff_only: true }, + metadata: { visible_to_staff_only: true, discussion_enabled: true }, }) .reply(200, { dummy: 'value' }); + axiosMock + .onGet(getCourseVerticalChildrenApiUrl(blockId)) + .reply(200, courseVerticalChildrenMock); axiosMock .onGet(getCourseSectionVerticalApiUrl(blockId)) .reply(200, { @@ -2432,24 +2452,21 @@ describe('', () => { }, }); - await executeThunk(editCourseUnitVisibilityAndData(blockId, PUBLISH_TYPES.republish, true), store.dispatch); + // Change Visibility to Staff Only + expect(await screen.findByRole('heading', { name: /visibility/i })).toBeInTheDocument(); + const staffOnlyButton = await screen.findByRole('button', { name: /staff only/i }); + expect(staffOnlyButton).toBeInTheDocument(); + await user.click(staffOnlyButton); + // Move to Details - const detailsTab = screen.getByRole('tab', { name: /details/i }); + const detailsTab = await screen.findByRole('tab', { name: /details/i }); await user.click(detailsTab); - expect(screen.getByRole('heading', { name: /visible to staff only/i })).toBeInTheDocument(); - - // Move to settings and change visibility to all - const editVisibilityButton = screen.getByRole('button', { name: /edit visibility/i }); - await user.click(editVisibilityButton); - const studentVisibleButton = screen.getByRole('button', { name: /student visible/i }); - await user.click(studentVisibleButton); + expect(await screen.findByRole('heading', { name: /visible to staff only/i })).toBeInTheDocument(); axiosMock .onPost(getXBlockBaseApiUrl(blockId), { publish: PUBLISH_TYPES.republish, - metadata: { - visible_to_staff_only: null, - }, + metadata: { visible_to_staff_only: null, discussion_enabled: true }, }) .reply(200, { dummy: 'value' }); axiosMock @@ -2463,13 +2480,11 @@ describe('', () => { }, }); - await executeThunk(editCourseUnitVisibilityAndData(blockId, PUBLISH_TYPES.republish, false), store.dispatch); - - // Move to Details - await user.click(detailsTab); - expect( - screen.getByRole('heading', { name: /draft \(unpublished changes\)/i }), - ).toBeInTheDocument(); + // Move to settings and change visibility to all + const editVisibilityButton = await screen.findByRole('button', { name: /edit visibility/i }); + await user.click(editVisibilityButton); + const studentVisibleButton = await screen.findByRole('button', { name: /student visible/i }); + await user.click(studentVisibleButton); }); it('displays the staff only state in the status bar', async () => { @@ -2516,11 +2531,6 @@ describe('', () => { expect(settingsTab).toBeInTheDocument(); await user.click(settingsTab); - // Disable discussions - const discussionButton = screen.getByRole('checkbox', { name: /enable discussion/i }); - expect(discussionButton).toBeChecked(); - await user.click(discussionButton); - axiosMock .onPost(getXBlockBaseApiUrl(blockId), { publish: PUBLISH_TYPES.republish, @@ -2540,13 +2550,10 @@ describe('', () => { }, }); - await executeThunk(editCourseUnitVisibilityAndData( - blockId, - PUBLISH_TYPES.republish, - false, - null, - false, - ), store.dispatch); + // Disable discussions + const discussionButton = screen.getByRole('checkbox', { name: /enable discussion/i }); + expect(discussionButton).toBeChecked(); + await user.click(discussionButton); expect(discussionButton).not.toBeChecked(); }); diff --git a/src/course-unit/CourseUnit.tsx b/src/course-unit/CourseUnit.tsx index 14fa7f9e54..cce4e5caa8 100644 --- a/src/course-unit/CourseUnit.tsx +++ b/src/course-unit/CourseUnit.tsx @@ -198,7 +198,6 @@ const CourseUnit = () => { handleTitleEditSubmit, headerNavigationsActions, handleTitleEdit, - handleConfigureSubmit, courseVerticalChildren, canPasteComponent, isMoveModalOpen, @@ -297,7 +296,6 @@ const CourseUnit = () => { isTitleEditFormOpen={isTitleEditFormOpen} handleTitleEdit={handleTitleEdit} handleTitleEditSubmit={handleTitleEditSubmit} - handleConfigureSubmit={handleConfigureSubmit} /> )} breadcrumbs={( @@ -352,7 +350,6 @@ const CourseUnit = () => { isUnitVerticalType={isUnitVerticalType} unitXBlockActions={unitXBlockActions} courseVerticalChildren={courseVerticalChildren.children} - handleConfigureSubmit={handleConfigureSubmit} /> )} {!readOnly && showPasteXBlock && canPasteComponent && isUnitVerticalType && sharedClipboardData diff --git a/src/course-unit/data/api.ts b/src/course-unit/data/api.ts index d4474ed5fb..69c99da8d6 100644 --- a/src/course-unit/data/api.ts +++ b/src/course-unit/data/api.ts @@ -1,7 +1,6 @@ import { camelCaseObject, getConfig } from '@edx/frontend-platform'; import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; -import { PUBLISH_TYPES } from '../constants'; import { CourseContainerChildrenData, CourseOutlineData, MoveInfoData } from './types'; import { isUnitImportedFromLib, normalizeCourseSectionVerticalData, updateXBlockBlockIdToId } from './utils'; @@ -41,34 +40,6 @@ export async function getVerticalData(unitId: string): Promise { return courseSectionVerticalData; } -/** - * Handles the visibility and data of a course unit, such as publishing, resetting to default values, - * and toggling visibility to students. - */ -export async function handleCourseUnitVisibilityAndData( - unitId: string, - type: string, // The action type (e.g., PUBLISH_TYPES.discardChanges). - isVisible: boolean, // The visibility status for students. - isDiscussionEnabled: boolean, - groupAccess: Record | null, -): Promise { - const body = { - publish: groupAccess ? null : type, - ...(type === PUBLISH_TYPES.republish ? { - metadata: { - visible_to_staff_only: isVisible ? true : null, - discussion_enabled: isDiscussionEnabled, - ...(groupAccess != null && { group_access: groupAccess }), - }, - } : {}), - }; - - const { data } = await getAuthenticatedHttpClient() - .post(getXBlockBaseApiUrl(unitId), body); - - return camelCaseObject(data); -} - /** * Get an object containing course vertical children data. */ diff --git a/src/course-unit/data/apiHooks.ts b/src/course-unit/data/apiHooks.ts index e07fd10004..05d44a37e7 100644 --- a/src/course-unit/data/apiHooks.ts +++ b/src/course-unit/data/apiHooks.ts @@ -1,6 +1,16 @@ -import { useMutation } from '@tanstack/react-query'; +import { useConfigureUnit } from '@src/course-outline/data/apiHooks'; +import { ConfigureUnitData } from '@src/course-outline/data/types'; +import { fetchCourseSectionVerticalDataSuccess, updateCourseVerticalChildren } from '@src/course-unit/data/slice'; +import { ParentIds } from '@src/generic/types'; +import { DefaultError, useMutation, UseMutationOptions } from '@tanstack/react-query'; +import { useDispatch } from 'react-redux'; -import { acceptLibraryBlockChanges, ignoreLibraryBlockChanges } from './api'; +import { + acceptLibraryBlockChanges, + getCourseContainerChildren, + getVerticalData, + ignoreLibraryBlockChanges, +} from './api'; /** * Hook that provides a "mutation" that can be used to accept library block changes. @@ -17,3 +27,28 @@ export const useAcceptLibraryBlockChanges = () => useMutation({ export const useIgnoreLibraryBlockChanges = () => useMutation({ mutationFn: ignoreLibraryBlockChanges, }); + +/** + * Wrapper around useConfigureUnit that updates unit data after processing + */ +export const useConfigureUnitWithPageUpdates = () => { + const mutationFn = useConfigureUnit(); + const dispatch = useDispatch(); + return { + ...mutationFn, + mutate: (mutationArgs: ConfigureUnitData & ParentIds, options?: UseMutationOptions< + object, + DefaultError, + ConfigureUnitData & ParentIds + >) => mutationFn.mutate(mutationArgs, { + ...options, + onSuccess: async (...onMutateArgs) => { + const courseSectionVerticalData = await getVerticalData(onMutateArgs[1].unitId); + dispatch(fetchCourseSectionVerticalDataSuccess(courseSectionVerticalData)); + const courseVerticalChildrenData = await getCourseContainerChildren(onMutateArgs[1].unitId); + dispatch(updateCourseVerticalChildren(courseVerticalChildrenData)); + options?.onSuccess?.(...onMutateArgs); + }, + }), + }; +}; diff --git a/src/course-unit/data/thunk.js b/src/course-unit/data/thunk.js index 74596e7265..8cc736bb8f 100644 --- a/src/course-unit/data/thunk.js +++ b/src/course-unit/data/thunk.js @@ -11,7 +11,6 @@ import { editUnitDisplayName, getVerticalData, getCourseContainerChildren, - handleCourseUnitVisibilityAndData, deleteUnitItem, duplicateUnitItem, getCourseOutlineInfo, @@ -26,13 +25,11 @@ import { updateLoadingCourseSectionVerticalDataStatus, updateCourseVerticalChildren, updateCourseVerticalChildrenLoadingStatus, - updateQueryPendingStatus, fetchStaticFileNoticesSuccess, updateCourseOutlineInfo, updateCourseOutlineInfoLoadingStatus, updateMovedXBlockParams, } from './slice'; -import { getNotificationMessage } from './utils'; export function fetchCourseSectionVerticalData(courseId, sequenceId) { return async (dispatch) => { @@ -94,48 +91,6 @@ export function editCourseItemQuery(itemId, displayName, sequenceId) { }; } -export function editCourseUnitVisibilityAndData( - itemId, - type, - isVisible, - groupAccess, - isDiscussionEnabled, - callback, - blockId = itemId, -) { - return async (dispatch) => { - dispatch(updateSavingStatus({ status: RequestStatus.PENDING })); - dispatch(updateQueryPendingStatus(true)); - const notification = getNotificationMessage(type, isVisible, true); - showToastOutsideReact(notification); - - try { - await handleCourseUnitVisibilityAndData( - itemId, - type, - isVisible, - isDiscussionEnabled, - groupAccess, - ).then(async (result) => { - if (result) { - if (callback) { - callback(); - } - const courseSectionVerticalData = await getVerticalData(blockId); - dispatch(fetchCourseSectionVerticalDataSuccess(courseSectionVerticalData)); - const courseVerticalChildrenData = await getCourseContainerChildren(blockId); - dispatch(updateCourseVerticalChildren(courseVerticalChildrenData)); - dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL })); - } - }); - } catch (error) { - handleResponseErrors(error, dispatch, updateSavingStatus); - } finally { - closeToastOutsideReact(); - } - }; -} - export function createNewCourseXBlock(body, callback, blockId, sendMessageToIframe) { return async (dispatch) => { if (body.stagedContent) { diff --git a/src/course-unit/header-title/HeaderTitle.test.tsx b/src/course-unit/header-title/HeaderTitle.test.tsx index 6c15964e40..03024bc2d1 100644 --- a/src/course-unit/header-title/HeaderTitle.test.tsx +++ b/src/course-unit/header-title/HeaderTitle.test.tsx @@ -4,6 +4,7 @@ import { initializeMocks, render, screen } from '@src/testUtils'; import userEvent from '@testing-library/user-event'; import { executeThunk } from '@src/utils'; +import { IframeProvider } from '@src/generic/hooks/context/iFrameContext'; import { getCourseSectionVerticalApiUrl } from '../data/api'; import { fetchCourseSectionVerticalData } from '../data/thunk'; import { courseSectionVerticalMock } from '../__mocks__'; @@ -20,14 +21,16 @@ let store; let axiosMock; const renderComponent = (props?: any) => render( - , + + , + , ); describe('', () => { diff --git a/src/course-unit/header-title/HeaderTitle.tsx b/src/course-unit/header-title/HeaderTitle.tsx index 758469cb95..00b67eb235 100644 --- a/src/course-unit/header-title/HeaderTitle.tsx +++ b/src/course-unit/header-title/HeaderTitle.tsx @@ -12,6 +12,9 @@ import ConfigureModal from '@src/generic/configure-modal/ConfigureModal'; import { COURSE_BLOCK_NAMES } from '@src/constants'; import { useIntl } from '@edx/frontend-platform/i18n'; import { ConfigureUnitData } from '@src/course-outline/data/types'; +import { useIframe } from '@src/generic/hooks/context/hooks'; +import { messageTypes, PUBLISH_TYPES } from '@src/course-unit/constants'; +import { useConfigureUnitWithPageUpdates } from '@src/course-unit/data/apiHooks'; import { getCourseUnitData } from '../data/selectors'; import { updateQueryPendingStatus } from '../data/slice'; import messages from './messages'; @@ -22,7 +25,6 @@ type HeaderTitleProps = { isTitleEditFormOpen: boolean; handleTitleEdit: () => void; handleTitleEditSubmit: (title: string) => void; - handleConfigureSubmit: (variables: ConfigureUnitData & { closeModalFn?: () => void }) => void; }; /** @@ -37,7 +39,6 @@ const HeaderTitle = ({ isTitleEditFormOpen, handleTitleEdit, handleTitleEditSubmit, - handleConfigureSubmit, }: HeaderTitleProps) => { const intl = useIntl(); const dispatch = useDispatch(); @@ -51,11 +52,19 @@ const HeaderTitle = ({ COURSE_BLOCK_NAMES.component.id, ].includes(currentItemData.category); + const configureFn = useConfigureUnitWithPageUpdates(); + const { sendMessageToIframe } = useIframe(); const onConfigureSubmit = (variables: Omit) => { - handleConfigureSubmit({ + configureFn.mutate({ ...variables, + type: PUBLISH_TYPES.republish, unitId: currentItemData.id, - closeModalFn: closeConfigureModal, + }, { + onSuccess: () => sendMessageToIframe( + messageTypes.completeManageXBlockAccess, + { locator: currentItemData.id }, + ), + onSettled: () => closeConfigureModal(), }); }; diff --git a/src/course-unit/hooks.tsx b/src/course-unit/hooks.tsx index 82559a51b5..4ce7405007 100644 --- a/src/course-unit/hooks.tsx +++ b/src/course-unit/hooks.tsx @@ -14,14 +14,12 @@ import { useEventListener } from '@src/generic/hooks'; import { useIframe } from '@src/generic/hooks/context/hooks'; import { COURSE_BLOCK_NAMES, iframeMessageTypes } from '@src/constants'; -import { ConfigureUnitData } from '@src/course-outline/data/types'; -import { messageTypes, PUBLISH_TYPES } from './constants'; +import { messageTypes } from './constants'; import { createNewCourseXBlock, deleteUnitItemQuery, duplicateUnitItemQuery, editCourseItemQuery, - editCourseUnitVisibilityAndData, fetchCourseSectionVerticalData, fetchCourseVerticalChildrenData, getCourseOutlineInfoQuery, @@ -100,19 +98,6 @@ export const useCourseUnit = ({ dispatch(changeEditTitleFormOpen(!isTitleEditFormOpen)); }; - const handleConfigureSubmit = (variables: ConfigureUnitData & { closeModalFn?: () => void }) => { - dispatch(editCourseUnitVisibilityAndData( - variables.unitId, - PUBLISH_TYPES.republish, - variables.isVisibleToStaffOnly, - variables.groupAccess, - variables.discussionEnabled, - () => sendMessageToIframe(messageTypes.completeManageXBlockAccess, { locator: variables.unitId }), - blockId, - )); - variables.closeModalFn?.(); - }; - const handleTitleEditSubmit = (displayName) => { if (unitTitle !== displayName) { dispatch(editCourseItemQuery(blockId, displayName, sequenceId)); @@ -274,7 +259,6 @@ export const useCourseUnit = ({ headerNavigationsActions, handleTitleEdit, handleTitleEditSubmit, - handleConfigureSubmit, courseVerticalChildren, canPasteComponent, isMoveModalOpen, diff --git a/src/course-unit/legacy-sidebar/index.tsx b/src/course-unit/legacy-sidebar/index.tsx index 313928a070..399a2f00c3 100644 --- a/src/course-unit/legacy-sidebar/index.tsx +++ b/src/course-unit/legacy-sidebar/index.tsx @@ -39,6 +39,10 @@ const LegacySidebar = ({ const { blockId } = useParams(); const { courseId } = useCourseAuthoringContext(); + if (!blockId) { + return null; + } + return ( {isUnitVerticalType && ( diff --git a/src/course-unit/unit-sidebar/unit-info/GenericUnitInfoSettings.tsx b/src/course-unit/unit-sidebar/unit-info/GenericUnitInfoSettings.tsx new file mode 100644 index 0000000000..8f2b23f9a6 --- /dev/null +++ b/src/course-unit/unit-sidebar/unit-info/GenericUnitInfoSettings.tsx @@ -0,0 +1,188 @@ +import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n'; +import { Button, ButtonGroup, useToggle } from '@openedx/paragon'; +import { PUBLISH_TYPES, UNIT_VISIBILITY_STATES } from '@src/course-unit/constants'; +import { UserPartitionInfoTypes } from '@src/data/types'; +import { AccessEditComponent, DiscussionEditComponent } from '@src/generic/configure-modal/UnitTab'; +import { SidebarContent, SidebarSection } from '@src/generic/sidebar'; +import { Form, Formik } from 'formik'; +import { useMemo } from 'react'; +import configureMessages from '@src/generic/configure-modal/messages'; +import { useConfigureUnit } from '@src/course-outline/data/apiHooks'; +import { useStateWithCallback } from '@src/hooks'; +import ModalNotification from '@src/generic/modal-notification'; +import { InfoOutline } from '@openedx/paragon/icons'; +import messages from './messages'; + +interface UnitInfoSettingsProps { + id: string; + visibilityState: string; + discussionEnabled?: boolean; + userPartitionInfo?: UserPartitionInfoTypes; + updateCallback?: () => void; + sectionId?: string; + subsectionId?: string; + configureHook?: typeof useConfigureUnit; +} + +/** + * Generic Component with forms to edit unit settings. + * + * It is used in settings tab of unit sidebar in both outline and unit page + */ +export const GenericUnitInfoSettings = (props: UnitInfoSettingsProps) => { + const intl = useIntl(); + const { + id, + visibilityState, + discussionEnabled, + userPartitionInfo, + sectionId, + subsectionId, + configureHook = useConfigureUnit, + } = props; + + const visibleToStaffOnly = visibilityState === UNIT_VISIBILITY_STATES.staffOnly; + const mutateFn = configureHook(); + const [isVisibleModalOpen, openVisibleModal, closeVisibleModal] = useToggle(false); + + const handleUpdate = ( + isVisible: boolean, + groupAccess: Record | null, + isDiscussionEnabled?: boolean, + ) => { + // oxlint-disable-next-line @typescript-eslint/await-thenable - this dispatch() IS returning a promise. + mutateFn.mutate({ + unitId: id, + type: PUBLISH_TYPES.republish, + isVisibleToStaffOnly: isVisible, + groupAccess, + discussionEnabled: !!isDiscussionEnabled, + sectionId, + subsectionId, + }, { + onSuccess: () => props.updateCallback?.(), + }); + }; + + const [localState, setLocalState] = useStateWithCallback<{ + isVisible?: boolean; + isDiscussionEnabled?: boolean; + }>({ + isVisible: visibleToStaffOnly, + isDiscussionEnabled: discussionEnabled, + }, (val) => { + if (val) { + handleUpdate(!!val.isVisible, null, val.isDiscussionEnabled); + } + }); + + const handleSaveGroups = async (data: { + selectedPartitionIndex: number; + selectedGroups: any[]; + }, { resetForm }: any) => { + const groupAccess = {}; + if (userPartitionInfo && data.selectedPartitionIndex >= 0) { + const partitionId = userPartitionInfo.selectablePartitions[data.selectedPartitionIndex].id; + groupAccess[partitionId] = data.selectedGroups.map(g => parseInt(g, 10)); + } + handleUpdate(visibleToStaffOnly, groupAccess, !!discussionEnabled); + resetForm({ values: data }); + }; + + /* istanbul ignore next */ + const getSelectedGroups = () => { + if (userPartitionInfo && userPartitionInfo.selectedPartitionIndex >= 0) { + return userPartitionInfo.selectablePartitions[userPartitionInfo?.selectedPartitionIndex] + ?.groups + .filter(({ selected }) => selected) + // eslint-disable-next-line @typescript-eslint/no-shadow + .map(({ id }) => `${id}`) + || []; + } + return []; + }; + + const setStudentVisible = () => { + closeVisibleModal(); + setLocalState((prev) => ({ ...prev, isVisible: false })); + }; + + const initialValues = useMemo(() => ( + { + selectedPartitionIndex: userPartitionInfo?.selectedPartitionIndex, + selectedGroups: getSelectedGroups(), + } + ), [userPartitionInfo]); + + return ( + <> + + + + + + + + + + {({ + values, setFieldValue, dirty, + }) => ( +
+ + {dirty && ( + + )} + + )} +
+
+ + setLocalState((prev) => ({ + ...prev, + isDiscussionEnabled: e.target.checked, + }))} + /> + +
+ + + ); +}; diff --git a/src/course-unit/unit-sidebar/unit-info/PublishControls.tsx b/src/course-unit/unit-sidebar/unit-info/PublishControls.tsx index f68882e5b1..08f1f52c47 100644 --- a/src/course-unit/unit-sidebar/unit-info/PublishControls.tsx +++ b/src/course-unit/unit-sidebar/unit-info/PublishControls.tsx @@ -1,20 +1,20 @@ -import { useDispatch, useSelector } from 'react-redux'; +import { useSelector } from 'react-redux'; import { Icon, Stack, useToggle } from '@openedx/paragon'; import { InfoOutline as InfoOutlineIcon, Person } from '@openedx/paragon/icons'; import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n'; import ModalNotification from '@src/generic/modal-notification'; import { useIframe } from '@src/generic/hooks/context/hooks'; import { getCourseUnitData } from '@src/course-unit/data/selectors'; -import { editCourseUnitVisibilityAndData } from '@src/course-unit/data/thunk'; import { messageTypes, PUBLISH_TYPES } from '@src/course-unit/constants'; import { SidebarFooter, SidebarHeader } from '@src/course-unit/legacy-sidebar/components'; import useCourseUnitData from '@src/course-unit/legacy-sidebar/hooks'; import ReleaseInfoComponent from '@src/course-unit/legacy-sidebar/components/ReleaseInfoComponent'; +import { useConfigureUnitWithPageUpdates } from '@src/course-unit/data/apiHooks'; import messages from './messages'; import UnitVisibilityInfo from './UnitVisibilityInfo'; interface PublishControlsProps { - blockId?: string, + blockId: string, hideCopyButton?: boolean, } @@ -44,28 +44,40 @@ const PublishControls = ({ publishedOn, } = unitData; - const dispatch = useDispatch(); + const publishMutation = useConfigureUnitWithPageUpdates(); const handleCourseUnitVisibility = () => { closeVisibleModal(); - dispatch(editCourseUnitVisibilityAndData(blockId, PUBLISH_TYPES.republish, null)); + publishMutation.mutate({ + unitId: blockId, + type: PUBLISH_TYPES.republish, + isVisibleToStaffOnly: false, + groupAccess: null, + }); }; const handleCourseUnitDiscardChanges = () => { closeDiscardModal(); - dispatch(editCourseUnitVisibilityAndData( - blockId, - PUBLISH_TYPES.discardChanges, - null, - null, - null, - /* istanbul ignore next */ - () => sendMessageToIframe(messageTypes.refreshXBlock, null), - )); + publishMutation.mutate( + { + unitId: blockId, + type: PUBLISH_TYPES.discardChanges, + isVisibleToStaffOnly: false, + groupAccess: null, + }, + { + onSuccess: () => sendMessageToIframe(messageTypes.refreshXBlock, null), + }, + ); }; const handleCourseUnitPublish = () => { - dispatch(editCourseUnitVisibilityAndData(blockId, PUBLISH_TYPES.makePublic)); + publishMutation.mutate({ + unitId: blockId, + type: PUBLISH_TYPES.makePublic, + isVisibleToStaffOnly: false, + groupAccess: null, + }); }; return ( diff --git a/src/course-unit/unit-sidebar/unit-info/UnitInfoSidebar.test.tsx b/src/course-unit/unit-sidebar/unit-info/UnitInfoSidebar.test.tsx new file mode 100644 index 0000000000..458eed5a1f --- /dev/null +++ b/src/course-unit/unit-sidebar/unit-info/UnitInfoSidebar.test.tsx @@ -0,0 +1,83 @@ +import { IframeProvider } from '@src/generic/hooks/context/iFrameContext'; +import { initializeMocks, render, screen } from '@src/testUtils'; +import { useParams } from 'react-router-dom'; +import { UnitInfoSidebar } from './UnitInfoSidebar'; + +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: jest.fn(), +})); + +jest.mock('@src/course-unit/data/selectors', () => ({ + getCourseUnitData: jest.fn(), + getCourseVerticalChildren: jest.fn(), +})); + +jest.mock('./PublishControls', () => ({ __esModule: true, default: () =>
PublishControls
})); +jest.mock('@src/generic/block-type-utils', () => ({ + ...jest.requireActual('@src/generic/block-type-utils'), + ComponentCountSnippet: ({ componentData }: any) =>
ComponentCount: {JSON.stringify(componentData)}
, + getItemIcon: () => () => null, +})); +jest.mock('@src/content-tags-drawer', () => ({ ContentTagsSnippet: ({ contentId }: any) =>
ContentTags:{contentId}
})); +jest.mock('@src/course-unit/unit-sidebar/unit-info/GenericUnitInfoSettings', () => ({ + __esModule: true, + GenericUnitInfoSettings: () =>
GenericUnitInfoSettings
, +})); + +jest.mock('../UnitSidebarContext', () => ({ useUnitSidebarContext: jest.fn() })); + +const mockUseParams = useParams as jest.MockedFunction; +const selectors = jest.requireMock('@src/course-unit/data/selectors') as any; +const unitSidebarContext = jest.requireMock('../UnitSidebarContext') as any; + +const renderComponent = () => { + render( + + + , + ); +}; + +describe('UnitInfoSidebar', () => { + beforeEach(() => { + initializeMocks(); + mockUseParams.mockReturnValue({ blockId: 'block-1' } as any); + + selectors.getCourseUnitData.mockReturnValue({ + displayName: 'Unit title', + id: 'block-1', + visibilityState: undefined, + discussionEnabled: false, + userPartitionInfo: null, + }); + + selectors.getCourseVerticalChildren.mockReturnValue({ + children: [ + { blockType: 'html' }, { blockType: 'problem' }, { blockType: 'html' }, + ], + }); + }); + + it('renders title and details components and sets default tab', () => { + const setCurrentTabKey = jest.fn(); + unitSidebarContext.useUnitSidebarContext.mockReturnValue({ currentTabKey: 'details', setCurrentTabKey }); + + renderComponent(); + + expect(screen.getByText('Unit title')).toBeInTheDocument(); + expect(screen.getByText(/ComponentCount/)).toBeInTheDocument(); + expect(screen.getByText('ContentTags:block-1')).toBeInTheDocument(); + // effect should set default tab to details + expect(setCurrentTabKey).toHaveBeenCalledWith('details'); + }); + + it('renders settings tab content when active', () => { + const setCurrentTabKey = jest.fn(); + unitSidebarContext.useUnitSidebarContext.mockReturnValue({ currentTabKey: 'settings', setCurrentTabKey }); + + renderComponent(); + + expect(screen.getByText('GenericUnitInfoSettings')).toBeInTheDocument(); + }); +}); diff --git a/src/course-unit/unit-sidebar/unit-info/UnitInfoSidebar.tsx b/src/course-unit/unit-sidebar/unit-info/UnitInfoSidebar.tsx index 4bcdc29157..1189740791 100644 --- a/src/course-unit/unit-sidebar/unit-info/UnitInfoSidebar.tsx +++ b/src/course-unit/unit-sidebar/unit-info/UnitInfoSidebar.tsx @@ -1,21 +1,19 @@ -import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n'; +import { useIntl } from '@edx/frontend-platform/i18n'; import { useParams } from 'react-router-dom'; import { ComponentCountSnippet, getItemIcon } from '@src/generic/block-type-utils'; import { SidebarContent, SidebarSection, SidebarTitle } from '@src/generic/sidebar'; import { useEffect, useMemo } from 'react'; import { Tag } from '@openedx/paragon/icons'; import { ContentTagsSnippet } from '@src/content-tags-drawer'; -import configureMessages from '@src/generic/configure-modal/messages'; import { - Button, ButtonGroup, Tab, Tabs, + Tab, Tabs, } from '@openedx/paragon'; -import { useDispatch, useSelector } from 'react-redux'; +import { useSelector } from 'react-redux'; import { useIframe } from '@src/generic/hooks/context/hooks'; -import { AccessEditComponent, DiscussionEditComponent } from '@src/generic/configure-modal/UnitTab'; -import { Form, Formik } from 'formik'; import { getCourseUnitData, getCourseVerticalChildren } from '@src/course-unit/data/selectors'; -import { messageTypes, PUBLISH_TYPES, UNIT_VISIBILITY_STATES } from '@src/course-unit/constants'; -import { editCourseUnitVisibilityAndData } from '@src/course-unit/data/thunk'; +import { messageTypes } from '@src/course-unit/constants'; +import { GenericUnitInfoSettings } from '@src/course-unit/unit-sidebar/unit-info/GenericUnitInfoSettings'; +import { useConfigureUnitWithPageUpdates } from '@src/course-unit/data/apiHooks'; import PublishControls from './PublishControls'; import { useUnitSidebarContext } from '../UnitSidebarContext'; import messages from './messages'; @@ -70,9 +68,7 @@ const UnitInfoDetails = () => { * * It's using in the settings tab of the unit info sidebar. */ -const UnitInfoSettings = () => { - const dispatch = useDispatch(); - const intl = useIntl(); +export const UnitInfoSettings = () => { const { sendMessageToIframe } = useIframe(); const { id, @@ -81,110 +77,19 @@ const UnitInfoSettings = () => { userPartitionInfo, } = useSelector(getCourseUnitData); - const visibleToStaffOnly = visibilityState === UNIT_VISIBILITY_STATES.staffOnly; - - const handleUpdate = async ( - isVisible: boolean, - groupAccess: Record | null, - isDiscussionEnabled: boolean, - ) => { - // oxlint-disable-next-line @typescript-eslint/await-thenable - this dispatch() IS returning a promise. - await dispatch(editCourseUnitVisibilityAndData( - id, - PUBLISH_TYPES.republish, - isVisible, - groupAccess, - isDiscussionEnabled, - () => sendMessageToIframe(messageTypes.refreshXBlock, null), - id, - )); - }; - - const handleSaveGroups = async (data, { resetForm }) => { - const groupAccess = {}; - if (data.selectedPartitionIndex >= 0) { - const partitionId = userPartitionInfo.selectablePartitions[data.selectedPartitionIndex].id; - groupAccess[partitionId] = data.selectedGroups.map(g => parseInt(g, 10)); - } - await handleUpdate(visibleToStaffOnly, groupAccess, discussionEnabled); - resetForm({ values: data }); - }; - - /* istanbul ignore next */ - const getSelectedGroups = () => { - if (userPartitionInfo?.selectedPartitionIndex >= 0) { - return userPartitionInfo?.selectablePartitions[userPartitionInfo?.selectedPartitionIndex] - ?.groups - .filter(({ selected }) => selected) - // eslint-disable-next-line @typescript-eslint/no-shadow - .map(({ id }) => `${id}`) - || []; - } - return []; + const updateCallback = () => { + sendMessageToIframe(messageTypes.refreshXBlock, null); }; - const initialValues = useMemo(() => ( - { - selectedPartitionIndex: userPartitionInfo?.selectedPartitionIndex, - selectedGroups: getSelectedGroups(), - } - ), [userPartitionInfo]); - return ( - - - - - - - - - - {({ - values, setFieldValue, dirty, - }) => ( -
- - {dirty && ( - - )} - - )} -
-
- - handleUpdate(visibleToStaffOnly, null, e.target.checked)} - /> - -
+ ); }; diff --git a/src/course-unit/unit-sidebar/unit-info/UnitVisibilityInfo.tsx b/src/course-unit/unit-sidebar/unit-info/UnitVisibilityInfo.tsx index d5536ea68d..edd0340ecc 100644 --- a/src/course-unit/unit-sidebar/unit-info/UnitVisibilityInfo.tsx +++ b/src/course-unit/unit-sidebar/unit-info/UnitVisibilityInfo.tsx @@ -1,4 +1,4 @@ -import { useDispatch, useSelector } from 'react-redux'; +import { useSelector } from 'react-redux'; import { Form, Icon, IconButton, Stack, } from '@openedx/paragon'; @@ -6,10 +6,10 @@ import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n'; import { useParams } from 'react-router-dom'; import { getCourseUnitData } from '@src/course-unit/data/selectors'; -import { editCourseUnitVisibilityAndData } from '@src/course-unit/data/thunk'; import { PUBLISH_TYPES } from '@src/course-unit/constants'; import { isUnitPageNewDesignEnabled } from '@src/course-unit/utils'; import { Edit, Groups, Lock } from '@openedx/paragon/icons'; +import { useConfigureUnitWithPageUpdates } from '@src/course-unit/data/apiHooks'; import messages from './messages'; import { useUnitSidebarContext } from '../UnitSidebarContext'; @@ -42,11 +42,18 @@ const LegacyVisibilityInfo = ({ } = useSelector(getCourseUnitData); const { blockId } = useParams(); - const dispatch = useDispatch(); + const publishMutation = useConfigureUnitWithPageUpdates(); const handleCourseUnitVisibility = () => { /* istanbul ignore next */ - dispatch(editCourseUnitVisibilityAndData(blockId, PUBLISH_TYPES.republish, true)); + if (blockId) { + publishMutation.mutate({ + unitId: blockId, + type: PUBLISH_TYPES.republish, + isVisibleToStaffOnly: true, + groupAccess: null, + }); + } }; return ( diff --git a/src/course-unit/xblock-container-iframe/index.tsx b/src/course-unit/xblock-container-iframe/index.tsx index a8f49928c9..281d79414d 100644 --- a/src/course-unit/xblock-container-iframe/index.tsx +++ b/src/course-unit/xblock-container-iframe/index.tsx @@ -24,7 +24,8 @@ import EditorPage from '@src/editors/EditorPage'; import { useCourseAuthoringContext } from '@src/CourseAuthoringContext'; import { ConfigureUnitData } from '@src/course-outline/data/types'; import { AccessManagedXBlockDataTypes } from '@src/data/types'; -import { messageTypes } from '../constants'; +import { useConfigureUnitWithPageUpdates } from '@src/course-unit/data/apiHooks'; +import { messageTypes, PUBLISH_TYPES } from '../constants'; import { fetchCourseSectionVerticalData, fetchCourseVerticalChildrenData, @@ -46,7 +47,6 @@ const XBlockContainerIframe: FC = ({ blockId, unitXBlockActions, courseVerticalChildren, - handleConfigureSubmit, isUnitVerticalType, readonly, }) => { @@ -152,12 +152,16 @@ const XBlockContainerIframe: FC = ({ } }; + const configureFn = useConfigureUnitWithPageUpdates(); const onManageXBlockAccessSubmit = (variables: Omit) => { if (configureXBlockId) { - handleConfigureSubmit({ + configureFn.mutate({ unitId: configureXBlockId, ...variables, - closeModalFn: closeConfigureModal, + type: PUBLISH_TYPES.republish, + }, { + onSuccess: () => sendMessageToIframe(messageTypes.completeManageXBlockAccess, { locator: configureXBlockId }), + onSettled: () => closeConfigureModal(), }); setAccessManagedXBlockData(undefined); } diff --git a/src/course-unit/xblock-container-iframe/types.ts b/src/course-unit/xblock-container-iframe/types.ts index 2c1b0757a4..f117f66cd0 100644 --- a/src/course-unit/xblock-container-iframe/types.ts +++ b/src/course-unit/xblock-container-iframe/types.ts @@ -1,4 +1,3 @@ -import { ConfigureUnitData } from '@src/course-outline/data/types'; import { UserPartitionTypes } from '@src/data/types'; export interface XBlockActionsTypes { @@ -37,6 +36,5 @@ export interface XBlockContainerIframeProps { handleUnlink: (XBlockId: string | null) => void; }; courseVerticalChildren: Array; - handleConfigureSubmit: (variables: ConfigureUnitData & { closeModalFn?: () => void }) => void; readonly?: boolean; } diff --git a/src/data/types.ts b/src/data/types.ts index 7dd267f4f8..1dd30420c2 100644 --- a/src/data/types.ts +++ b/src/data/types.ts @@ -97,7 +97,7 @@ export interface XBlockBase { actions: XBlockActions; explanatoryMessage?: string; userPartitions: UserPartitionTypes[]; - showCorrectness: string; + showCorrectness: 'always' | 'never' | 'past_due' | 'never_but_include_grade', highlights: string[]; highlightsEnabled: boolean; highlightsPreviewOnly: boolean; diff --git a/src/generic/FormikControl.tsx b/src/generic/FormikControl.tsx index 07c4da6239..6ce65c1bc1 100644 --- a/src/generic/FormikControl.tsx +++ b/src/generic/FormikControl.tsx @@ -10,6 +10,7 @@ interface Props { className?: string; controlClasses?: string; value: string | number; + setFieldValue?: (name: string, value: any) => void; } // Because is only typed as 'any' in Paragon so far, the props of the following become 'any' :/ @@ -22,14 +23,19 @@ const FormikControl: React.FC> help = <>, className = '', controlClasses = 'pb-2', + setFieldValue, ...params }) => { - const { - touched, errors, handleChange, handleBlur, setFieldError, - } = useFormikContext(); - const fieldTouched = getIn(touched, name); - const fieldError = getIn(errors, name); - const handleFocus = (e) => setFieldError(e.target.name, undefined); + const formikContext = useFormikContext() || null; + + const fieldTouched = formikContext ? getIn(formikContext.touched, name) : false; + const fieldError = formikContext ? getIn(formikContext.errors, name) : undefined; + const handleFocus = formikContext ? ( + e: { target: { name: any; } }, + ) => formikContext?.setFieldError(e.target.name, undefined) : undefined; + const handleBlur = formikContext ? formikContext.handleBlur : undefined; + const handleChange = formikContext ? formikContext.handleChange : undefined; + const formikSetFieldValue = formikContext ? formikContext.setFieldValue : undefined; return ( @@ -38,14 +44,28 @@ const FormikControl: React.FC> {...params} name={name} className={controlClasses} - onChange={handleChange} + onChange={async (e: { target: { value: any; }; }) => { + if (setFieldValue) { + setFieldValue(name, e.target.value); + return; + } + if (handleChange) { + handleChange(e); + return; + } + if (formikSetFieldValue) { + await formikSetFieldValue(name, e.target.value); + } + }} onBlur={handleBlur} onFocus={handleFocus} isInvalid={!!fieldTouched && !!fieldError} /> - - {help} - + {formikContext && ( + + {help} + + )} ); }; diff --git a/src/generic/configure-modal/AdvancedTab.test.jsx b/src/generic/configure-modal/AdvancedTab.test.jsx index 17cffeeafe..ed8150bc81 100644 --- a/src/generic/configure-modal/AdvancedTab.test.jsx +++ b/src/generic/configure-modal/AdvancedTab.test.jsx @@ -612,4 +612,95 @@ describe(' with enableTimedExams prop', () => { expect(screen.getByText('Set as a special exam')).toBeInTheDocument(); }); }); + + describe('ButtonGroupForm (useBtnGroup)', () => { + const mockSetFieldValue = jest.fn(); + + beforeEach(() => { + mockSetFieldValue.mockClear(); + }); + + it('handles timed button click', async () => { + const user = userEvent.setup(); + renderComponent({ useBtnGroup: true, setFieldValue: mockSetFieldValue }); + + const timedBtn = screen.getByRole('button', { name: 'Timed' }); + expect(timedBtn).toBeInTheDocument(); + + await user.click(timedBtn); + + expect(mockSetFieldValue).toHaveBeenCalledWith('isTimeLimited', true); + expect(mockSetFieldValue).toHaveBeenCalledWith('isOnboardingExam', false); + expect(mockSetFieldValue).toHaveBeenCalledWith('isPracticeExam', false); + expect(mockSetFieldValue).toHaveBeenCalledWith('isProctoredExam', false); + }); + + it('handles none button click', async () => { + const user = userEvent.setup(); + renderComponent({ + useBtnGroup: true, + setFieldValue: mockSetFieldValue, + values: { ...defaultProps.values, isTimeLimited: true }, + }); + + const noneBtn = screen.getByRole('button', { name: 'None' }); + expect(noneBtn).toBeInTheDocument(); + + await user.click(noneBtn); + + expect(mockSetFieldValue).toHaveBeenCalledWith('isTimeLimited', false); + expect(mockSetFieldValue).toHaveBeenCalledWith('isOnboardingExam', false); + expect(mockSetFieldValue).toHaveBeenCalledWith('isPracticeExam', false); + expect(mockSetFieldValue).toHaveBeenCalledWith('isProctoredExam', false); + }); + + it('handles proctored button click', async () => { + const user = userEvent.setup(); + renderComponent({ useBtnGroup: true, enableProctoredExams: true, setFieldValue: mockSetFieldValue }); + + const proctoredBtn = screen.getByRole('button', { name: 'Proctored' }); + expect(proctoredBtn).toBeInTheDocument(); + + await user.click(proctoredBtn); + + expect(mockSetFieldValue).toHaveBeenCalledWith('isProctoredExam', true); + expect(mockSetFieldValue).toHaveBeenCalledWith('isTimeLimited', true); + expect(mockSetFieldValue).toHaveBeenCalledWith('isOnboardingExam', false); + expect(mockSetFieldValue).toHaveBeenCalledWith('isPracticeExam', false); + }); + + it('shows practice button and handles practice click', async () => { + const user = userEvent.setup(); + renderComponent({ + useBtnGroup: true, enableProctoredExams: true, supportsOnboarding: false, setFieldValue: mockSetFieldValue, + }); + + const practiceBtn = screen.getByRole('button', { name: 'Practice proctored' }); + expect(practiceBtn).toBeInTheDocument(); + + await user.click(practiceBtn); + + expect(mockSetFieldValue).toHaveBeenCalledWith('isPracticeExam', true); + expect(mockSetFieldValue).toHaveBeenCalledWith('isProctoredExam', true); + expect(mockSetFieldValue).toHaveBeenCalledWith('isTimeLimited', true); + expect(mockSetFieldValue).toHaveBeenCalledWith('isOnboardingExam', false); + }); + + it('handles onboarding button click', async () => { + const user = userEvent.setup(); + renderComponent({ + useBtnGroup: true, enableProctoredExams: true, supportsOnboarding: true, setFieldValue: mockSetFieldValue, + }); + + const onboardingBtn = screen.getByRole('button', { name: 'Onboarding' }); + expect(onboardingBtn).toBeInTheDocument(); + + await user.click(onboardingBtn); + + expect(mockSetFieldValue).toHaveBeenCalledWith('isOnboardingExam', true); + expect(mockSetFieldValue).toHaveBeenCalledWith('isProctoredExam', true); + expect(mockSetFieldValue).toHaveBeenCalledWith('isTimeLimited', true); + expect(mockSetFieldValue).toHaveBeenCalledWith('isPracticeExam', false); + }); + }); }); diff --git a/src/generic/configure-modal/AdvancedTab.tsx b/src/generic/configure-modal/AdvancedTab.tsx index 7651bec41d..21b451b71e 100644 --- a/src/generic/configure-modal/AdvancedTab.tsx +++ b/src/generic/configure-modal/AdvancedTab.tsx @@ -2,6 +2,8 @@ import React, { useState } from 'react'; import moment from 'moment'; import { Alert, + Button, + ButtonGroup, Form, Hyperlink, OverlayTrigger, @@ -14,7 +16,7 @@ import messages from './messages'; import PrereqSettings from './PrereqSettings'; interface ValuesProps { - isTimeLimited: boolean; + isTimeLimited?: boolean; defaultTimeLimitMinutes?: number; isPrereq?: boolean; prereqUsageKey?: string; @@ -43,8 +45,177 @@ interface AdvancedTabProps { wasProctoredExam?: boolean; showReviewRules?: boolean; onlineProctoringRules?: string; + hideTitle?: boolean; + useBtnGroup?: boolean; } +interface SelectorProps { + handleChange: (value: string) => void; + examTypeValue: string; + renderAlerts: () => React.ReactNode; + enableTimedExams?: boolean, + enableProctoredExams?: boolean; + supportsOnboarding?: boolean; +} + +const RadioForm = ({ + handleChange, + examTypeValue, + renderAlerts, + enableTimedExams, + enableProctoredExams, + supportsOnboarding, +}: SelectorProps) => { + const eventHandler = (e) => handleChange(e.target.value); + return ( + + {renderAlerts()} + + + + } + controlClassName="mw-1-25rem" + > + + + {enableProctoredExams && ( + <> + + } + controlClassName="mw-1-25rem" + > + + + {supportsOnboarding ? ( + + } + value="onboardingExam" + controlClassName="mw-1-25rem" + > + + + ) : ( + + } + > + + + )} + + )} + + ); +}; + +const ButtonGroupForm = ({ + handleChange, + examTypeValue, + renderAlerts, + enableTimedExams, + enableProctoredExams, + supportsOnboarding, +}: SelectorProps) => ( + <> + {renderAlerts()} + + + + + + )} + > + + + {enableProctoredExams && ( + <> + + + + )} + > + + + {supportsOnboarding ? ( + + + + )} + > + + + ) : ( + + + + )} + > + + + )} + + )} + + +); + const AdvancedTab: React.FC = ({ values, setFieldValue, @@ -57,6 +228,8 @@ const AdvancedTab: React.FC = ({ wasProctoredExam = false, showReviewRules = false, onlineProctoringRules = '', + hideTitle = false, + useBtnGroup = false, }) => { const { isTimeLimited, @@ -111,23 +284,23 @@ const AdvancedTab: React.FC = ({ ); const showReviewRulesDiv = showReviewRules && isProctoredExam && !isPracticeExam && !isOnboardingExam; - const handleChange = (e: React.ChangeEvent) => { - if (e.target.value === 'timed') { + const handleChange = (value: string) => { + if (value === 'timed') { setFieldValue('isTimeLimited', true); setFieldValue('isOnboardingExam', false); setFieldValue('isPracticeExam', false); setFieldValue('isProctoredExam', false); - } else if (e.target.value === 'onboardingExam') { + } else if (value === 'onboardingExam') { setFieldValue('isOnboardingExam', true); setFieldValue('isProctoredExam', true); setFieldValue('isTimeLimited', true); setFieldValue('isPracticeExam', false); - } else if (e.target.value === 'practiceExam') { + } else if (value === 'practiceExam') { setFieldValue('isPracticeExam', true); setFieldValue('isProctoredExam', true); setFieldValue('isTimeLimited', true); setFieldValue('isOnboardingExam', false); - } else if (e.target.value === 'proctoredExam') { + } else if (value === 'proctoredExam') { setFieldValue('isProctoredExam', true); setFieldValue('isTimeLimited', true); setFieldValue('isOnboardingExam', false); @@ -177,79 +350,55 @@ const AdvancedTab: React.FC = ({ return ( <> -
-
- -
- {!enableTimedExams && ( - - - + {(!hideTitle || !enableTimedExams) + && ( + <> +
+ {!hideTitle && ( +
+ +
)} - > - - - )} -
-
- - {renderAlerts()} - - - - } - controlClassName="mw-1-25rem" - > - - - {enableProctoredExams && ( - <> - - } - controlClassName="mw-1-25rem" + {!enableTimedExams && ( + + + + )} > - - - {supportsOnboarding ? ( - - } - value="onboardingExam" - controlClassName="mw-1-25rem" - > - - - ) : ( - - } - > - - + +
)} - +
+
+ + )} + {useBtnGroup + ? ( + + ) + : ( + )} - {isTimeLimited && (
diff --git a/src/generic/configure-modal/ConfigureModal.tsx b/src/generic/configure-modal/ConfigureModal.tsx index 5b2ac38bba..8134a4b27a 100644 --- a/src/generic/configure-modal/ConfigureModal.tsx +++ b/src/generic/configure-modal/ConfigureModal.tsx @@ -179,7 +179,7 @@ const ConfigureModal = ({ isOnboardingExam: data.isOnboardingExam, isPracticeExam: data.isPracticeExam, examReviewRules: data.examReviewRules, - defaultTimeLimitMin: data.isTimeLimited ? data.defaultTimeLimitMinutes : 0, + defaultTimeLimitMinutes: data.isTimeLimited ? data.defaultTimeLimitMinutes : 0, hideAfterDue: data.hideAfterDue, showCorrectness: data.showCorrectness, isPrereq: data.isPrereq, @@ -256,19 +256,21 @@ const ConfigureModal = ({ /> - +
+ +
); diff --git a/src/generic/configure-modal/PrereqSettings.jsx b/src/generic/configure-modal/PrereqSettings.jsx index 4c291efa0d..98d129e4c4 100644 --- a/src/generic/configure-modal/PrereqSettings.jsx +++ b/src/generic/configure-modal/PrereqSettings.jsx @@ -61,6 +61,7 @@ const PrereqSettings = ({ setFieldValue(field, value === '' ? '' : Number(value))} label={{intl.formatMessage(messages.minScoreLabel)}} controlClassName="text-right" controlClasses="w-7rem" @@ -70,6 +71,7 @@ const PrereqSettings = ({ setFieldValue(field, value === '' ? '' : Number(value))} label={{intl.formatMessage(messages.minCompletionLabel)}} controlClassName="text-right" controlClasses="w-7rem" diff --git a/src/generic/configure-modal/UnitTab.tsx b/src/generic/configure-modal/UnitTab.tsx index 765aa918b6..bec82080f7 100644 --- a/src/generic/configure-modal/UnitTab.tsx +++ b/src/generic/configure-modal/UnitTab.tsx @@ -54,7 +54,7 @@ export const DiscussionEditComponent = ({ ); export interface AccessEditComponentProps { - selectedPartitionIndex: number, + selectedPartitionIndex?: number, setFieldValue: (key: string, value: any) => void, userPartitionInfo?: UserPartitionInfo, selectedGroups: string[], @@ -105,7 +105,8 @@ export const AccessEditComponent = ({ ))} - {selectedPartitionIndex >= 0 && userPartitionInfo?.selectablePartitions.length && ( + {selectedPartitionIndex !== undefined + && selectedPartitionIndex >= 0 && userPartitionInfo?.selectablePartitions.length && (
)} + {type === DATEPICKER_TYPES.time && ( + + )} { + const intl = useIntl(); + const [isExpanded, setIsExpanded] = useState(false); + const [needsExpansion, setNeedsExpansion] = useState(false); + const contentRef = useRef(null); + + const showMoreLabelText = showMoreLabel || intl.formatMessage(messages.showMore); + const showLessLabelText = showLessLabel || intl.formatMessage(messages.showLess); + + useEffect(() => { + const element = contentRef.current; + if (!element) { return () => {}; } + const checkNeedsExpansion = () => { + setNeedsExpansion(element.scrollHeight > maxHeight); + }; + // Initial check + checkNeedsExpansion(); + // Watch for resize + const resizeObserver = new ResizeObserver(checkNeedsExpansion); + resizeObserver.observe(element); + return () => resizeObserver.disconnect(); + }, [maxHeight, children]); + + return ( +
+
+ {children} +
+ + {needsExpansion && ( +
+ +
+ )} +
+ ); +}; diff --git a/src/generic/expandable-card/messages.ts b/src/generic/expandable-card/messages.ts new file mode 100644 index 0000000000..9f1b26d0a5 --- /dev/null +++ b/src/generic/expandable-card/messages.ts @@ -0,0 +1,16 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + showMore: { + id: 'generic.expandable-card.showMore.button', + defaultMessage: 'Show More', + description: 'Button text shown when card is collapsed', + }, + showLess: { + id: 'generic.expandable-card.showLess.button', + defaultMessage: 'Show Less', + description: 'Button text shown when card is expanded', + }, +}); + +export default messages; diff --git a/src/generic/key-utils.test.ts b/src/generic/key-utils.test.ts index aa14604faf..07ee9d6cd1 100644 --- a/src/generic/key-utils.test.ts +++ b/src/generic/key-utils.test.ts @@ -33,6 +33,7 @@ describe('component utils', () => { for (const input of ['', undefined, null, 'not a key', 'lb:foo', 'block-v1:foo']) { it(`throws an exception for usage key '${input}'`, () => { expect(() => getBlockType(input as any)).toThrow(`Invalid usageKey: ${input}`); + expect(getBlockType(input as any, 'empty')).toBe(''); }); } }); diff --git a/src/generic/key-utils.ts b/src/generic/key-utils.ts index 435766078f..c787686120 100644 --- a/src/generic/key-utils.ts +++ b/src/generic/key-utils.ts @@ -3,7 +3,10 @@ * @param usageKey e.g. `lb:org:lib:html:id`, `block-v1:org+type@html+block@1` * @returns The block type as a string */ -export function getBlockType(usageKey: string): string { +export function getBlockType( + usageKey: string, + onInvalid: 'empty' | 'error' = 'error', +): string { if (usageKey) { if (usageKey.startsWith('lb:') || usageKey.startsWith('lct:')) { const blockType = usageKey.split(':')[3]; @@ -17,6 +20,11 @@ export function getBlockType(usageKey: string): string { } } } + + if (onInvalid === 'empty') { + return ''; + } + throw new Error(`Invalid usageKey: ${usageKey}`); } diff --git a/src/generic/processing-notification/data/apiHooks.ts b/src/generic/processing-notification/data/apiHooks.ts new file mode 100644 index 0000000000..b26ad7fcda --- /dev/null +++ b/src/generic/processing-notification/data/apiHooks.ts @@ -0,0 +1,34 @@ +import { useMutation, DefaultError } from '@tanstack/react-query'; +import { useToastContext } from '@src/generic/toast-context'; +import { NOTIFICATION_MESSAGES } from '@src/constants'; + +/** + * Wraps useMutation to add a processing notification when the mutation is initiated and removes it + * when the mutation is settled. + */ +export const useMutationWithProcessingNotification = < + TData = unknown, + TError = DefaultError, + TVariables = void, + TOnMutateResult = unknown, +>(...args: Parameters>) => { + const { showToast, closeToast } = useToastContext(); + const originalOptions = args[0] || {}; + return useMutation({ + ...originalOptions, + onMutate: async (...onMutateArgs) => { + // Show processing notification + showToast(NOTIFICATION_MESSAGES.saving, undefined, 15000); + + // Call original onMutate if it exists + return originalOptions.onMutate?.(...onMutateArgs); + }, + onSettled: async (...onSettledArgs) => { + // Call original onSettled if it exists + await originalOptions.onSettled?.(...onSettledArgs); + + // Always hide processing notification + closeToast(); + }, + }); +}; diff --git a/src/generic/sidebar/index.scss b/src/generic/sidebar/index.scss index 8c2710de19..4a8362e35a 100644 --- a/src/generic/sidebar/index.scss +++ b/src/generic/sidebar/index.scss @@ -24,6 +24,16 @@ .pgn__tab_more { display: none; } + + /* + * On resizing the Sidebar last child is automatically attached below class + * and hidden from dom. We unset all changes. + */ + .pgn__tab_invisible { + position: relative; + pointer-events: unset; + visibility: unset; + } } } diff --git a/src/hooks.ts b/src/hooks.ts index c697e01fbf..a1bdb11897 100644 --- a/src/hooks.ts +++ b/src/hooks.ts @@ -9,6 +9,7 @@ import { } from 'react'; import { history } from '@edx/frontend-platform'; import { useLocation, useSearchParams } from 'react-router-dom'; +import { isEqual } from 'lodash'; export const useScrollToHashElement = ({ isLoading }: { isLoading: boolean }) => { const [elementWithHash, setElementWithHash] = useState(null); @@ -228,3 +229,70 @@ export function useToggleWithValue(defaultValue?: T): [ const isDefined = useMemo(() => value !== undefined, [value]); return [isDefined, value, define, undefine]; } + +type SetStateWithCallbackAction = React.SetStateAction | { + value: React.SetStateAction; + skipCallback?: boolean; +}; + +/** + * Hook to use `useState` and also trigger a callback when the state updates. This is particularly useful for + * scenarios where you want to update the UI or perform side effects every time the state changes. + * + * The returned setter also accepts `{ value, skipCallback }` to optionally suppress the callback for a + * specific update. + * + * @param defaultValue The default value of the state + * @param callback Receives the latest value as argument + * @param delay Time in milliseconds before the callback is triggered after state update (defaults to 500 ms) + */ +export function useStateWithCallback( + defaultValue?: T | (() => T | undefined), + callback?: (val: T | undefined) => void, + delay = 500, +): [T | undefined, React.Dispatch>] { + const [data, setData] = useState(defaultValue); + const timeoutRef = useRef(undefined); + const callbackRef = useRef(callback); + const skipCallbackRef = useRef(false); + const prevDataRef = useRef(defaultValue); // Track previous value + + useEffect(() => { + callbackRef.current = callback; + }, [callback]); + + useEffect(() => { + // Only run if data actually changed + if (isEqual(data, prevDataRef.current)) { + return () => {}; + } + + prevDataRef.current = data; + + if (skipCallbackRef.current) { + skipCallbackRef.current = false; + return () => {}; + } + + if (timeoutRef.current) { clearTimeout(timeoutRef.current); } + + timeoutRef.current = setTimeout(() => { + callbackRef.current?.(data); + }, delay); + + return () => clearTimeout(timeoutRef.current); + }, [data, delay]); + + const setDataWithCallback = (value: SetStateWithCallbackAction) => { + if (typeof value === 'object' && value !== null && 'value' in value) { + skipCallbackRef.current = !!value.skipCallback; + setData(value.value); + return; + } + + skipCallbackRef.current = false; + setData(value as React.SetStateAction); + }; + + return [data, setDataWithCallback]; +} diff --git a/src/taxonomy/tree-table/NestedRows.test.tsx b/src/taxonomy/tree-table/NestedRows.test.tsx index 7d68f5f15b..b642e7bd31 100644 --- a/src/taxonomy/tree-table/NestedRows.test.tsx +++ b/src/taxonomy/tree-table/NestedRows.test.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { IntlProvider } from '@edx/frontend-platform/i18n'; -import { fireEvent, render, screen, within } from '@testing-library/react'; +import { fireEvent, render, screen } from '@testing-library/react'; import NestedRows from './NestedRows';