Skip to content
Open
Show file tree
Hide file tree
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
18 changes: 15 additions & 3 deletions src/PickerInput/RangePicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import useRangePickerValue from './hooks/useRangePickerValue';
import useRangeValue, { useInnerValue } from './hooks/useRangeValue';
import useRangeValueChange, { type RangeValueChangeSource } from './hooks/useRangeValueChange';
import useShowNow from './hooks/useShowNow';
import type { InvalidateDateInfo } from './hooks/useInvalidate';
import Popup, { type PopupShowTimeConfig } from './Popup';
import RangeSelector, {
type RangeSelectorRef,
Expand Down Expand Up @@ -292,6 +293,17 @@ function RangePicker<DateType extends object = any>(
},
);

// ======================= Invalidate ======================
const isRangeInvalidateDate = useEvent((date: DateType, info?: InvalidateDateInfo<DateType>) => {
const infoActiveIndex = info?.activeIndex ?? activeIndex;

return isInvalidateDate(date, {
...(info || {}),
activeIndex: infoActiveIndex,
range: getActiveRange(infoActiveIndex),
});
});
Comment on lines +296 to +305

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

isRangeInvalidateDate 未补齐 info.from,导致弹层/字段校验与面板禁用不一致。

useRangeDisabledDate.ts 中的 rangeDisabledDate 每次调用都会用 getFromDate 重新计算 from(见该文件 Lines 24-28),但这里的 isRangeInvalidateDate 只是透传调用方传入的 info,未提供 from 时不会补算:

  • Lines 599-603 的 isPopupInvalidateDate 只传了 { activeIndex },没有 from
  • Lines 420-424 传给 useFieldsInvalidate 的同一个 isRangeInvalidateDate,若其内部未显式传 from,也会有相同缺口。

依赖 range === 'end' && info.fromdisabledDate 规则,会因此在“面板单元格禁用”和“弹层/字段失效判断(如 OK 按钮禁用态)”之间出现不一致的结果。这与此前评审中已指出的问题相同,当前代码未修复。

