mirror of
https://github.com/tutao/tutanota.git
synced 2025-10-19 16:03:43 +00:00
Adds Advanced Repeat Rules in Event Editor
Selecting the time frame for rules will now render day/week/month/year correctly
This commit is contained in:
parent
276b2441f0
commit
bd5f730c0b
2 changed files with 84 additions and 150 deletions
|
@ -5,14 +5,13 @@ import { lang } from "../../../../common/misc/LanguageViewModel.js"
|
|||
import { EndType, Keys, RepeatPeriod, TabIndex } from "../../../../common/api/common/TutanotaConstants.js"
|
||||
import { DatePicker, DatePickerAttrs, PickerPosition } from "../pickers/DatePicker.js"
|
||||
|
||||
import { createCustomEndTypeOptions, createIntervalValues, createRepeatRuleOptions, customFrequenciesOptions, IntervalOption } from "../CalendarGuiUtils.js"
|
||||
import { createCustomEndTypeOptions, createIntervalValues, createRepeatRuleOptions, IntervalOption } from "../CalendarGuiUtils.js"
|
||||
import { px, size } from "../../../../common/gui/size.js"
|
||||
import { Card } from "../../../../common/gui/base/Card.js"
|
||||
import { RadioGroup, RadioGroupAttrs, RadioGroupOption } from "../../../../common/gui/base/RadioGroup.js"
|
||||
import { RadioGroup, RadioGroupAttrs } from "../../../../common/gui/base/RadioGroup.js"
|
||||
import { InputMode, SingleLineTextField } from "../../../../common/gui/base/SingleLineTextField.js"
|
||||
import { Select, SelectAttributes } from "../../../../common/gui/base/Select.js"
|
||||
import stream from "mithril/stream"
|
||||
import { Divider } from "../../../../common/gui/Divider.js"
|
||||
import { theme } from "../../../../common/gui/theme.js"
|
||||
import { isApp } from "../../../../common/api/common/Env.js"
|
||||
import { BannerType, InfoBanner, InfoBannerAttrs } from "../../../../common/gui/base/InfoBanner.js"
|
||||
|
@ -27,42 +26,28 @@ export type RepeatRuleEditorAttrs = {
|
|||
backAction: () => void
|
||||
}
|
||||
|
||||
type RepeatRuleOption = RepeatPeriod | "CUSTOM" | null
|
||||
type RepeatRuleOption = RepeatPeriod | null
|
||||
|
||||
export class RepeatRuleEditor implements Component<RepeatRuleEditorAttrs> {
|
||||
private repeatRuleType: RepeatRuleOption | null = null
|
||||
private repeatInterval: number = 0
|
||||
private intervalOptions: stream<IntervalOption[]> = stream([])
|
||||
private intervalExpanded: boolean = false
|
||||
private hasUnsupportedRules: boolean = false
|
||||
private numberValues: IntervalOption[] = createIntervalValues()
|
||||
|
||||
private occurrencesOptions: stream<IntervalOption[]> = stream([])
|
||||
private occurrencesExpanded: boolean = false
|
||||
private repeatOccurrences: number
|
||||
|
||||
constructor({ attrs }: Vnode<RepeatRuleEditorAttrs>) {
|
||||
if (attrs.model.repeatPeriod != null) {
|
||||
this.repeatRuleType = this.getRepeatType(attrs.model.repeatPeriod, attrs.model.repeatInterval, attrs.model.repeatEndType)
|
||||
}
|
||||
|
||||
this.intervalOptions(this.numberValues)
|
||||
this.occurrencesOptions(this.numberValues)
|
||||
|
||||
this.repeatRuleType = attrs.model.repeatPeriod
|
||||
this.repeatInterval = attrs.model.repeatInterval
|
||||
this.repeatOccurrences = attrs.model.repeatEndOccurrences
|
||||
|
||||
this.hasUnsupportedRules = !areAllAdvancedRepeatRulesValid(attrs.model.advancedRules, attrs.model.repeatPeriod)
|
||||
}
|
||||
|
||||
private getRepeatType(period: RepeatPeriod, interval: number, endTime: EndType) {
|
||||
if (interval > 1 || endTime !== EndType.Never) {
|
||||
return "CUSTOM"
|
||||
}
|
||||
|
||||
return period
|
||||
}
|
||||
|
||||
private renderUnsupportedAdvancedRulesWarning(): Children {
|
||||
return m(InfoBanner, {
|
||||
message: () => m(".small.selectable", lang.get("unsupportedAdvancedRules_msg")),
|
||||
|
@ -73,21 +58,18 @@ export class RepeatRuleEditor implements Component<RepeatRuleEditorAttrs> {
|
|||
}
|
||||
|
||||
view({ attrs }: Vnode<RepeatRuleEditorAttrs>): Children {
|
||||
const customRuleOptions = customFrequenciesOptions.map((option) => ({
|
||||
...option,
|
||||
name: attrs.model.repeatInterval > 1 ? option.name.plural : option.name.singular,
|
||||
})) as RadioGroupOption<RepeatPeriod>[]
|
||||
|
||||
return m(
|
||||
".pb.pt.flex.col.gap-vpad.fit-height",
|
||||
{
|
||||
class: this.repeatRuleType === "CUSTOM" ? "box-content" : "",
|
||||
class: this.repeatRuleType !== null ? "box-content" : "",
|
||||
style: {
|
||||
width: px(attrs.width),
|
||||
},
|
||||
},
|
||||
[
|
||||
this.hasUnsupportedRules ? this.renderUnsupportedAdvancedRulesWarning() : null,
|
||||
m(".flex.col", [
|
||||
m("small.uppercase.pb-s.b.text-ellipsis", { style: { color: theme.navigation_button } }, "Frequency"), // TODO add label
|
||||
m(
|
||||
Card,
|
||||
{
|
||||
|
@ -102,26 +84,27 @@ export class RepeatRuleEditor implements Component<RepeatRuleEditorAttrs> {
|
|||
selectedOption: this.repeatRuleType,
|
||||
onOptionSelected: (option: RepeatRuleOption) => {
|
||||
this.repeatRuleType = option
|
||||
if (option === "CUSTOM") {
|
||||
attrs.model.repeatPeriod = attrs.model.repeatPeriod ?? RepeatPeriod.DAILY
|
||||
} else {
|
||||
if (option === null) {
|
||||
attrs.model.repeatInterval = 1
|
||||
attrs.model.repeatEndType = EndType.Never
|
||||
attrs.model.repeatPeriod = option as RepeatPeriod
|
||||
attrs.model.repeatPeriod = option
|
||||
attrs.backAction()
|
||||
} else {
|
||||
this.updateCustomRule(attrs.model, { intervalFrequency: option as RepeatPeriod })
|
||||
}
|
||||
},
|
||||
classes: ["cursor-pointer"],
|
||||
} satisfies RadioGroupAttrs<RepeatRuleOption>),
|
||||
),
|
||||
this.renderFrequencyOptions(attrs, customRuleOptions),
|
||||
]),
|
||||
this.renderFrequencyOptions(attrs),
|
||||
this.renderEndOptions(attrs),
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
private renderEndOptions(attrs: RepeatRuleEditorAttrs) {
|
||||
if (this.repeatRuleType !== "CUSTOM") {
|
||||
if (this.repeatRuleType === null) {
|
||||
return null
|
||||
}
|
||||
|
||||
|
@ -152,8 +135,8 @@ export class RepeatRuleEditor implements Component<RepeatRuleEditorAttrs> {
|
|||
])
|
||||
}
|
||||
|
||||
private renderFrequencyOptions(attrs: RepeatRuleEditorAttrs, customRuleOptions: RadioGroupOption<RepeatPeriod>[]) {
|
||||
if (this.repeatRuleType !== "CUSTOM") {
|
||||
private renderFrequencyOptions(attrs: RepeatRuleEditorAttrs) {
|
||||
if (this.repeatRuleType === null) {
|
||||
return null
|
||||
}
|
||||
|
||||
|
@ -163,24 +146,11 @@ export class RepeatRuleEditor implements Component<RepeatRuleEditorAttrs> {
|
|||
Card,
|
||||
{
|
||||
style: {
|
||||
padding: `0 0 ${size.vpad}px`,
|
||||
padding: "8px 14px",
|
||||
},
|
||||
classes: ["flex", "col", "rel"],
|
||||
},
|
||||
[
|
||||
this.renderIntervalPicker(attrs),
|
||||
m(Divider, { color: theme.button_bubble_bg, style: { margin: `0 0 ${size.vpad}px` } }),
|
||||
m(RadioGroup, {
|
||||
ariaLabel: "intervalFrequency_label",
|
||||
name: "intervalFrequency_label",
|
||||
options: customRuleOptions,
|
||||
selectedOption: attrs.model.repeatPeriod,
|
||||
onOptionSelected: (option: RepeatPeriod) => {
|
||||
this.updateCustomRule(attrs.model, { intervalFrequency: option })
|
||||
},
|
||||
classes: ["cursor-pointer", "capitalize", "pl-vpad-m", "pr-vpad-m"],
|
||||
} satisfies RadioGroupAttrs<RepeatPeriod>),
|
||||
],
|
||||
[this.renderIntervalPicker(attrs)],
|
||||
),
|
||||
])
|
||||
}
|
||||
|
@ -211,6 +181,8 @@ export class RepeatRuleEditor implements Component<RepeatRuleEditorAttrs> {
|
|||
|
||||
if (interval && !isNaN(interval)) {
|
||||
whenModel.repeatInterval = interval
|
||||
} else {
|
||||
this.repeatInterval = whenModel.repeatInterval
|
||||
}
|
||||
|
||||
if (intervalFrequency) {
|
||||
|
@ -219,8 +191,8 @@ export class RepeatRuleEditor implements Component<RepeatRuleEditorAttrs> {
|
|||
}
|
||||
|
||||
private renderIntervalPicker(attrs: RepeatRuleEditorAttrs): Children {
|
||||
return m(
|
||||
".rel",
|
||||
return m(".flex.rel", [
|
||||
m("", { style: { flex: "1" } }, "Every"),
|
||||
m(Select<IntervalOption, number>, {
|
||||
onchange: (newValue) => {
|
||||
if (this.repeatInterval === newValue.value) {
|
||||
|
@ -231,67 +203,15 @@ export class RepeatRuleEditor implements Component<RepeatRuleEditorAttrs> {
|
|||
this.updateCustomRule(attrs.model, { interval: this.repeatInterval })
|
||||
m.redraw.sync()
|
||||
},
|
||||
onclose: () => {
|
||||
this.intervalExpanded = false
|
||||
this.intervalOptions(this.numberValues)
|
||||
},
|
||||
onclose: () => {},
|
||||
selected: { value: this.repeatInterval, name: this.repeatInterval.toString(), ariaValue: this.repeatInterval.toString() },
|
||||
ariaLabel: lang.get("repeatsEvery_label"),
|
||||
options: this.intervalOptions,
|
||||
noIcon: true,
|
||||
expanded: true,
|
||||
tabIndex: isApp() ? Number(TabIndex.Default) : Number(TabIndex.Programmatic),
|
||||
noIcon: false,
|
||||
expanded: false,
|
||||
tabIndex: Number(TabIndex.Programmatic),
|
||||
classes: ["no-appearance"],
|
||||
renderDisplay: () =>
|
||||
m(SingleLineTextField, {
|
||||
classes: ["border-radius-bottom-0"],
|
||||
value: isNaN(this.repeatInterval) ? "" : this.repeatInterval.toString(),
|
||||
inputMode: isApp() ? InputMode.NONE : InputMode.TEXT,
|
||||
readonly: isApp(),
|
||||
oninput: (val: string) => {
|
||||
if (val !== "" && this.repeatInterval === Number(val)) {
|
||||
return
|
||||
}
|
||||
|
||||
this.repeatInterval = val === "" ? NaN : Number(val)
|
||||
if (!isNaN(this.repeatInterval)) {
|
||||
this.intervalOptions(this.numberValues.filter((opt) => opt.value.toString().startsWith(val)))
|
||||
this.updateCustomRule(attrs.model, { interval: this.repeatInterval })
|
||||
} else {
|
||||
this.intervalOptions(this.numberValues)
|
||||
}
|
||||
},
|
||||
ariaLabel: lang.get("repeatsEvery_label"),
|
||||
onclick: (e: MouseEvent) => {
|
||||
e.stopImmediatePropagation()
|
||||
if (!this.intervalExpanded) {
|
||||
;(e.target as HTMLElement).parentElement?.click()
|
||||
this.intervalExpanded = true
|
||||
}
|
||||
},
|
||||
onkeydown: (e: KeyboardEvent) => {
|
||||
if (isKeyPressed(e.key, Keys.RETURN) && !this.intervalExpanded) {
|
||||
;(e.target as HTMLElement).parentElement?.click()
|
||||
this.intervalExpanded = true
|
||||
m.redraw.sync()
|
||||
}
|
||||
},
|
||||
onblur: (event: FocusEvent) => {
|
||||
if (isNaN(this.repeatInterval)) {
|
||||
this.repeatInterval = this.numberValues[0].value
|
||||
this.updateCustomRule(attrs.model, { interval: this.repeatInterval })
|
||||
} else if (this.repeatInterval === 0) {
|
||||
this.repeatInterval = this.numberValues[0].value
|
||||
this.updateCustomRule(attrs.model, { interval: this.repeatInterval })
|
||||
}
|
||||
},
|
||||
style: {
|
||||
textAlign: "center",
|
||||
},
|
||||
max: 256,
|
||||
min: 1,
|
||||
type: TextFieldType.Number,
|
||||
}),
|
||||
renderDisplay: (option) => m(".flex.items-center.gap-vpad-s", [m("span", this.getNameAndAppendTimeFormat(option))]),
|
||||
renderOption: (option) =>
|
||||
m(
|
||||
"button.items-center.flex-grow",
|
||||
|
@ -304,7 +224,31 @@ export class RepeatRuleEditor implements Component<RepeatRuleEditorAttrs> {
|
|||
option.name,
|
||||
),
|
||||
} satisfies SelectAttributes<IntervalOption, number>),
|
||||
)
|
||||
])
|
||||
}
|
||||
|
||||
/**
|
||||
* Appends either "Day(s)", "Week(s)", "Month(s)" or "Year(s)" to the given number value.
|
||||
* Only do this for renderDisplay() to not re-populate the options array.
|
||||
* @param option
|
||||
*/
|
||||
private getNameAndAppendTimeFormat(option: IntervalOption) {
|
||||
if (this.repeatRuleType === null) {
|
||||
throw new Error("repeatRuleType was null")
|
||||
}
|
||||
|
||||
const isPlural = option.value > 1
|
||||
|
||||
switch (this.repeatRuleType) {
|
||||
case RepeatPeriod.DAILY:
|
||||
return `${option.name} ${isPlural ? lang.get("days_label") : lang.get("day_label")}`
|
||||
case RepeatPeriod.WEEKLY:
|
||||
return `${option.name} ${isPlural ? lang.get("weeks_label") : lang.get("week_label")}`
|
||||
case RepeatPeriod.MONTHLY:
|
||||
return `${option.name} ${isPlural ? lang.get("months_label") : lang.get("month_label")}`
|
||||
case RepeatPeriod.ANNUALLY:
|
||||
return `${option.name} ${isPlural ? lang.get("years_label") : lang.get("year_label")}`
|
||||
}
|
||||
}
|
||||
|
||||
private renderEndsPicker(attrs: RepeatRuleEditorAttrs): Child {
|
||||
|
@ -317,18 +261,18 @@ export class RepeatRuleEditor implements Component<RepeatRuleEditorAttrs> {
|
|||
}
|
||||
|
||||
this.repeatOccurrences = newValue.value
|
||||
attrs.model.repeatEndOccurrences = newValue.value
|
||||
this.repeatOccurrences = newValue.value
|
||||
},
|
||||
onclose: () => {
|
||||
this.occurrencesExpanded = false
|
||||
this.occurrencesOptions(this.numberValues)
|
||||
this.occurrencesExpanded = false
|
||||
},
|
||||
selected: { value: this.repeatOccurrences, name: this.repeatOccurrences.toString(), ariaValue: this.repeatOccurrences.toString() },
|
||||
ariaLabel: lang.get("occurrencesCount_label"),
|
||||
options: this.occurrencesOptions,
|
||||
options: this.intervalOptions,
|
||||
noIcon: true,
|
||||
expanded: true,
|
||||
tabIndex: isApp() ? Number(TabIndex.Default) : Number(TabIndex.Programmatic),
|
||||
tabIndex: Number(TabIndex.Programmatic),
|
||||
classes: ["no-appearance"],
|
||||
renderDisplay: () =>
|
||||
m(SingleLineTextField, {
|
||||
|
@ -338,18 +282,12 @@ export class RepeatRuleEditor implements Component<RepeatRuleEditorAttrs> {
|
|||
readonly: isApp(),
|
||||
disabled: attrs.model.repeatEndType !== EndType.Count,
|
||||
oninput: (val: string) => {
|
||||
if (val !== "" && this.repeatOccurrences === Number(val)) {
|
||||
if (this.repeatOccurrences === Number(val)) {
|
||||
return
|
||||
}
|
||||
|
||||
this.repeatOccurrences = val === "" ? NaN : Number(val)
|
||||
|
||||
if (!isNaN(this.repeatOccurrences)) {
|
||||
this.occurrencesOptions(this.numberValues.filter((opt) => opt.value.toString().startsWith(val)))
|
||||
attrs.model.repeatEndOccurrences = this.repeatOccurrences
|
||||
} else {
|
||||
this.occurrencesOptions(this.numberValues)
|
||||
}
|
||||
this.repeatOccurrences = val === "" ? NaN : Number(val)
|
||||
},
|
||||
ariaLabel: lang.get("occurrencesCount_label"),
|
||||
style: {
|
||||
|
@ -370,15 +308,6 @@ export class RepeatRuleEditor implements Component<RepeatRuleEditorAttrs> {
|
|||
m.redraw.sync()
|
||||
}
|
||||
},
|
||||
onblur: (event: FocusEvent) => {
|
||||
if (isNaN(this.repeatOccurrences)) {
|
||||
this.repeatOccurrences = this.numberValues[0].value
|
||||
attrs.model.repeatEndOccurrences = this.repeatOccurrences
|
||||
} else if (this.repeatOccurrences === 0) {
|
||||
this.repeatOccurrences = this.numberValues[0].value
|
||||
attrs.model.repeatEndOccurrences = this.repeatOccurrences
|
||||
}
|
||||
},
|
||||
max: 256,
|
||||
min: 1,
|
||||
type: TextFieldType.Number,
|
||||
|
|
|
@ -2807,6 +2807,11 @@ styles.registerStyle("main", () => {
|
|||
"grid-gap": px(size.vpad_small),
|
||||
"align-items": "center",
|
||||
},
|
||||
".repeats-every-grid": {
|
||||
display: "grid",
|
||||
"grid-template-columns": "6fr 3fr",
|
||||
"column-gap": px(size.vpad_small),
|
||||
},
|
||||
".time-selection-grid > *": {
|
||||
overflow: "hidden",
|
||||
"white-space": "nowrap",
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue