Skip to content

Commit 21b722d

Browse files
fix: update release date for sidebar on updating it from outline side
1 parent 3f531d5 commit 21b722d

4 files changed

Lines changed: 95 additions & 5 deletions

File tree

src/course-outline/outline-sidebar/info-sidebar/sharedSettings/ReleaseSection.test.tsx

Lines changed: 37 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,26 +4,36 @@ import userEvent from '@testing-library/user-event';
44
import { useCourseItemData } from '@src/course-outline/data/apiHooks';
55
import { ReleaseSection } from './ReleaseSection';
66

7-
// Make useStateWithCallback synchronous so callbacks call onChange immediately
7+
// Make useStateWithCallback synchronous so callbacks call onChange immediately.
8+
// Also handles the { value, skipCallback } object form used by the external-sync useEffect.
89
jest.mock('@src/hooks', () => ({
910
useStateWithCallback: (defaultValue: any, cb?: any) => {
1011
const { useState } = jest.requireActual('react');
1112
const [state, setState] = useState(defaultValue);
1213
const wrappedSetState = (val: any) => {
13-
const newVal = typeof val === 'function' ? val(state) : val;
14+
let newVal;
15+
let skip = false;
16+
if (typeof val === 'object' && val !== null && 'value' in val && 'skipCallback' in val) {
17+
newVal = val.value;
18+
skip = val.skipCallback;
19+
} else {
20+
newVal = typeof val === 'function' ? val(state) : val;
21+
}
1422
setState(newVal);
15-
if (cb) { cb(newVal); }
23+
if (cb && !skip) { cb(newVal); }
1624
};
1725
return [state, wrappedSetState];
1826
},
1927
}));
2028

21-
// Mock DatepickerControl so we can trigger onChange easily
29+
// Mock DatepickerControl so we can trigger onChange and inspect the current value.
2230
jest.mock('@src/generic/datepicker-control', () => ({
2331
DATEPICKER_TYPES: { date: 'date', time: 'time' },
24-
DatepickerControl: ({ onChange, type }: any) => (
32+
DatepickerControl: ({ onChange, type, value }: any) => (
2533
<button
2634
type="button"
35+
data-testid={`datepicker-${type}`}
36+
data-value={value}
2737
onClick={() => onChange(type === 'date' ? '2025-12-31' : '12:00')}
2838
>
2939
{type}
@@ -60,4 +70,26 @@ describe('ReleaseSection', () => {
6070
await user.click(screen.getByRole('button', { name: 'time' }));
6171
expect(onChange).toHaveBeenCalledWith('12:00');
6272
});
73+
74+
it('syncs displayed value when itemData.start changes externally without calling onChange', () => {
75+
// Simulate initial state (e.g. subsection has a release date set)
76+
const initialDate = '2024-01-01T00:00:00Z';
77+
mockUseCourseItemData.mockReturnValue({ data: { start: initialDate } });
78+
const onChange = jest.fn();
79+
const { rerender } = render(<ReleaseSection itemId="i" onChange={onChange} />);
80+
81+
expect(screen.getByTestId('datepicker-date')).toHaveAttribute('data-value', initialDate);
82+
83+
// Simulate the kebab-menu configure modal saving a new release date:
84+
// the mutation fires, the section refetches, and setQueryData updates the
85+
// subsection cache — causing useCourseItemData to return the new date.
86+
const updatedDate = '2025-06-15T00:00:00Z';
87+
mockUseCourseItemData.mockReturnValue({ data: { start: updatedDate } });
88+
rerender(<ReleaseSection itemId="i" onChange={onChange} />);
89+
90+
// The sidebar must reflect the updated date...
91+
expect(screen.getByTestId('datepicker-date')).toHaveAttribute('data-value', updatedDate);
92+
// ...but must NOT trigger onChange (which would fire a redundant mutation)
93+
expect(onChange).not.toHaveBeenCalled();
94+
});
6395
});

src/course-outline/outline-sidebar/info-sidebar/sharedSettings/ReleaseSection.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { useEffect } from 'react';
12
import { useIntl } from '@edx/frontend-platform/i18n';
23
import { Stack } from '@openedx/paragon';
34
import { useCourseItemData } from '@src/course-outline/data/apiHooks';
@@ -19,6 +20,14 @@ export const ReleaseSection = ({ itemId, onChange }: Props) => {
1920
(val) => onChange(val),
2021
);
2122

23+
// Sync localState when itemData changes externally (e.g. release date updated
24+
// from the left-side kebab configure modal). skipCallback prevents re-triggering
25+
// the onChange mutation — we're just reflecting a change that already happened.
26+
useEffect(() => {
27+
setLocalState({ value: itemData?.start, skipCallback: true });
28+
// eslint-disable-next-line react-hooks/exhaustive-deps
29+
}, [itemData?.start]);
30+
2231
return (
2332
<SidebarSection
2433
title={intl.formatMessage(messages.subsectionReleaseTitle)}

src/course-outline/outline-sidebar/info-sidebar/sharedSettings/VisibilitySection.test.tsx

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,4 +79,38 @@ describe('VisibilitySection component', () => {
7979
render(<VisibilitySection {...defaultProps} onChange={onChange} />);
8080
expect(screen.queryByRole('checkbox')).not.toBeInTheDocument();
8181
});
82+
83+
it('syncs displayed visibility when itemData changes externally without calling onChange', async () => {
84+
// Start as staff-only: checkbox must not be present
85+
mockUseCourseItemData.mockReturnValue({ data: { visibilityState: VisibilityTypes.STAFF_ONLY } });
86+
const onChange = jest.fn();
87+
const { rerender } = render(<VisibilitySection {...defaultProps} onChange={onChange} />);
88+
expect(screen.queryByRole('checkbox')).not.toBeInTheDocument();
89+
90+
// Simulate the kebab-menu configure modal switching to student-visible:
91+
// the mutation fires, the section refetches, and setQueryData updates the
92+
// subsection cache — causing useCourseItemData to return the new state.
93+
mockUseCourseItemData.mockReturnValue({ data: { visibilityState: undefined, hideAfterDue: false } });
94+
rerender(<VisibilitySection {...defaultProps} onChange={onChange} />);
95+
96+
// Sidebar must now show the checkbox (only visible when student-visible)...
97+
expect(await screen.findByRole('checkbox')).toBeInTheDocument();
98+
// ...but must NOT trigger onChange (which would fire a redundant mutation)
99+
expect(onChange).not.toHaveBeenCalled();
100+
});
101+
102+
it('syncs hideAfterDue checkbox state when itemData changes externally without calling onChange', async () => {
103+
// Start with hideAfterDue unchecked
104+
mockUseCourseItemData.mockReturnValue({ data: { visibilityState: undefined, hideAfterDue: false } });
105+
const onChange = jest.fn();
106+
const { rerender } = render(<VisibilitySection {...defaultProps} onChange={onChange} />);
107+
expect(await screen.findByRole('checkbox')).not.toBeChecked();
108+
109+
// External update sets hideAfterDue to true (e.g. saved via kebab configure modal)
110+
mockUseCourseItemData.mockReturnValue({ data: { visibilityState: undefined, hideAfterDue: true } });
111+
rerender(<VisibilitySection {...defaultProps} onChange={onChange} />);
112+
113+
expect(await screen.findByRole('checkbox')).toBeChecked();
114+
expect(onChange).not.toHaveBeenCalled();
115+
});
82116
});

src/course-outline/outline-sidebar/info-sidebar/sharedSettings/VisibilitySection.tsx

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { useEffect } from 'react';
12
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
23
import { Button, ButtonGroup, Form } from '@openedx/paragon';
34
import { useCourseItemData } from '@src/course-outline/data/apiHooks';
@@ -35,6 +36,20 @@ export const VisibilitySection = ({ itemId, isSubsection, onChange }: Props) =>
3536
},
3637
);
3738

39+
// Sync localState when itemData changes externally (e.g. visibility updated
40+
// from the left-side kebab configure modal). skipCallback prevents re-triggering
41+
// the onChange mutation — we're just reflecting a change that already happened.
42+
useEffect(() => {
43+
setLocalState({
44+
value: {
45+
isVisibleToStaffOnly: itemData?.visibilityState === VisibilityTypes.STAFF_ONLY,
46+
hideAfterDue: itemData?.hideAfterDue,
47+
},
48+
skipCallback: true,
49+
});
50+
// eslint-disable-next-line react-hooks/exhaustive-deps
51+
}, [itemData?.visibilityState, itemData?.hideAfterDue]);
52+
3853
return (
3954
<SidebarSection
4055
title={intl.formatMessage(messages.subsectionVisibilityTitle)}

0 commit comments

Comments
 (0)