From 0a7ba218d36174449ab5c057c72e1cdfd9ae16de Mon Sep 17 00:00:00 2001 From: Flavien DELANGLE Date: Tue, 7 Jan 2025 14:56:09 +0100 Subject: [PATCH] [pickers] Always use `setValue` internally to update the picker value (#16056) --- .../hooks/usePicker/usePickerValue.ts | 261 +++++------------- .../hooks/usePicker/usePickerValue.types.ts | 39 +-- 2 files changed, 73 insertions(+), 227 deletions(-) diff --git a/packages/x-date-pickers/src/internals/hooks/usePicker/usePickerValue.ts b/packages/x-date-pickers/src/internals/hooks/usePicker/usePickerValue.ts index b6a0f8e911f08..a084d22df648c 100644 --- a/packages/x-date-pickers/src/internals/hooks/usePicker/usePickerValue.ts +++ b/packages/x-date-pickers/src/internals/hooks/usePicker/usePickerValue.ts @@ -8,12 +8,10 @@ import { UsePickerValueProps, UsePickerValueParams, UsePickerValueResponse, - PickerValueUpdateAction, UsePickerValueState, UsePickerValueFieldResponse, UsePickerValueViewsResponse, PickerSelectionState, - PickerValueUpdaterParams, UsePickerValueContextValue, UsePickerValueProviderParams, UsePickerValueActionsContextValue, @@ -23,118 +21,6 @@ import { import { useValueWithTimezone } from '../useValueWithTimezone'; import { PickerValidValue } from '../../models'; -/** - * Decide if the new value should be published - * The published value will be passed to `onChange` if defined. - */ -const shouldPublishValue = ( - params: PickerValueUpdaterParams, -): boolean => { - const { action, hasChanged, dateState, isControlled } = params; - - const isCurrentValueTheDefaultValue = !isControlled && !dateState.hasBeenModifiedSinceMount; - - if (action.name === 'setValueFromAction') { - // If the component is not controlled, and the value has not been modified since the mount, - // Then we want to publish the default value whenever the user pressed the "Accept", "Today" or "Clear" button. - if ( - isCurrentValueTheDefaultValue && - ['accept', 'today', 'clear'].includes(action.pickerAction) - ) { - return true; - } - - return hasChanged(dateState.lastPublishedValue); - } - - if (action.name === 'setValueFromView' && action.selectionState !== 'shallow') { - // On the first view, - // If the value is not controlled, then clicking on any value (including the one equal to `defaultValue`) should call `onChange` - if (isCurrentValueTheDefaultValue) { - return true; - } - - return hasChanged(dateState.lastPublishedValue); - } - - if (action.name === 'setExplicitValue') { - // On the first view, - // If the value is not controlled, then clicking on any value (including the one equal to `defaultValue`) should call `onChange` - if (isCurrentValueTheDefaultValue) { - return true; - } - - return hasChanged(dateState.lastPublishedValue); - } - - return false; -}; - -/** - * Decide if the new value should be committed. - * The committed value will be passed to `onAccept` if defined. - * It will also be used as a reset target when calling the `cancel` picker action (when clicking on the "Cancel" button). - */ -const shouldCommitValue = ( - params: PickerValueUpdaterParams, -): boolean => { - const { action, hasChanged, dateState, isControlled, closeOnSelect } = params; - - const isCurrentValueTheDefaultValue = !isControlled && !dateState.hasBeenModifiedSinceMount; - - if (action.name === 'setValueFromAction') { - // If the component is not controlled, and the value has not been modified since the mount, - // Then we want to commit the default value whenever the user pressed the "Accept", "Today" or "Clear" button. - if ( - isCurrentValueTheDefaultValue && - ['accept', 'today', 'clear'].includes(action.pickerAction) - ) { - return true; - } - - return hasChanged(dateState.lastCommittedValue); - } - - if (action.name === 'setValueFromView' && action.selectionState === 'finish' && closeOnSelect) { - // On picker where the 1st view is also the last view, - // If the value is not controlled, then clicking on any value (including the one equal to `defaultValue`) should call `onAccept` - if (isCurrentValueTheDefaultValue) { - return true; - } - - return hasChanged(dateState.lastCommittedValue); - } - - if (action.name === 'setExplicitValue') { - return action.options.changeImportance === 'accept' && hasChanged(dateState.lastCommittedValue); - } - - return false; -}; - -/** - * Decide if the picker should be closed after the value is updated. - */ -const shouldClosePicker = ( - params: PickerValueUpdaterParams, -): boolean => { - const { action, closeOnSelect } = params; - - if (action.name === 'setValueFromAction') { - return true; - } - - if (action.name === 'setValueFromView') { - return action.selectionState === 'finish' && closeOnSelect; - } - - if (action.name === 'setExplicitValue') { - return action.options.changeImportance === 'accept'; - } - - return false; -}; - /** * Manage the value lifecycle of all the pickers. */ @@ -254,43 +140,46 @@ export const usePickerValue = < onError: props.onError, }); - const updateDate = useEventCallback((action: PickerValueUpdateAction) => { - const updaterParams: PickerValueUpdaterParams = { - action, - dateState, - hasChanged: (comparison) => !valueManager.areValuesEqual(utils, action.value, comparison), - isControlled, - closeOnSelect, - }; - - const shouldPublish = shouldPublishValue(updaterParams); - const shouldCommit = shouldCommitValue(updaterParams); - const shouldClose = shouldClosePicker(updaterParams); + const setValue = useEventCallback((newValue: TValue, options?: SetValueActionOptions) => { + const { + changeImportance = 'accept', + skipPublicationIfPristine = false, + validationError, + shortcut, + } = options ?? {}; + + let shouldPublish: boolean; + let shouldCommit: boolean; + if (!skipPublicationIfPristine && !isControlled && !dateState.hasBeenModifiedSinceMount) { + // If the value is not controlled and the value has never been modified before, + // Then clicking on any value (including the one equal to `defaultValue`) should call `onChange` and `onAccept` + shouldPublish = true; + shouldCommit = changeImportance === 'accept'; + } else { + shouldPublish = !valueManager.areValuesEqual(utils, newValue, dateState.lastPublishedValue); + shouldCommit = + changeImportance === 'accept' && + !valueManager.areValuesEqual(utils, newValue, dateState.lastCommittedValue); + } setDateState((prev) => ({ ...prev, - draft: action.value, - lastPublishedValue: shouldPublish ? action.value : prev.lastPublishedValue, - lastCommittedValue: shouldCommit ? action.value : prev.lastCommittedValue, + draft: newValue, + lastPublishedValue: shouldPublish ? newValue : prev.lastPublishedValue, + lastCommittedValue: shouldCommit ? newValue : prev.lastCommittedValue, hasBeenModifiedSinceMount: true, })); let cachedContext: PickerChangeHandlerContext | null = null; const getContext = (): PickerChangeHandlerContext => { if (!cachedContext) { - const validationError = - action.name === 'setExplicitValue' && action.options.validationError != null - ? action.options.validationError - : getValidationErrorForNewValue(action.value); - cachedContext = { - validationError, + validationError: + validationError == null ? getValidationErrorForNewValue(newValue) : validationError, }; - if (action.name === 'setExplicitValue') { - if (action.options.shortcut) { - cachedContext.shortcut = action.options.shortcut; - } + if (shortcut) { + cachedContext.shortcut = shortcut; } } @@ -298,14 +187,14 @@ export const usePickerValue = < }; if (shouldPublish) { - handleValueChange(action.value, getContext()); + handleValueChange(newValue, getContext()); } if (shouldCommit && onAccept) { - onAccept(action.value, getContext()); + onAccept(newValue, getContext()); } - if (shouldClose) { + if (changeImportance === 'accept') { setOpen(false); } }); @@ -331,23 +220,6 @@ export const usePickerValue = < })); } - const handleChange = useEventCallback( - (newValue: TValue, selectionState: PickerSelectionState = 'partial') => - updateDate({ name: 'setValueFromView', value: newValue, selectionState }), - ); - - const valueWithoutError = React.useMemo( - () => valueManager.cleanValue(utils, dateState.draft), - [utils, valueManager, dateState.draft], - ); - - const viewResponse: UsePickerValueViewsResponse = { - value: valueWithoutError, - onChange: handleChange, - open, - setOpen, - }; - const isValid = (testedValue: TValue) => { const error = validator({ adapter, @@ -359,51 +231,21 @@ export const usePickerValue = < return !valueManager.hasError(error); }; - const setValue = useEventCallback((newValue: TValue, options?: SetValueActionOptions) => - updateDate({ - name: 'setExplicitValue', - value: newValue, - options: { changeImportance: 'accept', ...options }, - }), - ); - - const clearValue = useEventCallback(() => - updateDate({ - value: valueManager.emptyValue, - name: 'setValueFromAction', - pickerAction: 'clear', - }), - ); + const clearValue = useEventCallback(() => setValue(valueManager.emptyValue)); const setValueToToday = useEventCallback(() => - updateDate({ - value: valueManager.getTodayValue(utils, timezone, valueType), - name: 'setValueFromAction', - pickerAction: 'today', - }), + setValue(valueManager.getTodayValue(utils, timezone, valueType)), ); - const acceptValueChanges = useEventCallback(() => - updateDate({ - value: dateState.lastPublishedValue, - name: 'setValueFromAction', - pickerAction: 'accept', - }), - ); + const acceptValueChanges = useEventCallback(() => setValue(dateState.lastPublishedValue)); const cancelValueChanges = useEventCallback(() => - updateDate({ - value: dateState.lastCommittedValue, - name: 'setValueFromAction', - pickerAction: 'cancel', - }), + setValue(dateState.lastCommittedValue, { skipPublicationIfPristine: true }), ); const dismissViews = useEventCallback(() => { - updateDate({ - value: dateState.lastPublishedValue, - name: 'setValueFromAction', - pickerAction: 'dismiss', + setValue(dateState.lastPublishedValue, { + skipPublicationIfPristine: true, }); }); @@ -413,6 +255,35 @@ export const usePickerValue = < setValue(newValue, { validationError: context.validationError }), }; + const setValueFromView = useEventCallback( + (newValue: TValue, selectionState: PickerSelectionState = 'partial') => { + // TODO: Expose a new method (private?) like `setView` that only updates the draft value. + if (selectionState === 'shallow') { + setDateState((prev) => ({ + ...prev, + draft: newValue, + hasBeenModifiedSinceMount: true, + })); + } + + setValue(newValue, { + changeImportance: selectionState === 'finish' && closeOnSelect ? 'accept' : 'set', + }); + }, + ); + + const valueWithoutError = React.useMemo( + () => valueManager.cleanValue(utils, dateState.draft), + [utils, valueManager, dateState.draft], + ); + + const viewResponse: UsePickerValueViewsResponse = { + value: valueWithoutError, + onChange: setValueFromView, + open, + setOpen, + }; + const actionsContextValue = React.useMemo>( () => ({ setValue, diff --git a/packages/x-date-pickers/src/internals/hooks/usePicker/usePickerValue.types.ts b/packages/x-date-pickers/src/internals/hooks/usePicker/usePickerValue.types.ts index 67a067c68f66c..f915a8171a89e 100644 --- a/packages/x-date-pickers/src/internals/hooks/usePicker/usePickerValue.types.ts +++ b/packages/x-date-pickers/src/internals/hooks/usePicker/usePickerValue.types.ts @@ -1,5 +1,4 @@ import * as React from 'react'; -import { MakeRequired } from '@mui/x-internals/types'; import { UseFieldInternalProps } from '../useField'; import { Validator } from '../../../validation'; import { @@ -155,37 +154,6 @@ export interface UsePickerValueState { hasBeenModifiedSinceMount: boolean; } -export interface PickerValueUpdaterParams { - action: PickerValueUpdateAction; - dateState: UsePickerValueState; - /** - * Check if the new draft value has changed compared to some given value. - * @template TValue The value type. It will be the same type as `value` or `null`. It can be in `[start, end]` format in case of range value. - * @param {TValue} comparisonValue The value to compare the new draft value with. - * @returns {boolean} `true` if the new draft value is equal to the comparison value. - */ - hasChanged: (comparisonValue: TValue) => boolean; - isControlled: boolean; - closeOnSelect: boolean; -} - -export type PickerValueUpdateAction = - | { - name: 'setValueFromView'; - value: TValue; - selectionState: PickerSelectionState; - } - | { - name: 'setValueFromAction'; - value: TValue; - pickerAction: 'accept' | 'today' | 'cancel' | 'dismiss' | 'clear'; - } - | { - name: 'setExplicitValue'; - value: TValue; - options: MakeRequired, 'changeImportance'>; - }; - /** * Props used to handle the value that are common to all pickers. */ @@ -376,4 +344,11 @@ export interface SetValueActionOptions { * Should not be defined if the change does not come from a shortcut. */ shortcut?: PickersShortcutsItemContext; + /** + * Decide if the value should call `onChange` and `onAccept` when the value is not controlled and has never been modified. + * If `true`, the `onChange` and `onAccept` callback will only be fired if the value has been modified (and is not equal to the last published value). + * If `false`, the `onChange` and `onAccept` callback will be fired when the value has never been modified (`onAccept` only if `changeImportance` is set to "accept"). + * @default false + */ + skipPublicationIfPristine?: boolean; }