♻️(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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user