Skip to content

[pickers] Always use setValue internally to update the picker value #16056

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

Merged
merged 5 commits into from
Jan 7, 2025
Merged
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
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') {
Copy link
Member

Choose a reason for hiding this comment

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

Could you merge the master to see if everything still checks out after #15944 has been merged? 🤔

Copy link
Member Author

Choose a reason for hiding this comment

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

Done
I'll double check once the CI has passed or failed

Copy link
Member

Choose a reason for hiding this comment

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

Thanks. All looks solid. 👍

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;
}
Loading