Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(ui): [date-ranger] use new layout for picker and support auto selection #795

Merged
merged 5 commits into from
Oct 31, 2024
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
159 changes: 153 additions & 6 deletions packages/ui/src/DateRanger/PickerPanel.tsx
Original file line number Diff line number Diff line change
@@ -24,6 +24,7 @@ import moment from 'moment';
import dayjs from 'dayjs';
import { useUpdate } from 'ahooks';
import { toArray } from '@oceanbase/util';
import { getPrefix } from '../_util';

type RangeValue = [Moment, Moment] | [Dayjs, Dayjs];
type ValidateTrigger = 'submit' | 'valueChange';
@@ -50,6 +51,8 @@ export interface PickerPanelProps {
locale: any;
}

const prefix = getPrefix('ranger-picker-panel');

const prefixCls = 'ant-picker';
const DATE_FORMAT = 'YYYY-MM-DD';
const TIME_FORMAT = 'HH:mm:ss';
@@ -75,8 +78,12 @@ const InternalPickerPanel = (props: PickerPanelProps) => {
const [activeIndex, setActiveIndex] = React.useState(0);

const getDateInstance = useCallback(
(v?: string | Dayjs | Moment) => {
return isMoment ? moment(v as Moment) : dayjs(v as Dayjs);
(
v?: string | Dayjs | Moment,
format?: typeof DATE_FORMAT | typeof TIME_FORMAT,
strict?: boolean
) => {
return isMoment ? moment(v as Moment, format, strict) : dayjs(v as Dayjs, format, strict);
},
[isMoment]
);
@@ -135,12 +142,106 @@ const InternalPickerPanel = (props: PickerPanelProps) => {

const validateInputDate = e => {
const v = e.target.value;
const date = getDateInstance(v);
const date = getDateInstance(v, DATE_FORMAT, true);
return date.isValid() ? date.format(DATE_FORMAT) : null;
};
const validateInputTime = e => {
const v: string = e.target.value;
const [h, m, s] = v?.split(':') ?? [];
if (!(h && m && s)) return null;
if (!(h.length === 2 && m.length === 2 && s.length === 2)) return null;
const date = getDateInstance(v, TIME_FORMAT, true);
return date.isValid() ? date.format(TIME_FORMAT) : null;
};

const selectDateInputRange = (inputDomRef: HTMLInputElement) => {
if (!inputDomRef) return;
// year
if (inputDomRef.selectionStart >= 0 && inputDomRef.selectionStart <= 4) {
inputDomRef.setSelectionRange(0, 4);
}
// month
if (inputDomRef.selectionStart > 4 && inputDomRef.selectionStart <= 7) {
inputDomRef.setSelectionRange(5, 7);
}
// day
if (inputDomRef.selectionStart > 7 && inputDomRef.selectionStart <= 10) {
inputDomRef.setSelectionRange(8, 10);
}
};

const selectTimeInputRange = (inputDomRef: HTMLInputElement) => {
if (!inputDomRef) return;
// hour
if (inputDomRef.selectionStart >= 0 && inputDomRef.selectionStart <= 2) {
inputDomRef.setSelectionRange(0, 2);
}
// minute
if (inputDomRef.selectionStart > 2 && inputDomRef.selectionStart <= 5) {
inputDomRef.setSelectionRange(3, 5);
}
// second
if (inputDomRef.selectionStart > 5 && inputDomRef.selectionStart <= 8) {
inputDomRef.setSelectionRange(6, 8);
}
};

const handleDateInputKeyDown = (
inputDomRef: HTMLInputElement,
e: React.KeyboardEvent<HTMLElement>
) => {
if (!inputDomRef) return;
if (e.key === 'Enter') {
inputDomRef.blur();
return;
}
if (e.key === 'ArrowLeft') {
const curIndex = inputDomRef.selectionStart;
inputDomRef.setSelectionRange(curIndex - 1, curIndex - 1);
}
if (e.key === 'ArrowRight') {
const curIndex = inputDomRef.selectionEnd;
inputDomRef.setSelectionRange(curIndex + 1, curIndex + 1);
}
// NOTE: onKeyDown事件执行时,由于TimePicker需要执行受控逻辑,这会引起React rerender,
// 导致onKeyDown事件中拿到的Event还未更新,将下述校验逻辑放入requestIdleCallback确保其跟在React fiber调用栈后执行,这可取到最新的Event对象。
requestIdleCallback(() => {
// 校验
if (validateInputDate(e)) {
selectDateInputRange(inputDomRef);
}
});
};

const handleTimeInputKeyDown = (
inputDomRef: HTMLInputElement,
e: React.KeyboardEvent<HTMLElement>
) => {
if (!inputDomRef) return;
if (e.key === 'Enter') {
inputDomRef.blur();
return;
}
if (e.key === 'ArrowLeft') {
const curIndex = inputDomRef.selectionStart;
inputDomRef.setSelectionRange(curIndex - 1, curIndex - 1);
}
if (e.key === 'ArrowRight') {
const curIndex = inputDomRef.selectionEnd;
inputDomRef.setSelectionRange(curIndex + 1, curIndex + 1);
}
// NOTE: onKeyDown事件执行时,由于TimePicker需要执行受控逻辑,这会引起React rerender,
// 导致onKeyDown事件中拿到的Event还未更新,将下述校验逻辑放入requestIdleCallback确保其跟在React fiber调用栈后执行,这可取到最新的Event对象。
requestIdleCallback(() => {
// 校验
if (validateInputTime(e)) {
selectTimeInputRange(inputDomRef);
}
});
};

return (
<div>
<div className={classNames(prefix)}>
<Space direction="vertical" size={12} style={{ margin: '12px 0' }}>
{tip && <Alert message={tip} type="info" showIcon></Alert>}
<Form
@@ -171,6 +272,12 @@ const InternalPickerPanel = (props: PickerPanelProps) => {
setFormatDateToForm();
}
}}
onClick={() => {
selectDateInputRange(form.getFieldInstance('startDate').nativeElement);
}}
onKeyDown={e => {
handleDateInputKeyDown(form.getFieldInstance('startDate').nativeElement, e);
}}
/>
</Form.Item>
</Col>
@@ -182,7 +289,23 @@ const InternalPickerPanel = (props: PickerPanelProps) => {
validateStatus={errorTypeMap['startTime']}
initialValue={defaultS || defaultTime}
>
<TimePicker suffixIcon={null} style={{ width: '100%' }} />
<TimePicker
suffixIcon={null}
needConfirm={false}
getPopupContainer={triggerNode => triggerNode.parentNode as HTMLElement}
style={{ width: '100%' }}
onClick={() => {
selectTimeInputRange(
form.getFieldInstance('startTime').nativeElement.querySelector('input')
);
}}
onKeyDown={e => {
handleTimeInputKeyDown(
form.getFieldInstance('startTime').nativeElement.querySelector('input'),
e
);
}}
/>
</Form.Item>
</Col>
</Row>
@@ -207,6 +330,12 @@ const InternalPickerPanel = (props: PickerPanelProps) => {
setFormatDateToForm();
}
}}
onClick={() => {
selectDateInputRange(form.getFieldInstance('endDate').nativeElement);
}}
onKeyDown={e => {
handleDateInputKeyDown(form.getFieldInstance('endDate').nativeElement, e);
}}
/>
</Form.Item>
</Col>
@@ -218,7 +347,23 @@ const InternalPickerPanel = (props: PickerPanelProps) => {
validateStatus={errorTypeMap['endTime']}
initialValue={defaultE || defaultTime}
>
<TimePicker suffixIcon={null} style={{ width: '100%' }} />
<TimePicker
suffixIcon={null}
needConfirm={false}
getPopupContainer={triggerNode => triggerNode.parentNode as HTMLElement}
style={{ width: '100%' }}
onClick={() => {
selectTimeInputRange(
form.getFieldInstance('endTime').nativeElement.querySelector('input')
);
}}
onKeyDown={e => {
handleTimeInputKeyDown(
form.getFieldInstance('endTime').nativeElement.querySelector('input'),
e
);
}}
/>
</Form.Item>
</Col>
</Row>
@@ -237,6 +382,8 @@ const InternalPickerPanel = (props: PickerPanelProps) => {
prefixCls={prefixCls}
// @ts-ignore
generateConfig={isMoment ? momentGenerateConfig : dayjsGenerateConfig}
// @ts-ignore
value={calendarValue}
disabledDate={disabledDate}
onHover={(...res) => {
onPanelHover(res[0]);
107 changes: 46 additions & 61 deletions packages/ui/src/DateRanger/Ranger.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React, { useEffect, useRef, useState } from 'react';
import { Button, DatePicker, Dropdown, Radio, Space, Tooltip, theme } from '@oceanbase/design';
import { Button, DatePicker, Divider, Dropdown, Radio, Space, theme } from '@oceanbase/design';
import type { TooltipProps, FormItemProps } from '@oceanbase/design';
import {
LeftOutlined,
@@ -311,8 +311,46 @@ const Ranger = (props: DateRangerProps) => {

setOpen(o);
}}
dropdownRender={originNode => {
return (
<div className={`${prefix}-dropdown-picker`}>
{originNode}
<Divider type="vertical" style={{ height: 'auto', margin: '0px 4px 0px 0px' }} />
<InternalPickerPanel
defaultValue={innerValue}
// @ts-ignore
locale={locale}
disabledDate={pastOnly ? disabledFuture : disabledDate}
tip={tip}
isMoment={isMoment}
rules={rules}
onOk={vList => {
setIsPlay(false);
handleNameChange(CUSTOMIZE);
rangeChange(
vList.map(v => {
return isMoment ? moment(v) : dayjs(v);
}) as RangeValue
);

closeTooltip();
}}
onCancel={() => {
closeTooltip();
}}
/>
</div>
);
}}
menu={{
selectable: true,
defaultSelectedKeys: [rangeName],
onClick: ({ key, domEvent }) => {
if (key === CUSTOMIZE) {
refState.current.tooltipOpen = true;
} else {
refState.current.tooltipOpen = false;
}
const selected = NEAR_TIME_LIST.find(_item => _item.name === key);
// 存在快捷选项切换为极简模式
if (selected?.range) {
@@ -335,66 +373,13 @@ const Ranger = (props: DateRangerProps) => {
.map(item => {
return {
key: item.name,
label:
item.name === CUSTOMIZE ? (
<Tooltip
open={tooltipOpen}
arrow={false}
onOpenChange={o => {
if (o) {
setTooltipOpen(true);
}
}}
placement="right"
{...tooltipProps}
overlayStyle={{
maxWidth: 336,
...tooltipProps?.overlayStyle,
}}
overlayInnerStyle={{
background: '#fff',
maxHeight: 'none',
margin: 16,
...tooltipProps?.overlayInnerStyle,
}}
title={
<InternalPickerPanel
defaultValue={innerValue}
// @ts-ignore
locale={locale}
disabledDate={pastOnly ? disabledFuture : disabledDate}
tip={tip}
isMoment={isMoment}
rules={rules}
onOk={vList => {
setIsPlay(false);
rangeChange(
vList.map(v => {
return isMoment ? moment(v) : dayjs(v);
}) as RangeValue
);

closeTooltip();
}}
onCancel={() => {
closeTooltip();
}}
/>
}
>
<Space size={8} style={isPlay ? {} : { width: 310 }}>
<span className={`${prefix}-label`}>{item.rangeLabel}</span>
{/* @ts-ignore */}
{locale[item.label] || item.label}
</Space>
</Tooltip>
) : (
<Space size={8} style={isPlay ? {} : { width: 310 }}>
<span className={`${prefix}-label`}>{item.rangeLabel}</span>
{/* @ts-ignore */}
{locale[item.label] || item.label}
</Space>
),
label: (
<Space size={8}>
<span className={`${prefix}-label`}>{item.rangeLabel}</span>
{/* @ts-ignore */}
{locale[item.label] || item.label}
</Space>
),
};
}),
}}
113 changes: 111 additions & 2 deletions packages/ui/src/DateRanger/__test__/index.test.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,119 @@
import React from 'react';
import { render } from '@testing-library/react';
import { render, fireEvent } from '@testing-library/react';
import { DateRanger } from '@oceanbase/ui';
import { NEAR_1_MINUTES, NEAR_30_MINUTES } from '../constant';
import dayjs from 'dayjs';

describe('DateRanger', () => {
it('Normal display', () => {
it('Display normally' /** 成功渲染组件 */, async () => {
const { container, asFragment } = render(<DateRanger />);
expect(container.querySelector('.ob-date-ranger-wrapper')).toBeTruthy();
});
it('Ranger panel can be triggered by clicking' /** 选择面板可以通过点击触发 */, () => {
const { container } = render(<DateRanger />);
const dropdownTrigger = container.querySelector(
'.ob-date-ranger-wrapper > .ant-dropdown-trigger'
);
fireEvent.click(dropdownTrigger);
expect(dropdownTrigger.classList.contains('ant-dropdown-open')).toBeTruthy();
expect(document.querySelector('.ob-date-ranger-dropdown-picker')).toBeTruthy();
});
it('Support setting default quick value' /** 支持设置默认的快捷选项值 */, () => {
// NEAR_1_MINUTES is default value of defaultQuickValue
const { container } = render(<DateRanger />);
expect(container.querySelector('.ob-date-ranger-label').textContent).toBe(
NEAR_1_MINUTES.rangeLabel
);
expect(container.querySelector('.ob-date-ranger-play').textContent).toBe(NEAR_1_MINUTES.label);

// Custom defaultQuickValue
const { container: containerWith30Minutes } = render(
<DateRanger defaultQuickValue={NEAR_30_MINUTES.name} />
);
expect(containerWith30Minutes.querySelector('.ob-date-ranger-label').textContent).toBe(
NEAR_30_MINUTES.rangeLabel
);
expect(containerWith30Minutes.querySelector('.ob-date-ranger-play').textContent).toBe(
NEAR_30_MINUTES.label
);
});
it('Should be simple mode when selected shortcut option' /** 选中快捷选项时,应当处于简单模式 */, () => {
const { container } = render(<DateRanger />);
// As simple mode
expect(container.querySelector('.ob-date-ranger-play')).toBeTruthy();
expect(container.querySelector('.ob-date-ranger-picker')).toBeFalsy();
});
it('Support setting default value' /** 支持设置默认时间值 */, () => {
const { container } = render(
<DateRanger defaultValue={[dayjs('2024/10/12'), dayjs('2024/10/20')]} />
);
// As normal mode
expect(container.querySelector('.ob-date-ranger-play')).toBeFalsy();
expect(container.querySelector('.ob-date-ranger-picker')).toBeTruthy();
});
suite('Panel shortcut options' /** 选择面板中的快捷选项 */, () => {
it('In simple mode, the shortcut option that is consistent with the ranger label should be selected' /** 在简单模式下,快捷选项应选中和ranger label 一致的快捷选项 */, () => {
const { container } = render(<DateRanger defaultQuickValue={NEAR_30_MINUTES.name} />);
const dropdownTrigger = container.querySelector(
'.ob-date-ranger-wrapper > .ant-dropdown-trigger'
);
fireEvent.click(dropdownTrigger);
// Should be selected NEAR_30_MINUTES item that the same as ranger-label when open panel
const dropdownLayerPicker = document.querySelector('.ob-date-ranger-dropdown-picker');
expect(
dropdownLayerPicker.querySelector(
'.ant-dropdown-menu .ant-dropdown-menu-item-selected .ob-date-ranger-label'
).textContent
).toBe(NEAR_30_MINUTES.rangeLabel);
});
it('In normal mode, the shortcut option should be selected custom item' /** 设置了时间值即为普通模式,选择面板中的快捷选项应当选中“自定义”项 */, () => {
const { container } = render(
<DateRanger defaultValue={[dayjs('2024/10/12'), dayjs('2024/10/20')]} />
);
const dropdownTrigger = container.querySelector(
'.ob-date-ranger-wrapper > .ant-dropdown-trigger'
);
fireEvent.click(dropdownTrigger);
const dropdownLayerPicker = document.querySelector('.ob-date-ranger-dropdown-picker');
expect(
dropdownLayerPicker.querySelector(
'.ant-dropdown-menu .ant-dropdown-menu-item-selected .ob-date-ranger-label'
).textContent
).toBe('自定义');
});
it('Should selected shortcut option and close panel when click quick time item' /** 当点击快捷时间选项时应该选中该项的时间并关闭选择面板 */, () => {
let value = [dayjs('2024/10/12'), dayjs('2024/10/20')];
const onChange = vi.fn(v => {
value = v;
});
const { container } = render(<DateRanger value={value} onChange={onChange} />);
const dropdownTrigger = container.querySelector(
'.ob-date-ranger-wrapper > .ant-dropdown-trigger'
);
fireEvent.click(dropdownTrigger);
const dropdownLayerPicker = document.querySelector('.ob-date-ranger-dropdown-picker');
// By default, "NEAR_30_MINUTES" is the second option.
fireEvent.click(dropdownLayerPicker.querySelector('.ant-dropdown-menu').childNodes[1]);
expect(onChange).toHaveBeenCalled();
expect(value.map(v => v.format())).toStrictEqual(
NEAR_30_MINUTES.range(dayjs()).map(v => v.format())
);
// Dropdown panel should be destroyed when close.
expect(dropdownTrigger.classList.contains('ant-dropdown-open')).toBeFalsy();
expect(document.querySelector('.ob-date-ranger-dropdown-picker')).toBeFalsy();
});
it('Should not close panel when select custom time option' /** 当选中“自定义时间”时不应该关闭选择面板 */, () => {
const { container } = render(<DateRanger />);
const dropdownTrigger = container.querySelector(
'.ob-date-ranger-wrapper > .ant-dropdown-trigger'
);
fireEvent.click(dropdownTrigger);
const dropdownLayerPicker = document.querySelector('.ob-date-ranger-dropdown-picker');
// By default, "CUSTOMIZE" is the last option.
fireEvent.click(dropdownLayerPicker.querySelector('.ant-dropdown-menu').lastChild);
// The panel should remain open when select the custom option.
expect(dropdownTrigger.classList.contains('ant-dropdown-open')).toBeTruthy();
expect(document.querySelector('.ob-date-ranger-dropdown-picker')).toBeTruthy();
});
});
});
25 changes: 25 additions & 0 deletions packages/ui/src/DateRanger/index.less
Original file line number Diff line number Diff line change
@@ -43,6 +43,31 @@
}
}

.@{prefix}-dropdown-picker {
display: flex;
padding: 4px;
gap: 4px;
background-color: #ffffff;
background-clip: padding-box;
border-radius: 8px;
outline: none;
box-shadow:
0 6px 16px 0 rgba(54, 69, 99, 0.08),
0 3px 6px -4px rgba(54, 69, 99, 0.12),
0 9px 28px 8px rgba(54, 69, 99, 0.05);

.ant-dropdown-menu {
padding: 0;
box-shadow: none;
}

.ant-picker-time-panel-container {
.ant-picker-content {
height: 148px;
}
}
}

.@{prefix}-show-range {
.@{prefix}-quick-picker.@{prefix}-quick-picker-select {
margin-right: -1px;