Grafana-UI: Add time range input (#26158)
* Grafana UI: Do not submit form on range change * Grafana UI: Add TimeRangeInput * Grafana UI: Style input * Grafana UI: Customize content * Grafana UI: Adjust caret style * Grafana UI: Add mdx * Grafana UI: Fix caret styles * Grafana UI: Fix typo * Grafana UI: Do not reload page on timerange change * Grafana UI: Sync TimeRangeForm state with external value * Grafana UI: Close overlay on apply * Grafana UI: Remove unused props * Grafana UI: Fix story * Grafana-UI: Make time zone optional * Grafana-UI: Update styles * Grafana-UI: Extract button label props * Grafana-UI: hideHistory => showHistory * Grafana-UI: Fix caret styles
This commit is contained in:
@@ -203,7 +203,7 @@ export const isValidTimeSpan = (value: string) => {
|
||||
return info.invalid !== true;
|
||||
};
|
||||
|
||||
export const describeTimeRangeAbbrevation = (range: TimeRange, timeZone?: TimeZone) => {
|
||||
export const describeTimeRangeAbbreviation = (range: TimeRange, timeZone?: TimeZone) => {
|
||||
if (isDateTime(range.from)) {
|
||||
return timeZoneAbbrevation(range.from, { timeZone });
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ const getStyles = stylesFactory((theme: GrafanaTheme) => {
|
||||
transform: translateY(-50%);
|
||||
display: inline-block;
|
||||
text-align: right;
|
||||
z-index: 1071;
|
||||
color: ${theme.colors.textWeak};
|
||||
`,
|
||||
picker: css`
|
||||
.rc-time-picker-panel-select {
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Story, Preview, Props } from '@storybook/addon-docs/blocks';
|
||||
import { TimeRangeInput } from './TimeRangeInput';
|
||||
|
||||
# TimeRangeInput
|
||||
|
||||
A variant of `TimeRangePicker` for use in forms.
|
||||
|
||||
### Usage
|
||||
|
||||
```jsx
|
||||
import { TimeRangeInput } from '@grafana/ui';
|
||||
|
||||
<TimeRangeInput
|
||||
value={timeRange}
|
||||
onChange={range => console.log('range', range)}
|
||||
onChangeTimeZone={tz => console.log('timezone', tz)}
|
||||
/>
|
||||
```
|
||||
|
||||
### Props
|
||||
<Props of={TimeRangeInput} />
|
||||
@@ -0,0 +1,44 @@
|
||||
import React from 'react';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import { dateTime, TimeFragment } from '@grafana/data';
|
||||
import { withCenteredStory } from '../../utils/storybook/withCenteredStory';
|
||||
import { UseState } from '../../utils/storybook/UseState';
|
||||
import { TimeRangeInput } from './TimeRangeInput';
|
||||
import mdx from './TimeRangeInput.mdx';
|
||||
|
||||
export default {
|
||||
title: 'Pickers and Editors/TimePickers/TimeRangeInput',
|
||||
component: TimeRangeInput,
|
||||
decorators: [withCenteredStory],
|
||||
parameters: {
|
||||
docs: {
|
||||
page: mdx,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const basic = () => {
|
||||
return (
|
||||
<UseState
|
||||
initialState={{
|
||||
from: dateTime(),
|
||||
to: dateTime(),
|
||||
raw: { from: 'now-6h' as TimeFragment, to: 'now' as TimeFragment },
|
||||
}}
|
||||
>
|
||||
{(value, updateValue) => {
|
||||
return (
|
||||
<TimeRangeInput
|
||||
onChangeTimeZone={tz => action('onTimeZoneChange fired')(tz)}
|
||||
timeZone="browser"
|
||||
value={value}
|
||||
onChange={timeRange => {
|
||||
action('onChange fired')(timeRange);
|
||||
updateValue(timeRange);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
</UseState>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,104 @@
|
||||
import React, { FC, FormEvent, useState } from 'react';
|
||||
import { css, cx } from 'emotion';
|
||||
import { GrafanaTheme, TimeRange, TimeZone } from '@grafana/data';
|
||||
import { useStyles } from '../../themes/ThemeContext';
|
||||
import { ClickOutsideWrapper } from '../ClickOutsideWrapper/ClickOutsideWrapper';
|
||||
import { Icon } from '../Icon/Icon';
|
||||
import { getInputStyles } from '../Input/Input';
|
||||
import { getFocusStyle } from '../Forms/commonStyles';
|
||||
import { TimePickerButtonLabel } from './TimeRangePicker';
|
||||
import { TimePickerContent } from './TimeRangePicker/TimePickerContent';
|
||||
import { otherOptions, quickOptions } from './rangeOptions';
|
||||
|
||||
export interface Props {
|
||||
value: TimeRange;
|
||||
timeZone?: TimeZone;
|
||||
onChange: (timeRange: TimeRange) => void;
|
||||
onChangeTimeZone?: (timeZone: TimeZone) => void;
|
||||
hideTimeZone?: boolean;
|
||||
}
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
export const TimeRangeInput: FC<Props> = ({
|
||||
value,
|
||||
onChange,
|
||||
onChangeTimeZone,
|
||||
hideTimeZone = true,
|
||||
timeZone = 'browser',
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const styles = useStyles(getStyles);
|
||||
|
||||
const onOpen = (event: FormEvent<HTMLDivElement>) => {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
setIsOpen(!isOpen);
|
||||
};
|
||||
|
||||
const onClose = () => {
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const onRangeChange = (timeRange: TimeRange) => {
|
||||
onClose();
|
||||
onChange(timeRange);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div tabIndex={0} className={styles.pickerInput} aria-label="TimePicker Open Button" onClick={onOpen}>
|
||||
<TimePickerButtonLabel value={value} />
|
||||
<span className={styles.caretIcon}>
|
||||
<Icon name={isOpen ? 'angle-up' : 'angle-down'} size="lg" />
|
||||
</span>
|
||||
</div>
|
||||
{isOpen && (
|
||||
<ClickOutsideWrapper includeButtonPress={false} onClick={onClose}>
|
||||
<TimePickerContent
|
||||
timeZone={timeZone}
|
||||
value={value}
|
||||
onChange={onRangeChange}
|
||||
otherOptions={otherOptions}
|
||||
quickOptions={quickOptions}
|
||||
onChangeTimeZone={onChangeTimeZone || noop}
|
||||
className={styles.content}
|
||||
hideTimeZone={hideTimeZone}
|
||||
/>
|
||||
</ClickOutsideWrapper>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const getStyles = (theme: GrafanaTheme) => {
|
||||
const inputStyles = getInputStyles({ theme, invalid: false });
|
||||
return {
|
||||
container: css`
|
||||
display: flex;
|
||||
position: relative;
|
||||
`,
|
||||
content: css`
|
||||
margin-left: 0;
|
||||
`,
|
||||
pickerInput: cx(
|
||||
inputStyles.input,
|
||||
inputStyles.wrapper,
|
||||
css`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
cursor: pointer;
|
||||
padding-right: 0;
|
||||
${getFocusStyle(theme)};
|
||||
`
|
||||
),
|
||||
caretIcon: cx(
|
||||
inputStyles.suffix,
|
||||
css`
|
||||
position: relative;
|
||||
margin-left: ${theme.spacing.xs};
|
||||
`
|
||||
),
|
||||
};
|
||||
};
|
||||
@@ -14,44 +14,9 @@ import { withTheme, useTheme } from '../../themes/ThemeContext';
|
||||
|
||||
// Types
|
||||
import { isDateTime, rangeUtil, GrafanaTheme, dateTimeFormat, timeZoneFormatUserFriendly } from '@grafana/data';
|
||||
import { TimeRange, TimeOption, TimeZone, dateMath } from '@grafana/data';
|
||||
import { TimeRange, TimeZone, dateMath } from '@grafana/data';
|
||||
import { Themeable } from '../../types';
|
||||
|
||||
const quickOptions: TimeOption[] = [
|
||||
{ from: 'now-5m', to: 'now', display: 'Last 5 minutes', section: 3 },
|
||||
{ from: 'now-15m', to: 'now', display: 'Last 15 minutes', section: 3 },
|
||||
{ from: 'now-30m', to: 'now', display: 'Last 30 minutes', section: 3 },
|
||||
{ from: 'now-1h', to: 'now', display: 'Last 1 hour', section: 3 },
|
||||
{ from: 'now-3h', to: 'now', display: 'Last 3 hours', section: 3 },
|
||||
{ from: 'now-6h', to: 'now', display: 'Last 6 hours', section: 3 },
|
||||
{ from: 'now-12h', to: 'now', display: 'Last 12 hours', section: 3 },
|
||||
{ from: 'now-24h', to: 'now', display: 'Last 24 hours', section: 3 },
|
||||
{ from: 'now-2d', to: 'now', display: 'Last 2 days', section: 3 },
|
||||
{ from: 'now-7d', to: 'now', display: 'Last 7 days', section: 3 },
|
||||
{ from: 'now-30d', to: 'now', display: 'Last 30 days', section: 3 },
|
||||
{ from: 'now-90d', to: 'now', display: 'Last 90 days', section: 3 },
|
||||
{ from: 'now-6M', to: 'now', display: 'Last 6 months', section: 3 },
|
||||
{ from: 'now-1y', to: 'now', display: 'Last 1 year', section: 3 },
|
||||
{ from: 'now-2y', to: 'now', display: 'Last 2 years', section: 3 },
|
||||
{ from: 'now-5y', to: 'now', display: 'Last 5 years', section: 3 },
|
||||
];
|
||||
|
||||
const otherOptions: TimeOption[] = [
|
||||
{ from: 'now-1d/d', to: 'now-1d/d', display: 'Yesterday', section: 3 },
|
||||
{ from: 'now-2d/d', to: 'now-2d/d', display: 'Day before yesterday', section: 3 },
|
||||
{ from: 'now-7d/d', to: 'now-7d/d', display: 'This day last week', section: 3 },
|
||||
{ from: 'now-1w/w', to: 'now-1w/w', display: 'Previous week', section: 3 },
|
||||
{ from: 'now-1M/M', to: 'now-1M/M', display: 'Previous month', section: 3 },
|
||||
{ from: 'now-1y/y', to: 'now-1y/y', display: 'Previous year', section: 3 },
|
||||
{ from: 'now/d', to: 'now/d', display: 'Today', section: 3 },
|
||||
{ from: 'now/d', to: 'now', display: 'Today so far', section: 3 },
|
||||
{ from: 'now/w', to: 'now/w', display: 'This week', section: 3 },
|
||||
{ from: 'now/w', to: 'now', display: 'This week so far', section: 3 },
|
||||
{ from: 'now/M', to: 'now/M', display: 'This month', section: 3 },
|
||||
{ from: 'now/M', to: 'now', display: 'This month so far', section: 3 },
|
||||
{ from: 'now/y', to: 'now/y', display: 'This year', section: 3 },
|
||||
{ from: 'now/y', to: 'now', display: 'This year so far', section: 3 },
|
||||
];
|
||||
import { otherOptions, quickOptions } from './rangeOptions';
|
||||
|
||||
const getStyles = stylesFactory((theme: GrafanaTheme) => {
|
||||
return {
|
||||
@@ -122,6 +87,7 @@ export class UnthemedTimeRangePicker extends PureComponent<Props, State> {
|
||||
onOpen = (event: FormEvent<HTMLButtonElement>) => {
|
||||
const { isOpen } = this.state;
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
this.setState({ isOpen: !isOpen });
|
||||
};
|
||||
|
||||
@@ -178,6 +144,7 @@ export class UnthemedTimeRangePicker extends PureComponent<Props, State> {
|
||||
otherOptions={otherOptions}
|
||||
quickOptions={quickOptions}
|
||||
history={history}
|
||||
showHistory
|
||||
onChangeTimeZone={onChangeTimeZone}
|
||||
/>
|
||||
</ClickOutsideWrapper>
|
||||
@@ -225,7 +192,9 @@ const TimePickerTooltip = ({ timeRange, timeZone }: { timeRange: TimeRange; time
|
||||
);
|
||||
};
|
||||
|
||||
const TimePickerButtonLabel = memo<Props>(({ hideText, value, timeZone }) => {
|
||||
type LabelProps = Pick<Props, 'hideText' | 'value' | 'timeZone'>;
|
||||
|
||||
export const TimePickerButtonLabel = memo<LabelProps>(({ hideText, value, timeZone }) => {
|
||||
const theme = useTheme();
|
||||
const styles = getLabelStyles(theme);
|
||||
|
||||
@@ -236,7 +205,7 @@ const TimePickerButtonLabel = memo<Props>(({ hideText, value, timeZone }) => {
|
||||
return (
|
||||
<span className={styles.container}>
|
||||
<span>{formattedRange(value, timeZone)}</span>
|
||||
<span className={styles.utc}>{rangeUtil.describeTimeRangeAbbrevation(value, timeZone)}</span>
|
||||
<span className={styles.utc}>{rangeUtil.describeTimeRangeAbbreviation(value, timeZone)}</span>
|
||||
</span>
|
||||
);
|
||||
});
|
||||
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
import React, { memo, useState, useEffect, useCallback } from 'react';
|
||||
import React, { memo, useState, useEffect, useCallback, FormEvent } from 'react';
|
||||
import { css } from 'emotion';
|
||||
import Calendar from 'react-calendar/dist/entry.nostyle';
|
||||
import { GrafanaTheme, DateTime, TimeZone, dateTimeParse } from '@grafana/data';
|
||||
@@ -189,7 +189,7 @@ interface Props {
|
||||
from: DateTime;
|
||||
to: DateTime;
|
||||
onClose: () => void;
|
||||
onApply: () => void;
|
||||
onApply: (e: FormEvent<HTMLButtonElement>) => void;
|
||||
onChange: (from: DateTime, to: DateTime) => void;
|
||||
isFullscreen: boolean;
|
||||
timeZone?: TimeZone;
|
||||
|
||||
+30
-21
@@ -1,5 +1,5 @@
|
||||
import { GrafanaTheme, isDateTime, TimeOption, TimeRange, TimeZone } from '@grafana/data';
|
||||
import { css } from 'emotion';
|
||||
import { css, cx } from 'emotion';
|
||||
import React, { memo, useState } from 'react';
|
||||
import { useMedia } from 'react-use';
|
||||
import { stylesFactory, useTheme } from '../../../themes';
|
||||
@@ -134,6 +134,9 @@ interface Props {
|
||||
quickOptions?: TimeOption[];
|
||||
otherOptions?: TimeOption[];
|
||||
history?: TimeRange[];
|
||||
showHistory?: boolean;
|
||||
className?: string;
|
||||
hideTimeZone?: boolean;
|
||||
}
|
||||
|
||||
interface PropsWithScreenSize extends Props {
|
||||
@@ -152,7 +155,7 @@ export const TimePickerContentWithScreenSize: React.FC<PropsWithScreenSize> = pr
|
||||
const { quickOptions = [], otherOptions = [], isFullscreen } = props;
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={cx(styles.container, props.className)}>
|
||||
<div className={styles.body}>
|
||||
<div className={styles.leftSide}>
|
||||
<FullScreenForm {...props} visible={isFullscreen} historyOptions={historyOptions} />
|
||||
@@ -176,7 +179,9 @@ export const TimePickerContentWithScreenSize: React.FC<PropsWithScreenSize> = pr
|
||||
/>
|
||||
</CustomScrollbar>
|
||||
</div>
|
||||
{isFullscreen && <TimePickerFooter timeZone={props.timeZone} onChangeTimeZone={props.onChangeTimeZone} />}
|
||||
{!props.hideTimeZone && isFullscreen && (
|
||||
<TimePickerFooter timeZone={props.timeZone} onChangeTimeZone={props.onChangeTimeZone} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -218,14 +223,16 @@ const NarrowScreenForm: React.FC<FormProps> = props => {
|
||||
isFullscreen={false}
|
||||
/>
|
||||
</div>
|
||||
<TimeRangeList
|
||||
title="Recently used absolute ranges"
|
||||
options={props.historyOptions || []}
|
||||
onSelect={props.onChange}
|
||||
value={props.value}
|
||||
placeholderEmpty={null}
|
||||
timeZone={props.timeZone}
|
||||
/>
|
||||
{props.showHistory && (
|
||||
<TimeRangeList
|
||||
title="Recently used absolute ranges"
|
||||
options={props.historyOptions || []}
|
||||
onSelect={props.onChange}
|
||||
value={props.value}
|
||||
placeholderEmpty={null}
|
||||
timeZone={props.timeZone}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
@@ -248,16 +255,18 @@ const FullScreenForm: React.FC<FormProps> = props => {
|
||||
</div>
|
||||
<TimeRangeForm value={props.value} timeZone={props.timeZone} onApply={props.onChange} isFullscreen={true} />
|
||||
</div>
|
||||
<div className={styles.recent}>
|
||||
<TimeRangeList
|
||||
title="Recently used absolute ranges"
|
||||
options={props.historyOptions || []}
|
||||
onSelect={props.onChange}
|
||||
value={props.value}
|
||||
placeholderEmpty={<EmptyRecentList />}
|
||||
timeZone={props.timeZone}
|
||||
/>
|
||||
</div>
|
||||
{props.showHistory && (
|
||||
<div className={styles.recent}>
|
||||
<TimeRangeList
|
||||
title="Recently used absolute ranges"
|
||||
options={props.historyOptions || []}
|
||||
onSelect={props.onChange}
|
||||
value={props.value}
|
||||
placeholderEmpty={<EmptyRecentList />}
|
||||
timeZone={props.timeZone}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { FormEvent, useState, useCallback } from 'react';
|
||||
import React, { FormEvent, useState, useCallback, useEffect } from 'react';
|
||||
import {
|
||||
TimeZone,
|
||||
isDateTime,
|
||||
@@ -37,6 +37,12 @@ export const TimeRangeForm: React.FC<Props> = props => {
|
||||
const [to, setTo] = useState<InputState>(valueToState(value.raw.to, true, timeZone));
|
||||
const [isOpen, setOpen] = useState(false);
|
||||
|
||||
// Synchronize internal state with external value
|
||||
useEffect(() => {
|
||||
setFrom(valueToState(value.raw.from, false, timeZone));
|
||||
setTo(valueToState(value.raw.to, true, timeZone));
|
||||
}, [value.raw.from, value.raw.to, timeZone]);
|
||||
|
||||
const onOpen = useCallback(
|
||||
(event: FormEvent<HTMLElement>) => {
|
||||
event.preventDefault();
|
||||
@@ -55,16 +61,20 @@ export const TimeRangeForm: React.FC<Props> = props => {
|
||||
[isFullscreen, onOpen]
|
||||
);
|
||||
|
||||
const onApply = useCallback(() => {
|
||||
if (to.invalid || from.invalid) {
|
||||
return;
|
||||
}
|
||||
const onApply = useCallback(
|
||||
(e: FormEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault();
|
||||
if (to.invalid || from.invalid) {
|
||||
return;
|
||||
}
|
||||
|
||||
const raw: RawTimeRange = { from: from.value, to: to.value };
|
||||
const timeRange = rangeUtil.convertRawToRange(raw, timeZone);
|
||||
const raw: RawTimeRange = { from: from.value, to: to.value };
|
||||
const timeRange = rangeUtil.convertRawToRange(raw, timeZone);
|
||||
|
||||
props.onApply(timeRange);
|
||||
}, [from, to, roundup, timeZone]);
|
||||
props.onApply(timeRange);
|
||||
},
|
||||
[from, to, roundup, timeZone]
|
||||
);
|
||||
|
||||
const onChange = useCallback(
|
||||
(from: DateTime, to: DateTime) => {
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { TimeOption } from '@grafana/data';
|
||||
|
||||
export const quickOptions: TimeOption[] = [
|
||||
{ from: 'now-5m', to: 'now', display: 'Last 5 minutes', section: 3 },
|
||||
{ from: 'now-15m', to: 'now', display: 'Last 15 minutes', section: 3 },
|
||||
{ from: 'now-30m', to: 'now', display: 'Last 30 minutes', section: 3 },
|
||||
{ from: 'now-1h', to: 'now', display: 'Last 1 hour', section: 3 },
|
||||
{ from: 'now-3h', to: 'now', display: 'Last 3 hours', section: 3 },
|
||||
{ from: 'now-6h', to: 'now', display: 'Last 6 hours', section: 3 },
|
||||
{ from: 'now-12h', to: 'now', display: 'Last 12 hours', section: 3 },
|
||||
{ from: 'now-24h', to: 'now', display: 'Last 24 hours', section: 3 },
|
||||
{ from: 'now-2d', to: 'now', display: 'Last 2 days', section: 3 },
|
||||
{ from: 'now-7d', to: 'now', display: 'Last 7 days', section: 3 },
|
||||
{ from: 'now-30d', to: 'now', display: 'Last 30 days', section: 3 },
|
||||
{ from: 'now-90d', to: 'now', display: 'Last 90 days', section: 3 },
|
||||
{ from: 'now-6M', to: 'now', display: 'Last 6 months', section: 3 },
|
||||
{ from: 'now-1y', to: 'now', display: 'Last 1 year', section: 3 },
|
||||
{ from: 'now-2y', to: 'now', display: 'Last 2 years', section: 3 },
|
||||
{ from: 'now-5y', to: 'now', display: 'Last 5 years', section: 3 },
|
||||
];
|
||||
|
||||
export const otherOptions: TimeOption[] = [
|
||||
{ from: 'now-1d/d', to: 'now-1d/d', display: 'Yesterday', section: 3 },
|
||||
{ from: 'now-2d/d', to: 'now-2d/d', display: 'Day before yesterday', section: 3 },
|
||||
{ from: 'now-7d/d', to: 'now-7d/d', display: 'This day last week', section: 3 },
|
||||
{ from: 'now-1w/w', to: 'now-1w/w', display: 'Previous week', section: 3 },
|
||||
{ from: 'now-1M/M', to: 'now-1M/M', display: 'Previous month', section: 3 },
|
||||
{ from: 'now-1y/y', to: 'now-1y/y', display: 'Previous year', section: 3 },
|
||||
{ from: 'now/d', to: 'now/d', display: 'Today', section: 3 },
|
||||
{ from: 'now/d', to: 'now', display: 'Today so far', section: 3 },
|
||||
{ from: 'now/w', to: 'now/w', display: 'This week', section: 3 },
|
||||
{ from: 'now/w', to: 'now', display: 'This week so far', section: 3 },
|
||||
{ from: 'now/M', to: 'now/M', display: 'This month', section: 3 },
|
||||
{ from: 'now/M', to: 'now', display: 'This month so far', section: 3 },
|
||||
{ from: 'now/y', to: 'now/y', display: 'This year', section: 3 },
|
||||
{ from: 'now/y', to: 'now', display: 'This year so far', section: 3 },
|
||||
];
|
||||
@@ -159,6 +159,7 @@ export { Checkbox } from './Forms/Checkbox';
|
||||
|
||||
export { TextArea } from './TextArea/TextArea';
|
||||
export { FileUpload } from './FileUpload/FileUpload';
|
||||
export { TimeRangeInput } from './TimePicker/TimeRangeInput';
|
||||
|
||||
// Legacy forms
|
||||
|
||||
|
||||
Reference in New Issue
Block a user