Skip to content
Merged
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 @@ -2,10 +2,16 @@ import { createSelector, createSelectorMemoized } from '@base-ui-components/util
import {
RecurringEventPresetKey,
RecurringEventRecurrenceRule,
RecurringEventWeekDayCode,
SchedulerProcessedDate,
SchedulerValidDate,
} from '../models';
import { SchedulerState as State } from '../utils/SchedulerStore/SchedulerStore.types';
import { getWeekDayCode, serializeRRule } from '../utils/recurring-event-utils';
import {
computeMonthlyOrdinal,
getWeekDayCode,
serializeRRule,
} from '../utils/recurring-event-utils';

export const schedulerRecurringEventSelectors = {
/**
Expand Down Expand Up @@ -129,4 +135,41 @@ export const schedulerRecurringEventSelectors = {
return serializeRRule(adapter, rruleA) === serializeRRule(adapter, rruleB);
},
),
/**
* Returns the 7 week days with code and date, starting at startOfWeek(visibleDate).
*/
weeklyDays: createSelectorMemoized(
(state: State) => state.adapter,
(state: State) => state.visibleDate,
(adapter, visibleDate): { code: RecurringEventWeekDayCode; date: SchedulerValidDate }[] => {
const start = adapter.startOfWeek(visibleDate);
return Array.from({ length: 7 }, (_, i) => {
const date = adapter.addDays(start, i);
return { code: getWeekDayCode(adapter, date), date };
});
},
),

/**
* Returns month reference for the given occurrence: dayOfMonth, weekday code and ordinal.
*/
monthlyReference: createSelectorMemoized(
(state: State) => state.adapter,
(
adapter,
date: SchedulerProcessedDate,
): {
dayOfMonth: number;
code: RecurringEventWeekDayCode;
ord: number;
date: SchedulerValidDate;
} => {
return {
dayOfMonth: adapter.getDate(date.value),
code: getWeekDayCode(adapter, date.value),
ord: computeMonthlyOrdinal(adapter, date.value),
date: date.value,
};
},
),
};
Original file line number Diff line number Diff line change
Expand Up @@ -389,7 +389,6 @@ p {
border: none;
padding: 0;
margin: 0;
font-size: var(--font-size-2);

&[data-disabled] {
color: var(--disabled-text-color);
Expand All @@ -409,6 +408,7 @@ p {
display: flex;
align-items: center;
gap: var(--space-2);
font-size: var(--font-size-2);
}

.EventPopoverAfterTimesInputWrapper {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -942,6 +942,124 @@ describe('<EventPopoverContent />', () => {
adapter.startOfDay(adapter.date('2025-07-20T00:00:00')),
);
});

it('should submit custom weekly with selected weekdays', async () => {
const onEventsChange = spy();

const { user } = render(
<EventCalendarProvider
events={[DEFAULT_EVENT]}
resources={resources}
onEventsChange={onEventsChange}
>
<Popover.Root open>
<EventPopoverContent {...defaultProps} />
</Popover.Root>
</EventCalendarProvider>,
);

await user.click(screen.getByRole('tab', { name: /recurrence/i }));
await user.click(screen.getByRole('combobox', { name: /recurrence/i }));
await user.click(await screen.findByRole('option', { name: /custom/i }));

const repeatGroup = screen.getByRole('group', { name: /repeat/i });
const freqCombo = within(repeatGroup).getByRole('combobox');
await user.click(freqCombo);
await user.click(await screen.findByRole('option', { name: /weeks/i }));

// Select Monday and Friday in the weekly day toggles
await user.click(screen.getByRole('button', { name: /monday/i }));
await user.click(screen.getByRole('button', { name: /friday/i }));

await user.click(screen.getByRole('button', { name: /save changes/i }));

expect(onEventsChange.calledOnce).to.equal(true);
const updated = onEventsChange.firstCall.firstArg[0];

expect(updated.rrule).to.deep.equal({
freq: 'WEEKLY',
interval: 1,
byDay: ['MO', 'FR'],
byMonthDay: [],
});
});

it('should submit custom monthly with "day of month" option', async () => {
const onEventsChange = spy();

const { user } = render(
<EventCalendarProvider
events={[DEFAULT_EVENT]}
resources={resources}
onEventsChange={onEventsChange}
>
<Popover.Root open>
<EventPopoverContent {...defaultProps} />
</Popover.Root>
</EventCalendarProvider>,
);

await user.click(screen.getByRole('tab', { name: /recurrence/i }));
await user.click(screen.getByRole('combobox', { name: /recurrence/i }));
await user.click(await screen.findByRole('option', { name: /custom/i }));

const repeatGroup = screen.getByRole('group', { name: /repeat/i });
const freqCombo = within(repeatGroup).getByRole('combobox');
await user.click(freqCombo);
await user.click(await screen.findByRole('option', { name: /months/i }));

await user.click(screen.getByRole('button', { name: /day 26/i })); // DEFAULT_EVENT is 2025-05-26

await user.click(screen.getByRole('button', { name: /save changes/i }));

expect(onEventsChange.calledOnce).to.equal(true);
const updated = onEventsChange.firstCall.firstArg[0];

expect(updated.rrule).to.deep.equal({
freq: 'MONTHLY',
interval: 1,
byMonthDay: [26],
});
});

it('should submit custom monthly with "ordinal weekday" option', async () => {
const onEventsChange = spy();

const { user } = render(
<EventCalendarProvider
events={[DEFAULT_EVENT]}
resources={resources}
onEventsChange={onEventsChange}
>
<Popover.Root open>
<EventPopoverContent {...defaultProps} />
</Popover.Root>
</EventCalendarProvider>,
);

await user.click(screen.getByRole('tab', { name: /recurrence/i }));
await user.click(screen.getByRole('combobox', { name: /recurrence/i }));
await user.click(await screen.findByRole('option', { name: /custom/i }));

const repeatGroup = screen.getByRole('group', { name: /repeat/i });
const freqCombo = within(repeatGroup).getByRole('combobox');
await user.click(freqCombo);
await user.click(await screen.findByRole('option', { name: /months/i }));

// The DEFAULT_EVENT (2025-05-26 Mon) is the last Monday of the month ("-1MO")
await user.click(screen.getByRole('button', { name: /mon.*last week/i }));

await user.click(screen.getByRole('button', { name: /save changes/i }));

expect(onEventsChange.calledOnce).to.equal(true);
const updated = onEventsChange.firstCall.firstArg[0];

expect(updated.rrule).to.deep.equal({
freq: 'MONTHLY',
interval: 1,
byDay: ['-1MO'],
});
});
});
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -143,9 +143,7 @@ export function FormContent(props: FormContentProps) {
if (controlled.recurrenceSelection === null) {
rruleToSubmit = undefined;
} else if (controlled.recurrenceSelection === 'custom') {
rruleToSubmit = {
...controlled.rruleDraft,
};
rruleToSubmit = controlled.rruleDraft;
} else {
rruleToSubmit = recurrencePresets[controlled.recurrenceSelection];
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,22 @@ import { Input } from '@base-ui-components/react/input';
import { Select } from '@base-ui-components/react/select';
import { RadioGroup } from '@base-ui-components/react/radio-group';
import { Radio } from '@base-ui-components/react/radio';
import { Separator } from '@base-ui-components/react/separator';
import { ChevronDown } from 'lucide-react';
import { Toggle } from '@base-ui-components/react/toggle';
import { ToggleGroup } from '@base-ui-components/react/toggle-group';
import {
SchedulerEventOccurrence,
RecurringEventFrequency,
RecurringEventPresetKey,
RecurringEventByDayValue,
RecurringEventWeekDayCode,
} from '@mui/x-scheduler-headless/models';
import { useSchedulerStoreContext } from '@mui/x-scheduler-headless/use-scheduler-store-context';
import { useAdapter } from '@mui/x-scheduler-headless/use-adapter';
import { schedulerEventSelectors } from '@mui/x-scheduler-headless/scheduler-selectors';
import {
schedulerEventSelectors,
schedulerRecurringEventSelectors,
} from '@mui/x-scheduler-headless/scheduler-selectors';
import { Tabs } from '@base-ui-components/react/tabs';
import { useTranslations } from '../../utils/TranslationsContext';
import { ControlledValue, EndsSelection, getEndsSelectionFromRRule } from './utils';
Expand All @@ -42,6 +48,12 @@ export function RecurrenceTab(props: RecurrenceTabProps) {
occurrence.id,
);
const customDisabled = controlled.recurrenceSelection !== 'custom' || isPropertyReadOnly('rrule');
const monthlyRef = useStore(
store,
schedulerRecurringEventSelectors.monthlyReference,
occurrence.start,
);
const weeklyDays = useStore(store, schedulerRecurringEventSelectors.weeklyDays);

const handleRecurrenceSelectionChange = (value: RecurringEventPresetKey | null | 'custom') => {
if (value === 'custom') {
Expand Down Expand Up @@ -133,6 +145,30 @@ export function RecurrenceTab(props: RecurrenceTabProps) {
}));
};

const handleChangeWeeklyDays = (next: RecurringEventWeekDayCode[]) => {
setControlled((prev) => ({
...prev,
rruleDraft: {
...prev.rruleDraft,
byDay: next,
},
}));
};

const handleChangeMonthlyGroup = (next: string[]) => {
const nextKey = next[0];

setControlled((prev) => {
if (nextKey === 'byDay') {
const value = `${monthlyRef.ord}${monthlyRef.code}` as RecurringEventByDayValue;
const { byMonthDay, ...rest } = prev.rruleDraft;
return { ...prev, rruleDraft: { ...rest, byDay: [value] } };
}
const { byDay, ...rest } = prev.rruleDraft;
return { ...prev, rruleDraft: { ...rest, byMonthDay: [monthlyRef.dayOfMonth] } };
});
};

const customEndsValue: 'never' | 'after' | 'until' = getEndsSelectionFromRRule(
controlled.rruleDraft,
);
Expand Down Expand Up @@ -183,6 +219,46 @@ export function RecurrenceTab(props: RecurrenceTabProps) {
},
];

