Merge pull request #8726 from lurunze1226/feat-date-time-picker-confirm

feat: InputDateTime在closeOnSelect为false时开启确认模式; chore: datetime类选择器首次选择时时间设置为当前值
This commit is contained in:
hsm-lv 2023-11-15 14:49:23 +08:00 committed by GitHub
commit 19766cd093
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 451 additions and 44 deletions

View File

@ -379,6 +379,34 @@ order: 14
}
```
## 确认模式
> `3.6.0`及以上版本
设置`"closeOnSelect": false`,点选日期时间后,不会自动关闭浮层,需要点击底部工具栏的确认才会关闭。点击**取消按钮**或者**浮层外部区域**也会关闭浮层,并将值重置为初始状态。
> 注意:该特性仅对`input-datetime`有效,其他日期时间组件无效。开启内嵌模式后,该特性无效。
```schema: scope="body"
{
"type": "form",
"api": "/api/mock2/form/saveForm",
"debug": true,
"body": [
{
"type": "input-datetime",
"name": "datetime",
"label": "日期时间",
"shortcuts": ["yesterday", "today", "tomorrow"],
"closeOnSelect": false,
"valueFormat": "YYYY-MM-DD HH:mm:ss",
"displayFormat": "YYYY-MM-DD HH:mm:ss",
"clearable": true
}
]
}
```
## 属性表
除了支持 [普通表单项属性表](./formitem#%E5%B1%9E%E6%80%A7%E8%A1%A8) 中的配置以外,还支持下面一些配置

View File

@ -13,18 +13,22 @@ import {
isExpression,
FormulaExec,
filterDate,
string2regExp
string2regExp,
autobind
} from 'amis-core';
import PopUp from './PopUp';
import {Overlay} from 'amis-core';
import {ClassNamesFn, themeable, ThemeProps} from 'amis-core';
import {themeable, ThemeProps} from 'amis-core';
import Calendar from './calendar/Calendar';
import {localeable, LocaleProps, TranslateFn} from 'amis-core';
import {ucFirst} from 'amis-core';
import CalendarMobile from './CalendarMobile';
import Input from './Input';
import type {PlainObject} from 'amis-core';
import type {RendererEnv} from 'amis-core';
import Button from './Button';
import type {Moment} from 'moment';
import type {PlainObject, RendererEnv} from 'amis-core';
import type {ChangeEventViewMode, MutableUnitOfTime} from './calendar/Calendar';
const availableShortcuts: {[propName: string]: any} = {
now: {
@ -344,6 +348,7 @@ export interface DatePickerState {
inputValue: string | undefined; // 手动输入的值
curTimeFormat: string; // 根据displayFormat / inputFormat 计算展示的时间粒度
curDateFormat: string; // 根据displayFormat / inputFormat 计算展示的日期粒度
isModified: boolean;
}
export class DatePicker extends React.Component<DateProps, DatePickerState> {
@ -420,7 +425,8 @@ export class DatePicker extends React.Component<DateProps, DatePickerState> {
displayFormat || inputFormat
) || '',
curTimeFormat,
curDateFormat
curDateFormat,
isModified: false
} as DatePickerState;
}
@ -490,6 +496,14 @@ export class DatePicker extends React.Component<DateProps, DatePickerState> {
}
}
isConfirmMode() {
const {closeOnSelect, embed, mobileUI} = this.props;
const {curTimeFormat} = this.state;
/** 日期时间选择器才支持confirm */
return closeOnSelect === false && !!curTimeFormat && !embed && !mobileUI;
}
focus() {
if (!this.dom) {
return;
@ -545,9 +559,22 @@ export class DatePicker extends React.Component<DateProps, DatePickerState> {
}
close() {
this.setState({
isOpened: false
});
const isConfirmMode = this.isConfirmMode();
if (isConfirmMode) {
const {value, valueFormat, format, displayFormat, inputFormat} =
this.props;
this.setState({
value: normalizeDate(value, valueFormat || format),
inputValue:
normalizeDate(value, valueFormat || format)?.format(
displayFormat || inputFormat
) || ''
});
}
this.setState({isOpened: false, isModified: false});
}
clearValue(e: React.MouseEvent<any>) {
@ -555,14 +582,14 @@ export class DatePicker extends React.Component<DateProps, DatePickerState> {
e.stopPropagation();
const onChange = this.props.onChange;
onChange('');
this.setState({inputValue: ''});
this.setState({inputValue: '', isModified: false});
}
// 清空
clear() {
const onChange = this.props.onChange;
onChange('');
this.setState({inputValue: ''});
this.setState({inputValue: '', isModified: false});
}
// 重置
@ -576,11 +603,16 @@ export class DatePicker extends React.Component<DateProps, DatePickerState> {
this.setState({
inputValue: normalizeDate(resetValue, valueFormat || format)?.format(
displayFormat || inputFormat || ''
)
),
isModified: false
});
}
handleChange(value: moment.Moment) {
/**
* onChange
*/
@autobind
handleConfirm() {
const {
onChange,
format,
@ -589,14 +621,12 @@ export class DatePicker extends React.Component<DateProps, DatePickerState> {
maxDate,
inputFormat,
displayFormat,
closeOnSelect,
utc,
viewMode
utc
} = this.props;
let value = this.state.value;
const isConfirmMode = this.isConfirmMode();
const {curDateFormat, curTimeFormat} = this.state;
if (!moment.isMoment(value)) {
if (!isConfirmMode || !value) {
return;
}
@ -611,17 +641,88 @@ export class DatePicker extends React.Component<DateProps, DatePickerState> {
? moment.utc(value).format(valueFormat || format)
: value.format(valueFormat || format)
);
if (closeOnSelect && curDateFormat && !curTimeFormat) {
this.close();
}
this.setState({
inputValue: utc
? moment.utc(value).format(displayFormat || inputFormat)
: value.format(displayFormat || inputFormat)
: value.format(displayFormat || inputFormat),
isOpened: false,
isModified: true
});
}
handleChange(value: Moment, viewMode?: ChangeEventViewMode) {
const {
onChange,
format,
valueFormat,
minDate,
maxDate,
inputFormat,
displayFormat,
closeOnSelect,
utc,
value: defaultValue
} = this.props;
const {curDateFormat, curTimeFormat, isModified} = this.state;
const isConfirmMode = this.isConfirmMode();
if (!moment.isMoment(value)) {
return;
}
if (minDate && value && value.isBefore(minDate, 'second')) {
value = minDate;
} else if (maxDate && value && value.isAfter(maxDate, 'second')) {
value = maxDate;
}
/** 首次选择且当前未绑定值,则默认使用当前时间 */
if (!defaultValue && !!curTimeFormat && !isModified) {
const now = moment();
const timePart: Record<MutableUnitOfTime, number> = {
date: value.get('date'),
hour: value.get('hour'),
minute: value.get('minute'),
second: value.get('second'),
millisecond: value.get('millisecond')
};
Object.keys(timePart).forEach((unit: MutableUnitOfTime) => {
/** 首次选择时间,日期使用当前时间; 将未设置过的时间字段设置为当前值 */
if (
(unit === 'date' && viewMode === 'time') ||
(unit !== 'date' && timePart[unit] === 0)
) {
timePart[unit] = now.get(unit);
}
});
value.set(timePart);
}
const updatedValue = utc
? moment.utc(value).format(valueFormat || format)
: value.format(valueFormat || format);
const updatedInputValue = utc
? moment.utc(value).format(displayFormat || inputFormat)
: value.format(displayFormat || inputFormat);
if (isConfirmMode) {
this.setState({value, inputValue: updatedInputValue});
this.inputValueCache = updatedInputValue;
} else {
onChange(updatedValue);
if (closeOnSelect && curDateFormat && !curTimeFormat) {
this.close();
}
this.setState({inputValue: updatedInputValue});
}
this.setState({isModified: true});
}
// 手动输入日期
inputChange(e: React.ChangeEvent<HTMLInputElement>) {
const {
@ -850,13 +951,24 @@ export class DatePicker extends React.Component<DateProps, DatePickerState> {
env,
onClick,
onMouseEnter,
onMouseLeave
onMouseLeave,
closeOnSelect
} = this.props;
const __ = this.props.translate;
const {curTimeFormat, curDateFormat} = this.state;
const isOpened = this.state.isOpened;
const {curTimeFormat, curDateFormat, isOpened} = this.state;
const isConfirmMode = this.isConfirmMode();
let date: moment.Moment | undefined = this.state.value;
let isConfirmBtnDisbaled = false;
if (isConfirmMode) {
const lastModifiedValue = normalizeDate(value, valueFormat || format);
isConfirmBtnDisbaled =
date && lastModifiedValue
? moment(date).isSame(lastModifiedValue, 'second')
: date === lastModifiedValue;
}
const calendarMobile = (
<CalendarMobile
@ -1041,6 +1153,22 @@ export class DatePicker extends React.Component<DateProps, DatePickerState> {
onMouseLeave={onMouseLeave}
// utc={utc}
/>
{isConfirmMode ? (
<div className={`${ns}DateRangePicker-actions`}>
<Button size="sm" onClick={this.close}>
{__('cancel')}
</Button>
<Button
level="primary"
size="sm"
disabled={isConfirmBtnDisbaled}
className={cx('m-l-sm')}
onClick={this.handleConfirm}
>
{__('confirm')}
</Button>
</div>
) : null}
</PopOver>
</Overlay>
) : null}

View File

@ -32,7 +32,11 @@ import Button from './Button';
import type {Moment} from 'moment';
import type {PlainObject, ThemeProps, LocaleProps} from 'amis-core';
import type {ViewMode} from './calendar/Calendar';
import type {
ViewMode,
ChangeEventViewMode,
MutableUnitOfTime
} from './calendar/Calendar';
export interface DateRangePickerProps extends ThemeProps, LocaleProps {
className?: string;
@ -890,24 +894,66 @@ export class DateRangePicker extends React.Component<
type: 'start' | 'end';
originValue?: moment.Moment;
timeFormat?: string;
subControlViewMode?: 'time';
subControlViewMode?: ChangeEventViewMode;
/** 自动初始化绑定值,用于首次选择且当前未绑定值,默认使用当前时间 */
autoInitDefaultValue?: boolean;
} = {type: 'start'}
): moment.Moment {
const {type, originValue, timeFormat, subControlViewMode} = options || {
const {
type,
originValue,
timeFormat,
subControlViewMode,
autoInitDefaultValue
} = options || {
type: 'start'
};
let value = date.clone();
const {transform, data} = this.props;
const transformFn =
transform && typeof transform === 'string'
? str2function(transform, 'value', 'config', 'props', 'data', 'moment')
: transform;
const {startDate, endDate} = this.state;
/** 此时为点选后的值初始化设置不应该被内部转化逻辑和transformFn限制 */
if (autoInitDefaultValue === true) {
const now = moment();
/** 如果已经设置了结束时间且当前时间已经超出了结束时间,则开始时间不能超过结束时间 */
if (!startDate && endDate && type === 'start' && now.isAfter(endDate)) {
value = endDate.clone();
return value;
}
const timePart: Record<MutableUnitOfTime, number> = {
date: value.get('date'),
hour: value.get('hour'),
minute: value.get('minute'),
second: value.get('second'),
millisecond: value.get('millisecond')
};
Object.keys(timePart).forEach((unit: MutableUnitOfTime) => {
/** 首次选择时间,日期使用当前时间; 将未设置过的时间字段设置为当前值 */
if (
(unit === 'date' && subControlViewMode === 'time') ||
(unit !== 'date' && timePart[unit] === 0)
) {
timePart[unit] = now.get(unit);
}
});
value.set(timePart);
return value;
}
/** 日期时间选择器组件支持用户选择时间,如果用户手动选择了时间,则不需要走默认处理 */
if (subControlViewMode && subControlViewMode === 'time') {
return value;
}
const transformFn =
transform && typeof transform === 'string'
? str2function(transform, 'value', 'config', 'props', 'data', 'moment')
: transform;
// 没有初始值
if (!originValue) {
value = value[type === 'start' ? 'startOf' : 'endOf']('day');
@ -951,19 +997,31 @@ export class DateRangePicker extends React.Component<
*/
handleStartDateChange(
newValue: moment.Moment,
subControlViewMode?: Extract<ViewMode, 'time'>
subControlViewMode?: ChangeEventViewMode
) {
const {minDate, inputFormat, displayFormat, type} = this.props;
let {startDate, endDateOpenedFirst, curTimeFormat: timeFormat} = this.state;
const {
minDate,
inputFormat,
displayFormat,
type,
value: defaultValue
} = this.props;
let {
startDate,
oldStartDate,
endDateOpenedFirst,
curTimeFormat: timeFormat
} = this.state;
if (minDate && newValue.isBefore(minDate)) {
newValue = minDate;
}
const date = this.filterDate(newValue, {
type: 'start',
originValue: startDate || minDate,
timeFormat,
subControlViewMode
subControlViewMode,
autoInitDefaultValue:
!!timeFormat && newValue && (!oldStartDate || !startDate)
});
const newState = {
startDate: date,
@ -988,12 +1046,19 @@ export class DateRangePicker extends React.Component<
*/
handelEndDateChange(
newValue: moment.Moment,
subControlViewMode?: Extract<ViewMode, 'time'>
subControlViewMode?: ChangeEventViewMode
) {
const {embed, inputFormat, displayFormat, type} = this.props;
const {
embed,
inputFormat,
displayFormat,
type,
value: defaultValue
} = this.props;
let {
startDate,
endDate,
oldEndDate,
endDateOpenedFirst,
curTimeFormat: timeFormat
} = this.state;
@ -1003,8 +1068,11 @@ export class DateRangePicker extends React.Component<
type: 'end',
originValue: endDate,
timeFormat,
subControlViewMode
subControlViewMode,
autoInitDefaultValue:
!!timeFormat && newValue && (!oldEndDate || !endDate)
});
this.setState(
{
endDate: date,

View File

@ -14,7 +14,9 @@ import {
import {PickerOption} from '../PickerColumn';
import 'moment/locale/zh-cn';
import 'moment/locale/de';
import type {RendererEnv} from 'amis-core';
import type {unitOfTime} from 'moment';
/** 视图模式 */
export type ViewMode = 'days' | 'months' | 'years' | 'time' | 'quarters';
@ -26,6 +28,16 @@ export type DateType =
| 'hours'
| 'minutes'
| 'seconds';
/** 底层View组件修改的值类型time时间、days日期 */
export type ChangeEventViewMode = Extract<ViewMode, 'time' | 'days'>;
/** 可改变的时间单位 */
export type MutableUnitOfTime = Extract<
unitOfTime.All,
'date' | 'hour' | 'minute' | 'second' | 'millisecond'
>;
export interface BoundaryObject {
max: number;
min: number;
@ -69,7 +81,7 @@ interface BaseDatePickerProps {
onMouseEnter?: (date: moment.Moment) => any;
onMouseLeave?: (date: moment.Moment) => any;
onClose?: () => void;
onChange?: (value: any, viewMode?: Extract<ViewMode, 'time'>) => void;
onChange?: (value: any, viewMode?: ChangeEventViewMode) => void;
isEndDate?: boolean;
minDate?: moment.Moment;
maxDate?: moment.Moment;
@ -595,7 +607,7 @@ class BaseDatePicker extends React.Component<
}
}
that.props.onChange(date);
that.props.onChange(date, 'days');
};
getDateBoundary = (currentDate: moment.Moment) => {

View File

@ -21,6 +21,7 @@ import {PickerOption} from '../PickerColumn';
import {DateType} from './Calendar';
import {Icon} from '../icons';
import type {Moment} from 'moment';
import type {TimeScale} from './TimeView';
import type {ViewMode} from './Calendar';
@ -252,12 +253,33 @@ export class CustomDaysView extends React.Component<CustomDaysViewProps> {
componentDidMount() {
const {timeFormat, selectedDate, viewDate, isEndDate} = this.props;
const date = selectedDate || (isEndDate ? viewDate.endOf('day') : viewDate);
this.setupTime(date, timeFormat, 'init');
}
componentDidUpdate(
prevProps: Readonly<CustomDaysViewProps>,
prevState: Readonly<{}>,
snapshot?: any
): void {
const currentDate = this.props.selectedDate;
if (
moment.isMoment(currentDate) &&
currentDate.isValid() &&
!currentDate.isSame(prevProps.selectedDate)
) {
const {timeFormat} = this.props;
this.setupTime(currentDate, timeFormat);
}
}
setupTime(date: Moment, timeFormat: string, mode?: 'init') {
const formatMap = {
hours: 'HH',
minutes: 'mm',
seconds: 'ss'
};
const date = selectedDate || (isEndDate ? viewDate.endOf('day') : viewDate);
timeFormat.split(':').forEach((format, i) => {
const type = /h/i.test(format)
? 'hours'
@ -271,7 +293,7 @@ export class CustomDaysView extends React.Component<CustomDaysViewProps> {
type,
parseInt(date.format(formatMap[type]), 10),
i,
'init'
mode
);
}
});

View File

@ -81,3 +81,152 @@ test('Renderer:datetime displayFormat valueFormat', async () => {
moment(1559826660, 'X').format('YYYY/MM/DD HH:mm:ss')
);
});
/**
* CASE: 日期时间选择器确认模式
*
* - input-datetime类型
* - closeOnSelect为false
* -
* -
*
* 1.
* 2.
*/
test('Renderer:InputDateTime confirm mode', async () => {
const {container} = render(
amisRender({
type: 'form',
body: [
{
"name": "datetime",
"label": "日期",
"type": "input-datetime",
"closeOnSelect": false
}
],
title: 'The form',
actions: []
}, {}, makeEnv({}))
);
const trigger = container.querySelector('.cxd-DatePicker')!;
const inputEl = (container.querySelector(".cxd-DatePicker-input") as HTMLInputElement)!;
const getCancelBtn = () => (container.querySelector('.cxd-DateRangePicker-actions > button[type=button]')!);
const getConfirmBtn = () => (container.querySelector('.cxd-DateRangePicker-actions > .cxd-Button.cxd-Button--primary')!);
expect(trigger).toBeInTheDocument();
fireEvent.click(trigger);
wait(200);
/** 未选择新值,确认按钮禁用 */
expect(getConfirmBtn()).toHaveClass('is-disabled');
let todayEl = document.querySelector('.rdtDay.rdtToday')!;
let yesterdayEl = todayEl?.previousSibling!;
let tomorrowEl = todayEl?.nextSibling!;
if (yesterdayEl) {
fireEvent.click(yesterdayEl);
}
else {
fireEvent.click(tomorrowEl);
}
wait(200);
/** 选择日期之后禁用消失 */
expect(getConfirmBtn()).not.toHaveClass('is-disabled');
fireEvent.click(getCancelBtn());
wait(200);
/** 取消之后重置值 */
expect(inputEl?.value).toEqual('');
fireEvent.click(trigger);
wait(200);
todayEl = document.querySelector('.rdtDay.rdtToday')!;
yesterdayEl = todayEl?.previousSibling!;
tomorrowEl = todayEl?.nextSibling!;
if (yesterdayEl) {
fireEvent.click(yesterdayEl);
}
else {
fireEvent.click(tomorrowEl);
}
wait(200);
fireEvent.click(getConfirmBtn());
wait(200);
/** 确定之后有值 */
expect(inputEl?.value).not.toEqual('');
}, 7000);
/**
* CASE: 日期时间选择器首次选择日期或时间后
*
* - input-datetime或者input-datetime-range类型
* -
* -
*
* 1.
* 2. Hms
* 3.
* 4.
*/
test('Renderer:InputDateTime Picker selects date or time for the first time', async () => {
const {container} = render(
amisRender({
type: 'form',
body: [
{
"name": "datetime",
"label": "日期",
"type": "input-datetime",
"valueFormat": "YYYY-MM-DD HH:mm:ss",
"displayFormat": "YYYY-MM-DD HH:mm:ss",
"closeOnSelect": false
}
],
title: 'The form',
actions: []
}, {}, makeEnv({}))
);
const trigger = container.querySelector('.cxd-DatePicker')!;
const inputEl = (container.querySelector(".cxd-DatePicker-input") as HTMLInputElement)!;
const getConfirmBtn = () => (container.querySelector('.cxd-DateRangePicker-actions > .cxd-Button.cxd-Button--primary')!);
expect(trigger).toBeInTheDocument();
fireEvent.click(trigger);
wait(200);
let todayEl = document.querySelector('.rdtDay.rdtToday')!;
let yesterdayEl = todayEl?.previousSibling!;
let tomorrowEl = todayEl?.nextSibling!;
const currentTime = new Date();
const currentSeconds = currentTime.getSeconds();
/** 跳过0秒用于后续测试值和00:00:00的Diff */
if (currentSeconds === 0) {
wait(1000);
}
if (yesterdayEl) {
fireEvent.click(yesterdayEl);
}
else {
fireEvent.click(tomorrowEl);
}
wait(200);
const timeStr = inputEl?.value?.split(/\s+/)?.[1];
/** 时间值设置为当前时间 */
expect(timeStr !== '00:00:00').toEqual(true);
fireEvent.click(todayEl);
wait(200);
const newTimeStr = inputEl?.value?.split(/\s+/)?.[1];
/** 切换日期后时间不会再变 */
expect(newTimeStr === timeStr).toEqual(true);
}, 7000);