♻️(front) refactor RecurrenceEditor component

Improve RecurrenceEditor with better RRULE parsing, support
for all recurrence patterns (daily, weekly, monthly, yearly)
and cleaner UI with Cunningham components.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Nathan Panchout
2026-01-25 20:34:52 +01:00
parent 833d14d796
commit 8be9b1db52
2 changed files with 265 additions and 68 deletions

View File

@@ -1,4 +1,10 @@
.recurrence-editor {
&__label {
font-weight: 500;
color: #333;
font-size: 0.9375rem;
}
&__weekday-button {
width: 32px;
height: 32px;
@@ -23,6 +29,20 @@
}
}
}
&__warning {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1rem;
margin-top: 0.5rem;
background-color: #fff3cd;
border: 1px solid #ffc107;
border-radius: 4px;
color: #856404;
font-size: 0.875rem;
line-height: 1.4;
}
}
// Utility classes for layout
@@ -38,6 +58,10 @@
align-items: center;
}
&--flex-wrap {
flex-wrap: wrap;
}
&--gap-1rem {
gap: 1rem;
}

View File

@@ -1,41 +1,110 @@
import { Select, Input } from '@openfun/cunningham-react';
import { useState } from 'react';
import type { IcsRecurrenceRule } from 'ts-ics';
import { Select, Input } from '@gouvfr-lasuite/cunningham-react';
import { useState, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import type { IcsRecurrenceRule, IcsWeekDay } from 'ts-ics';
const WEEKDAYS = [
{ value: 'MO', label: 'L' },
{ value: 'TU', label: 'M' },
{ value: 'WE', label: 'M' },
{ value: 'TH', label: 'J' },
{ value: 'FR', label: 'V' },
{ value: 'SA', label: 'S' },
{ value: 'SU', label: 'D' },
] as const;
type RecurrenceFrequency = IcsRecurrenceRule['frequency'];
const RECURRENCE_OPTIONS = [
{ value: 'NONE', label: 'Non' },
{ value: 'DAILY', label: 'Tous les jours' },
{ value: 'WEEKLY', label: 'Toutes les semaines' },
{ value: 'MONTHLY', label: 'Tous les mois' },
{ value: 'YEARLY', label: 'Tous les ans' },
{ value: 'CUSTOM', label: 'Personnalisé...' },
] as const;
const WEEKDAY_KEYS: IcsWeekDay[] = ['MO', 'TU', 'WE', 'TH', 'FR', 'SA', 'SU'];
const MONTHS = [
{ value: 1, key: 'january' },
{ value: 2, key: 'february' },
{ value: 3, key: 'march' },
{ value: 4, key: 'april' },
{ value: 5, key: 'may' },
{ value: 6, key: 'june' },
{ value: 7, key: 'july' },
{ value: 8, key: 'august' },
{ value: 9, key: 'september' },
{ value: 10, key: 'october' },
{ value: 11, key: 'november' },
{ value: 12, key: 'december' },
];
interface RecurrenceEditorProps {
value?: IcsRecurrenceRule;
onChange: (rule: IcsRecurrenceRule | undefined) => void;
}
/**
* Validate day of month based on month (handles leap years and month lengths)
*/
function isValidDayForMonth(day: number, month?: number): boolean {
if (day < 1 || day > 31) return false;
if (!month) return true; // No month specified, allow any valid day
// Days per month (non-leap year)
const daysInMonth: Record<number, number> = {
1: 31, 2: 29, 3: 31, 4: 30, 5: 31, 6: 30,
7: 31, 8: 31, 9: 30, 10: 31, 11: 30, 12: 31
};
return day <= (daysInMonth[month] || 31);
}
/**
* Get warning message for invalid dates
*/
function getDateWarning(t: (key: string) => string, day: number, month?: number): string | null {
if (!month) return null;
if (month === 2 && day > 29) {
return t('calendar.recurrence.warnings.februaryMax');
}
if (month === 2 && day === 29) {
return t('calendar.recurrence.warnings.leapYear');
}
if ([4, 6, 9, 11].includes(month) && day > 30) {
return t('calendar.recurrence.warnings.monthMax30');
}
if (day > 31) {
return t('calendar.recurrence.warnings.dayMax31');
}
return null;
}
export function RecurrenceEditor({ value, onChange }: RecurrenceEditorProps) {
const { t } = useTranslation();
const [isCustom, setIsCustom] = useState(() => {
if (!value) return false;
return value.interval !== 1 || value.byDay?.length || value.count || value.until;
return value.interval !== 1 || value.byDay?.length || value.byMonthday?.length || value.byMonth?.length || value.count || value.until;
});
const getSimpleValue = () => {
// Generate options from translations
const recurrenceOptions = useMemo(() => [
{ value: 'NONE', label: t('calendar.recurrence.none') },
{ value: 'DAILY', label: t('calendar.recurrence.daily') },
{ value: 'WEEKLY', label: t('calendar.recurrence.weekly') },
{ value: 'MONTHLY', label: t('calendar.recurrence.monthly') },
{ value: 'YEARLY', label: t('calendar.recurrence.yearly') },
{ value: 'CUSTOM', label: t('calendar.recurrence.custom') },
], [t]);
const frequencyOptions = useMemo(() => [
{ value: 'DAILY', label: t('calendar.recurrence.days') },
{ value: 'WEEKLY', label: t('calendar.recurrence.weeks') },
{ value: 'MONTHLY', label: t('calendar.recurrence.months') },
{ value: 'YEARLY', label: t('calendar.recurrence.years') },
], [t]);
const weekdays = useMemo(() => WEEKDAY_KEYS.map(key => ({
value: key,
label: t(`calendar.recurrence.weekdays.${key.toLowerCase()}`),
})), [t]);
const monthOptions = useMemo(() => MONTHS.map(month => ({
value: String(month.value),
label: t(`calendar.recurrence.months.${month.key}`),
})), [t]);
const getSimpleValue = (): string => {
if (!value) return 'NONE';
if (isCustom) return 'CUSTOM';
return value.freq;
return value.frequency;
};
const handleSimpleChange = (newValue: string) => {
@@ -44,21 +113,23 @@ export function RecurrenceEditor({ value, onChange }: RecurrenceEditorProps) {
onChange(undefined);
return;
}
if (newValue === 'CUSTOM') {
setIsCustom(true);
onChange({
freq: 'WEEKLY',
frequency: 'WEEKLY',
interval: 1
});
return;
}
setIsCustom(false);
onChange({
freq: newValue as IcsRecurrenceRule['freq'],
frequency: newValue as RecurrenceFrequency,
interval: 1,
byDay: undefined,
byMonthday: undefined,
byMonth: undefined,
count: undefined,
until: undefined
});
@@ -66,63 +137,100 @@ export function RecurrenceEditor({ value, onChange }: RecurrenceEditorProps) {
const handleChange = (updates: Partial<IcsRecurrenceRule>) => {
onChange({
freq: 'WEEKLY',
frequency: 'WEEKLY',
interval: 1,
...value,
...updates
});
};
// Get selected day strings from byDay array
const getSelectedDays = (): IcsWeekDay[] => {
if (!value?.byDay) return [];
return value.byDay.map(d => typeof d === 'string' ? d as IcsWeekDay : d.day);
};
const isDaySelected = (day: IcsWeekDay): boolean => {
return getSelectedDays().includes(day);
};
const toggleDay = (day: IcsWeekDay) => {
const currentDays = getSelectedDays();
const newDays = currentDays.includes(day)
? currentDays.filter(d => d !== day)
: [...currentDays, day];
handleChange({ byDay: newDays.map(d => ({ day: d })) });
};
// Get selected month day
const getMonthDay = (): number => {
return value?.byMonthday?.[0] ?? 1;
};
// Get selected month for yearly recurrence
const getMonth = (): string => {
return String(value?.byMonth?.[0] ?? 1);
};
const handleMonthDayChange = (day: number) => {
handleChange({ byMonthday: [day] });
};
const handleMonthChange = (month: number) => {
handleChange({ byMonth: [month] });
};
// Calculate date warning
const monthDay = getMonthDay();
const selectedMonth = value?.frequency === 'YEARLY' ? parseInt(getMonth()) : undefined;
const dateWarning = isCustom && (value?.frequency === 'MONTHLY' || value?.frequency === 'YEARLY')
? getDateWarning(t, monthDay, selectedMonth)
: null;
return (
<div className="recurrence-editor-layout recurrence-editor-layout--gap-1rem">
<Select
label="Répéter"
label={t('calendar.recurrence.label')}
value={getSimpleValue()}
options={RECURRENCE_OPTIONS}
onChange={(e) => handleSimpleChange(e.target.value)}
options={recurrenceOptions}
onChange={(e) => handleSimpleChange(String(e.target.value ?? ''))}
fullWidth
/>
{isCustom && (
<div className="recurrence-editor-layout recurrence-editor-layout--gap-1rem recurrence-editor-layout--margin-left-2rem">
<div className="recurrence-editor-layout recurrence-editor-layout--row recurrence-editor-layout--align-center recurrence-editor-layout--gap-1rem">
<span>Répéter tous les</span>
<span>{t('calendar.recurrence.everyLabel')}</span>
<Input
label=""
type="number"
min={1}
value={value?.interval || 1}
onChange={(e) => handleChange({ interval: parseInt(e.target.value) })}
style={{ width: '80px' }}
onChange={(e) => handleChange({ interval: parseInt(e.target.value) || 1 })}
/>
<Select
value={value?.freq || 'WEEKLY'}
options={[
{ value: 'DAILY', label: 'jours' },
{ value: 'WEEKLY', label: 'semaines' },
{ value: 'MONTHLY', label: 'mois' },
{ value: 'YEARLY', label: 'années' },
]}
onChange={(e) => handleChange({ freq: e.target.value as IcsRecurrenceRule['freq'] })}
label=""
value={value?.frequency || 'WEEKLY'}
options={frequencyOptions}
onChange={(e) => handleChange({ frequency: String(e.target.value ?? '') as RecurrenceFrequency })}
/>
</div>
{value?.freq === 'WEEKLY' && (
{/* WEEKLY: Day selection */}
{value?.frequency === 'WEEKLY' && (
<div className="recurrence-editor-layout">
<span>Répéter le</span>
<div className="recurrence-editor-layout recurrence-editor-layout--row recurrence-editor-layout--gap-0-5rem recurrence-editor-layout--margin-top-0-5rem">
{WEEKDAYS.map(day => {
const isSelected = value?.byDay?.includes(day.value) || false;
<span className="recurrence-editor__label">{t('calendar.recurrence.repeatOn')}</span>
<div className="recurrence-editor-layout recurrence-editor-layout--row recurrence-editor-layout--gap-0-5rem recurrence-editor-layout--margin-top-0-5rem recurrence-editor-layout--flex-wrap">
{weekdays.map(day => {
const isSelected = isDaySelected(day.value);
return (
<button
key={day.value}
type="button"
className={`recurrence-editor__weekday-button ${isSelected ? 'recurrence-editor__weekday-button--selected' : ''}`}
onClick={(e) => {
e.preventDefault();
const byDay = value?.byDay || [];
handleChange({
byDay: byDay.includes(day.value)
? byDay.filter(d => d !== day.value)
: [...byDay, day.value]
});
toggleDay(day.value);
}}
>
{day.label}
@@ -133,36 +241,100 @@ export function RecurrenceEditor({ value, onChange }: RecurrenceEditorProps) {
</div>
)}
{/* MONTHLY: Day of month selection */}
{value?.frequency === 'MONTHLY' && (
<div className="recurrence-editor-layout">
<span className="recurrence-editor__label">{t('calendar.recurrence.repeatOnDay')}</span>
<div className="recurrence-editor-layout recurrence-editor-layout--row recurrence-editor-layout--align-center recurrence-editor-layout--gap-0-5rem recurrence-editor-layout--margin-top-0-5rem">
<span>{t('calendar.recurrence.dayOfMonth')}</span>
<Input
label=""
type="number"
min={1}
max={31}
value={getMonthDay()}
onChange={(e) => {
const day = parseInt(e.target.value) || 1;
if (day >= 1 && day <= 31) {
handleMonthDayChange(day);
}
}}
/>
</div>
{dateWarning && (
<div className="recurrence-editor__warning">
{dateWarning}
</div>
)}
</div>
)}
{/* YEARLY: Month + Day selection */}
{value?.frequency === 'YEARLY' && (
<div className="recurrence-editor-layout">
<span className="recurrence-editor__label">{t('calendar.recurrence.repeatOnDate')}</span>
<div className="recurrence-editor-layout recurrence-editor-layout--row recurrence-editor-layout--align-center recurrence-editor-layout--gap-0-5rem recurrence-editor-layout--margin-top-0-5rem">
<Select
label=""
value={getMonth()}
options={monthOptions}
onChange={(e) => handleMonthChange(parseInt(String(e.target.value)) || 1)}
/>
<Input
label=""
type="number"
min={1}
max={31}
value={getMonthDay()}
onChange={(e) => {
const day = parseInt(e.target.value) || 1;
if (day >= 1 && day <= 31) {
handleMonthDayChange(day);
}
}}
/>
</div>
{dateWarning && (
<div className="recurrence-editor__warning">
{dateWarning}
</div>
)}
</div>
)}
{/* End conditions */}
<div className="recurrence-editor-layout">
<span>Se termine</span>
<span className="recurrence-editor__label">{t('calendar.recurrence.endsLabel')}</span>
<div className="recurrence-editor-layout recurrence-editor-layout--gap-0-5rem recurrence-editor-layout--margin-top-0-5rem">
<div className="recurrence-editor-layout recurrence-editor-layout--row recurrence-editor-layout--align-center recurrence-editor-layout--gap-0-5rem">
<input
type="radio"
id="end-never"
name="end-type"
checked={!value?.count && !value?.until}
onChange={() => handleChange({ count: undefined, until: undefined })}
/>
<span>Jamais</span>
<label htmlFor="end-never">{t('calendar.recurrence.never')}</label>
</div>
<div className="recurrence-editor-layout recurrence-editor-layout--row recurrence-editor-layout--align-center recurrence-editor-layout--gap-0-5rem">
<input
type="radio"
id="end-date"
name="end-type"
checked={!!value?.until}
onChange={() => handleChange({ until: new Date(), count: undefined })}
onChange={() => handleChange({ until: { type: 'DATE', date: new Date() }, count: undefined })}
/>
<span>Le</span>
<label htmlFor="end-date">{t('calendar.recurrence.on')}</label>
{value?.until && (
<Input
label=""
type="date"
value={value.until.date ? new Date(value.until.date).toISOString().split('T')[0] : ''}
onChange={(e) => handleChange({
value={value.until.date instanceof Date ? value.until.date.toISOString().split('T')[0] : ''}
onChange={(e) => handleChange({
until: {
type: 'DATE-TIME',
type: 'DATE',
date: new Date(e.target.value),
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
}
})}
/>
@@ -172,21 +344,22 @@ export function RecurrenceEditor({ value, onChange }: RecurrenceEditorProps) {
<div className="recurrence-editor-layout recurrence-editor-layout--row recurrence-editor-layout--align-center recurrence-editor-layout--gap-0-5rem">
<input
type="radio"
id="end-count"
name="end-type"
checked={!!value?.count}
onChange={() => handleChange({ count: 1, until: undefined })}
onChange={() => handleChange({ count: 10, until: undefined })}
/>
<span>Après</span>
<label htmlFor="end-count">{t('calendar.recurrence.after')}</label>
{value?.count !== undefined && (
<>
<Input
label=""
type="number"
min={1}
value={value.count}
onChange={(e) => handleChange({ count: parseInt(e.target.value) })}
style={{ width: '80px' }}
/>
<span>occurrences</span>
onChange={(e) => handleChange({ count: parseInt(e.target.value) || 1 })}
/>
<span>{t('calendar.recurrence.occurrences')}</span>
</>
)}
</div>
@@ -196,4 +369,4 @@ export function RecurrenceEditor({ value, onChange }: RecurrenceEditorProps) {
)}
</div>
);
}
}