Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -4,26 +4,36 @@ 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
// Make useStateWithCallback synchronous so callbacks call onChange immediately.
// Also handles the { value, skipCallback } object form used by the external-sync useEffect.
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;
let newVal;
let skip = false;
if (typeof val === 'object' && val !== null && 'value' in val && 'skipCallback' in val) {
newVal = val.value;
skip = val.skipCallback;
} else {
newVal = typeof val === 'function' ? val(state) : val;
}
setState(newVal);
if (cb) { cb(newVal); }
if (cb && !skip) { cb(newVal); }
};
return [state, wrappedSetState];
},
}));

// Mock DatepickerControl so we can trigger onChange easily
// Mock DatepickerControl so we can trigger onChange and inspect the current value.
jest.mock('@src/generic/datepicker-control', () => ({
DATEPICKER_TYPES: { date: 'date', time: 'time' },
DatepickerControl: ({ onChange, type }: any) => (
DatepickerControl: ({ onChange, type, value }: any) => (
<button
type="button"
data-testid={`datepicker-${type}`}
data-value={value}
onClick={() => onChange(type === 'date' ? '2025-12-31' : '12:00')}
>
{type}
Expand Down Expand Up @@ -60,4 +70,26 @@ describe('ReleaseSection', () => {
await user.click(screen.getByRole('button', { name: 'time' }));
expect(onChange).toHaveBeenCalledWith('12:00');
});

it('syncs displayed value when itemData.start changes externally without calling onChange', () => {
// Simulate initial state (e.g. subsection has a release date set)
const initialDate = '2024-01-01T00:00:00Z';
mockUseCourseItemData.mockReturnValue({ data: { start: initialDate } });
const onChange = jest.fn();
const { rerender } = render(<ReleaseSection itemId="i" onChange={onChange} />);

expect(screen.getByTestId('datepicker-date')).toHaveAttribute('data-value', initialDate);

// Simulate the kebab-menu configure modal saving a new release date:
// the mutation fires, the section refetches, and setQueryData updates the
// subsection cache — causing useCourseItemData to return the new date.
const updatedDate = '2025-06-15T00:00:00Z';
mockUseCourseItemData.mockReturnValue({ data: { start: updatedDate } });
rerender(<ReleaseSection itemId="i" onChange={onChange} />);

// The sidebar must reflect the updated date...
expect(screen.getByTestId('datepicker-date')).toHaveAttribute('data-value', updatedDate);
// ...but must NOT trigger onChange (which would fire a redundant mutation)
expect(onChange).not.toHaveBeenCalled();
});
});
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { useEffect } from 'react';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Stack } from '@openedx/paragon';
import { useCourseItemData } from '@src/course-outline/data/apiHooks';
Expand All @@ -19,6 +20,14 @@ export const ReleaseSection = ({ itemId, onChange }: Props) => {
(val) => onChange(val),
);

// Sync localState when itemData changes externally (e.g. release date updated
// from the left-side kebab configure modal). skipCallback prevents re-triggering
// the onChange mutation — we're just reflecting a change that already happened.
useEffect(() => {
setLocalState({ value: itemData?.start, skipCallback: true });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [itemData?.start]);

return (
<SidebarSection
title={intl.formatMessage(messages.subsectionReleaseTitle)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,4 +79,38 @@ describe('VisibilitySection component', () => {
render(<VisibilitySection {...defaultProps} onChange={onChange} />);
expect(screen.queryByRole('checkbox')).not.toBeInTheDocument();
});

it('syncs displayed visibility when itemData changes externally without calling onChange', async () => {
// Start as staff-only: checkbox must not be present
mockUseCourseItemData.mockReturnValue({ data: { visibilityState: VisibilityTypes.STAFF_ONLY } });
const onChange = jest.fn();
const { rerender } = render(<VisibilitySection {...defaultProps} onChange={onChange} />);
expect(screen.queryByRole('checkbox')).not.toBeInTheDocument();

// Simulate the kebab-menu configure modal switching to student-visible:
// the mutation fires, the section refetches, and setQueryData updates the
// subsection cache — causing useCourseItemData to return the new state.
mockUseCourseItemData.mockReturnValue({ data: { visibilityState: undefined, hideAfterDue: false } });
rerender(<VisibilitySection {...defaultProps} onChange={onChange} />);

// Sidebar must now show the checkbox (only visible when student-visible)...
expect(await screen.findByRole('checkbox')).toBeInTheDocument();
// ...but must NOT trigger onChange (which would fire a redundant mutation)
expect(onChange).not.toHaveBeenCalled();
});

it('syncs hideAfterDue checkbox state when itemData changes externally without calling onChange', async () => {
// Start with hideAfterDue unchecked
mockUseCourseItemData.mockReturnValue({ data: { visibilityState: undefined, hideAfterDue: false } });
const onChange = jest.fn();
const { rerender } = render(<VisibilitySection {...defaultProps} onChange={onChange} />);
expect(await screen.findByRole('checkbox')).not.toBeChecked();

// External update sets hideAfterDue to true (e.g. saved via kebab configure modal)
mockUseCourseItemData.mockReturnValue({ data: { visibilityState: undefined, hideAfterDue: true } });
rerender(<VisibilitySection {...defaultProps} onChange={onChange} />);

expect(await screen.findByRole('checkbox')).toBeChecked();
expect(onChange).not.toHaveBeenCalled();
});
});
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { useEffect } from 'react';
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
import { Button, ButtonGroup, Form } from '@openedx/paragon';
import { useCourseItemData } from '@src/course-outline/data/apiHooks';
Expand Down Expand Up @@ -35,6 +36,20 @@ export const VisibilitySection = ({ itemId, isSubsection, onChange }: Props) =>
},
);

// Sync localState when itemData changes externally (e.g. visibility updated
// from the left-side kebab configure modal). skipCallback prevents re-triggering
// the onChange mutation — we're just reflecting a change that already happened.
useEffect(() => {
setLocalState({
value: {
isVisibleToStaffOnly: itemData?.visibilityState === VisibilityTypes.STAFF_ONLY,
hideAfterDue: itemData?.hideAfterDue,
},
skipCallback: true,
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [itemData?.visibilityState, itemData?.hideAfterDue]);

return (
<SidebarSection
title={intl.formatMessage(messages.subsectionVisibilityTitle)}
Expand Down