diff --git a/src/components/RelativeRangeDatePicker/__stories__/RelativeRangeDatePiker.stories.tsx b/src/components/RelativeRangeDatePicker/__stories__/RelativeRangeDatePiker.stories.tsx index 261200c..71d7136 100644 --- a/src/components/RelativeRangeDatePicker/__stories__/RelativeRangeDatePiker.stories.tsx +++ b/src/components/RelativeRangeDatePicker/__stories__/RelativeRangeDatePiker.stories.tsx @@ -11,6 +11,25 @@ import type {Value} from '../../RelativeDatePicker'; import {RelativeRangeDatePicker} from '../RelativeRangeDatePicker'; import type {RelativeRangeDatePickerProps} from '../types'; +const DEFAULT_RANGE_DATE_PICKER_PRESET: RelativeRangeDatePickerProps['presetTabs'] = [ + { + id: 'main', + title: 'Presets', + presets: [ + { + from: 'now', + to: 'now+30d', + title: '30 days', + }, + { + from: 'now', + to: null, + title: 'Unlimited', + }, + ], + }, +]; + const meta: Meta = { title: 'Components/RelativeRangeDatePicker', component: RelativeRangeDatePicker, @@ -43,8 +62,19 @@ export const Default = { const timeZone = props.timeZone; const minValue = props.minValue ? dateTimeParse(props.minValue, {timeZone}) : undefined; const maxValue = props.maxValue ? dateTimeParse(props.maxValue, {timeZone}) : undefined; + let presetTabs; + if (props.withPresets) { + presetTabs = DEFAULT_RANGE_DATE_PICKER_PRESET; + } - return ; + return ( + + ); }, args: { onUpdate: (res, timeZone) => { @@ -88,6 +118,16 @@ export const Default = { }, }, timeZone: timeZoneControl, + allowNullableValues: { + control: { + type: 'boolean', + }, + }, + withPresets: { + control: { + type: 'boolean', + }, + }, }, } satisfies Story; diff --git a/src/components/RelativeRangeDatePicker/components/Control/Control.tsx b/src/components/RelativeRangeDatePicker/components/Control/Control.tsx index bbd1677..a1b4df1 100644 --- a/src/components/RelativeRangeDatePicker/components/Control/Control.tsx +++ b/src/components/RelativeRangeDatePicker/components/Control/Control.tsx @@ -44,7 +44,7 @@ export const Control = React.forwardRef( }, ref, ) => { - const {alwaysShowAsAbsolute, presetTabs, getRangeTitle} = props; + const {alwaysShowAsAbsolute, presetTabs, getRangeTitle, allowNullableValues} = props; const format = props.format || 'L'; const text = React.useMemo( @@ -55,6 +55,7 @@ export const Control = React.forwardRef( value: state.value, timeZone: state.timeZone, alwaysShowAsAbsolute, + allowNullableValues, format, presets: presetTabs?.flatMap(({presets}) => presets), }), diff --git a/src/components/RelativeRangeDatePicker/components/PickerDialog/PickerDoc.tsx b/src/components/RelativeRangeDatePicker/components/PickerDialog/PickerDoc.tsx index 168b43d..31d8199 100644 --- a/src/components/RelativeRangeDatePicker/components/PickerDialog/PickerDoc.tsx +++ b/src/components/RelativeRangeDatePicker/components/PickerDialog/PickerDoc.tsx @@ -155,8 +155,8 @@ interface PresetsDocProps { className?: string; size?: 's' | 'm' | 'l' | 'xl'; docs?: Preset[]; - onStartUpdate: (start: string) => void; - onEndUpdate: (end: string) => void; + onStartUpdate: (start: string | null) => void; + onEndUpdate: (end: string | null) => void; } export function PickerDoc({docs = data, ...props}: PresetsDocProps) { diff --git a/src/components/RelativeRangeDatePicker/components/PickerDialog/PickerForm.tsx b/src/components/RelativeRangeDatePicker/components/PickerDialog/PickerForm.tsx index 1e50430..a88fdf2 100644 --- a/src/components/RelativeRangeDatePicker/components/PickerDialog/PickerForm.tsx +++ b/src/components/RelativeRangeDatePicker/components/PickerDialog/PickerForm.tsx @@ -77,10 +77,12 @@ export function PickerForm( size={props.size} docs={props.docs} onStartUpdate={(start) => { - state.setStart({type: 'relative', value: start}); + state.setStart( + start === null ? null : {type: 'relative', value: start}, + ); }} onEndUpdate={(end) => { - state.setEnd({type: 'relative', value: end}); + state.setEnd(end === null ? null : {type: 'relative', value: end}); }} /> @@ -137,14 +139,15 @@ export function PickerForm( presetTabs={props.presetTabs} onChoosePreset={(start, end) => { state.setRange( - {type: 'relative', value: start}, - {type: 'relative', value: end}, + start === null ? null : {type: 'relative', value: start}, + end === null ? null : {type: 'relative', value: end}, ); if (!props.withApplyButton) { props.onApply(); } }} minValue={props.minValue} + allowNullableValues={props.allowNullableValues} className={b('presets')} /> ) : null} diff --git a/src/components/RelativeRangeDatePicker/components/PickerDialog/useRelativeRangeDatePickerDialogState.tsx b/src/components/RelativeRangeDatePicker/components/PickerDialog/useRelativeRangeDatePickerDialogState.tsx index 8481e9d..794af3e 100644 --- a/src/components/RelativeRangeDatePicker/components/PickerDialog/useRelativeRangeDatePickerDialogState.tsx +++ b/src/components/RelativeRangeDatePicker/components/PickerDialog/useRelativeRangeDatePickerDialogState.tsx @@ -89,7 +89,7 @@ export function useRelativeRangeDatePickerDialogState(props: PickerFormProps) { } } - function setRange(newStart: Value, newEnd: Value) { + function setRange(newStart: Value | null, newEnd: Value | null) { if (props.readOnly) { return; } diff --git a/src/components/RelativeRangeDatePicker/components/Presets/Presets.tsx b/src/components/RelativeRangeDatePicker/components/Presets/Presets.tsx index a7aaf98..f3d7d5f 100644 --- a/src/components/RelativeRangeDatePicker/components/Presets/Presets.tsx +++ b/src/components/RelativeRangeDatePicker/components/Presets/Presets.tsx @@ -17,11 +17,12 @@ const b = block('relative-range-date-picker-presets'); export interface PresetProps { className?: string; - onChoosePreset: (start: string, end: string) => void; + onChoosePreset: (start: string | null, end: string | null) => void; withTime?: boolean; minValue?: DateTime; size?: 's' | 'm' | 'l' | 'xl'; presetTabs?: PresetTab[]; + allowNullableValues?: boolean; } export function Presets({ className, @@ -30,9 +31,13 @@ export function Presets({ withTime, onChoosePreset, presetTabs, + allowNullableValues, }: PresetProps) { const tabs = React.useMemo(() => { - return filterPresetTabs(presetTabs ?? getDefaultPresetTabs({withTime}), {minValue}); + return filterPresetTabs( + presetTabs ?? getDefaultPresetTabs({withTime, allowNullableValues}), + {minValue, allowNullableValues}, + ); }, [withTime, minValue, presetTabs]); const [activeTabId, setActiveTab] = React.useState(tabs[0]?.id); @@ -83,7 +88,7 @@ export const SIZE_TO_ITEM_HEIGHT = { interface PresetsListProps { size?: 's' | 'm' | 'l' | 'xl'; presets: Preset[]; - onChoosePreset: (start: string, end: string) => void; + onChoosePreset: (start: string | null, end: string | null) => void; } function PresetsList({presets, onChoosePreset, size = 'm'}: PresetsListProps) { const ref = React.useRef>(null); diff --git a/src/components/RelativeRangeDatePicker/components/Presets/defaultPresets.ts b/src/components/RelativeRangeDatePicker/components/Presets/defaultPresets.ts index 3e71800..3d10078 100644 --- a/src/components/RelativeRangeDatePicker/components/Presets/defaultPresets.ts +++ b/src/components/RelativeRangeDatePicker/components/Presets/defaultPresets.ts @@ -1,8 +1,8 @@ import {i18n} from './i18n'; export interface Preset { - from: string; - to: string; + from: string | null; + to: string | null; title: string; } diff --git a/src/components/RelativeRangeDatePicker/components/Presets/utils.ts b/src/components/RelativeRangeDatePicker/components/Presets/utils.ts index 7fa4540..176acdd 100644 --- a/src/components/RelativeRangeDatePicker/components/Presets/utils.ts +++ b/src/components/RelativeRangeDatePicker/components/Presets/utils.ts @@ -32,17 +32,27 @@ const countUnit = { y: 'Last {count} year', } as const; -export function getPresetTitle(start: string, end: string, presets: Preset[] = allPresets) { - const startText = start.replace(/\s+/g, ''); - const endText = end.replace(/\s+/g, ''); +export function getPresetTitle( + start: string | null, + end: string | null, + presets: Preset[] = allPresets, +) { + const startText = start?.replace(/\s+/g, '') ?? start; + const endText = end?.replace(/\s+/g, '') ?? end; for (const preset of presets) { if (preset.from === startText && preset.to === endText) { return preset.title; } } + if (!startText) { + return `${i18n('To')}: ${endText}`; + } + if (!endText) { + return `${i18n('From')}: ${startText}`; + } - if (end === 'now') { + if (endText === 'now') { const match = lastRe.exec(startText); if (match) { const [, count, unit] = match; @@ -60,20 +70,24 @@ function isDateUnit(value: string): value is 's' | 'm' | 'h' | 'd' | 'w' | 'M' | return ['s', 'm', 'h', 'd', 'w', 'M', 'y'].includes(value); } -export function filterPresets(presets: Preset[], minValue?: DateTime) { +export function filterPresets( + presets: Preset[], + minValue?: DateTime, + allowNullableValues?: boolean, +) { return presets.filter((preset) => { const from = dateTimeParse(preset.from); const to = dateTimeParse(preset.to, {roundUp: true}); - if (!from || !to) { + if (!allowNullableValues && (!from || !to)) { return false; } - if (to.isBefore(from)) { + if (to?.isBefore(from)) { return false; } - if (minValue && from.isBefore(minValue)) { + if (minValue && from?.isBefore(minValue)) { return false; } @@ -90,9 +104,11 @@ export interface PresetTab { export function getDefaultPresetTabs({ withTime, minValue, + allowNullableValues, }: { minValue?: DateTime; withTime?: boolean; + allowNullableValues?: boolean; }) { const tabs: PresetTab[] = []; @@ -105,7 +121,7 @@ export function getDefaultPresetTabs({ if (withTime) { mainPresets.unshift(...DEFAULT_TIME_PRESETS); } - mainTab.presets = filterPresets(mainPresets, minValue); + mainTab.presets = filterPresets(mainPresets, minValue, allowNullableValues); if (mainTab.presets.length > 0) { tabs.push(mainTab); @@ -114,7 +130,7 @@ export function getDefaultPresetTabs({ const otherTab: PresetTab = { id: 'other', title: i18n('Other'), - presets: filterPresets(DEFAULT_OTHERS_PRESETS, minValue), + presets: filterPresets(DEFAULT_OTHERS_PRESETS, minValue, allowNullableValues), }; if (otherTab.presets.length > 0) { @@ -124,9 +140,12 @@ export function getDefaultPresetTabs({ return tabs; } -export function filterPresetTabs(tabs: PresetTab[], {minValue}: {minValue?: DateTime} = {}) { +export function filterPresetTabs( + tabs: PresetTab[], + {minValue, allowNullableValues}: {minValue?: DateTime; allowNullableValues?: boolean} = {}, +) { return tabs.reduce((acc, tab) => { - const presets = filterPresets(tab.presets, minValue); + const presets = filterPresets(tab.presets, minValue, allowNullableValues); if (presets.length) { acc.push({ ...tab, diff --git a/src/components/RelativeRangeDatePicker/utils.ts b/src/components/RelativeRangeDatePicker/utils.ts index 791f115..5a00afa 100644 --- a/src/components/RelativeRangeDatePicker/utils.ts +++ b/src/components/RelativeRangeDatePicker/utils.ts @@ -31,13 +31,34 @@ interface GetDefaultTitleArgs { value: RangeValue | null; timeZone: string; alwaysShowAsAbsolute?: boolean; + allowNullableValues?: boolean; format?: string; presets?: Preset[]; } + +const isPresetValue = (value: RangeValue | null, allowNullableValues?: boolean) => { + if (!value || value.start?.type === 'absolute' || value.end?.type === 'absolute') { + return null; + } + if (!allowNullableValues && (value.start === null || value.end === null)) { + // we can't get here with no nullable values allowed but just in case... + return null; + } + let start = null; + let end = null; + if (value.start?.type === 'relative') { + start = value.start.value; + } + if (value.end?.type === 'relative') { + end = value.end.value; + } + return {start, end}; +}; export function getDefaultTitle({ value, timeZone, alwaysShowAsAbsolute, + allowNullableValues, format = 'L', presets, }: GetDefaultTitleArgs) { @@ -62,12 +83,9 @@ export function getDefaultTitle({ : dateTimeParse(value.end.value, {timeZone, roundUp: true})?.format(format) ?? ''; } - if ( - !alwaysShowAsAbsolute && - value.start?.type === 'relative' && - value.end?.type === 'relative' - ) { - return `${getPresetTitle(value.start.value, value.end.value, presets)}`; + const presetSearch = isPresetValue(value, allowNullableValues); + if (!alwaysShowAsAbsolute && presetSearch) { + return `${getPresetTitle(presetSearch.start, presetSearch.end, presets)}`; } const delimiter = ' — ';