Skip to content
This repository has been archived by the owner on Oct 1, 2024. It is now read-only.

Commit

Permalink
Add support for custom dirty state comparator
Browse files Browse the repository at this point in the history
  • Loading branch information
michaelmelanson committed Feb 25, 2020
1 parent 0d30b0e commit da0c883
Show file tree
Hide file tree
Showing 9 changed files with 151 additions and 62 deletions.
6 changes: 5 additions & 1 deletion packages/react-form/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,11 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/)
and adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html).

<!-- ## [Unreleased] -->
## [Unreleased]

### Added

- Add option to use a custom comparator for determining if a field is dirty [#1296](https://github.com/Shopify/quilt/pull/1296/)

## [0.3.24]

Expand Down
20 changes: 15 additions & 5 deletions packages/react-form/src/hooks/field/field.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
import {useCallback, useEffect, useMemo, ChangeEvent} from 'react';
import isEqual from 'fast-deep-equal';

import {Validates, Field} from '../../types';
import {normalizeValidation, isChangeEvent} from '../../utilities';
import {Validates, Field, DirtyStateComparator} from '../../types';
import {
normalizeValidation,
isChangeEvent,
defaultDirtyComparator,
} from '../../utilities';

import {
updateAction,
Expand All @@ -15,6 +19,7 @@ import {
export interface FieldConfig<Value> {
value: Value;
validates: Validates<Value>;
dirtyStateComparator?: DirtyStateComparator<Value>;
}

/**
Expand Down Expand Up @@ -105,10 +110,14 @@ export function useField<Value = string>(
input: FieldConfig<Value> | Value,
dependencies: unknown[] = [],
): Field<Value> {
const {value, validates} = normalizeFieldConfig(input);
const {
value,
validates,
dirtyStateComparator = defaultDirtyComparator,
} = normalizeFieldConfig(input);
const validators = normalizeValidation(validates);

const [state, dispatch] = useFieldReducer(value);
const [state, dispatch] = useFieldReducer(value, dirtyStateComparator);

const resetActionObject = useMemo(() => resetAction(), []);
const reset = useCallback(() => dispatch(resetActionObject), [
Expand Down Expand Up @@ -238,12 +247,13 @@ export function useChoiceField(

function normalizeFieldConfig<Value>(
input: FieldConfig<Value> | Value,
dirtyStateComparator?: DirtyStateComparator<Value>,
): FieldConfig<Value> {
if (isFieldConfig(input)) {
return input;
}

return {value: input, validates: () => undefined};
return {value: input, validates: () => undefined, dirtyStateComparator};
}

function isFieldConfig<Value>(input: unknown): input is FieldConfig<Value> {
Expand Down
1 change: 1 addition & 0 deletions packages/react-form/src/hooks/field/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export {
} from './field';
export {
reduceField,
makeFieldReducer,
FieldAction,
updateErrorAction,
initialFieldState,
Expand Down
121 changes: 68 additions & 53 deletions packages/react-form/src/hooks/field/reducer.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import {useReducer, Reducer} from 'react';

import {FieldState, ErrorValue} from '../../types';
import {shallowArrayComparison} from '../../utilities';
import {FieldState, ErrorValue, DirtyStateComparator} from '../../types';
import {defaultDirtyComparator} from '../../utilities';

interface UpdateErrorAction {
type: 'updateError';
Expand Down Expand Up @@ -55,62 +55,77 @@ export type FieldAction<Value> =
| UpdateAction<Value>
| NewDefaultAction<Value>;

export function reduceField<Value>(
state: FieldState<Value>,
action: FieldAction<Value>,
) {
switch (action.type) {
case 'update': {
const newValue = action.payload;
const {defaultValue} = state;
const isDirty = Array.isArray(defaultValue)
? !shallowArrayComparison(defaultValue, newValue)
: defaultValue !== newValue;

return {
...state,
dirty: isDirty,
value: newValue,
touched: true,
};
export function makeFieldReducer<Value>({
dirtyStateComparator,
}: {
dirtyStateComparator: DirtyStateComparator<Value>;
}): Reducer<FieldState<Value>, FieldAction<Value>> {
return (state: FieldState<Value>, action: FieldAction<Value>) => {
switch (action.type) {
case 'update': {
const newValue = action.payload;
const {defaultValue} = state;
const dirty = dirtyStateComparator(defaultValue, newValue);

return {
...state,
dirty,
value: newValue,
touched: true,
};
}

case 'updateError': {
return {
...state,
error: action.payload,
};
}

case 'reset': {
const {defaultValue} = state;

return {
...state,
error: undefined,
value: defaultValue,
dirty: false,
touched: false,
};
}

case 'newDefaultValue': {
const newDefaultValue = action.payload;
return {
...state,
error: undefined,
value: newDefaultValue,
defaultValue: newDefaultValue,
touched: false,
dirty: false,
};
}
}
};
}

case 'updateError': {
return {
...state,
error: action.payload,
};
}

case 'reset': {
const {defaultValue} = state;

return {
...state,
error: undefined,
value: defaultValue,
dirty: false,
touched: false,
};
}
const shallowFieldReducer = makeFieldReducer({
dirtyStateComparator: defaultDirtyComparator,
});

case 'newDefaultValue': {
const newDefaultValue = action.payload;
return {
...state,
error: undefined,
value: newDefaultValue,
defaultValue: newDefaultValue,
touched: false,
dirty: false,
};
}
}
export function reduceField<Value>(
prevState: FieldState<Value>,
action: FieldAction<Value>,
): FieldState<Value> {
return shallowFieldReducer(prevState, action) as FieldState<Value>;
}

export function useFieldReducer<Value>(value: Value) {
return useReducer<Reducer<FieldState<Value>, FieldAction<Value>>>(
reduceField,
export function useFieldReducer<Value>(
value: Value,
dirtyStateComparator: DirtyStateComparator<Value>,
) {
return useReducer(
makeFieldReducer<Value>({dirtyStateComparator}),
initialFieldState(value),
);
}
Expand Down
48 changes: 47 additions & 1 deletion packages/react-form/src/hooks/field/test/field.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import {mount} from '@shopify/react-testing';

import {asChoiceField, useChoiceField, useField, FieldConfig} from '../field';
import {FieldState} from '../../../types';
import {FieldAction, reduceField} from '../reducer';
import {FieldAction, reduceField, makeFieldReducer} from '../reducer';

describe('useField', () => {
function TestField({config}: {config: string | FieldConfig<string>}) {
Expand Down Expand Up @@ -372,6 +372,52 @@ describe('useField', () => {
});
});
});

describe('when using a custom comparator', () => {
it("marks as dirty if the comparator says it's dirty", () => {
const dirtyStateComparator = () => true;
const reducer = makeFieldReducer({dirtyStateComparator});
const originalState = buildState({
value: 'original value',
defaultValue: 'default value',
});
const action: FieldAction<string> = {
type: 'update',
payload: 'updated value',
};
const expectedNewState = buildState({
value: 'updated value',
defaultValue: 'default value',
dirty: true,
});

const newState = reducer(originalState, action);

expect(newState).toStrictEqual(expectedNewState);
});

it("marks as clean if the comparator says it's not dirty", () => {
const dirtyStateComparator = () => false;
const reducer = makeFieldReducer({dirtyStateComparator});
const originalState = buildState({
value: 'original value',
defaultValue: 'default value',
});
const action: FieldAction<string> = {
type: 'update',
payload: 'updated value',
};
const expectedNewState = buildState({
value: 'updated value',
defaultValue: 'default value',
dirty: false,
});

const newState = reducer(originalState, action);

expect(newState).toStrictEqual(expectedNewState);
});
});
});

describe('imperative methods', () => {
Expand Down
2 changes: 1 addition & 1 deletion packages/react-form/src/hooks/list/reducer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@ import {useReducer, Reducer} from 'react';

import {FieldStates, ErrorValue} from '../../types';
import {
reduceField,
FieldAction,
updateErrorAction as updateFieldError,
initialFieldState,
reduceField,
} from '../field';
import {mapObject} from '../../utilities';

Expand Down
2 changes: 1 addition & 1 deletion packages/react-form/src/test/utilities.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {shallowArrayComparison} from '../utilities';
import {shallowArrayComparison, deepEquals} from '../utilities';

describe('shallowArrayComparison()', () => {
describe('when the two arrays are the same', () => {
Expand Down
4 changes: 4 additions & 0 deletions packages/react-form/src/types.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import {ChangeEvent} from 'react';

export type ErrorValue = string | undefined;
export type DirtyStateComparator<Value> = (
defaultValue: Value,
value: Value,
) => boolean;

export interface Validator<Value, Context> {
(value: Value, context: Context): ErrorValue;
Expand Down
9 changes: 9 additions & 0 deletions packages/react-form/src/utilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,3 +120,12 @@ export function shallowArrayComparison(arrA: unknown[], arrB: any) {

return true;
}

export function defaultDirtyComparator<Value>(
defaultValue: Value,
newValue: Value,
): boolean {
return Array.isArray(defaultValue)
? !shallowArrayComparison(defaultValue, newValue)
: defaultValue !== newValue;
}

0 comments on commit da0c883

Please sign in to comment.