From 8df4a61d3b81e6054369197ff44e1416ea1aefbb Mon Sep 17 00:00:00 2001 From: Simon Guo Date: Fri, 26 Aug 2022 11:03:59 +0800 Subject: [PATCH] feat(DateRangePicker): supports placing predefined ranges on the left (#2670) --- docs/pages/_common/types/range.md | 4 + .../fragments/custom-shortcut-options.md | 140 +++++++++++++++--- .../components/date-range-picker/index.tsx | 22 ++- src/DatePicker/PredefinedRanges.tsx | 67 +++++++++ src/DatePicker/Toolbar.tsx | 132 ++++++++--------- src/DatePicker/styles/index.less | 10 -- src/DateRangePicker/DateRangePicker.tsx | 65 +++++--- src/DateRangePicker/styles/index.less | 17 ++- .../test/DateRangePickerSpec.js | 40 ++++- src/DateRangePicker/types.ts | 3 +- 10 files changed, 369 insertions(+), 131 deletions(-) create mode 100644 src/DatePicker/PredefinedRanges.tsx diff --git a/docs/pages/_common/types/range.md b/docs/pages/_common/types/range.md index 00bc511ef..a0c2ffbf1 100644 --- a/docs/pages/_common/types/range.md +++ b/docs/pages/_common/types/range.md @@ -5,5 +5,9 @@ interface Range { label: React.ReactNode; value: Date | ((date: Date) => Date); closeOverlay?: boolean; + + // Sets the position where the predefined range is displayed, the default is bottom. + // Only supported on DateRangePicker。 + placement?: 'bottom' | 'left'; } ``` diff --git a/docs/pages/components/date-range-picker/fragments/custom-shortcut-options.md b/docs/pages/components/date-range-picker/fragments/custom-shortcut-options.md index 6d25e69fa..fecae3d37 100644 --- a/docs/pages/components/date-range-picker/fragments/custom-shortcut-options.md +++ b/docs/pages/components/date-range-picker/fragments/custom-shortcut-options.md @@ -1,30 +1,134 @@ ```js -import { DateRangePicker } from 'rsuite'; +import { DateRangePicker, Stack } from 'rsuite'; import subDays from 'date-fns/subDays'; +import startOfWeek from 'date-fns/startOfWeek'; +import endOfWeek from 'date-fns/endOfWeek'; import addDays from 'date-fns/addDays'; +import startOfMonth from 'date-fns/startOfMonth'; +import endOfMonth from 'date-fns/endOfMonth'; +import addMonths from 'date-fns/addMonths'; + +const predefinedRanges = [ + { + label: 'Today', + value: [new Date(), new Date()], + placement: 'left' + }, + { + label: 'Yesterday', + value: [addDays(new Date(), -1), addDays(new Date(), -1)], + placement: 'left' + }, + { + label: 'This week', + value: [startOfWeek(new Date()), endOfWeek(new Date())], + placement: 'left' + }, + { + label: 'Last 7 days', + value: [subDays(new Date(), 6), new Date()], + placement: 'left' + }, + { + label: 'Last 30 days', + value: [subDays(new Date(), 29), new Date()], + placement: 'left' + }, + { + label: 'This month', + value: [startOfMonth(new Date()), new Date()], + placement: 'left' + }, + { + label: 'Last month', + value: [startOfMonth(addMonths(new Date(), -1)), endOfMonth(addMonths(new Date(), -1))], + placement: 'left' + }, + { + label: 'This year', + value: [new Date(new Date().getFullYear(), 0, 1), new Date()], + placement: 'left' + }, + { + label: 'Last year', + value: [new Date(new Date().getFullYear() - 1, 0, 1), new Date(new Date().getFullYear(), 0, 0)], + placement: 'left' + }, + { + label: 'All time', + value: [new Date(new Date().getFullYear() - 1, 0, 1), new Date()], + placement: 'left' + }, + { + label: 'Last week', + closeOverlay: false, + value: value => { + const [start = new Date()] = value || []; + return [ + addDays(startOfWeek(start, { weekStartsOn: 0 }), -7), + addDays(endOfWeek(start, { weekStartsOn: 0 }), -7) + ]; + }, + appearance: 'default' + }, + { + label: 'Next week', + closeOverlay: false, + value: value => { + const [start = new Date()] = value || []; + return [ + addDays(startOfWeek(start, { weekStartsOn: 0 }), 7), + addDays(endOfWeek(start, { weekStartsOn: 0 }), 7) + ]; + }, + appearance: 'default' + } +]; + +const predefinedBottomRanges = [ + { + label: 'Today', + value: [new Date(), new Date()] + }, + { + label: 'Yesterday', + value: [addDays(new Date(), -1), addDays(new Date(), -1)] + }, + { + label: 'This week', + value: [startOfWeek(new Date()), endOfWeek(new Date())] + }, + { + label: 'Last 7 days', + value: [subDays(new Date(), 6), new Date()] + }, + { + label: 'Last 30 days', + value: [subDays(new Date(), 29), new Date()] + } +]; const App = () => ( -
+ + + -
+ ); ReactDOM.render(, document.getElementById('root')); diff --git a/docs/pages/components/date-range-picker/index.tsx b/docs/pages/components/date-range-picker/index.tsx index 387ad5ac7..d4c0e2085 100644 --- a/docs/pages/components/date-range-picker/index.tsx +++ b/docs/pages/components/date-range-picker/index.tsx @@ -1,14 +1,32 @@ import React from 'react'; -import { DateRangePicker, Button, Divider } from 'rsuite'; +import { DateRangePicker, Button, Divider, Stack } from 'rsuite'; import DefaultPage from '@/components/Page'; +import startOfWeek from 'date-fns/startOfWeek'; +import endOfWeek from 'date-fns/endOfWeek'; import addDays from 'date-fns/addDays'; +import startOfMonth from 'date-fns/startOfMonth'; +import endOfMonth from 'date-fns/endOfMonth'; import subDays from 'date-fns/subDays'; import isAfter from 'date-fns/isAfter'; +import addMonths from 'date-fns/addMonths'; export default function Page() { return ( ); diff --git a/src/DatePicker/PredefinedRanges.tsx b/src/DatePicker/PredefinedRanges.tsx new file mode 100644 index 000000000..6cfe2ae5a --- /dev/null +++ b/src/DatePicker/PredefinedRanges.tsx @@ -0,0 +1,67 @@ +import React, { useCallback, useState } from 'react'; +import Button from '../Button'; +import Stack, { StackProps } from '../Stack'; +import { useUpdateEffect } from '../utils'; +import { getDefaultRanges, getRanges } from './utils'; +import { InnerRange, RangeType } from './types'; +import { CalendarLocale } from '../locales'; + +export interface PredefinedRangesProps extends StackProps { + ranges?: RangeType[]; + calendarDate: T; + locale: CalendarLocale; + disabledShortcut?: (value: T) => boolean; + onClickShortcut?: (value: Shortcut, closeOverlay: boolean, event: React.MouseEvent) => void; +} + +const PredefinedRanges = React.forwardRef((props, ref) => { + const { + className, + disabledShortcut, + onClickShortcut, + calendarDate, + ranges: rangesProp, + locale, + ...rest + } = props; + const [ranges, setRanges] = useState[]>(getRanges(props)); + + useUpdateEffect(() => { + setRanges(getRanges({ ranges: rangesProp, calendarDate })); + }, [calendarDate, rangesProp]); + + const hasLocaleKey = useCallback( + (key: React.ReactNode) => getDefaultRanges(calendarDate).some(item => item.label === key), + [calendarDate] + ); + + return ( + + {ranges.map(({ value, closeOverlay, label, ...rest }, index: number) => { + const disabled = disabledShortcut?.(value); + + const handleClickShortcut = (event: React.MouseEvent) => { + if (disabled) { + return; + } + onClickShortcut?.(value, closeOverlay !== false ? true : false, event); + }; + + return ( + + ); + })} + + ); +}); + +export default PredefinedRanges; diff --git a/src/DatePicker/Toolbar.tsx b/src/DatePicker/Toolbar.tsx index 11cce9487..86b46a02e 100644 --- a/src/DatePicker/Toolbar.tsx +++ b/src/DatePicker/Toolbar.tsx @@ -1,29 +1,49 @@ -import React, { ReactNode, useCallback, useState } from 'react'; +import React from 'react'; import PropTypes from 'prop-types'; import Button from '../Button'; -import { useClassNames, useUpdateEffect } from '../utils'; -import { StandardProps } from '../@types/common'; -import { getDefaultRanges, getRanges } from './utils'; -import { InnerRange, RangeType } from './types'; -import { CalendarLocale } from '../locales'; +import { useClassNames } from '../utils'; +import PredefinedRanges, { PredefinedRangesProps } from './PredefinedRanges'; +import Stack from '../Stack'; export type { RangeType } from './types'; -export interface ToolbarProps extends StandardProps { +export interface ToolbarProps extends PredefinedRangesProps { hideOkBtn?: boolean; - locale?: CalendarLocale; - calendarDate: T; - ranges?: RangeType[]; disabledOkBtn?: (value: T) => boolean; - disabledShortcut?: (value: T) => boolean; onOk?: (event: React.MouseEvent) => void; - onClickShortcut?: (value: Shortcut, closeOverlay: boolean, event: React.MouseEvent) => void; } type ToolbarComponent = React.ForwardRefExoticComponent & { (props: ToolbarProps): React.ReactElement | null; }; +interface SubmitButtonProps { + calendarDate: any; + children: React.ReactNode; + hide?: boolean; + disabledOkBtn?: (value: any) => boolean; + onOk?: (event: React.MouseEvent) => void; +} + +const SubmitButton = ({ hide, disabledOkBtn, calendarDate, onOk, children }: SubmitButtonProps) => { + if (hide) { + return null; + } + + const disabled = disabledOkBtn?.(calendarDate); + + return ( + + ); +}; + /** * Toolbar for DatePicker and DateRangePicker */ @@ -37,76 +57,46 @@ const Toolbar: ToolbarComponent = React.forwardRef onOk, onClickShortcut, calendarDate, - ranges: rangesProp, + ranges, locale, ...rest } = props; - const [ranges, setRanges] = useState[]>(getRanges(props)); - const { merge, prefix, withClassPrefix } = useClassNames(classPrefix); - - useUpdateEffect(() => { - setRanges(getRanges({ ranges: rangesProp, calendarDate })); - }, [calendarDate, rangesProp]); - - const hasLocaleKey = useCallback( - (key: ReactNode) => getDefaultRanges(calendarDate).some(item => item.label === key), - [calendarDate] - ); - - const renderOkButton = useCallback(() => { - if (hideOkBtn) { - return null; - } - const disabled = disabledOkBtn?.(calendarDate); - - return ( -
- -
- ); - }, [disabledOkBtn, hideOkBtn, locale, onOk, calendarDate, prefix]); + const { merge, prefix, withClassPrefix } = useClassNames(classPrefix); - if (hideOkBtn && ranges.length === 0) { + if (hideOkBtn && ranges?.length === 0) { return null; } const classes = merge(className, withClassPrefix()); - return ( -
-
- {ranges.map(({ value, closeOverlay, label }, index: number) => { - const disabled = disabledShortcut?.(value); - const handleClickShortcut = (event: React.MouseEvent) => { - if (disabled) { - return; - } - onClickShortcut?.(value, closeOverlay !== false ? true : false, event); - }; - - return ( - - ); - })} + return ( + + +
+ + {locale?.ok} +
- {renderOkButton()} -
+ ); }); diff --git a/src/DatePicker/styles/index.less b/src/DatePicker/styles/index.less index 4fa980bc0..e85574c35 100644 --- a/src/DatePicker/styles/index.less +++ b/src/DatePicker/styles/index.less @@ -19,18 +19,8 @@ // Toolbar .rs-picker-toolbar { - .clearfix(); - padding: @calendar-picker-padding; border-top: 1px solid @calendar-toolbar-border-color; - - &-ranges { - display: inline-block; - } - - &-right { - float: right; - } } // Picker date diff --git a/src/DateRangePicker/DateRangePicker.tsx b/src/DateRangePicker/DateRangePicker.tsx index 10c1e11b0..fcb537788 100644 --- a/src/DateRangePicker/DateRangePicker.tsx +++ b/src/DateRangePicker/DateRangePicker.tsx @@ -8,6 +8,8 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'; import { FormControlBaseProps, PickerBaseProps } from '../@types/common'; import { FormattedDate } from '../CustomProvider'; import Toolbar from '../DatePicker/Toolbar'; +import PredefinedRanges from '../DatePicker/PredefinedRanges'; +import Stack from '../Stack'; import { DateRangePickerLocale } from '../locales'; import { omitTriggerPropKeys, @@ -540,12 +542,18 @@ const DateRangePicker: DateRangePicker = React.forwardRef((props: DateRangePicke */ const handleShortcutPageDate = useCallback( (value: DateRange, closeOverlay = false, event: React.SyntheticEvent) => { - handleValueUpdate(event, value, closeOverlay); + updateCalendarDateRange(value); + + if (closeOverlay) { + handleValueUpdate(event, value, closeOverlay); + } else { + setSelectedDates(value ?? []); + } // End unfinished selections. hasDoneSelect.current = true; }, - [handleValueUpdate] + [handleValueUpdate, updateCalendarDateRange] ); const handleOK = useCallback( @@ -742,29 +750,48 @@ const DateRangePicker: DateRangePicker = React.forwardRef((props: DateRangePicke return (
-
-
{getDisplayString(selectedDates)}
-
- - {!showOneCalendar && } -
-
- - locale={locale} - calendarDate={selectedDates} - disabledOkBtn={disabledOkButton} - disabledShortcut={disabledShortcutButton} - hideOkBtn={oneTap} - onOk={handleOK} - onClickShortcut={handleShortcutPageDate} - ranges={ranges} - /> + + range?.placement === 'left') || []} + calendarDate={calendarDate} + locale={locale} + disabledShortcut={disabledShortcutButton} + onClickShortcut={handleShortcutPageDate} + /> + <> +
+
{getDisplayString(selectedDates)}
+
+ + {!showOneCalendar && } +
+
+ + locale={locale} + calendarDate={selectedDates} + disabledOkBtn={disabledOkButton} + disabledShortcut={disabledShortcutButton} + hideOkBtn={oneTap} + onOk={handleOK} + onClickShortcut={handleShortcutPageDate} + ranges={ranges?.filter( + range => range?.placement === 'bottom' || range?.placement === undefined + )} + /> + +
); diff --git a/src/DateRangePicker/styles/index.less b/src/DateRangePicker/styles/index.less index ecdebbbd0..47ae6c3c1 100644 --- a/src/DateRangePicker/styles/index.less +++ b/src/DateRangePicker/styles/index.less @@ -10,7 +10,7 @@ .rs-picker-daterange-menu { .rs-calendar { display: inline-block; - height: 278px; + height: 276px; padding-bottom: 12px; &:first-child { @@ -35,10 +35,6 @@ } } - .rs-picker-toolbar { - margin-top: 4px; - } - .rs-picker-daterange-panel-show-one-calendar .rs-picker-toolbar { max-width: @date-range-picker-calendar-default-width; @@ -71,3 +67,14 @@ // Make sure group wrapper can put 2 date-panels even screen width is not enough. min-width: 492px; } + +// Predefined Ranges +.rs-picker-daterange-predefined { + height: 366px; + border-right: 1px solid var(--rs-border-primary); + padding: 4px 0; + + .rs-btn { + display: block; + } +} diff --git a/src/DateRangePicker/test/DateRangePickerSpec.js b/src/DateRangePicker/test/DateRangePickerSpec.js index cf835d028..679e6bc75 100644 --- a/src/DateRangePicker/test/DateRangePickerSpec.js +++ b/src/DateRangePicker/test/DateRangePickerSpec.js @@ -692,6 +692,7 @@ describe('DateRangePicker', () => { it('Should not close picker', async () => { const onCloseSpy = sinon.spy(); const onChangeSpy = sinon.spy(); + const yesterday = addDays(new Date(), -1); const { getByRole } = render( { ranges={[ { label: 'Yesterday', - value: [addDays(new Date(), -1), addDays(new Date(), -1)], + value: [yesterday, yesterday], closeOverlay: false } ]} @@ -708,12 +709,14 @@ describe('DateRangePicker', () => { /> ); - act(() => { - userEvent.click(getByRole('button', { name: 'Yesterday' })); - }); + fireEvent.click(getByRole('button', { name: 'Yesterday' })); + + expect(getByRole('dialog').querySelector('.rs-picker-daterange-header')).to.text( + `${format(yesterday, 'yyyy-MM-dd')} ~ ${format(yesterday, 'yyyy-MM-dd')}` + ); await waitFor(() => { - expect(onChangeSpy).to.calledOnce; + expect(onChangeSpy).to.not.called; expect(onCloseSpy).to.not.called; }); }); @@ -748,4 +751,31 @@ describe('DateRangePicker', () => { expect(getByRole('button', { name: '00:00:00' })).to.be.visible; expect(getByRole('button', { name: '23:59:59' })).to.be.visible; }); + + it('Should render ranges on the left', () => { + const onCloseSpy = sinon.spy(); + const onChangeSpy = sinon.spy(); + const yesterday = addDays(new Date(), -1); + + const { getByRole } = render( + + ); + + expect( + getByRole('dialog').querySelector('.rs-picker-daterange-predefined').firstChild.firstChild + ).to.equal(getByRole('button', { name: 'Yesterday' })); + + expect(getByRole('dialog').querySelector('.rs-picker-toolbar-ranges button')).to.not.exist; + }); }); diff --git a/src/DateRangePicker/types.ts b/src/DateRangePicker/types.ts index 48aa2bc0e..9c816300c 100644 --- a/src/DateRangePicker/types.ts +++ b/src/DateRangePicker/types.ts @@ -7,8 +7,9 @@ export type DateRange = [Date, Date]; export interface RangeType { label: React.ReactNode; - closeOverlay?: boolean; value: DateRange | ((value?: ValueType) => DateRange); + closeOverlay?: boolean; + placement?: 'bottom' | 'left'; } export type DisabledDateFunction = (