-
Notifications
You must be signed in to change notification settings - Fork 545
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Advanced repeat rules UI #8422
base: dev-calendar
Are you sure you want to change the base?
Advanced repeat rules UI #8422
Changes from all commits
0eb03f2
0ba6b4a
9e7aceb
956b336
900ca35
d531b91
3566815
3938133
bcb809e
81bed50
7e185d0
2f2a7d6
a1006e7
03f14a6
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -34,6 +34,7 @@ import { | |
AlarmInterval, | ||
alarmIntervalToLuxonDurationLikeObject, | ||
AlarmIntervalUnit, | ||
ByRule, | ||
CalendarDay, | ||
CalendarMonth, | ||
eventEndsAfterDay, | ||
|
@@ -61,13 +62,14 @@ import { | |
EventTextTimeOption, | ||
RepeatPeriod, | ||
ShareCapability, | ||
Weekday, | ||
WeekStart, | ||
} from "../../../common/api/common/TutanotaConstants.js" | ||
import { AllIcons } from "../../../common/gui/base/Icon.js" | ||
import { SelectorItemList } from "../../../common/gui/base/DropDownSelector.js" | ||
import { DateTime, Duration } from "luxon" | ||
import { CalendarEventTimes, CalendarViewType, cleanMailAddress, isAllDayEvent } from "../../../common/api/common/utils/CommonCalendarUtils.js" | ||
import { CalendarEvent, UserSettingsGroupRoot } from "../../../common/api/entities/tutanota/TypeRefs.js" | ||
import { AdvancedRepeatRule, CalendarEvent, UserSettingsGroupRoot } from "../../../common/api/entities/tutanota/TypeRefs.js" | ||
import { ProgrammingError } from "../../../common/api/common/error/ProgrammingError.js" | ||
import { size } from "../../../common/gui/size.js" | ||
import { hslToHex, isColorLight, isValidColorCode, MAX_HUE_ANGLE } from "../../../common/gui/base/Color.js" | ||
|
@@ -84,6 +86,8 @@ import { SelectOption } from "../../../common/gui/base/Select.js" | |
import { RadioGroupOption } from "../../../common/gui/base/RadioGroup.js" | ||
import { ColorPickerModel } from "../../../common/gui/base/colorPicker/ColorPickerModel.js" | ||
import { theme } from "../../../common/gui/theme.js" | ||
import { WeekdayToTranslation } from "./eventeditor-view/WeekdaySelector.js" | ||
import { ByDayRule } from "./eventeditor-view/RepeatRuleEditor.js" | ||
|
||
export interface IntervalOption { | ||
value: number | ||
|
@@ -421,7 +425,7 @@ export const createRepeatRuleFrequencyValues = (): SelectorItemList<RepeatPeriod | |
}, | ||
] | ||
} | ||
export const createRepeatRuleOptions = (): ReadonlyArray<RadioGroupOption<RepeatPeriod | "CUSTOM" | null>> => { | ||
export const createRepeatRuleOptions = (): ReadonlyArray<RadioGroupOption<RepeatPeriod | null>> => { | ||
return [ | ||
{ | ||
name: "calendarRepeatIntervalNoRepeat_label", | ||
|
@@ -443,10 +447,6 @@ export const createRepeatRuleOptions = (): ReadonlyArray<RadioGroupOption<Repeat | |
name: "calendarRepeatIntervalAnnually_label", | ||
value: RepeatPeriod.ANNUALLY, | ||
}, | ||
{ | ||
name: "custom_label", | ||
value: "CUSTOM", | ||
}, | ||
] | ||
} | ||
|
||
|
@@ -486,24 +486,122 @@ export const createCustomEndTypeOptions = (): ReadonlyArray<RadioGroupOption<End | |
] | ||
} | ||
|
||
export const createRepeatRuleEndTypeValues = (): SelectorItemList<EndType> => { | ||
export const weekdayToTranslation = (): Array<WeekdayToTranslation> => { | ||
return [ | ||
{ | ||
name: lang.get("calendarRepeatStopConditionNever_label"), | ||
value: EndType.Never, | ||
value: Weekday.MONDAY, | ||
label: lang.get("monday_label"), | ||
}, | ||
{ | ||
name: lang.get("calendarRepeatStopConditionOccurrences_label"), | ||
value: EndType.Count, | ||
value: Weekday.TUESDAY, | ||
label: lang.get("tuesday_label"), | ||
}, | ||
{ | ||
name: lang.get("calendarRepeatStopConditionDate_label"), | ||
value: EndType.UntilDate, | ||
value: Weekday.WEDNESDAY, | ||
label: lang.get("wednesday_label"), | ||
}, | ||
{ | ||
value: Weekday.THURSDAY, | ||
label: lang.get("thursday_label"), | ||
}, | ||
{ | ||
value: Weekday.FRIDAY, | ||
label: lang.get("friday_label"), | ||
}, | ||
{ | ||
value: Weekday.SATURDAY, | ||
label: lang.get("saturday_label"), | ||
}, | ||
{ | ||
value: Weekday.SUNDAY, | ||
label: lang.get("sunday_label"), | ||
}, | ||
] | ||
} | ||
|
||
export const createIntervalValues = (): IntervalOption[] => numberRange(1, 256).map((n) => ({ name: String(n), value: n, ariaValue: String(n) })) | ||
|
||
/** | ||
* Returns an array of IntervalOptions based on the given Weekday. | ||
* (1 = Monday, ..., 7 = Sunday). Since our internal format Starts with 0 = Monday, we have to decrement the weekday number. | ||
* | ||
* @param weekday | ||
* @param numberOfWeekdaysInMonth how many times this Weekday occurs in the current month. Per default assume 4. | ||
*/ | ||
export const createRepetitionValuesForWeekday = (weekday: number, numberOfWeekdaysInMonth: number = 4): { options: IntervalOption[]; weekday: number } => { | ||
const weekdayLabel = weekdayToTranslation()[weekday - 1].label | ||
const options: IntervalOption[] = [ | ||
{ | ||
value: 0, | ||
ariaValue: "same day", | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Use the translation as aria value so screen readers can announce the correct value for the options |
||
name: lang.get("sameDay_label"), | ||
}, | ||
{ | ||
value: 1, | ||
ariaValue: "first", | ||
name: lang.get("firstOfPeriod_label", { | ||
"{day}": weekdayLabel, | ||
}), | ||
}, | ||
{ | ||
value: 2, | ||
ariaValue: "second", | ||
name: lang.get("secondOfPeriod_label", { | ||
"{day}": weekdayLabel, | ||
}), | ||
}, | ||
{ | ||
value: 3, | ||
ariaValue: "third", | ||
name: lang.get("thirdOfPeriod_label", { | ||
"{day}": weekdayLabel, | ||
}), | ||
}, | ||
{ | ||
value: -1, | ||
ariaValue: "last", | ||
name: lang.get("lastOfPeriod_label", { | ||
"{day}": weekdayLabel, | ||
}), | ||
}, | ||
] | ||
|
||
if (numberOfWeekdaysInMonth > 4) { | ||
options.splice(4, 0, { | ||
value: 4, | ||
ariaValue: "fourth", | ||
name: lang.get("fourthOfPeriod_label", { | ||
"{day}": weekdayLabel, | ||
}), | ||
}) | ||
} | ||
|
||
return { options, weekday } | ||
} | ||
|
||
/** | ||
* From a given Array of AdvancedRules, collect all BYDAY Rules and cast them to Weekday enum. | ||
* this is necessary for opening the RepeatEditor for a given event that has AdvancedRules configured. | ||
* @param advancedRepeatRules AdvancedRepeatRules that have been written on the Event already. | ||
*/ | ||
export const getByDayRulesFromAdvancedRules = (advancedRepeatRules: AdvancedRepeatRule[]): ByDayRule | null => { | ||
if (advancedRepeatRules.length == 0) return null | ||
|
||
let interval: number = 0 | ||
const weekdays = advancedRepeatRules | ||
.filter((rr) => rr.ruleType === ByRule.BYDAY) | ||
.map((rr) => { | ||
if (rr.interval.length > 2) { | ||
// if length > 2, interval is specified on the BYDAY rule | ||
interval = parseInt(rr.interval.slice(0, rr.interval.length - 2)) // get interval | ||
return <Weekday>rr.interval.substring(rr.interval.length - 2) // get Weekday shorthand | ||
} else { | ||
return <Weekday>rr.interval | ||
} | ||
}) | ||
return { weekdays, interval } | ||
} | ||
|
||
export function humanDescriptionForAlarmInterval<P>(value: AlarmInterval, locale: string): string { | ||
if (value.value === 0) return lang.get("calendarReminderIntervalAtEventStart_label") | ||
|
||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -2,11 +2,11 @@ import m, { Children, Component, Vnode, VnodeDOM } from "mithril" | |
import { AttendeeListEditor } from "./AttendeeListEditor.js" | ||
import { locator } from "../../../../common/api/main/CommonLocator.js" | ||
import { EventTimeEditor, EventTimeEditorAttrs } from "./EventTimeEditor.js" | ||
import { defaultCalendarColor, TabIndex, TimeFormat } from "../../../../common/api/common/TutanotaConstants.js" | ||
import { defaultCalendarColor, RepeatPeriod, TabIndex, TimeFormat, Weekday } from "../../../../common/api/common/TutanotaConstants.js" | ||
import { lang, TranslationKey } from "../../../../common/misc/LanguageViewModel.js" | ||
import { RecipientsSearchModel } from "../../../../common/misc/RecipientsSearchModel.js" | ||
import { CalendarInfo } from "../../model/CalendarModel.js" | ||
import { AlarmInterval } from "../../../../common/calendar/date/CalendarUtils.js" | ||
import { AlarmInterval, ByRule } from "../../../../common/calendar/date/CalendarUtils.js" | ||
import { HtmlEditor } from "../../../../common/gui/editor/HtmlEditor.js" | ||
import { BannerType, InfoBanner, InfoBannerAttrs } from "../../../../common/gui/base/InfoBanner.js" | ||
import { CalendarEventModel, CalendarOperation, ReadonlyReason } from "../eventeditor-model/CalendarEventModel.js" | ||
|
@@ -22,11 +22,12 @@ import { deepEqual } from "@tutao/tutanota-utils" | |
import { ButtonColor, getColors } from "../../../../common/gui/base/Button.js" | ||
import stream from "mithril/stream" | ||
import { RepeatRuleEditor, RepeatRuleEditorAttrs } from "./RepeatRuleEditor.js" | ||
import type { CalendarRepeatRule } from "../../../../common/api/entities/tutanota/TypeRefs.js" | ||
import { AdvancedRepeatRule, CalendarRepeatRule, createAdvancedRepeatRule } from "../../../../common/api/entities/tutanota/TypeRefs.js" | ||
import { formatRepetitionEnd, formatRepetitionFrequency } from "../eventpopup/EventPreviewView.js" | ||
import { TextFieldType } from "../../../../common/gui/base/TextField.js" | ||
import { DefaultAnimationTime } from "../../../../common/gui/animation/Animations.js" | ||
import { Icons } from "../../../../common/gui/base/icons/Icons.js" | ||
import { DateTime } from "luxon" | ||
import { SectionButton } from "../../../../common/gui/base/buttons/SectionButton.js" | ||
|
||
export type CalendarEventEditViewAttrs = { | ||
|
@@ -240,14 +241,34 @@ export class CalendarEventEditView implements Component<CalendarEventEditViewAtt | |
|
||
private renderEventTimeEditor(attrs: CalendarEventEditViewAttrs): Children { | ||
const padding = px(size.vpad_small) | ||
const { whenModel } = attrs.model.editModels | ||
return m( | ||
Card, | ||
{ style: { padding: `${padding} 0 ${padding} ${padding}` } }, | ||
m(EventTimeEditor, { | ||
editModel: attrs.model.editModels.whenModel, | ||
editModel: whenModel, | ||
timeFormat: this.timeFormat, | ||
startOfTheWeekOffset: this.startOfTheWeekOffset, | ||
disabled: !attrs.model.isFullyWritable(), | ||
dateSelectionChanged: (date: Date) => { | ||
whenModel.startDate = date | ||
|
||
// for monthly, we have to re-create our advanced repeat rules, as they are weekday bound. | ||
// If we do not reset them, it would lead to inconsistencies. | ||
if (whenModel.repeatPeriod == RepeatPeriod.MONTHLY && whenModel.advancedRules.length != 0) { | ||
const bydayRules = whenModel.advancedRules.filter((rule) => rule.ruleType == ByRule.BYDAY) | ||
if (bydayRules.length == 1) { | ||
const weekday: Weekday = Object.values(Weekday)[DateTime.fromJSDate(date).weekday - 1] | ||
const regex = /^-?\d/g // Regex for extracting the first digit from interval | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This will fail if the user has imported an event with a ByDay rule with +2TH (RFC allows this). |
||
const interval = Array.from(bydayRules[0].interval.matchAll(regex)).flat()[0] // collect interval | ||
|
||
this.createAdvancedRulesFromWeekdays([weekday], parseInt(interval)).then((advancedRules) => { | ||
whenModel.advancedRules = advancedRules | ||
m.redraw() | ||
}) | ||
} | ||
} | ||
}, | ||
} satisfies EventTimeEditorAttrs), | ||
) | ||
} | ||
|
@@ -465,10 +486,37 @@ export class CalendarEventEditView implements Component<CalendarEventEditViewAtt | |
model: whenModel, | ||
startOfTheWeekOffset: this.startOfTheWeekOffset, | ||
width: this.pageWidth, | ||
backAction: () => navigationCallback(EditorPages.MAIN), | ||
backAction: () => { | ||
navigationCallback(EditorPages.MAIN) | ||
}, | ||
writeWeekdaysToModel: (weekdays: Weekday[], interval?: number) => { | ||
this.createAdvancedRulesFromWeekdays(weekdays, interval).then((advancedRules) => { | ||
whenModel.advancedRules = advancedRules | ||
m.redraw() | ||
}) | ||
}, | ||
} satisfies RepeatRuleEditorAttrs) | ||
} | ||
|
||
/** | ||
* Returns an Array of AdvancedRepeatRules that can be written to the CalendarWhenModel, which then writes them to the CalendarEvent. | ||
* @param weekdays Either the weekdays a weekly event - or a singular weekday (first, second, ..., last) in a month that a monthly event should repeat on. | ||
* @param interval will only be set if weekdays.length() == 1. In this case we are writing a BYDAY Rule for FREQ=MONTHLY, in which case | ||
* we only specify what weekday of the month this event repeats on. (Ex.: BYDAY=2TH = Repeats on second THURSDAY of every month) | ||
* In case weekdays.length() == 0 && interval == 0, no BYDAY Rule shall be written, as the event will repeat on the same DAY every month. | ||
*/ | ||
private async createAdvancedRulesFromWeekdays(weekdays: Weekday[], interval?: number): Promise<AdvancedRepeatRule[]> { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why async? |
||
if (weekdays.length == 0 || interval == 0) return Promise.resolve([]) | ||
return Promise.resolve( | ||
weekdays.map((wd) => { | ||
return createAdvancedRepeatRule({ | ||
interval: interval ? interval.toString() + wd : wd, | ||
ruleType: ByRule.BYDAY, | ||
}) | ||
}), | ||
) | ||
} | ||
|
||
private getTranslatedRepeatRule(rule: CalendarRepeatRule | null, isAllDay: boolean): string { | ||
if (rule == null) return lang.get("calendarRepeatIntervalNoRepeat_label") | ||
|
||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think we can change it to an Array instead of a function. I'm not sure if it will break any rendering