建议修复
   const isRangeInvalidateDate = useEvent((date: DateType, info?: InvalidateDateInfo<DateType>) => {
     const infoActiveIndex = info?.activeIndex ?? activeIndex;
+    const fromDate = info?.from ?? getFromDate(calendarValue, triggeredFields, infoActiveIndex);
 
     return isInvalidateDate(date, {
       ...(info || {}),
       activeIndex: infoActiveIndex,
+      from: fromDate,
       range: getActiveRange(infoActiveIndex),
     });
   });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/PickerInput/RangePicker.tsx` around lines 296 - 305, 更新
isRangeInvalidateDate,在解析 activeIndex 后补齐 info.from:当调用方未提供 from 时,使用与
rangeDisabledDate 一致的 getFromDate 逻辑计算当前范围的起始日期,并将其传入
isInvalidateDate。保留调用方显式提供的 info.from,确保 isPopupInvalidateDate 和
useFieldsInvalidate 复用的校验结果与面板禁用规则一致。


// ======================== Value =========================
const [
/** Trigger `onChange` by check `disabledDate` */
Expand All @@ -308,7 +320,7 @@ function RangePicker<DateType extends object = any>(
triggerCalendarChange,
disabled,
formatList,
isInvalidateDate,
isRangeInvalidateDate,
);

const triggerFieldCalendarChange = useEvent((index: number, date: DateType) => {
Expand Down Expand Up @@ -407,7 +419,7 @@ function RangePicker<DateType extends object = any>(
// ======================= Validate =======================
const [submitInvalidates, onSelectorInvalid] = useFieldsInvalidate(
calendarValue,
isInvalidateDate,
isRangeInvalidateDate,
allowEmpty,
);

Expand Down Expand Up @@ -585,7 +597,7 @@ function RangePicker<DateType extends object = any>(

// >>> invalid
const isPopupInvalidateDate = useEvent((date: DateType) => {
return isInvalidateDate(date, {
return isRangeInvalidateDate(date, {
activeIndex,
});
});
Expand Down
96 changes: 50 additions & 46 deletions src/PickerInput/hooks/useInvalidate.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,18 @@
import { useEvent } from '@rc-component/util';
import type { GenerateConfig } from '../../generate';
import type {
BaseInfo,
PanelMode,
RangeTimeProps,
SharedPickerProps,
SharedTimeProps,
} from '../../interface';

export type InvalidateDateInfo<DateType = any> = BaseInfo & {
from?: DateType;
activeIndex: number;
};

/**
* Check if provided date is valid for the `disabledDate` & `showTime.disabledTime`.
*/
Expand All @@ -17,62 +23,60 @@ export default function useInvalidate<DateType extends object = any>(
showTime?: SharedTimeProps<DateType> | RangeTimeProps<DateType>,
) {
// Check disabled date
const isInvalidate = useEvent(
(date: DateType, info?: { from?: DateType; activeIndex: number }) => {
const outsideInfo = { type: picker, ...info };
delete outsideInfo.activeIndex;
const isInvalidate = useEvent((date: DateType, info?: InvalidateDateInfo<DateType>) => {
const outsideInfo = { type: picker, ...info };
delete outsideInfo.activeIndex;

if (
// Date object is invalid
!generateConfig.isValidate(date) ||
// Date is disabled by `disabledDate`
(disabledDate && disabledDate(date, outsideInfo))
) {
return true;
}
if (
// Date object is invalid
!generateConfig.isValidate(date) ||
// Date is disabled by `disabledDate`
(disabledDate && disabledDate(date, outsideInfo))
) {
return true;
}

if ((picker === 'date' || picker === 'time') && showTime) {
const range = info && info.activeIndex === 1 ? 'end' : 'start';
const { disabledHours, disabledMinutes, disabledSeconds, disabledMilliseconds } =
showTime.disabledTime?.(date, range, { from: outsideInfo.from }) || {};
if ((picker === 'date' || picker === 'time') && showTime) {
const range = info && info.activeIndex === 1 ? 'end' : 'start';
const { disabledHours, disabledMinutes, disabledSeconds, disabledMilliseconds } =
showTime.disabledTime?.(date, range, { from: outsideInfo.from }) || {};

const {
disabledHours: legacyDisabledHours,
disabledMinutes: legacyDisabledMinutes,
disabledSeconds: legacyDisabledSeconds,
} = showTime;
const {
disabledHours: legacyDisabledHours,
disabledMinutes: legacyDisabledMinutes,
disabledSeconds: legacyDisabledSeconds,
} = showTime;

const mergedDisabledHours = disabledHours || legacyDisabledHours;
const mergedDisabledMinutes = disabledMinutes || legacyDisabledMinutes;
const mergedDisabledSeconds = disabledSeconds || legacyDisabledSeconds;
const mergedDisabledHours = disabledHours || legacyDisabledHours;
const mergedDisabledMinutes = disabledMinutes || legacyDisabledMinutes;
const mergedDisabledSeconds = disabledSeconds || legacyDisabledSeconds;

const hour = generateConfig.getHour(date);
const minute = generateConfig.getMinute(date);
const second = generateConfig.getSecond(date);
const millisecond = generateConfig.getMillisecond(date);
const hour = generateConfig.getHour(date);
const minute = generateConfig.getMinute(date);
const second = generateConfig.getSecond(date);
const millisecond = generateConfig.getMillisecond(date);

if (mergedDisabledHours && mergedDisabledHours().includes(hour)) {
return true;
}
if (mergedDisabledHours && mergedDisabledHours().includes(hour)) {
return true;
}

if (mergedDisabledMinutes && mergedDisabledMinutes(hour).includes(minute)) {
return true;
}
if (mergedDisabledMinutes && mergedDisabledMinutes(hour).includes(minute)) {
return true;
}

if (mergedDisabledSeconds && mergedDisabledSeconds(hour, minute).includes(second)) {
return true;
}
if (mergedDisabledSeconds && mergedDisabledSeconds(hour, minute).includes(second)) {
return true;
}

if (
disabledMilliseconds &&
disabledMilliseconds(hour, minute, second).includes(millisecond)
) {
return true;
}
if (
disabledMilliseconds &&
disabledMilliseconds(hour, minute, second).includes(millisecond)
) {
return true;
}
return false;
},
);
}
return false;
});

return isInvalidate;
}
2 changes: 2 additions & 0 deletions src/PickerInput/hooks/useRangeDisabledDate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,12 @@ export default function useRangeDisabledDate<DateType extends object = any>(
) {
const rangeDisabledDate: DisabledDate<DateType> = (date, info) => {
const [start, end] = values;
const range: 'start' | 'end' = activeIndex === 1 ? 'end' : 'start';

const mergedInfo = {
...info,
from: getFromDate(values, triggeredFields, activeIndex),
range,
};
Comment thread
QDyanbing marked this conversation as resolved.

// ============================ Disabled ============================
Expand Down
3 changes: 2 additions & 1 deletion src/PickerInput/hooks/useRangeValue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { formatValue, isSame, isSameTimestamp } from '../../utils/dateUtil';
import { fillIndex } from '../../utils/miscUtil';
import type { RangePickerProps } from '../RangePicker';
import type { ReplacedPickerProps } from '../SinglePicker';
import type { InvalidateDateInfo } from './useInvalidate';

const EMPTY_VALUE: any[] = [];

Expand Down Expand Up @@ -172,7 +173,7 @@ export default function useRangeValue<ValueType extends DateType[], DateType ext
triggerCalendarChange: TriggerCalendarChange<ValueType>,
disabled: ReplaceListType<Required<ValueType>, boolean>,
formatList: FormatType[],
isInvalidateDate: (date: DateType, info?: { from?: DateType; activeIndex: number }) => boolean,
isInvalidateDate: (date: DateType, info?: InvalidateDateInfo<DateType>) => boolean,
): [
/** Trigger `onChange` by check `disabledDate` */
flushSubmit: (index: number, needTriggerChange: boolean) => void,
Expand Down
4 changes: 2 additions & 2 deletions src/generate/dateFns.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ const localeParse = (format: string) => {

const parse = (text: string, format: string, locale: string) => {
return parseDate(text, localeParse(format), new Date(), { locale: getLocale(locale) });
}
};

/**
* Check if the text is a valid date considering the format and locale
Expand All @@ -69,7 +69,7 @@ const isStrictValidDate = (text: string, format: string, locale: string) => {
}
const formattedDate = formatDate(date, format, { locale: getLocale(locale) });
return text === formattedDate;
}
};

const generateConfig: GenerateConfig<Date> = {
// get
Expand Down
11 changes: 6 additions & 5 deletions src/interface.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -89,9 +89,14 @@ export type InternalMode = PanelMode | 'datetime';

export type PickerMode = Exclude<PanelMode, 'datetime' | 'decade'>;

export interface BaseInfo {
/** Only work in RangePicker. Tell the related start or end field. */
range?: 'start' | 'end';
}

export type DisabledDate<DateType = any> = (
date: DateType,
info: {
info: BaseInfo & {
type: PanelMode;
/**
* Only work in RangePicker.
Expand All @@ -102,10 +107,6 @@ export type DisabledDate<DateType = any> = (
},
) => boolean;

export interface BaseInfo {
range?: 'start' | 'end';
}

export interface CellRenderInfo<DateType> extends BaseInfo {
prefixCls: string;
// The cell wrapper element
Expand Down
89 changes: 89 additions & 0 deletions tests/new-range.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -563,6 +563,29 @@ describe('NewPicker.Range', () => {
expect(document.querySelector('.rc-picker-ok button')).toBeDisabled();
});

it('disabledDate range should control ok button', () => {
const disabledDate = (date: Dayjs, info: { range?: 'start' | 'end' }) =>
info.range === 'end' && date.isBefore(dayjs('2024-11-20'), 'day');

const { container } = render(
<DayRangePicker
showTime
defaultValue={[getDay('2024-11-19 00:00:00'), getDay('2024-11-21 00:00:00')]}
disabledDate={disabledDate}
/>,
);

openPicker(container, 1);
expect(document.querySelector('.rc-picker-ok button')).not.toBeDisabled();

fireEvent.change(container.querySelectorAll<HTMLInputElement>('input')[1], {
target: {
value: '2024-11-19 00:00:00',
},
});
expect(document.querySelector('.rc-picker-ok button')).toBeDisabled();
});

it('disabledDate provides info.type', () => {
const disabledDate = jest.fn(() => false);

Expand All @@ -576,6 +599,72 @@ describe('NewPicker.Range', () => {
);
});

it('disabledDate provides info.range', () => {
const disabledDate = jest.fn(() => false);

const { container } = render(<DayRangePicker allowEmpty disabledDate={disabledDate} />);

openPicker(container);
expect(disabledDate).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
range: 'start',
}),
);

disabledDate.mockClear();
openPicker(container, 1);
expect(disabledDate).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
range: 'end',
}),
);
});

it('disabledDate keeps from info behavior', () => {
const disabledDate = jest.fn(
(_date: Dayjs, _info: { range?: 'start' | 'end'; from?: Dayjs }) => false,
);

const { container } = render(<DayRangePicker disabledDate={disabledDate} />);

openPicker(container);
selectCell(15);

const endCall = disabledDate.mock.calls.find(([, info]) => info.range === 'end' && info.from);

expect(endCall).toBeTruthy();
expect(isSame(endCall![1].from, '1990-09-15')).toBeTruthy();
});

it('disabledDate can apply to end field only', () => {
const onChange = jest.fn();
const disabledDate = (date: Dayjs, info: { range?: 'start' | 'end' }) =>
info.range === 'end' && date <= dayjs('2024-11-20').endOf('day');

const { container } = render(
<DayRangePicker
disabled={[true, false]}
defaultValue={[getDay('2024-10-28'), getDay('2024-11-20')]}
disabledDate={disabledDate}
onChange={onChange}
/>,
);

openPicker(container, 1);

const disabledCell = selectCell('19', 1);
expect(disabledCell).toHaveClass('rc-picker-cell-disabled');
expect(onChange).not.toHaveBeenCalled();

selectCell('21', 1);
expect(onChange).toHaveBeenCalledWith(
[expect.anything(), expect.anything()],
['2024-10-28', '2024-11-21'],
);
});

it('disabled should patch className', () => {
const { container, rerender } = render(<DayRangePicker disabled />);
expect(container.querySelector('.rc-picker-disabled')).toBeTruthy();
Expand Down
Loading