Files

251 lines
9.7 KiB
TypeScript

import { css, cx } from '@emotion/css';
import { concat, uniq, upperFirst, without } from 'lodash';
import React, { useEffect, useState } from 'react';
import { useFieldArray, useFormContext } from 'react-hook-form';
import { GrafanaTheme2 } from '@grafana/data';
import { Stack } from '@grafana/experimental';
import { Button, Field, FieldSet, Icon, Input, useStyles2 } from '@grafana/ui';
import { MuteTimingFields } from '../../types/mute-timing-form';
import { DAYS_OF_THE_WEEK, defaultTimeInterval, MONTHS, validateArrayField } from '../../utils/mute-timings';
import { MuteTimingTimeRange } from './MuteTimingTimeRange';
import { TimezoneSelect } from './timezones';
export const MuteTimingTimeInterval = () => {
const styles = useStyles2(getStyles);
const { formState, register, setValue } = useFormContext();
const {
fields: timeIntervals,
append: addTimeInterval,
remove: removeTimeInterval,
} = useFieldArray<MuteTimingFields>({
name: 'time_intervals',
});
return (
<FieldSet label="Time intervals">
<>
<p>
A time interval is a definition for a moment in time. All fields are lists, and at least one list element must
be satisfied to match the field. If a field is left blank, any moment of time will match the field. For an
instant of time to match a complete time interval, all fields must match. A mute timing can contain multiple
time intervals.
</p>
<Stack direction="column" gap={2}>
{timeIntervals.map((timeInterval, timeIntervalIndex) => {
const errors = formState.errors;
// manually register the "location" field, react-hook-form doesn't handle nested field arrays well and will refuse to set
// the default value for the field when using "useFieldArray"
register(`time_intervals.${timeIntervalIndex}.location`);
return (
<div key={timeInterval.id} className={styles.timeIntervalSection}>
<MuteTimingTimeRange intervalIndex={timeIntervalIndex} />
<Field label="Location" invalid={Boolean(errors.location)} error={errors.location?.message}>
<TimezoneSelect
prefix={<Icon name="map-marker" />}
width={50}
onChange={(selectedTimezone) => {
setValue(`time_intervals.${timeIntervalIndex}.location`, selectedTimezone.value);
}}
// @ts-ignore react-hook-form doesn't handle nested field arrays well
defaultValue={{ label: timeInterval.location, value: timeInterval.location }}
data-testid="mute-timing-location"
/>
</Field>
<Field label="Days of the week">
<DaysOfTheWeek
onChange={(daysOfWeek) => {
setValue(`time_intervals.${timeIntervalIndex}.weekdays`, daysOfWeek);
}}
// @ts-ignore react-hook-form doesn't handle nested field arrays well
defaultValue={timeInterval.weekdays}
/>
</Field>
<Field
label="Days of the month"
description="The days of the month, 1-31, of a month. Negative values can be used to represent days which begin at the end of the month"
invalid={!!errors.time_intervals?.[timeIntervalIndex]?.days_of_month}
error={errors.time_intervals?.[timeIntervalIndex]?.days_of_month?.message}
>
<Input
{...register(`time_intervals.${timeIntervalIndex}.days_of_month`, {
validate: (value) =>
validateArrayField(
value,
(day) => {
const parsedDay = parseInt(day, 10);
return (parsedDay > -31 && parsedDay < 0) || (parsedDay > 0 && parsedDay < 32);
},
'Invalid day'
),
})}
width={50}
// @ts-ignore react-hook-form doesn't handle nested field arrays well
defaultValue={timeInterval.days_of_month}
placeholder="Example: 1, 14:16, -1"
data-testid="mute-timing-days"
/>
</Field>
<Field
label="Months"
description="The months of the year in either numerical or the full calendar month"
invalid={!!errors.time_intervals?.[timeIntervalIndex]?.months}
error={errors.time_intervals?.[timeIntervalIndex]?.months?.message}
>
<Input
{...register(`time_intervals.${timeIntervalIndex}.months`, {
validate: (value) =>
validateArrayField(
value,
(month) => MONTHS.includes(month) || (parseInt(month, 10) < 13 && parseInt(month, 10) > 0),
'Invalid month'
),
})}
width={50}
placeholder="Example: 1:3, may:august, december"
// @ts-ignore react-hook-form doesn't handle nested field arrays well
defaultValue={timeInterval.months}
data-testid="mute-timing-months"
/>
</Field>
<Field
label="Years"
invalid={!!errors.time_intervals?.[timeIntervalIndex]?.years}
error={errors.time_intervals?.[timeIntervalIndex]?.years?.message ?? ''}
>
<Input
{...register(`time_intervals.${timeIntervalIndex}.years`, {
validate: (value) => validateArrayField(value, (year) => /^\d{4}$/.test(year), 'Invalid year'),
})}
width={50}
placeholder="Example: 2021:2022, 2030"
// @ts-ignore react-hook-form doesn't handle nested field arrays well
defaultValue={timeInterval.years}
data-testid="mute-timing-years"
/>
</Field>
<Button
type="button"
variant="destructive"
fill="outline"
icon="trash-alt"
onClick={() => removeTimeInterval(timeIntervalIndex)}
>
Remove time interval
</Button>
</div>
);
})}
</Stack>
<Button
type="button"
variant="secondary"
className={styles.removeTimeIntervalButton}
onClick={() => {
addTimeInterval(defaultTimeInterval);
}}
icon="plus"
>
Add another time interval
</Button>
</>
</FieldSet>
);
};
interface DaysOfTheWeekProps {
defaultValue?: string;
onChange: (input: string) => void;
}
const parseDays = (input: string): string[] => {
const parsedDays = input
.split(',')
.map((day) => day.trim())
// each "day" could still be a range of days, so we parse the range
.flatMap((day) => (day.includes(':') ? parseWeekdayRange(day) : day))
.map((day) => day.toLowerCase())
// remove invalid weekdays
.filter((day) => DAYS_OF_THE_WEEK.includes(day));
return uniq(parsedDays);
};
// parse monday:wednesday to ["monday", "tuesday", "wednesday"]
function parseWeekdayRange(input: string): string[] {
const [start = '', end = ''] = input.split(':');
const startIndex = DAYS_OF_THE_WEEK.indexOf(start);
const endIndex = DAYS_OF_THE_WEEK.indexOf(end);
return DAYS_OF_THE_WEEK.slice(startIndex, endIndex + 1);
}
const DaysOfTheWeek = ({ defaultValue = '', onChange }: DaysOfTheWeekProps) => {
const styles = useStyles2(getStyles);
const defaultValues = parseDays(defaultValue);
const [selectedDays, setSelectedDays] = useState<string[]>(defaultValues);
const toggleDay = (day: string) => {
selectedDays.includes(day)
? setSelectedDays((selectedDays) => without(selectedDays, day))
: setSelectedDays((selectedDays) => concat(selectedDays, day));
};
useEffect(() => {
onChange(selectedDays.join(', '));
}, [selectedDays, onChange]);
return (
<div data-testid="mute-timing-weekdays">
<Stack gap={1}>
{DAYS_OF_THE_WEEK.map((day) => {
const style = cx(styles.dayOfTheWeek, selectedDays.includes(day) && 'selected');
const abbreviated = day.slice(0, 3);
return (
<button type="button" key={day} className={style} onClick={() => toggleDay(day)}>
{upperFirst(abbreviated)}
</button>
);
})}
</Stack>
</div>
);
};
const getStyles = (theme: GrafanaTheme2) => ({
input: css`
width: 400px;
`,
timeIntervalSection: css`
background-color: ${theme.colors.background.secondary};
padding: ${theme.spacing(2)};
`,
removeTimeIntervalButton: css`
margin-top: ${theme.spacing(2)};
`,
dayOfTheWeek: css`
cursor: pointer;
user-select: none;
padding: ${theme.spacing(1)} ${theme.spacing(3)};
border: solid 1px ${theme.colors.border.medium};
background: none;
border-radius: ${theme.shape.borderRadius()};
color: ${theme.colors.text.secondary};
&.selected {
font-weight: ${theme.typography.fontWeightBold};
color: ${theme.colors.primary.text};
border-color: ${theme.colors.primary.border};
background: ${theme.colors.primary.transparent};
}
`,
});