Skip to content

Commit

Permalink
[pickers] Always use setValue internally to update the picker value (
Browse files Browse the repository at this point in the history
  • Loading branch information
flaviendelangle authored Jan 7, 2025
1 parent cad579c commit 0a7ba21
Show file tree
Hide file tree
Showing 2 changed files with 73 additions and 227 deletions.
261 changes: 66 additions & 195 deletions packages/x-date-pickers/src/internals/hooks/usePicker/usePickerValue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,10 @@ import {
UsePickerValueProps,
UsePickerValueParams,
UsePickerValueResponse,
PickerValueUpdateAction,
UsePickerValueState,
UsePickerValueFieldResponse,
UsePickerValueViewsResponse,
PickerSelectionState,
PickerValueUpdaterParams,
UsePickerValueContextValue,
UsePickerValueProviderParams,
UsePickerValueActionsContextValue,
Expand All @@ -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 = <TValue extends PickerValidValue, TError>(
params: PickerValueUpdaterParams<TValue, TError>,
): 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 = <TValue extends PickerValidValue, TError>(
params: PickerValueUpdaterParams<TValue, TError>,
): 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 = <TValue extends PickerValidValue, TError>(
params: PickerValueUpdaterParams<TValue, TError>,
): 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.
*/
Expand Down Expand Up @@ -254,58 +140,61 @@ export const usePickerValue = <
onError: props.onError,
});

const updateDate = useEventCallback((action: PickerValueUpdateAction<TValue, TError>) => {
const updaterParams: PickerValueUpdaterParams<TValue, TError> = {
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<TError>) => {
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<TError> | null = null;
const getContext = (): PickerChangeHandlerContext<TError> => {
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;
}
}

return cachedContext;
};

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);
}
});
Expand All @@ -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<TValue> = {
value: valueWithoutError,
onChange: handleChange,
open,
setOpen,
};

const isValid = (testedValue: TValue) => {
const error = validator({
adapter,
Expand All @@ -359,51 +231,21 @@ export const usePickerValue = <
return !valueManager.hasError(error);
};

const setValue = useEventCallback((newValue: TValue, options?: SetValueActionOptions<TError>) =>
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,
});
});

Expand All @@ -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<TValue> = {
value: valueWithoutError,
onChange: setValueFromView,
open,
setOpen,
};

const actionsContextValue = React.useMemo<UsePickerValueActionsContextValue<TValue, TError>>(
() => ({
setValue,
Expand Down
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -155,37 +154,6 @@ export interface UsePickerValueState<TValue extends PickerValidValue> {
hasBeenModifiedSinceMount: boolean;
}

export interface PickerValueUpdaterParams<TValue extends PickerValidValue, TError> {
action: PickerValueUpdateAction<TValue, TError>;
dateState: UsePickerValueState<TValue>;
/**
* 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<TValue extends PickerValidValue, TError> =
| {
name: 'setValueFromView';
value: TValue;
selectionState: PickerSelectionState;
}
| {
name: 'setValueFromAction';
value: TValue;
pickerAction: 'accept' | 'today' | 'cancel' | 'dismiss' | 'clear';
}
| {
name: 'setExplicitValue';
value: TValue;
options: MakeRequired<SetValueActionOptions<TError>, 'changeImportance'>;
};

/**
* Props used to handle the value that are common to all pickers.
*/
Expand Down Expand Up @@ -376,4 +344,11 @@ export interface SetValueActionOptions<TError = string> {
* 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;
}

0 comments on commit 0a7ba21

Please sign in to comment.