Skip to content
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

Open
wants to merge 14 commits into
base: dev-calendar
Choose a base branch
from
Open
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
2 changes: 1 addition & 1 deletion libs/webassembly/liboqs
Submodule liboqs updated 2639 files
124 changes: 111 additions & 13 deletions src/calendar-app/calendar/gui/CalendarGuiUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import {
AlarmInterval,
alarmIntervalToLuxonDurationLikeObject,
AlarmIntervalUnit,
ByRule,
CalendarDay,
CalendarMonth,
eventEndsAfterDay,
Expand Down Expand Up @@ -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"
Expand All @@ -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
Expand Down Expand Up @@ -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",
Expand All @@ -443,10 +447,6 @@ export const createRepeatRuleOptions = (): ReadonlyArray<RadioGroupOption<Repeat
name: "calendarRepeatIntervalAnnually_label",
value: RepeatPeriod.ANNUALLY,
},
{
name: "custom_label",
value: "CUSTOM",
},
]
}

Expand Down Expand Up @@ -486,24 +486,122 @@ export const createCustomEndTypeOptions = (): ReadonlyArray<RadioGroupOption<End
]
}

export const createRepeatRuleEndTypeValues = (): SelectorItemList<EndType> => {
export const weekdayToTranslation = (): Array<WeekdayToTranslation> => {
Copy link
Contributor

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

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",
Copy link
Contributor

Choose a reason for hiding this comment

The 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")

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,12 @@ export class CalendarEventWhenModel {
this.uiUpdateCallback()
}

set advancedRules(advancedRules: AdvancedRepeatRule[]) {
if (this.repeatRule && this.repeatRule.advancedRules !== advancedRules) {
this.repeatRule.advancedRules = advancedRules
}
}

/**
* get the current interval this series repeats in.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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 = {
Expand Down Expand Up @@ -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
Copy link
Contributor

Choose a reason for hiding this comment

The 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).
Either we take care of this during import, or we adapt this regex to support the + sign

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),
)
}
Expand Down Expand Up @@ -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[]> {
Copy link
Contributor

Choose a reason for hiding this comment

The 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")

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export type EventTimeEditorAttrs = {
timeFormat: TimeFormat
editModel: CalendarEventWhenModel
disabled: boolean
dateSelectionChanged: (date: Date) => void
}

/**
Expand Down Expand Up @@ -62,7 +63,7 @@ export class EventTimeEditor implements Component<EventTimeEditorAttrs> {
m(DatePicker, {
classes: appClasses,
date: attrs.editModel.startDate,
onDateSelected: (date) => date && (editModel.startDate = date),
onDateSelected: (date) => date && vnode.attrs.dateSelectionChanged(date),
startOfTheWeekOffset,
label: "dateFrom_label",
useInputButton: true,
Expand Down
Loading
Loading