const weeklyDayItems = React.useMemo(
() =>
weeklyDays.map(({ code, date }) => ({
value: code,
ariaLabel: adapter.format(date, 'weekday'),
label: adapter.format(date, 'weekdayShort'),
})),
[adapter, weeklyDays],
);

const monthlyItems = React.useMemo(() => {
const ordinal = monthlyRef.ord;
const dayOfMonthLabel = translations.recurrenceMonthlyDayOfMonthLabel?.(monthlyRef.dayOfMonth);
const isLast = ordinal === -1;
const weekdayShort = adapter.formatByString(monthlyRef.date, 'ccc');
const weekAriaLabel = isLast
? translations.recurrenceMonthlyLastWeekAriaLabel(weekday)
: translations.recurrenceMonthlyWeekNumberAriaLabel?.(ordinal, weekday);
const weekLabel = isLast
? translations.recurrenceMonthlyLastWeekLabel(weekdayShort)
: translations.recurrenceMonthlyWeekNumberLabel?.(ordinal, weekdayShort);

return [
{
value: 'byMonthDay',
ariaLabel: dayOfMonthLabel,
label: dayOfMonthLabel,
},
{
value: 'byDay',
ariaLabel: weekAriaLabel,
label: weekLabel,
},
];
}, [adapter, monthlyRef.date, monthlyRef.dayOfMonth, monthlyRef.ord, translations, weekday]);

