Skip to content

Commit

Permalink
Integrate fieldReducer
Browse files Browse the repository at this point in the history
  • Loading branch information
mj12albert committed Mar 1, 2024
1 parent b3505a6 commit ce8d8df
Show file tree
Hide file tree
Showing 6 changed files with 117 additions and 50 deletions.
116 changes: 76 additions & 40 deletions packages/mui-base/src/FormField/FormField.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,19 @@
'use client';
import * as React from 'react';
import { unstable_useControlled as useControlled, unstable_useId as useId } from '@mui/utils';
import { FormFieldProps, FormFieldOwnerState } from './FormField.types';
import { unstable_useId as useId } from '@mui/utils';
import { useControllableReducer } from '../utils/useControllableReducer';
import {
FormFieldProps,
FormFieldOwnerState,
FieldState,
FieldActionContext,
FieldReducerAction,
} from './FormField.types';
import { FormFieldContext } from './FormFieldContext';
import { useRegisterChild } from './useRegisterChild';
import { FieldAction, FieldActionTypes } from './fieldAction.types';
import { fieldReducer } from './fieldReducer';
import { StateChangeCallback } from '../utils/useControllableReducer.types';

function defaultRender(props: React.ComponentPropsWithRef<'div'>) {
// TODO: support:
Expand All @@ -17,11 +27,12 @@ const FormField = React.forwardRef(function FormField(
forwardedRef: React.ForwardedRef<HTMLDivElement>,
) {
const {
dirty: dirtyProp,
dirty: dirtyProp = false,
disabled = false,
error = null,
focused: focusedProp = false,
error: errorProp = null,
invalid = false,
touched,
touched: touchedProp,
value: valueProp,
defaultValue,
id: idProp,
Expand All @@ -30,13 +41,6 @@ const FormField = React.forwardRef(function FormField(
...other
} = props;

const [value, setValue] = useControlled({
controlled: valueProp,
default: defaultValue,
name: 'FormField',
state: 'value',
});

// why does TS complain about this
const id = useId(idProp) as string;
const helpTextId = `${id}-help-text`;
Expand All @@ -49,36 +53,70 @@ const FormField = React.forwardRef(function FormField(

const { current: initialValueRef } = React.useRef(valueProp ?? defaultValue);

const [isDirty, setDirty] = React.useState(false);
const initialState = {
value: initialValueRef,
dirty: dirtyProp,
disabled,
focused: focusedProp,
invalid,
touched: false,
error: errorProp,
};

React.useEffect(() => {
if (initialValueRef !== value) {
if (!isDirty) {
setDirty(true);
}
}
}, [isDirty, setDirty, initialValueRef, value]);
const controlledState = React.useMemo(
() => ({
value: valueProp,
dirty: dirtyProp,
focused: focusedProp,
invalid,
error: errorProp,
}),
[valueProp, dirtyProp, focusedProp, invalid, errorProp],
);

const [focusedState, setFocused] = React.useState(false);
const focused = focusedState && !disabled;
const handleStateChange: StateChangeCallback<FieldState> = React.useCallback(
(event, field, fieldValue, reason) => {
console.log('handleStateChange', event, field, fieldValue, reason);
},
[],
);

React.useEffect(() => setFocused((isFocused) => (disabled ? false : isFocused)), [disabled]);
const [state, dispatch] = useControllableReducer<FieldState, FieldAction, FieldActionContext>({
reducer: fieldReducer as React.Reducer<FieldState, FieldReducerAction>,
controlledProps: controlledState,
initialState,
onStateChange: handleStateChange,
actionContext: React.useMemo(
() => ({
disabled,
}),
[disabled],
),
componentName: 'FormField',
});

const [isTouched, setTouched] = React.useState(false);
const { value, dirty, error, focused, touched } = state;

React.useEffect(() => {
if (focusedState && !isTouched) {
setTouched(true);
if (disabled) {
dispatch({
type: FieldActionTypes.blur,
});
} else if (focused) {
dispatch({
type: FieldActionTypes.focus,
});
}
}, [focusedState, isTouched, setTouched]);
}, [disabled, focused, dispatch]);

const render = renderProp ?? defaultRender;

const ownerState: FormFieldOwnerState = {
...props,
dirty: isDirty,
touched: isTouched,
dirty,
touched,
focused,
invalid,
};

const childContext = React.useMemo(() => {
Expand All @@ -87,14 +125,13 @@ const FormField = React.forwardRef(function FormField(
helpTextId,
labelId,
value,
setValue,
dirty,
disabled,
invalid,
dirty: isDirty,
touched: isTouched,
focused,
setFocused,
invalid,
touched,
error,
dispatch,
hasLabel,
hasHelpText,
registerChild(name: 'Label' | 'HelpText') {
Expand All @@ -112,14 +149,13 @@ const FormField = React.forwardRef(function FormField(
helpTextId,
labelId,
value,
setValue,
dirty,
disabled,
invalid,
isDirty,
isTouched,
focused,
setFocused,
error,
focused,
invalid,
touched,
dispatch,
hasLabel,
setHasLabel,
hasHelpText,
Expand Down
11 changes: 6 additions & 5 deletions packages/mui-base/src/FormField/FormField.types.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
import { ActionWithContext } from '@mui/base/utils/useControllableReducer.types';
import { FieldAction } from './fieldAction.types';

export type FieldError = string | null | Record<string, unknown>; // this could be expanded to sth to the effect of `validationResult`
import { FieldAction, FieldError } from './fieldAction.types';
import { ActionWithContext } from '../utils/useControllableReducer.types';

export interface FieldState {
value: unknown;
Expand All @@ -13,7 +11,9 @@ export interface FieldState {
error: FieldError;
}

export type FieldActionContext = {};
export type FieldActionContext = {
disabled: boolean;
};

export type FieldReducerAction = ActionWithContext<FieldAction, FieldActionContext>;

Expand All @@ -27,6 +27,7 @@ export interface FormFieldProps {
value?: unknown;
defaultValue?: unknown;
disabled?: boolean;
focused?: boolean;
invalid?: boolean;
touched?: boolean;
dirty?: boolean;
Expand Down
4 changes: 2 additions & 2 deletions packages/mui-base/src/FormField/FormFieldContext.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import * as React from 'react';
import { FieldAction } from './fieldAction.types';
// import { FormFieldProps } from './FormField.types';

export interface FormFieldContextValue {
Expand All @@ -11,8 +12,7 @@ export interface FormFieldContextValue {
focused: boolean;
invalid: boolean;
touched: boolean;
setFocused: React.Dispatch<React.SetStateAction<boolean>>;
setValue: React.Dispatch<React.SetStateAction<unknown>>;
dispatch: (action: FieldAction) => void;
error: string | null | Record<string, unknown>;
hasLabel: boolean;
hasHelpText: boolean;
Expand Down
4 changes: 2 additions & 2 deletions packages/mui-base/src/FormField/fieldAction.types.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import { FieldError } from './FormField.types';

export const FieldActionTypes = {
touch: 'field:touch',
untouch: 'field:untouch', // as an escape hatch
Expand All @@ -14,6 +12,8 @@ export const FieldActionTypes = {
// - changeValidationState: cycle error/warning/success/null
} as const;

export type FieldError = string | null | Record<string, unknown>; // this could be expanded to sth to the effect of `validationResult`

// try to always pass event in Action always just so stateChangeCallback has a truthy event argument

interface FieldTouchAction {
Expand Down
25 changes: 25 additions & 0 deletions packages/mui-base/src/FormField/fieldReducer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,16 @@ const initialState = {
error: null,
};

const defaultContext = {
disabled: false,
};

describe('fieldReducer', () => {
describe('action: touch', () => {
it('sets the touched state to true', () => {
const action: FieldReducerAction = {
type: FieldActionTypes.touch,
context: defaultContext,
};

const result = fieldReducer(initialState, action);
Expand All @@ -30,6 +35,7 @@ describe('fieldReducer', () => {
it('sets the touched state to false', () => {
const action: FieldReducerAction = {
type: FieldActionTypes.untouch,
context: defaultContext,
};

const result = fieldReducer(initialState, action);
Expand All @@ -42,13 +48,25 @@ describe('fieldReducer', () => {
it('sets the focused state and touched state to true', () => {
const action: FieldReducerAction = {
type: FieldActionTypes.focus,
context: defaultContext,
};

const result = fieldReducer(initialState, action);

expect(result.focused).to.equal(true);
expect(result.touched).to.equal(true);
});

it('does not change the state if the field is disabled', () => {
const action: FieldReducerAction = {
type: FieldActionTypes.focus,
context: { disabled: true },
};

const result = fieldReducer(initialState, action);

expect(result).to.deep.equal(initialState);
});
});

describe('action: blur', () => {
Expand All @@ -61,6 +79,7 @@ describe('fieldReducer', () => {

const action: FieldReducerAction = {
type: FieldActionTypes.blur,
context: defaultContext,
};

const result = fieldReducer(state, action);
Expand All @@ -74,19 +93,22 @@ describe('fieldReducer', () => {
it('updates the value', () => {
const action: FieldReducerAction = {
type: FieldActionTypes.changeValue,
context: defaultContext,
value: 'Hello world',
};

const result = fieldReducer(initialState, action);

expect(result.value).to.equal('Hello world');
expect(result.dirty).to.equal(true);
});
});

describe('action: setError', () => {
it('sets the invalid state without extra error details', () => {
const action: FieldReducerAction = {
type: FieldActionTypes.setError,
context: defaultContext,
};

const result = fieldReducer(initialState, action);
Expand All @@ -98,6 +120,7 @@ describe('fieldReducer', () => {
it('sets the invalid state with error message', () => {
const action: FieldReducerAction = {
type: FieldActionTypes.setError,
context: defaultContext,
error: 'Username already taken',
};

Expand All @@ -115,6 +138,7 @@ describe('fieldReducer', () => {
};
const action: FieldReducerAction = {
type: FieldActionTypes.setError,
context: defaultContext,
error,
};

Expand All @@ -129,6 +153,7 @@ describe('fieldReducer', () => {
it('clears the invalid state and error', () => {
const action: FieldReducerAction = {
type: FieldActionTypes.clearError,
context: defaultContext,
};

const result = fieldReducer(initialState, action);
Expand Down
7 changes: 6 additions & 1 deletion packages/mui-base/src/FormField/fieldReducer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { FieldState, FieldReducerAction } from './FormField.types';
import { FieldActionTypes } from './fieldAction.types';

export function fieldReducer(state: FieldState, action: FieldReducerAction): FieldState {
const { type } = action;
const { type, context } = action;

switch (type) {
case FieldActionTypes.touch:
Expand All @@ -16,6 +16,10 @@ export function fieldReducer(state: FieldState, action: FieldReducerAction): Fie
touched: false,
};
case FieldActionTypes.focus:
if (context.disabled) {
return state;
}

return {
...state,
focused: true,
Expand All @@ -29,6 +33,7 @@ export function fieldReducer(state: FieldState, action: FieldReducerAction): Fie
case FieldActionTypes.changeValue:
return {
...state,
dirty: true,
value: action.value,
};
case FieldActionTypes.setError:
Expand Down

0 comments on commit ce8d8df

Please sign in to comment.