Merge: implement rrule support when editing events

This is a merge of #73 in addition to some fixes and cleanups.
There are still things to be done, especially in the design department,
but things are at least working and usable.
master
Tom Hacohen 5 years ago
commit 09ff87ba33

@ -33,6 +33,8 @@ import * as EteSync from 'etesync';
import { getCurrentTimezone } from '../helpers'; import { getCurrentTimezone } from '../helpers';
import { EventType, timezoneLoadFromName } from '../pim-types'; import { EventType, timezoneLoadFromName } from '../pim-types';
import RRule, { RRuleOptions } from '../widgets/RRule';
interface PropsType { interface PropsType {
collections: EteSync.CollectionInfo[]; collections: EteSync.CollectionInfo[];
@ -52,6 +54,7 @@ class EventEdit extends React.PureComponent<PropsType> {
start?: Date; start?: Date;
end?: Date; end?: Date;
timezone: string | null; timezone: string | null;
rrule?: RRuleOptions;
location: string; location: string;
description: string; description: string;
journalUid: string; journalUid: string;
@ -104,6 +107,7 @@ class EventEdit extends React.PureComponent<PropsType> {
this.state.location = event.location ? event.location : ''; this.state.location = event.location ? event.location : '';
this.state.description = event.description ? event.description : ''; this.state.description = event.description ? event.description : '';
this.state.timezone = event.timezone; this.state.timezone = event.timezone;
this.state.rrule = this.props.item?.component.getFirstPropertyValue<ICAL.Recur>('rrule')?.toJSON();
} else { } else {
this.state.uid = uuid.v4(); this.state.uid = uuid.v4();
} }
@ -121,6 +125,8 @@ class EventEdit extends React.PureComponent<PropsType> {
this.handleInputChange = this.handleInputChange.bind(this); this.handleInputChange = this.handleInputChange.bind(this);
this.toggleAllDay = this.toggleAllDay.bind(this); this.toggleAllDay = this.toggleAllDay.bind(this);
this.onDeleteRequest = this.onDeleteRequest.bind(this); this.onDeleteRequest = this.onDeleteRequest.bind(this);
this.toggleRecurring = this.toggleRecurring.bind(this);
this.handleRRuleChange = this.handleRRuleChange.bind(this);
} }
public UNSAFE_componentWillReceiveProps(nextProps: any) { public UNSAFE_componentWillReceiveProps(nextProps: any) {
@ -154,7 +160,15 @@ class EventEdit extends React.PureComponent<PropsType> {
public toggleAllDay() { public toggleAllDay() {
this.setState({ allDay: !this.state.allDay }); this.setState({ allDay: !this.state.allDay });
} }
public toggleRecurring() {
const value = this.state.rrule ? undefined : { freq: 'WEEKLY', interval: 1 };
this.setState({ rrule: value });
}
public handleRRuleChange(rrule: RRuleOptions): void {
this.setState({ rrule: rrule });
}
public onSubmit(e: React.FormEvent<any>) { public onSubmit(e: React.FormEvent<any>) {
e.preventDefault(); e.preventDefault();
@ -191,6 +205,7 @@ class EventEdit extends React.PureComponent<PropsType> {
: :
new EventType() new EventType()
; ;
event.uid = this.state.uid; event.uid = this.state.uid;
event.summary = this.state.title; event.summary = this.state.title;
event.startDate = startDate; event.startDate = startDate;
@ -204,6 +219,18 @@ class EventEdit extends React.PureComponent<PropsType> {
event.endDate = event.endDate?.convertToZone(timezone); event.endDate = event.endDate?.convertToZone(timezone);
} }
} }
if (this.state.rrule) {
const rruleData: ICAL.RecurData = {};
for (const key of Object.keys(this.state.rrule)) {
const value = this.state.rrule[key];
if ((value === undefined) || (value?.length === 0)) {
continue;
}
rruleData[key] = value;
}
event.component.updatePropertyWithValue('rrule', new ICAL.Recur(rruleData));
}
event.component.updatePropertyWithValue('last-modified', ICAL.Time.now()); event.component.updatePropertyWithValue('last-modified', ICAL.Time.now());
@ -236,7 +263,7 @@ class EventEdit extends React.PureComponent<PropsType> {
const differentTimezone = this.state.timezone && (this.state.timezone !== getCurrentTimezone()) && timezoneLoadFromName(this.state.timezone); const differentTimezone = this.state.timezone && (this.state.timezone !== getCurrentTimezone()) && timezoneLoadFromName(this.state.timezone);
return ( return (
<React.Fragment> <>
<h2> <h2>
{this.props.item ? 'Edit Event' : 'New Event'} {this.props.item ? 'Edit Event' : 'New Event'}
</h2> </h2>
@ -335,7 +362,25 @@ class EventEdit extends React.PureComponent<PropsType> {
value={this.state.description} value={this.state.description}
onChange={this.handleInputChange} onChange={this.handleInputChange}
/> />
<FormGroup>
<FormControlLabel
control={
<Switch
name="recurring"
checked={!!this.state.rrule}
onChange={this.toggleRecurring}
color="primary"
/>
}
label="Recurring"
/>
</FormGroup>
{this.state.rrule &&
<RRule
onChange={this.handleRRuleChange}
rrule={this.state.rrule ? this.state.rrule : { freq: 'DAILY', interval: 1 }}
/>
}
<div style={styles.submit}> <div style={styles.submit}>
<Button <Button
variant="contained" variant="contained"
@ -382,7 +427,7 @@ class EventEdit extends React.PureComponent<PropsType> {
> >
Are you sure you would like to delete this event? Are you sure you would like to delete this event?
</ConfirmationDialog> </ConfirmationDialog>
</React.Fragment> </>
); );
} }
} }

@ -173,7 +173,8 @@ declare module 'ical.js' {
public bysetpos?: number[]; public bysetpos?: number[];
} }
export class Recur extends RecurData { export class Recur {
constructor(data?: RecurData); constructor(data?: RecurData);
public toJSON(): RecurData;
} }
} }

