From 8006c2c68ec2b1e275fa96b99cb920f3e8812a79 Mon Sep 17 00:00:00 2001 From: Raphael Krut-Landau <raphael.kl@gmail.com> Date: Fri, 12 Apr 2024 23:13:03 -0400 Subject: [PATCH] Add Schedule strategy to Admin > Performance (#41230) Adds a Schedule strategy to the Admin / Performance page  [Loom](https://www.loom.com/share/5623cd5d772e417ba2e58c29ae1b8c14?sid=9844f0db-5bb1-4d35-af9b-206d263f5516) As discussed with @luizarakaki, rather than modify the legacy `SchedulePicker` to work with this use case, I made a new version of it - a `Schedule` component - that uses Mantine and a better (though imperfect) approach to localization. For now, the dropdowns are not vertically aligned in this Schedule picker - something I can try to remedy later if that is important. Tests for the Schedule component will be added in a follow-up PR. --- .../components/StrategyFormLauncher.tsx | 2 +- .../src/metabase-types/api/performance.ts | 15 +- .../performance/components/StrategyForm.tsx | 56 +++- .../metabase/admin/performance/strategies.ts | 10 + .../src/metabase/admin/performance/utils.ts | 140 +++++++++ .../admin/performance/utils.unit.spec.ts | 249 ++++++++++++++++ .../components/Schedule/Schedule.stories.tsx | 41 +++ .../metabase/components/Schedule/Schedule.tsx | 281 ++++++++++++++++++ .../components/Schedule/components.tsx | 186 ++++++++++++ .../metabase/components/Schedule/constants.ts | 62 ++++ .../src/metabase/components/Schedule/types.ts | 9 + 11 files changed, 1044 insertions(+), 7 deletions(-) create mode 100644 frontend/src/metabase/admin/performance/utils.ts create mode 100644 frontend/src/metabase/admin/performance/utils.unit.spec.ts create mode 100644 frontend/src/metabase/components/Schedule/Schedule.stories.tsx create mode 100644 frontend/src/metabase/components/Schedule/Schedule.tsx create mode 100644 frontend/src/metabase/components/Schedule/components.tsx create mode 100644 frontend/src/metabase/components/Schedule/constants.ts create mode 100644 frontend/src/metabase/components/Schedule/types.ts diff --git a/enterprise/frontend/src/metabase-enterprise/caching/components/StrategyFormLauncher.tsx b/enterprise/frontend/src/metabase-enterprise/caching/components/StrategyFormLauncher.tsx index 3aeff75b514..2df04c63263 100644 --- a/enterprise/frontend/src/metabase-enterprise/caching/components/StrategyFormLauncher.tsx +++ b/enterprise/frontend/src/metabase-enterprise/caching/components/StrategyFormLauncher.tsx @@ -80,7 +80,7 @@ export const StrategyFormLauncher = ({ <Tooltip position="bottom" disabled={!inheritsRootStrategy} - label={t`Inheriting from default policy`} + label={t`Using default policy`} > <PolicyToken onClick={() => { diff --git a/frontend/src/metabase-types/api/performance.ts b/frontend/src/metabase-types/api/performance.ts index ffd7fab62c5..735bc89a29b 100644 --- a/frontend/src/metabase-types/api/performance.ts +++ b/frontend/src/metabase-types/api/performance.ts @@ -1,6 +1,11 @@ type Model = "root" | "database" | "collection" | "dashboard" | "question"; -export type StrategyType = "nocache" | "ttl" | "duration" | "inherit"; +export type StrategyType = + | "nocache" + | "ttl" + | "duration" + | "schedule" + | "inherit"; interface StrategyBase { type: StrategyType; @@ -34,12 +39,18 @@ export interface InheritStrategy extends StrategyBase { type: "inherit"; } +export interface ScheduleStrategy extends StrategyBase { + type: "schedule"; + schedule: string; +} + /** Cache invalidation strategy */ export type Strategy = | DoNotCacheStrategy | TTLStrategy | DurationStrategy - | InheritStrategy; + | InheritStrategy + | ScheduleStrategy; /** Cache invalidation configuration */ export interface Config { diff --git a/frontend/src/metabase/admin/performance/components/StrategyForm.tsx b/frontend/src/metabase/admin/performance/components/StrategyForm.tsx index d8982a84ac4..078a443efad 100644 --- a/frontend/src/metabase/admin/performance/components/StrategyForm.tsx +++ b/frontend/src/metabase/admin/performance/components/StrategyForm.tsx @@ -1,9 +1,11 @@ import { useFormikContext } from "formik"; import type { ReactNode } from "react"; -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { t } from "ttag"; import _ from "underscore"; +import LoadingAndErrorWrapper from "metabase/components/LoadingAndErrorWrapper"; +import { Schedule } from "metabase/components/Schedule/Schedule"; import type { FormTextInputProps } from "metabase/forms"; import { Form, @@ -14,6 +16,8 @@ import { useFormContext, } from "metabase/forms"; import { color } from "metabase/lib/colors"; +import { useSelector } from "metabase/lib/redux"; +import { getSetting } from "metabase/selectors/settings"; import { Button, Group, @@ -25,11 +29,17 @@ import { Title, Tooltip, } from "metabase/ui"; -import type { Strategy, StrategyType } from "metabase-types/api"; +import type { + ScheduleSettings, + ScheduleStrategy, + Strategy, + StrategyType, +} from "metabase-types/api"; import { DurationUnit } from "metabase-types/api"; import { useRecentlyTrue } from "../hooks/useRecentlyTrue"; import { rootId, Strategies, strategyValidationSchema } from "../strategies"; +import { cronToScheduleSettings, scheduleSettingsToCron } from "../utils"; export const StrategyForm = ({ targetId, @@ -127,12 +137,49 @@ const StrategyFormBody = ({ <input type="hidden" name="unit" /> </> )} + {selectedStrategyType === "schedule" && <ScheduleStrategyFormFields />} </Stack> <FormButtons /> </Form> ); }; +const ScheduleStrategyFormFields = () => { + const { values, setFieldValue } = useFormikContext<ScheduleStrategy>(); + const { schedule: scheduleInCronFormat } = values; + const initialSchedule = cronToScheduleSettings(scheduleInCronFormat); + const [schedule, setSchedule] = useState<ScheduleSettings>( + initialSchedule || {}, + ); + const timezone = useSelector(state => + getSetting(state, "report-timezone-short"), + ); + const onScheduleChange = useCallback( + (nextSchedule: ScheduleSettings) => { + setSchedule(nextSchedule); + const cron = scheduleSettingsToCron(nextSchedule); + setFieldValue("schedule", cron); + }, + [setFieldValue, setSchedule], + ); + if (!initialSchedule) { + return ( + <LoadingAndErrorWrapper + error={t`Error: Cannot interpret schedule: ${scheduleInCronFormat}`} + /> + ); + } + return ( + <Schedule + schedule={schedule} + scheduleOptions={["hourly", "daily", "weekly", "monthly"]} + onScheduleChange={onScheduleChange} + verb={t`Invalidate`} + timezone={timezone} + /> + ); +}; + export const FormButtons = () => { const { dirty } = useFormikContext<Strategy>(); const { status } = useFormContext(); @@ -180,8 +227,9 @@ export const FormButtons = () => { const StrategySelector = ({ targetId }: { targetId: number | null }) => { const { values } = useFormikContext<Strategy>(); - const availableStrategies = - targetId === rootId ? _.omit(Strategies, "inherit") : Strategies; + const availableStrategies = useMemo(() => { + return targetId === rootId ? _.omit(Strategies, "inherit") : Strategies; + }, [targetId]); return ( <section> diff --git a/frontend/src/metabase/admin/performance/strategies.ts b/frontend/src/metabase/admin/performance/strategies.ts index 2bb3edf3bee..9300ad8bf59 100644 --- a/frontend/src/metabase/admin/performance/strategies.ts +++ b/frontend/src/metabase/admin/performance/strategies.ts @@ -53,6 +53,11 @@ export const durationStrategyValidationSchema = Yup.object({ ), }); +export const scheduleStrategyValidationSchema = Yup.object({ + type: Yup.string().equals(["schedule"]), + schedule: Yup.string().required(t`A cron expression is required`), +}); + export const strategyValidationSchema = Yup.object().test( "strategy-validation", "The object must match one of the strategy validation schemas", @@ -94,6 +99,11 @@ export const Strategies: Record<StrategyType, StrategyData> = { shortLabel: c("'TTL' is short for 'time-to-live'").t`TTL`, validateWith: ttlStrategyValidationSchema, }, + schedule: { + label: t`Schedule: at regular intervals`, + shortLabel: t`Schedule`, + validateWith: scheduleStrategyValidationSchema, + }, duration: { label: t`Duration: after a specific number of hours`, validateWith: durationStrategyValidationSchema, diff --git a/frontend/src/metabase/admin/performance/utils.ts b/frontend/src/metabase/admin/performance/utils.ts new file mode 100644 index 00000000000..950cc4347d4 --- /dev/null +++ b/frontend/src/metabase/admin/performance/utils.ts @@ -0,0 +1,140 @@ +import { pick } from "underscore"; + +import { Cron, weekdays } from "metabase/components/Schedule/constants"; +import type { + ScheduleDayType, + ScheduleFrameType, + ScheduleSettings, + ScheduleType, +} from "metabase-types/api"; + +const dayToCron = (day: ScheduleSettings["schedule_day"]) => { + const index = weekdays.findIndex(o => o.value === day); + if (index === -1) { + throw new Error(`Invalid day: ${day}`); + } + return index + 1; +}; + +const frameToCronMap = { first: "1", last: "L", mid: "15" }; +const frameToCron = (frame: ScheduleFrameType) => frameToCronMap[frame]; + +const frameFromCronMap: Record<string, ScheduleFrameType> = { + "15": "mid", + "1": "first", + L: "last", +}; +const frameFromCron = (frameInCronFormat: string) => + frameFromCronMap[frameInCronFormat]; + +export const scheduleSettingsToCron = (settings: ScheduleSettings): string => { + const second = "0"; + const minute = settings.schedule_minute?.toString() ?? Cron.AllValues; + const hour = settings.schedule_hour?.toString() ?? Cron.AllValues; + let weekday = settings.schedule_day + ? dayToCron(settings.schedule_day).toString() + : Cron.NoSpecificValue; + const month = Cron.AllValues; + let dayOfMonth: string = settings.schedule_day + ? Cron.NoSpecificValue + : Cron.AllValues; + if (settings.schedule_type === "monthly" && settings.schedule_frame) { + // There are two kinds of monthly schedule: + // - weekday-based (e.g. "on the first Monday of the month") + // - date-based (e.g. "on the 15th of the month") + if (settings.schedule_day) { + // Handle weekday-based monthly schedule + const frameInCronFormat = frameToCron(settings.schedule_frame).replace( + /^1$/, + "#1", + ); + const dayInCronFormat = dayToCron(settings.schedule_day); + weekday = `${dayInCronFormat}${frameInCronFormat}`; + } else { + // Handle date-based monthly schedule + dayOfMonth = frameToCron(settings.schedule_frame); + } + } + const cronExpression = [ + second, + minute, + hour, + dayOfMonth, + month, + weekday, + ].join(" "); + return cronExpression; +}; + +/** Returns null if we can't convert the cron expression to a ScheduleSettings object */ +export const cronToScheduleSettings = ( + cron: string | null | undefined, +): ScheduleSettings | null => { + if (!cron) { + return defaultSchedule; + } + + // The Quartz cron library used in the backend distinguishes between 'no specific value' and 'all values', + // but for simplicity we can treat them as the same here + cron = cron.replace( + new RegExp(Cron.NoSpecificValue_Escaped, "g"), + Cron.AllValues, + ); + + const [_second, minute, hour, dayOfMonth, month, weekday] = cron.split(" "); + + if (month !== Cron.AllValues) { + return null; + } + let schedule_type: ScheduleType | undefined; + if (dayOfMonth === Cron.AllValues) { + if (weekday === Cron.AllValues) { + schedule_type = hour === Cron.AllValues ? "hourly" : "daily"; + } else { + // If the weekday part of the cron expression is something like '1#1' (first Monday), + // or '2L' (last Tuesday), then the frequency is monthly + const oneWeekPerMonth = weekday.match(/[#L]/); + schedule_type = oneWeekPerMonth ? "monthly" : "weekly"; + } + } else { + schedule_type = "monthly"; + } + let schedule_frame: ScheduleFrameType | undefined; + let schedule_day: ScheduleDayType | undefined; + if (schedule_type === "monthly") { + if (weekday === Cron.AllValues) { + schedule_frame = frameFromCron(dayOfMonth); + } else { + // Split on transition from number to non-number + const weekdayParts = weekday.split(/(?<=\d)(?=\D)/); + const day = parseInt(weekdayParts[0]); + schedule_day = weekdays[day - 1]?.value as ScheduleDayType; + if (dayOfMonth === Cron.AllValues) { + const frameInCronFormat = weekdayParts[1].replace(/^#/, ""); + schedule_frame = frameFromCron(frameInCronFormat); + } else { + schedule_frame = frameFromCron(dayOfMonth); + } + } + } else { + if (weekday !== Cron.AllValues) { + schedule_day = weekdays[parseInt(weekday) - 1]?.value as ScheduleDayType; + } + } + return { + schedule_type, + schedule_minute: parseInt(minute), + schedule_hour: parseInt(hour), + schedule_day, + schedule_frame, + }; +}; + +const defaultSchedule: ScheduleSettings = { + schedule_type: "hourly", + schedule_minute: 0, +}; + +export const hourToTwelveHourFormat = (hour: number) => hour % 12 || 12; + +export const removeFalsyValues = (obj: any) => pick(obj, val => val); diff --git a/frontend/src/metabase/admin/performance/utils.unit.spec.ts b/frontend/src/metabase/admin/performance/utils.unit.spec.ts new file mode 100644 index 00000000000..0e0d4beb306 --- /dev/null +++ b/frontend/src/metabase/admin/performance/utils.unit.spec.ts @@ -0,0 +1,249 @@ +import type { ScheduleSettings } from "metabase-types/api"; + +import { + cronToScheduleSettings, + hourToTwelveHourFormat, + removeFalsyValues, + scheduleSettingsToCron, +} from "./utils"; + +describe("scheduleSettingsToCron", () => { + it("converts hourly schedule to cron", () => { + const settings: ScheduleSettings = { + schedule_type: "hourly", + schedule_minute: 1, + schedule_hour: 1, + }; + const cron = scheduleSettingsToCron(settings); + expect(cron).toEqual("0 1 1 * * ?"); + }); + + it("converts daily schedule to cron", () => { + const settings: ScheduleSettings = { + schedule_type: "daily", + schedule_minute: 30, + schedule_hour: 14, + }; + const cron = scheduleSettingsToCron(settings); + expect(cron).toEqual("0 30 14 * * ?"); + }); + + it("converts weekly schedule to cron", () => { + const settings: ScheduleSettings = { + schedule_type: "weekly", + schedule_day: "mon", + schedule_minute: 0, + schedule_hour: 12, + }; + const cron = scheduleSettingsToCron(settings); + expect(cron).toEqual("0 0 12 ? * 2"); + }); + + it("converts 'first Wednesday of the month at 9:15am' to cron", () => { + const settings: ScheduleSettings = { + schedule_type: "monthly", + schedule_day: "wed", + schedule_frame: "first", + schedule_minute: 15, + schedule_hour: 9, + }; + const cron = scheduleSettingsToCron(settings); + expect(cron).toEqual("0 15 9 ? * 4#1"); + }); + + it("converts 'last calendar day of the month' to cron", () => { + const settings: ScheduleSettings = { + schedule_type: "monthly", + schedule_frame: "last", + schedule_minute: 45, + schedule_hour: 16, + }; + const cron = scheduleSettingsToCron(settings); + expect(cron).toEqual("0 45 16 L * ?"); + }); + + it("converts 'monthly on the 15th' to cron", () => { + const settings: ScheduleSettings = { + schedule_type: "monthly", + schedule_frame: "mid", + schedule_minute: 5, + schedule_hour: 23, + }; + const cron = scheduleSettingsToCron(settings); + expect(cron).toEqual("0 5 23 15 * ?"); + }); + + it("missing minute and hour should default to wildcard", () => { + const settings: ScheduleSettings = { + schedule_type: "daily", + }; + const cron = scheduleSettingsToCron(settings); + expect(cron).toEqual("0 * * * * ?"); + }); +}); + +describe("cronToScheduleSettings", () => { + it("returns default schedule when input is null or undefined", () => { + const defaultSchedule = { + schedule_type: "hourly", + schedule_minute: 0, + }; + expect(cronToScheduleSettings(null)).toEqual(defaultSchedule); + expect(cronToScheduleSettings(undefined)).toEqual(defaultSchedule); + }); + + it("returns null when month is specified in cron", () => { + const cron = "0 15 10 15 1 ?"; + expect(cronToScheduleSettings(cron)).toBeNull(); + }); + + describe("schedule type determination", () => { + it('sets schedule type to "hourly" when hour is "*" and both dayOfMonth and dayOfWeek are "*"', () => { + const cron = "0 30 * * * *"; + expect(cronToScheduleSettings(cron)?.schedule_type).toBe("hourly"); + }); + + it('sets schedule type to "daily" when dayOfMonth and dayOfWeek are "*", and hour is specified', () => { + const cron = "0 30 8 * * *"; + expect(cronToScheduleSettings(cron)?.schedule_type).toBe("daily"); + }); + + it('sets schedule type to "weekly" when dayOfWeek is specified', () => { + const cron = "0 30 8 ? * 1"; + expect(cronToScheduleSettings(cron)?.schedule_type).toBe("weekly"); + }); + + it('sets schedule type to "monthly" when dayOfMonth is specific', () => { + const cron = "0 30 8 15 * ?"; + expect(cronToScheduleSettings(cron)?.schedule_type).toBe("monthly"); + }); + }); + + describe("monthly schedule determination", () => { + it('sets schedule frame to "mid" when dayOfMonth is "15"', () => { + const cron = "0 30 8 15 * ?"; + expect(cronToScheduleSettings(cron)?.schedule_frame).toBe("mid"); + }); + + it('sets schedule frame to "first" when dayOfMonth is "1"', () => { + const cron = "0 30 8 1 * ?"; + expect(cronToScheduleSettings(cron)?.schedule_frame).toBe("first"); + }); + + it('sets schedule frame to "last" when dayOfMonth is "L"', () => { + const cron = "0 30 8 L * ?"; + expect(cronToScheduleSettings(cron)?.schedule_frame).toBe("last"); + }); + }); + + describe("weekly schedule determination", () => { + it('sets schedule_day to "sun" when dayOfWeek is 1', () => { + const cron = "0 30 8 ? * 1"; + expect(cronToScheduleSettings(cron)?.schedule_day).toBe("sun"); + }); + + it('sets schedule_day to "mon" when dayOfWeek is 2', () => { + const cron = "0 30 8 ? * 2"; + expect(cronToScheduleSettings(cron)?.schedule_day).toBe("mon"); + }); + + it('sets schedule_day to "tue" when dayOfWeek is 3', () => { + const cron = "0 30 8 ? * 3"; + expect(cronToScheduleSettings(cron)?.schedule_day).toBe("tue"); + }); + + it('sets schedule_day to "wed" when dayOfWeek is 4', () => { + const cron = "0 30 8 ? * 4"; + expect(cronToScheduleSettings(cron)?.schedule_day).toBe("wed"); + }); + + it('sets schedule_day to "thu" when dayOfWeek is 5', () => { + const cron = "0 30 8 ? * 5"; + expect(cronToScheduleSettings(cron)?.schedule_day).toBe("thu"); + }); + + it('sets schedule_day to "fri" when dayOfWeek is 6', () => { + const cron = "0 30 8 ? * 6"; + expect(cronToScheduleSettings(cron)?.schedule_day).toBe("fri"); + }); + + it('sets schedule_day to "sat" when dayOfWeek is 7', () => { + const cron = "0 30 8 ? * 7"; + expect(cronToScheduleSettings(cron)?.schedule_day).toBe("sat"); + }); + it('sets schedule_day to undefined if dayOfWeek is "?"', () => { + const cron = "0 30 8 ? * ?"; + expect(cronToScheduleSettings(cron)?.schedule_day).toBeUndefined(); + }); + }); + + describe("hours and minutes extraction", () => { + it("correctly extracts the hour and minute from the cron", () => { + const cron = "0 20 3 * * *"; + const result = cronToScheduleSettings(cron); + expect(result).toEqual( + expect.objectContaining({ + schedule_minute: 20, + schedule_hour: 3, + }), + ); + }); + }); + + describe("specific cron strings", () => { + it("converts 0 0 22 ? * 2L correctly", () => { + const cron = "0 0 22 ? * 2L"; + expect(cronToScheduleSettings(cron)).toEqual({ + schedule_type: "monthly", + schedule_minute: 0, + schedule_hour: 22, + schedule_day: "mon", + schedule_frame: "last", + }); + }); + it("converts 0 0 6 ? * 6#1 correctly", () => { + const cron = "0 0 6 ? * 6#1"; + expect(cronToScheduleSettings(cron)).toEqual({ + schedule_type: "monthly", + schedule_minute: 0, + schedule_hour: 6, + schedule_day: "fri", + schedule_frame: "first", + }); + }); + }); +}); + +describe("hourToTwelveHourFormat", () => { + it("converts 24-hour format to 12-hour format correctly", () => { + expect(hourToTwelveHourFormat(0)).toBe(12); + expect(hourToTwelveHourFormat(13)).toBe(1); + expect(hourToTwelveHourFormat(23)).toBe(11); + expect(hourToTwelveHourFormat(12)).toBe(12); + }); + it("does not change hours that are already in 12-hour format", () => { + expect(hourToTwelveHourFormat(11)).toBe(11); + expect(hourToTwelveHourFormat(10)).toBe(10); + expect(hourToTwelveHourFormat(1)).toBe(1); + }); +}); + +describe("removeFalsyValues", () => { + it("removes falsy values from an object", () => { + const obj = { + a: 1, + b: false, + c: null, + d: 0, + e: "", + f: "some string", + g: undefined, + }; + const truthyObj = { a: 1, f: "some string" }; + expect(removeFalsyValues(obj)).toEqual(truthyObj); + }); + it("keeps truthy values", () => { + const obj = { a: 1, b: true, c: "string", d: [], e: {} }; + expect(removeFalsyValues(obj)).toEqual(obj); + }); +}); diff --git a/frontend/src/metabase/components/Schedule/Schedule.stories.tsx b/frontend/src/metabase/components/Schedule/Schedule.stories.tsx new file mode 100644 index 00000000000..fdce96762fc --- /dev/null +++ b/frontend/src/metabase/components/Schedule/Schedule.stories.tsx @@ -0,0 +1,41 @@ +import { useArgs } from "@storybook/addons"; +import type { ComponentStory } from "@storybook/react"; + +import { Schedule } from "./Schedule"; + +export default { + title: "Components/Schedule", + component: Schedule, +}; + +const Template: ComponentStory<typeof Schedule> = args => { + const [ + { + schedule, + scheduleOptions = ["hourly", "daily", "weekly", "monthly"], + timezone = "UTC", + }, + updateArgs, + ] = useArgs(); + const handleChange = (schedule: unknown) => updateArgs({ schedule }); + return ( + <Schedule + {...args} + schedule={schedule} + scheduleOptions={scheduleOptions} + timezone={timezone} + onScheduleChange={handleChange} + /> + ); +}; + +export const Default = Template.bind({}); +Default.args = { + schedule: { + schedule_day: "mon", + schedule_frame: null, + schedule_hour: 0, + schedule_type: "daily", + }, + verb: "Deliver", +}; diff --git a/frontend/src/metabase/components/Schedule/Schedule.tsx b/frontend/src/metabase/components/Schedule/Schedule.tsx new file mode 100644 index 00000000000..52a0baeaa0c --- /dev/null +++ b/frontend/src/metabase/components/Schedule/Schedule.tsx @@ -0,0 +1,281 @@ +import { useCallback } from "react"; +import { c } from "ttag"; + +import { removeFalsyValues } from "metabase/admin/performance/utils"; +import { capitalize } from "metabase/lib/formatting/strings"; +import { Box } from "metabase/ui"; +import type { ScheduleSettings, ScheduleType } from "metabase-types/api"; + +import { + AutoWidthSelect, + TimeDetails, + SelectFrame, + SelectMinute, + SelectTime, + SelectWeekday, + SelectWeekdayOfMonth, +} from "./components"; +import { defaultDay, optionNameTranslations } from "./constants"; +import type { ScheduleChangeProp, UpdateSchedule } from "./types"; + +type ScheduleProperty = keyof ScheduleSettings; + +const getOptionName = (option: ScheduleType) => + optionNameTranslations[option] || capitalize(option); + +export interface ScheduleProps { + schedule: ScheduleSettings; + scheduleOptions: ScheduleType[]; + onScheduleChange: ( + nextSchedule: ScheduleSettings, + change: ScheduleChangeProp, + ) => void; + timezone?: string; + verb?: string; + textBeforeSendTime?: string; + minutesOnHourPicker?: boolean; +} + +const defaults: Record<string, Partial<ScheduleSettings>> = { + hourly: { + schedule_day: null, + schedule_frame: null, + schedule_hour: null, + schedule_minute: 0, + }, + daily: { + schedule_day: null, + schedule_frame: null, + }, + weekly: { + schedule_day: defaultDay, + schedule_frame: null, + }, + monthly: { + schedule_frame: "first", + schedule_day: defaultDay, + }, +}; + +export const Schedule = ({ + schedule, + scheduleOptions, + timezone, + verb, + textBeforeSendTime, + minutesOnHourPicker, + onScheduleChange, +}: { + schedule: ScheduleSettings; + scheduleOptions: ScheduleType[]; + timezone?: string; + verb?: string; + textBeforeSendTime?: string; + minutesOnHourPicker?: boolean; + onScheduleChange: ( + nextSchedule: ScheduleSettings, + change: ScheduleChangeProp, + ) => void; +}) => { + const updateSchedule: UpdateSchedule = useCallback( + (field: ScheduleProperty, value: ScheduleSettings[typeof field]) => { + let newSchedule: ScheduleSettings = { + ...schedule, + [field]: value, + }; + + newSchedule = removeFalsyValues(newSchedule); + + if (field === "schedule_type") { + newSchedule = { + ...defaults[value as ScheduleType], + ...newSchedule, + }; + } else if (field === "schedule_frame") { + // when the monthly schedule frame is the 15th, clear out the schedule_day + if (value === "mid") { + newSchedule = { ...newSchedule, schedule_day: null }; + } else { + // first or last, needs a day of the week + newSchedule = { + schedule_day: newSchedule.schedule_day || defaultDay, + ...newSchedule, + }; + } + } + + onScheduleChange(newSchedule, { name: field, value }); + }, + [onScheduleChange, schedule], + ); + + return ( + <Box lh="41px" display="flex" style={{ flexWrap: "wrap", gap: ".5rem" }}> + <ScheduleBody + schedule={schedule} + updateSchedule={updateSchedule} + scheduleOptions={scheduleOptions} + timezone={timezone} + verb={verb} + textBeforeSendTime={textBeforeSendTime} + minutesOnHourPicker={minutesOnHourPicker} + /> + </Box> + ); +}; + +const ScheduleBody = ({ + schedule, + updateSchedule, + scheduleOptions, + timezone, + verb, + textBeforeSendTime, + minutesOnHourPicker, +}: Omit<ScheduleProps, "onScheduleChange"> & { + updateSchedule: UpdateSchedule; +}) => { + const itemProps = { + schedule, + updateSchedule, + }; + + const frequencyProps = { + ...itemProps, + key: "frequency", + scheduleType: schedule.schedule_type, + scheduleOptions, + }; + const timeProps = { + ...itemProps, + key: "time", + }; + const minuteProps = { + ...itemProps, + key: "minute", + }; + const weekdayProps = { + ...itemProps, + key: "weekday", + }; + const frameProps = { + ...itemProps, + key: "frame", + }; + const weekdayOfMonthProps = { + ...itemProps, + key: "weekday-of-month", + }; + const timeDetailsProps = { + key: "time-details", + hour: schedule.schedule_hour ?? null, + amPm: schedule.schedule_hour + ? schedule.schedule_hour >= 12 + ? 1 + : 0 + : null, + timezone: timezone ?? null, + textBeforeSendTime, + }; + + const scheduleType = schedule.schedule_type; + + if (scheduleType === "hourly") { + if (minutesOnHourPicker) { + // e.g. "Send hourly at 15 minutes past the hour" + return ( + <>{c( + "{0} is a verb like 'Send', {1} is an adverb like 'hourly', {2} is a number of minutes", + ).jt`${verb} ${(<SelectFrequency {...frequencyProps} />)} at ${( + <SelectMinute {...minuteProps} /> + )} minutes past the hour`}</> + ); + } else { + // e.g. "Send hourly" + return ( + <> + {verb} <SelectFrequency {...frequencyProps} /> + </> + ); + } + } else if (scheduleType === "daily") { + // e.g. "Send daily at 12:00pm" + return ( + <> + {c( + "{0} is a verb like 'Send', {1} is an adverb like 'hourly', {2} is a time like '12:00pm'", + ).jt`${verb} ${(<SelectFrequency {...frequencyProps} />)} at ${( + <SelectTime {...timeProps} /> + )}`} + <TimeDetails {...timeDetailsProps} /> + </> + ); + } else if (scheduleType === "weekly") { + // e.g. "Send weekly on Tuesday at 12:00pm" + return ( + <> + {c( + "{0} is a verb like 'Send', {1} is an adverb like 'hourly', {2} is a day like 'Tuesday', {3} is a time like '12:00pm'", + ).jt`${verb} ${(<SelectFrequency {...frequencyProps} />)} on ${( + <SelectWeekday {...weekdayProps} /> + )} at ${(<SelectTime {...timeProps} />)}`} + <TimeDetails {...timeDetailsProps} /> + </> + ); + } else if (scheduleType === "monthly") { + // e.g. "Send monthly on the 15th at 12:00pm" + if (schedule.schedule_frame === "mid") { + return ( + <> + {c( + "{0} is a verb like 'Send', {1} is an adverb like 'hourly', {2} is the noun '15th' (as in 'the 15th of the month'), {3} is a time like '12:00pm'", + ).jt`${verb} ${(<SelectFrequency {...frequencyProps} />)} on the ${( + <SelectFrame {...frameProps} /> + )} at ${(<SelectTime {...timeProps} />)} `} + <TimeDetails {...timeDetailsProps} /> + </> + ); + } else { + // e.g. "Send monthly on the first Tuesday at 12:00pm" + return ( + <> + {c( + "{0} is a verb like 'Send', {1} is an adverb like 'hourly', {2} is an adjective like 'first', {3} is a day like 'Tuesday', {4} is a time like '12:00pm'", + ).jt`${verb} ${(<SelectFrequency {...frequencyProps} />)} on the ${( + <SelectFrame {...frameProps} /> + )} ${(<SelectWeekdayOfMonth {...weekdayOfMonthProps} />)} at ${( + <SelectTime {...timeProps} /> + )}`} + </> + ); + } + } else { + return null; + } +}; + +/** A Select that changes the schedule frequency (e.g., daily, hourly, monthly, etc.), + * also known as the schedule 'type'. */ +const SelectFrequency = ({ + scheduleType, + updateSchedule, + scheduleOptions, +}: { + scheduleType?: ScheduleType | null; + updateSchedule: UpdateSchedule; + scheduleOptions: ScheduleType[]; +}) => { + const scheduleTypeOptions = scheduleOptions.map(option => ({ + label: getOptionName(option), + value: option, + })); + + return ( + <AutoWidthSelect + display="flex" + value={scheduleType} + onChange={(value: ScheduleType) => updateSchedule("schedule_type", value)} + data={scheduleTypeOptions} + /> + ); +}; diff --git a/frontend/src/metabase/components/Schedule/components.tsx b/frontend/src/metabase/components/Schedule/components.tsx new file mode 100644 index 00000000000..11e8e311653 --- /dev/null +++ b/frontend/src/metabase/components/Schedule/components.tsx @@ -0,0 +1,186 @@ +import { useMemo } from "react"; +import { t } from "ttag"; + +import { hourToTwelveHourFormat } from "metabase/admin/performance/utils"; +import { useSelector } from "metabase/lib/redux"; +import { getApplicationName } from "metabase/selectors/whitelabel"; +import type { SelectProps } from "metabase/ui"; +import { Group, SegmentedControl, Select, Text } from "metabase/ui"; +import type { + ScheduleDayType, + ScheduleFrameType, + ScheduleSettings, +} from "metabase-types/api"; + +import { + addZeroesToHour, + amAndPM, + defaultHour, + frames, + hours, + minutes, + weekdays, +} from "./constants"; +import type { UpdateSchedule } from "./types"; + +export const SelectFrame = ({ + schedule, + updateSchedule, +}: { + schedule: ScheduleSettings; + updateSchedule: UpdateSchedule; +}) => { + return ( + <AutoWidthSelect + value={schedule.schedule_frame} + onChange={(value: ScheduleFrameType) => + updateSchedule("schedule_frame", value) + } + data={frames} + /> + ); +}; + +export const SelectTime = ({ + schedule, + updateSchedule, +}: { + schedule: ScheduleSettings; + updateSchedule: UpdateSchedule; +}) => { + const hourIn24HourFormat = + schedule.schedule_hour && !isNaN(schedule.schedule_hour) + ? schedule.schedule_hour + : defaultHour; + const hour = hourToTwelveHourFormat(hourIn24HourFormat); + const amPm = hourIn24HourFormat >= 12 ? 1 : 0; + return ( + <Group spacing="xs"> + <AutoWidthSelect + value={hour.toString()} + data={hours} + onChange={(value: string) => + updateSchedule("schedule_hour", Number(value) + amPm * 12) + } + /> + <SegmentedControl + radius="sm" + value={amPm.toString()} + onChange={value => + updateSchedule("schedule_hour", hour + Number(value) * 12) + } + data={amAndPM} + /> + </Group> + ); +}; + +export const TimeDetails = ({ + hour, + amPm, + timezone, + textBeforeSendTime, +}: { + hour: number | null; + amPm: number | null; + timezone: string | null; + textBeforeSendTime?: string; +}) => { + const applicationName = useSelector(getApplicationName); + if (hour === null || amPm === null || !timezone) { + return null; + } + const time = addZeroesToHour(hourToTwelveHourFormat(hour)); + const amOrPM = amAndPM[amPm].label; + return ( + <Text w="100%" mt="xs" size="sm" fw="bold" color="text-light"> + {textBeforeSendTime} {time} {amOrPM} {timezone},{" "} + {t`your ${applicationName} timezone`} + </Text> + ); +}; + +export const SelectWeekday = ({ + schedule, + updateSchedule, +}: { + schedule: ScheduleSettings; + updateSchedule: UpdateSchedule; +}) => { + return ( + <AutoWidthSelect + value={schedule.schedule_day} + onChange={(value: ScheduleDayType) => + updateSchedule("schedule_day", value) + } + data={weekdays} + /> + ); +}; + +/** Selects the weekday of the month, e.g. the first Monday of the month + "First" is selected via SelectFrame. This component provides the weekday */ +export const SelectWeekdayOfMonth = ({ + schedule, + updateSchedule, +}: { + schedule: ScheduleSettings; + updateSchedule: UpdateSchedule; +}) => ( + <AutoWidthSelect + value={schedule.schedule_day || "calendar-day"} + onChange={(value: ScheduleDayType | "calendar-day") => + updateSchedule("schedule_day", value === "calendar-day" ? null : value) + } + data={[{ label: t`calendar day`, value: "calendar-day" }, ...weekdays]} + /> +); + +export const SelectMinute = ({ + schedule, + updateSchedule, +}: { + schedule: ScheduleSettings; + updateSchedule: UpdateSchedule; +}) => { + const minuteOfHour = isNaN(schedule.schedule_minute as number) + ? 0 + : schedule.schedule_minute; + return ( + <AutoWidthSelect + value={(minuteOfHour || 0).toString()} + data={minutes} + onChange={(value: string) => + updateSchedule("schedule_minute", Number(value)) + } + /> + ); +}; + +export const AutoWidthSelect = (props: SelectProps) => { + const longestLabel = useMemo( + () => + props.data.reduce((acc, option) => { + const label = typeof option === "string" ? option : option.label || ""; + return label.length > acc.length ? label : acc; + }, ""), + [props.data], + ); + const maxWidth = + longestLabel.length > 10 ? "unset" : `${longestLabel.length + 0.75}rem`; + return ( + <Select + miw="5rem" + maw={maxWidth} + styles={{ + wrapper: { + paddingRight: 0, + marginTop: 0, + "&:not(:only-child)": { marginTop: "0" }, + }, + input: { paddingRight: 0 }, + }} + {...props} + /> + ); +}; diff --git a/frontend/src/metabase/components/Schedule/constants.ts b/frontend/src/metabase/components/Schedule/constants.ts new file mode 100644 index 00000000000..33142ba5640 --- /dev/null +++ b/frontend/src/metabase/components/Schedule/constants.ts @@ -0,0 +1,62 @@ +import { c, t } from "ttag"; +import { times } from "underscore"; + +import type { ScheduleDayType } from "metabase-types/api"; + +export const minutes = times(60, n => ({ + label: n.toString(), + value: n.toString(), +})); + +export const addZeroesToHour = (hour: number) => + c("This is a time like 12:00pm. {0} is the hour part of the time").t`${ + hour || 12 + }:00`; + +export const hours = times(12, n => ({ + label: addZeroesToHour(n), + value: `${n}`, +})); + +export const optionNameTranslations = { + // The context is needed because 'hourly' can be an adjective ('hourly rate') or adverb ('update hourly'). Same with 'daily', 'weekly', and 'monthly'. + hourly: c("adverb").t`hourly`, + daily: c("adverb").t`daily`, + weekly: c("adverb").t`weekly`, + monthly: c("adverb").t`monthly`, +}; + +export type Weekday = { + label: string; + value: ScheduleDayType; +}; + +export const weekdays: Weekday[] = [ + { label: t`Sunday`, value: "sun" }, + { label: t`Monday`, value: "mon" }, + { label: t`Tuesday`, value: "tue" }, + { label: t`Wednesday`, value: "wed" }, + { label: t`Thursday`, value: "thu" }, + { label: t`Friday`, value: "fri" }, + { label: t`Saturday`, value: "sat" }, +]; + +export const amAndPM = [ + { label: c("As in 9:00 AM").t`AM`, value: "0" }, + { label: c("As in 9:00 PM").t`PM`, value: "1" }, +]; + +export const frames = [ + { label: t`first`, value: "first" }, + { label: t`last`, value: "last" }, + { label: t`15th`, value: "mid" }, +]; + +export const defaultDay = "mon"; +export const defaultHour = 8; + +export enum Cron { + AllValues = "*", + NoSpecificValue = "?", + NoSpecificValue_Escaped = "\\?", +} diff --git a/frontend/src/metabase/components/Schedule/types.ts b/frontend/src/metabase/components/Schedule/types.ts new file mode 100644 index 00000000000..9196ae660a4 --- /dev/null +++ b/frontend/src/metabase/components/Schedule/types.ts @@ -0,0 +1,9 @@ +import type { ScheduleSettings } from "metabase-types/api"; + +type ScheduleProperty = keyof ScheduleSettings; +export type ScheduleChangeProp = { name: ScheduleProperty; value: unknown }; + +export type UpdateSchedule = ( + field: ScheduleProperty, + value: ScheduleSettings[typeof field], +) => void; -- GitLab