const monthlyMode: 'byMonthDay' | 'byDay' = controlled.rruleDraft.byDay?.length
? 'byDay'
: 'byMonthDay';

return (
<Tabs.Panel value="recurrence" keepMounted>
<div className="EventPopoverMainContent">
Expand Down Expand Up @@ -235,7 +311,6 @@ export function RecurrenceTab(props: RecurrenceTabProps) {
<Field.Root className="EventPopoverInputsRow">
{translations.recurrenceEveryLabel}
<Input
name="interval"
type="number"
min={1}
value={controlled.rruleDraft.interval}
Expand Down Expand Up @@ -275,15 +350,40 @@ export function RecurrenceTab(props: RecurrenceTabProps) {
</Field.Root>
</Fieldset.Root>
{controlled.recurrenceSelection === 'custom' && controlled.rruleDraft.freq === 'WEEKLY' && (
<p className="EventPopoverRecurrenceFieldset">TODO: Weekly Fields</p>
<Field.Root className="EventPopoverInputsRow">
<Field.Label>{translations.recurrenceWeeklyMonthlySpecificInputsLabel}</Field.Label>
<ToggleGroup
className="ToggleGroup"
multiple
value={controlled.rruleDraft.byDay}
onValueChange={handleChangeWeeklyDays}
>
{weeklyDayItems.map(({ value, ariaLabel, label }) => (
<Toggle key={value} aria-label={ariaLabel} value={value} className="ToggleItem">
{label}
</Toggle>
))}
</ToggleGroup>
</Field.Root>
)}
{controlled.recurrenceSelection === 'custom' &&
controlled.rruleDraft.freq === 'MONTHLY' && (
<p className="EventPopoverRecurrenceFieldset">TODO: Monthly Fields</p>
<Field.Root className="EventPopoverInputsRow">
<Field.Label>{translations.recurrenceWeeklyMonthlySpecificInputsLabel}</Field.Label>
<ToggleGroup
className="ToggleGroup"
value={[monthlyMode]}
onValueChange={handleChangeMonthlyGroup}
>
{monthlyItems.map(({ value, ariaLabel, label }) => (
<Toggle key={value} aria-label={ariaLabel} value={value} className="ToggleItem">
{label}
</Toggle>
))}
</ToggleGroup>
</Field.Root>
)}

<Separator className="EventPopoverSeparator" />

<Fieldset.Root
className="EventPopoverRecurrenceFieldset"
disabled={customDisabled}
Expand Down Expand Up @@ -327,7 +427,6 @@ export function RecurrenceTab(props: RecurrenceTabProps) {
</Radio.Root>
<div className="EventPopoverAfterTimesInputWrapper">
<Input
name="count"
type="number"
min={1}
value={customEndsValue === 'after' ? (controlled.rruleDraft.count ?? 1) : 1}
Expand All @@ -353,7 +452,6 @@ export function RecurrenceTab(props: RecurrenceTabProps) {
</span>
</Radio.Root>
<Input
name="until"
type="date"
value={
customEndsValue === 'until' && controlled.rruleDraft.until
Expand Down
Loading
Loading