Files
2024-02-15 17:01:50 +00:00

545 lines
17 KiB
TypeScript

import {Fragment, useCallback, useState} from 'react';
import styled from '@emotion/styled';
import {Button} from 'sentry/components/button';
import type {SelectOption, SingleSelectProps} from 'sentry/components/compactSelect';
import {CompactSelect} from 'sentry/components/compactSelect';
import type {Item} from 'sentry/components/dropdownAutoComplete/types';
import DropdownButton from 'sentry/components/dropdownButton';
import HookOrDefault from 'sentry/components/hookOrDefault';
import {DEFAULT_RELATIVE_PERIODS, DEFAULT_STATS_PERIOD} from 'sentry/constants';
import {IconArrow, IconCalendar} from 'sentry/icons';
import {t} from 'sentry/locale';
import {space} from 'sentry/styles/space';
import type {DateString} from 'sentry/types';
import {trackAnalytics} from 'sentry/utils/analytics';
import {
getDateWithTimezoneInUtc,
getInternalDate,
getLocalToSystem,
getPeriodAgo,
getUserTimezone,
getUtcToSystem,
parsePeriodToHours,
} from 'sentry/utils/dates';
import getRouteStringFromRoutes from 'sentry/utils/getRouteStringFromRoutes';
import useOrganization from 'sentry/utils/useOrganization';
import useRouter from 'sentry/utils/useRouter';
import DateRange from './dateRange';
import SelectorItems from './selectorItems';
import {
getAbsoluteSummary,
getArbitraryRelativePeriod,
getSortedRelativePeriods,
timeRangeAutoCompleteFilter,
} from './utils';
const ABSOLUTE_OPTION_VALUE = 'absolute';
const DateRangeHook = HookOrDefault({
hookName: 'component:header-date-range',
defaultComponent: DateRange,
});
const SelectorItemsHook = HookOrDefault({
hookName: 'component:header-selector-items',
defaultComponent: SelectorItems,
});
export type ChangeData = {
relative: string | null;
end?: Date;
start?: Date;
utc?: boolean | null;
};
export interface TimeRangeSelectorProps
extends Omit<
SingleSelectProps<string>,
| 'multiple'
| 'searchable'
| 'disableSearchFilter'
| 'options'
| 'hideOptions'
| 'value'
| 'defaultValue'
| 'onChange'
| 'onInteractOutside'
| 'closeOnSelect'
| 'onKeyDown'
> {
/**
* Set an optional default value to prefill absolute date with
*/
defaultAbsolute?: {end?: Date; start?: Date};
/**
* When the default period is selected, it is visually dimmed and makes the selector
* unclearable.
*/
defaultPeriod?: string;
/**
* Forces the user to select from the set of defined relative options
*/
disallowArbitraryRelativeRanges?: boolean;
/**
* End date value for absolute date selector
*/
end?: DateString;
/**
* The largest date range (ie. end date - start date) allowed
*/
maxDateRange?: number;
/**
* The maximum number of days in the past you can pick
*/
maxPickableDays?: number;
/**
* Message to show in the menu footer
*/
menuFooterMessage?: React.ReactNode;
onChange?: (data: ChangeData) => void;
/**
* Relative date value
*/
relative?: string | null;
/**
* Override defaults. Accepts a function where defaultRelativeOptions =
* DEFAULT_RELATIVE_PERIODS, and arbitraryRelativeOptions contains the custom
* user-created periods (via the search box).
*/
relativeOptions?:
| Record<string, React.ReactNode>
| ((props: {
arbitraryOptions: Record<string, React.ReactNode>;
defaultOptions: Record<string, React.ReactNode>;
}) => Record<string, React.ReactNode>);
/**
* Show absolute date selectors
*/
showAbsolute?: boolean;
/**
* Show relative date selectors
*/
showRelative?: boolean;
/**
* Start date value for absolute date selector
*/
start?: DateString;
/**
* Optional prefix for the storage key, for areas of the app that need separate pagefilters (i.e Starfish)
*/
storageNamespace?: string;
/**
* Default initial value for using UTC
*/
utc?: boolean | null;
}
export function TimeRangeSelector({
start,
end,
utc,
relative,
relativeOptions,
onChange,
onSearch,
onClose,
searchPlaceholder,
showAbsolute = true,
showRelative = true,
defaultAbsolute,
defaultPeriod = DEFAULT_STATS_PERIOD,
maxPickableDays = 90,
maxDateRange,
disallowArbitraryRelativeRanges = false,
trigger,
menuWidth,
menuBody,
menuFooter,
menuFooterMessage,
...selectProps
}: TimeRangeSelectorProps) {
const router = useRouter();
const organization = useOrganization({allowNull: true});
const [search, setSearch] = useState('');
const [hasChanges, setHasChanges] = useState(false);
const [hasDateRangeErrors, setHasDateRangeErrors] = useState(false);
const [showAbsoluteSelector, setShowAbsoluteSelector] = useState(!showRelative);
const [internalValue, setInternalValue] = useState<ChangeData>(() => {
const internalUtc = utc ?? getUserTimezone() === 'UTC';
return {
start: start ? getInternalDate(start, internalUtc) : undefined,
end: end ? getInternalDate(end, internalUtc) : undefined,
utc: internalUtc,
relative: relative ?? null,
};
});
const getOptions = useCallback(
(items: Item[]): SelectOption<string>[] => {
// Return the default options if there's nothing in search
if (!search) {
return items.map(item => {
if (item.value === 'absolute') {
return {
value: item.value,
// Wrap inside OptionLabel to offset custom margins from SelectorItemLabel
// TODO: Remove SelectorItemLabel & OptionLabel
label: <OptionLabel>{item.label}</OptionLabel>,
details:
start && end ? (
<AbsoluteSummary>{getAbsoluteSummary(start, end, utc)}</AbsoluteSummary>
) : null,
trailingItems: ({isFocused, isSelected}) => (
<IconArrow
direction="right"
size="xs"
color={isFocused || isSelected ? undefined : 'subText'}
/>
),
textValue: item.searchKey,
};
}
return {
value: item.value,
label: <OptionLabel>{item.label}</OptionLabel>,
textValue: item.searchKey,
};
});
}
const filteredItems = disallowArbitraryRelativeRanges
? items.filter(i => i.searchKey?.includes(search))
: // If arbitrary relative ranges are allowed, then generate a list of them based
// on the search query
timeRangeAutoCompleteFilter(items, search, {
maxDays: maxPickableDays,
maxDateRange,
});
return filteredItems.map(item => ({
value: item.value,
label: item.label,
textValue: item.searchKey,
}));
},
[
start,
end,
utc,
search,
maxPickableDays,
maxDateRange,
disallowArbitraryRelativeRanges,
]
);
const commitChanges = useCallback(() => {
showRelative && setShowAbsoluteSelector(false);
setSearch('');
if (!hasChanges) {
return;
}
setHasChanges(false);
onChange?.(
internalValue.start && internalValue.end
? {
...internalValue,
start: getDateWithTimezoneInUtc(internalValue.start, internalValue.utc),
end: getDateWithTimezoneInUtc(internalValue.end, internalValue.utc),
}
: internalValue
);
}, [showRelative, onChange, internalValue, hasChanges]);
const handleChange = useCallback<NonNullable<SingleSelectProps<string>['onChange']>>(
option => {
// The absolute option was selected -> open absolute selector
if (option.value === ABSOLUTE_OPTION_VALUE) {
setInternalValue(current => {
const defaultStart = defaultAbsolute?.start
? defaultAbsolute.start
: getPeriodAgo(
'hours',
parsePeriodToHours(relative || defaultPeriod || DEFAULT_STATS_PERIOD)
).toDate();
const defaultEnd = defaultAbsolute?.end ? defaultAbsolute.end : new Date();
return {
...current,
// Update default values for absolute selector
start: start ? getInternalDate(start, utc) : defaultStart,
end: end ? getInternalDate(end, utc) : defaultEnd,
};
});
setShowAbsoluteSelector(true);
return;
}
setInternalValue(current => ({...current, relative: option.value}));
onChange?.({relative: option.value, start: undefined, end: undefined});
},
[start, end, utc, defaultAbsolute, defaultPeriod, relative, onChange]
);
const arbitraryRelativePeriods = getArbitraryRelativePeriod(relative);
const defaultRelativePeriods = {
...DEFAULT_RELATIVE_PERIODS,
...arbitraryRelativePeriods,
};
return (
<SelectorItemsHook
shouldShowAbsolute={showAbsolute}
shouldShowRelative={showRelative}
relativePeriods={getSortedRelativePeriods(
typeof relativeOptions === 'function'
? relativeOptions({
defaultOptions: DEFAULT_RELATIVE_PERIODS,
arbitraryOptions: arbitraryRelativePeriods,
})
: relativeOptions ?? defaultRelativePeriods
)}
handleSelectRelative={value => handleChange({value})}
>
{items => (
<CompactSelect
{...selectProps}
searchable={!showAbsoluteSelector}
disableSearchFilter
onSearch={s => {
onSearch?.(s);
setSearch(s);
}}
searchPlaceholder={
searchPlaceholder ?? disallowArbitraryRelativeRanges
? t('Search…')
: t('Custom range: 2h, 4d, 8w…')
}
options={getOptions(items)}
hideOptions={showAbsoluteSelector}
value={start && end ? ABSOLUTE_OPTION_VALUE : relative ?? ''}
onChange={handleChange}
// Keep menu open when clicking on absolute range option
closeOnSelect={opt => opt.value !== ABSOLUTE_OPTION_VALUE}
onClose={() => {
onClose?.();
setHasChanges(false);
setSearch('');
}}
onInteractOutside={commitChanges}
onKeyDown={e => e.key === 'Escape' && commitChanges()}
trigger={
trigger ??
((triggerProps, isOpen) => {
const relativeSummary =
items.findIndex(item => item.value === relative) > -1
? relative?.toUpperCase()
: t('Invalid Period');
const defaultLabel =
start && end ? getAbsoluteSummary(start, end, utc) : relativeSummary;
return (
<DropdownButton
isOpen={isOpen}
size={selectProps.size}
icon={<IconCalendar />}
data-test-id="page-filter-timerange-selector"
{...triggerProps}
{...selectProps.triggerProps}
>
<TriggerLabel>{selectProps.triggerLabel ?? defaultLabel}</TriggerLabel>
</DropdownButton>
);
})
}
menuWidth={showAbsoluteSelector ? undefined : menuWidth ?? '16rem'}
menuBody={
(showAbsoluteSelector || menuBody) && (
<Fragment>
{!showAbsoluteSelector && menuBody}
{showAbsoluteSelector && (
<AbsoluteDateRangeWrap>
<StyledDateRangeHook
start={internalValue.start ?? null}
end={internalValue.end ?? null}
utc={internalValue.utc}
organization={organization ?? undefined}
showTimePicker
onChange={val => {
if (val.hasDateRangeErrors) {
setHasDateRangeErrors(true);
return;
}
setHasDateRangeErrors(false);
setInternalValue(cur => ({
...cur,
relative: null,
start: val.start,
end: val.end,
}));
setHasChanges(true);
}}
onChangeUtc={() => {
setHasChanges(true);
setInternalValue(current => {
const newUtc = !current.utc;
const newStart =
start ?? getDateWithTimezoneInUtc(current.start, current.utc);
const newEnd =
end ?? getDateWithTimezoneInUtc(current.end, current.utc);
trackAnalytics('dateselector.utc_changed', {
utc: newUtc,
path: getRouteStringFromRoutes(router.routes),
organization,
});
return {
relative: null,
start: newUtc
? getLocalToSystem(newStart)
: getUtcToSystem(newStart),
end: newUtc
? getLocalToSystem(newEnd)
: getUtcToSystem(newEnd),
utc: newUtc,
};
});
}}
maxPickableDays={maxPickableDays}
maxDateRange={maxDateRange}
/>
</AbsoluteDateRangeWrap>
)}
</Fragment>
)
}
menuFooter={
menuFooter || menuFooterMessage || showAbsoluteSelector
? ({closeOverlay}) => (
<Fragment>
{menuFooterMessage && (
<FooterMessage>{menuFooterMessage}</FooterMessage>
)}
<FooterWrap>
<FooterInnerWrap>{menuFooter}</FooterInnerWrap>
{showAbsoluteSelector && (
<AbsoluteSelectorFooter>
{showRelative && (
<Button
size="xs"
borderless
icon={<IconArrow direction="left" />}
onClick={() => setShowAbsoluteSelector(false)}
>
{t('Back')}
</Button>
)}
<Button
size="xs"
priority="primary"
disabled={!hasChanges || hasDateRangeErrors}
onClick={() => {
commitChanges();
closeOverlay();
}}
>
{t('Apply')}
</Button>
</AbsoluteSelectorFooter>
)}
</FooterWrap>
</Fragment>
)
: null
}
/>
)}
</SelectorItemsHook>
);
}
const TriggerLabel = styled('span')`
${p => p.theme.overflowEllipsis}
width: auto;
`;
const OptionLabel = styled('span')`
/* Remove custom margin added by SelectorItemLabel. Once we update custom hooks and
remove SelectorItemLabel, we can delete this. */
div {
margin: 0;
}
`;
const AbsoluteSummary = styled('span')`
time {
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
`;
const AbsoluteDateRangeWrap = styled('div')`
overflow: auto;
`;
const StyledDateRangeHook = styled(DateRangeHook)`
border: none;
width: max-content;
`;
const AbsoluteSelectorFooter = styled('div')`
display: flex;
gap: ${space(1)};
justify-content: flex-end;
`;
const FooterMessage = styled('p')`
padding: ${space(0.75)} ${space(1)};
margin: ${space(0.5)} 0;
border-radius: ${p => p.theme.borderRadius};
border: solid 1px ${p => p.theme.alert.warning.border};
background: ${p => p.theme.alert.warning.backgroundLight};
color: ${p => p.theme.textColor};
font-size: ${p => p.theme.fontSizeSmall};
`;
const FooterWrap = styled('div')`
display: grid;
grid-auto-flow: column;
gap: ${space(2)};
/* If there's FooterMessage above */
&:not(:first-child) {
margin-top: ${space(1)};
}
`;
const FooterInnerWrap = styled('div')`
grid-row: -1;
display: grid;
grid-auto-flow: column;
gap: ${space(1)};
&:empty {
display: none;
}
&:last-of-type {
justify-self: end;
justify-items: end;
}
&:first-of-type,
&:only-child {
justify-self: start;
justify-items: start;
}
`;