@ -1,40 +1,16 @@
import * as React from 'react'; import * as React from 'react';
import { TextField, Select, MenuItem, FormGroup, FormControlLabel, Checkbox, InputLabel, FormControl } from '@material-ui/core'; import { TextField, Select, MenuItem, FormControlLabel, InputLabel, FormControl } from '@material-ui/core';
import DateTimePicker from '../widgets/DateTimePicker'; import DateTimePicker from '../widgets/DateTimePicker';
import { isNumber } from 'util'; import * as ICAL from 'ical.js';
interface PropsType { export type RRuleOptions = ICAL.RecurData;
onChange: (rrule: RRuleOptions) => void;
rrule: RRuleOptions;
}
export interface RRuleOptions {
freq: Frequency;
interval: number;
until?: Date;
count?: number;
byweekday?: Weekday[];
bymonthday?: number;
byyearday?: number;
byweekno?: number;
bymonth?: Months;
bysetpos?: number;
wkst?: Weekday;
bysecond?: number[];
byminute?: number[];
byday?: number[];
}
enum Frequency {
YEARLY,
MONTHLY,
WEEKLY,
DAILY,
}
enum Ends { enum Ends {
Never, Never,
Date, Date,
After, After,
} }
enum Months { enum Months {
Jan = 1, Jan = 1,
Feb, Feb,
@ -49,45 +25,64 @@ enum Months {
Nov, Nov,
Dec, Dec,
} }
enum MonthRepeat { enum MonthRepeat {
Bysetpos, Bysetpos,
Bymonthday, Bymonthday,
} }
enum Weekday {
enum WeekDay {
Su = 1,
Mo, Mo,
Tu, Tu,
We, We,
Th, Th,
Fr, Fr,
Sa, Sa,
Su
} }
const menuItemsMonths = Object.keys(Months).filter((key) => Number(key)).map((key) => { const disableComplex = true;
const weekdays: WeekDay[] = Array.from(Array(7)).map((_, i) => i + 1);
const months: Months[] = Array.from(Array(12)).map((_, i) => i + 1);
const menuItemsEnds = [Ends.Never, Ends.Date, Ends.After].map((key) => {
return ( return (
<MenuItem key={key} value={key}>{Months[key]}</MenuItem> <MenuItem key={key} value={key}>{Ends[key]}</MenuItem>
); );
}); });
const menuItemsEnds = [Ends.Never, Ends.Date, Ends.After].map((key) => { const menuItemsFrequency = ['YEARLY', 'MONTHLY', 'WEEKLY', 'DAILY'].map((value) => {
return ( return (
<MenuItem key={key} value={key}>{Ends[key]}</MenuItem> <MenuItem key={value} value={value}>{value.toLowerCase()}</MenuItem>
);
});
const menuItemMonths = months.map((month) => {
return (
<MenuItem key={month} value={month}>{Months[month]}</MenuItem>
); );
}); });
const weekdays = [Weekday.Mo, Weekday.Tu, Weekday.We, Weekday.Th, Weekday.Fr, Weekday.Sa, Weekday.Su]; const menuItemsWeekDays = weekdays.map((day) => {
const menuItemsFrequency = [Frequency.YEARLY, Frequency.MONTHLY, Frequency.WEEKLY, Frequency.DAILY].map((value) => {
return ( return (
<MenuItem key={value} value={value}>{Frequency[value]}</MenuItem> <MenuItem key={day} value={WeekDay[day].toUpperCase()}>{WeekDay[day]}</MenuItem>
); );
}); });
export default function RRuleEteSync(props: PropsType) { const styles = {
const options = props.rrule; multiSelect: { minWidth: 120, maxWidth: '100%' },
width: { width: 120 },
};
interface PropsType {
onChange: (rrule: RRuleOptions) => void;
rrule: RRuleOptions;
}
export default function RRule(props: PropsType) {
const options = props.rrule;
function updateRule(newOptions: Partial<RRuleOptions>): void { function updateRule(newOptions: Partial<RRuleOptions>): void {
const updatedOptions = { ...options, ...newOptions }; const updatedOptions = { ...options, ...newOptions };
props.onChange(updatedOptions); props.onChange(updatedOptions);
} }
function getEnds(): Ends { function getEnds(): Ends {
if (options.until && !options.count) { if (options.until && !options.count) {
return Ends.Date; return Ends.Date;
@ -97,63 +92,24 @@ export default function RRuleEteSync(props: PropsType) {
return Ends.Never; return Ends.Never;
} }
} }
function handleCheckboxWeekday(event: React.FormEvent<{ value: unknown }>): void {
const checkbox = event.target as HTMLInputElement;
const weekday = Number(checkbox.value);
let byweekdayArray = options.byweekday as Weekday[];
let byweekday;
if (!checkbox.checked && byweekdayArray) {
byweekday = byweekdayArray.filter((day) => day !== weekday);
} else if (byweekdayArray) {
byweekdayArray = byweekdayArray.filter((day) => day !== weekday);
byweekday = [...byweekdayArray, weekday];
} else {
byweekday = [weekday];
}
updateRule({ byweekday: byweekday });
}
function isWeekdayChecked(day: number): boolean {
const weekdayArray = options.byweekday;
if (weekdayArray) {
return isNumber(weekdayArray.find((value) => Weekday[value] === Weekday[day]));
} else {
return false;
}
}
const checkboxWeekDays = weekdays.map((_, index) => {
return (
<FormControlLabel
control={
<Checkbox
key={index}
value={index}
checked={isWeekdayChecked(index)}
onChange={handleCheckboxWeekday}
/>}
key={index}
label={Weekday[index]} />
);
});
return ( return (
<> <>
<div style={{ display: 'flex' }}> <div style={{ display: 'flex' }}>
<FormControlLabel <FormControlLabel
style={{ marginRight: 0 }}
value={options.freq} value={options.freq}
label="Repeat every :" label="Repeat every"
labelPlacement="start" labelPlacement="start"
control={<TextField control={<TextField
style={{ marginLeft: '0.5em', width: '4em' }}
type="number" type="number"
inputProps={{ min: 1, max: 1000 }} inputProps={{ min: 1, max: 1000 }}
value={options.interval} value={options.interval ?? 1}
onChange={(event: React.FormEvent<{ value: unknown }>) => { onChange={(event: React.FormEvent<{ value: unknown }>) => {
event.preventDefault(); event.preventDefault();
const inputNode = event.currentTarget as HTMLInputElement; const inputNode = event.currentTarget as HTMLInputElement;
if (inputNode.value === '') { if (inputNode.value === '') {
updateRule({ interval: undefined }); updateRule({ interval: 1 });
} else if (inputNode.valueAsNumber) { } else if (inputNode.valueAsNumber) {
updateRule({ interval: inputNode.valueAsNumber }); updateRule({ interval: inputNode.valueAsNumber });
} }
@ -162,47 +118,35 @@ export default function RRuleEteSync(props: PropsType) {
/> />
<Select <Select
value={options.freq} value={options.freq}
style={{ alignSelf: 'flex-end', marginLeft: 20 }} style={{ marginLeft: '0.5em' }}
onChange={(event: React.FormEvent<{ value: unknown }>) => { onChange={(event: React.FormEvent<{ value: unknown }>) => {
const freq = Number((event.target as HTMLSelectElement).value); const freq = (event.target as HTMLSelectElement).value as ICAL.FrequencyValues;
const updatedOptions = { updateRule({ freq: freq });
freq: freq,
bysetpos: undefined,
bymonthday: freq === Frequency.MONTHLY || Frequency.YEARLY === freq ? 1 : undefined,
byweekday: undefined,
bymonth: freq === Frequency.YEARLY ? Months.Jan : undefined,
};
updateRule(updatedOptions);
}} }}
> >
{menuItemsFrequency} {menuItemsFrequency}
</Select> </Select>
</div> </div>
{!disableComplex && (
<div style={{ display: 'flex' }}> <div style={{ display: 'flex' }}>
{(options.freq === 'MONTHLY') &&
{(options.freq === Frequency.MONTHLY) && <Select value={options.bysetpos ? MonthRepeat.Bysetpos : MonthRepeat.Bymonthday}
<Select
value={options.bysetpos ? MonthRepeat.Bysetpos : MonthRepeat.Bymonthday}
onChange={(event: React.FormEvent<{ value: unknown }>) => { onChange={(event: React.FormEvent<{ value: unknown }>) => {
const value = Number((event.target as HTMLInputElement).value); const value = Number((event.target as HTMLInputElement).value);
if (value === MonthRepeat.Bymonthday) { if (value === MonthRepeat.Bymonthday) {
updateRule({ bymonthday: 1, bysetpos: undefined, bymonth: Months.Jan }); updateRule({ bymonthday: [1], bysetpos: undefined, bymonth: [Months.Jan] });
} else if (value === MonthRepeat.Bysetpos) { } else if (value === MonthRepeat.Bysetpos) {
updateRule({ bysetpos: 1, bymonthday: undefined, bymonth: undefined }); updateRule({ bysetpos: [1], bymonthday: undefined, bymonth: undefined });
} }
}} }}>
>
<MenuItem value={MonthRepeat.Bymonthday}>On</MenuItem> <MenuItem value={MonthRepeat.Bymonthday}>On</MenuItem>
<MenuItem value={MonthRepeat.Bysetpos}>On the</MenuItem> <MenuItem value={MonthRepeat.Bysetpos}>On the</MenuItem>
</Select> </Select>
} }
{options.bysetpos && {options.bysetpos &&
<Select <Select value={options.bysetpos[0]}
value={options.bysetpos}
onChange={(event: React.FormEvent<{ value: unknown }>) => { onChange={(event: React.FormEvent<{ value: unknown }>) => {
updateRule({ bysetpos: Number((event.target as HTMLInputElement).value) }); updateRule({ bysetpos: [Number((event.target as HTMLInputElement).value)] });
}}> }}>
<MenuItem value={1}>First</MenuItem> <MenuItem value={1}>First</MenuItem>
<MenuItem value={2}>Second</MenuItem> <MenuItem value={2}>Second</MenuItem>
@ -211,49 +155,18 @@ export default function RRuleEteSync(props: PropsType) {
<MenuItem value={-1}>Last</MenuItem> <MenuItem value={-1}>Last</MenuItem>
</Select> </Select>
} }
{(options.freq === Frequency.YEARLY && options.bymonth) &&
<Select
value={options.bymonth}
onChange={(event: React.FormEvent<{ value: unknown }>) => {
updateRule({ bymonth: Number((event.target as HTMLInputElement).value) });
}}>
{menuItemsMonths}
</Select>
}
{options.bymonthday &&
<TextField
type="number"
value={options.bymonthday}
label="Month day"
style={{ width: 100 }}
inputProps={{ min: 1, step: 1, max: 31 }}
onChange={(event: React.FormEvent<{ value: unknown }>) => {
event.preventDefault();
const value = (event.currentTarget as HTMLInputElement).value;
const numberValue = Number(value);
if (value === '') {
updateRule({ bymonthday: undefined });
} else if (numberValue < 32 && numberValue > 0) {
updateRule({ bymonthday: numberValue });
}
}}
/>
}
</div> </div>
<div> )}
{options.freq !== Frequency.DAILY &&
<FormGroup row>{checkboxWeekDays}</FormGroup>
}
<FormControl> <FormControl>
<InputLabel>Ends</InputLabel> <InputLabel>Ends</InputLabel>
<Select <Select
value={getEnds()} value={getEnds()}
style={styles.width}
onChange={(event: React.FormEvent<{ value: unknown }>) => { onChange={(event: React.FormEvent<{ value: unknown }>) => {
const value = Number((event.target as HTMLSelectElement).value); const value = Number((event.target as HTMLSelectElement).value);
let updateOptions; let updateOptions;
if (value === Ends.Date) { if (value === Ends.Date) {
updateOptions = { count: undefined, until: new Date() }; updateOptions = { count: undefined, until: ICAL.Time.now() };
} else if (value === Ends.After) { } else if (value === Ends.After) {
updateOptions = { until: undefined, count: 1 }; updateOptions = { until: undefined, count: 1 };
} else { } else {
@ -267,9 +180,12 @@ export default function RRuleEteSync(props: PropsType) {
{options.until && {options.until &&
<DateTimePicker <DateTimePicker
dateOnly dateOnly
value={options.until || undefined} value={options.until?.toJSDate() || undefined}
placeholder="Ends" placeholder="Ends"
onChange={(date?: Date) => updateRule({ until: date })} onChange={(date?: Date) => {
const value = date ? date : null;
updateRule({ until: ICAL.Time.fromJSDate(value, true) });
}}
/> />
} }
{options.count && {options.count &&
@ -290,6 +206,60 @@ export default function RRuleEteSync(props: PropsType) {
}} }}
/> />
} }
<div>
{(options.freq && options.freq !== 'DAILY') &&
<div>
<FormControl>
<InputLabel>Weekdays</InputLabel>
<Select
value={options.byday ? options.byday : []}
multiple
style={styles.multiSelect}
onChange={(event: React.ChangeEvent<{ value: unknown }>) => {
const value = event.target.value as string[];
updateRule({ byday: value });
}}>
{menuItemsWeekDays}
</Select>
</FormControl>
</div>
}
{options.freq === 'MONTHLY' &&
<TextField
type="number"
value={options.bymonthday ? options.bymonthday[0] : undefined}
label="Month day"
style={styles.width}
inputProps={{ min: 1, step: 1, max: 31 }}
onChange={(event: React.FormEvent<{ value: unknown }>) => {
event.preventDefault();
const value = (event.currentTarget as HTMLInputElement).value;
const numberValue = Number(value);
if (value === '') {
updateRule({ bymonthday: undefined });
} else if (numberValue < 32 && numberValue > 0) {
updateRule({ bymonthday: [numberValue] });
}
}}
/>
}
{options.freq === 'YEARLY' &&
<div>
<FormControl>
<InputLabel>Months</InputLabel>
<Select
style={styles.multiSelect}
value={options.bymonth ? options.bymonth : []}
multiple
onChange={(event: React.ChangeEvent<{ value: unknown }>) => {
const value = event.target.value as string[];
updateRule({ bymonth: value.map((month) => Number(month)) });
}}>
{menuItemMonths}
</Select>
</FormControl>
</div>
}
</div> </div>
</> </>
); );

Loading…
Cancel
Save