Skip to content

Commit

Permalink
[Form lib] Add validations prop to UseArray and expose "moveItem" han…
Browse files Browse the repository at this point in the history
…dler (#76949)
  • Loading branch information
sebelga authored Sep 9, 2020
1 parent d89e6d3 commit 1ea58d9
Show file tree
Hide file tree
Showing 8 changed files with 153 additions and 67 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -17,18 +17,25 @@
* under the License.
*/

import { useState, useEffect, useRef, useCallback } from 'react';
import { useEffect, useRef, useCallback, useMemo } from 'react';

import { FormHook, FieldConfig } from '../types';
import { getFieldValidityAndErrorMessage } from '../helpers';
import { useFormContext } from '../form_context';
import { useField, InternalFieldConfig } from '../hooks';

interface Props {
path: string;
initialNumberOfItems?: number;
readDefaultValueOnForm?: boolean;
validations?: FieldConfig<any, ArrayItem[]>['validations'];
children: (args: {
items: ArrayItem[];
error: string | null;
addItem: () => void;
removeItem: (id: number) => void;
moveItem: (sourceIdx: number, destinationIdx: number) => void;
form: FormHook;
}) => JSX.Element;
}

Expand Down Expand Up @@ -56,32 +63,62 @@ export interface ArrayItem {
export const UseArray = ({
path,
initialNumberOfItems,
validations,
readDefaultValueOnForm = true,
children,
}: Props) => {
const didMountRef = useRef(false);
const form = useFormContext();
const defaultValues = readDefaultValueOnForm && (form.getFieldDefaultValue(path) as any[]);
const isMounted = useRef(false);
const uniqueId = useRef(0);

const getInitialItemsFromValues = (values: any[]): ArrayItem[] =>
values.map((_, index) => ({
const form = useFormContext();
const { getFieldDefaultValue } = form;

const getNewItemAtIndex = useCallback(
(index: number): ArrayItem => ({
id: uniqueId.current++,
path: `${path}[${index}]`,
isNew: false,
}));
isNew: true,
}),
[path]
);

const fieldDefaultValue = useMemo<ArrayItem[]>(() => {
const defaultValues = readDefaultValueOnForm
? (getFieldDefaultValue(path) as any[])
: undefined;

const getNewItemAtIndex = (index: number): ArrayItem => ({
id: uniqueId.current++,
path: `${path}[${index}]`,
isNew: true,
});
const getInitialItemsFromValues = (values: any[]): ArrayItem[] =>
values.map((_, index) => ({
id: uniqueId.current++,
path: `${path}[${index}]`,
isNew: false,
}));

const initialState = defaultValues
? getInitialItemsFromValues(defaultValues)
: new Array(initialNumberOfItems).fill('').map((_, i) => getNewItemAtIndex(i));
return defaultValues
? getInitialItemsFromValues(defaultValues)
: new Array(initialNumberOfItems).fill('').map((_, i) => getNewItemAtIndex(i));
}, [path, initialNumberOfItems, readDefaultValueOnForm, getFieldDefaultValue, getNewItemAtIndex]);

const [items, setItems] = useState<ArrayItem[]>(initialState);
// Create a new hook field with the "hasValue" set to false so we don't use its value to build the final form data.
// Apart from that the field behaves like a normal field and is hooked into the form validation lifecycle.
const fieldConfigBase: FieldConfig<any, ArrayItem[]> & InternalFieldConfig<ArrayItem[]> = {
defaultValue: fieldDefaultValue,
errorDisplayDelay: 0,
isIncludedInOutput: false,
};

const fieldConfig: FieldConfig<any, ArrayItem[]> & InternalFieldConfig<ArrayItem[]> = validations
? { validations, ...fieldConfigBase }
: fieldConfigBase;

const field = useField(form, path, fieldConfig);
const { setValue, value, isChangingValue, errors } = field;

// Derived state from the field
const error = useMemo(() => {
const { errorMessage } = getFieldValidityAndErrorMessage({ isChangingValue, errors });
return errorMessage;
}, [isChangingValue, errors]);

const updatePaths = useCallback(
(_rows: ArrayItem[]) => {
Expand All @@ -96,29 +133,51 @@ export const UseArray = ({
[path]
);

const addItem = () => {
setItems((previousItems) => {
const addItem = useCallback(() => {
setValue((previousItems) => {
const itemIndex = previousItems.length;
return [...previousItems, getNewItemAtIndex(itemIndex)];
});
};
}, [setValue, getNewItemAtIndex]);

const removeItem = (id: number) => {
setItems((previousItems) => {
const updatedItems = previousItems.filter((item) => item.id !== id);
return updatePaths(updatedItems);
});
};
const removeItem = useCallback(
(id: number) => {
setValue((previousItems) => {
const updatedItems = previousItems.filter((item) => item.id !== id);
return updatePaths(updatedItems);
});
},
[setValue, updatePaths]
);

useEffect(() => {
if (didMountRef.current) {
setItems((prev) => {
return updatePaths(prev);
const moveItem = useCallback(
(sourceIdx: number, destinationIdx: number) => {
setValue((previousItems) => {
const nextItems = [...previousItems];
const removed = nextItems.splice(sourceIdx, 1)[0];
nextItems.splice(destinationIdx, 0, removed);
return updatePaths(nextItems);
});
} else {
didMountRef.current = true;
},
[setValue, updatePaths]
);

useEffect(() => {
if (!isMounted.current) {
return;
}
}, [path, updatePaths]);

return children({ items, addItem, removeItem });
setValue((prev) => {
return updatePaths(prev);
});
}, [path, updatePaths, setValue]);

useEffect(() => {
isMounted.current = true;
return () => {
isMounted.current = false;
};
}, []);

return children({ items: value, error, form, addItem, removeItem, moveItem });
};
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,10 @@

import { FieldHook } from './types';

export const getFieldValidityAndErrorMessage = (
field: FieldHook<any>
): { isInvalid: boolean; errorMessage: string | null } => {
export const getFieldValidityAndErrorMessage = (field: {
isChangingValue: FieldHook['isChangingValue'];
errors: FieldHook['errors'];
}): { isInvalid: boolean; errorMessage: string | null } => {
const isInvalid = !field.isChangingValue && field.errors.length > 0;
const errorMessage =
!field.isChangingValue && field.errors.length ? field.errors[0].message : null;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,6 @@
* under the License.
*/

export { useField } from './use_field';
export { useField, InternalFieldConfig } from './use_field';
export { useForm } from './use_form';
export { useFormData } from './use_form_data';
Original file line number Diff line number Diff line change
Expand Up @@ -22,16 +22,22 @@ import { useMemo, useState, useEffect, useRef, useCallback } from 'react';
import { FormHook, FieldHook, FieldConfig, FieldValidateResponse, ValidationError } from '../types';
import { FIELD_TYPES, VALIDATION_TYPES } from '../constants';

export interface InternalFieldConfig<T> {
initialValue?: T;
isIncludedInOutput?: boolean;
}

export const useField = <T>(
form: FormHook,
path: string,
config: FieldConfig<any, T> & { initialValue?: T } = {},
config: FieldConfig<any, T> & InternalFieldConfig<T> = {},
valueChangeListener?: (value: T) => void
) => {
const {
type = FIELD_TYPES.TEXT,
defaultValue = '', // The value to use a fallback mecanism when no initial value is passed
initialValue = config.defaultValue ?? '', // The value explicitly passed
isIncludedInOutput = true,
label = '',
labelAppend = '',
helpText = '',
Expand Down Expand Up @@ -201,7 +207,7 @@ export const useField = <T>(
validationTypeToValidate,
}: {
formData: any;
value: unknown;
value: T;
validationTypeToValidate?: string;
}): ValidationError[] | Promise<ValidationError[]> => {
if (!validations) {
Expand Down Expand Up @@ -234,7 +240,7 @@ export const useField = <T>(
}

inflightValidation.current = validator({
value: (valueToValidate as unknown) as string,
value: valueToValidate,
errors: validationErrors,
form: { getFormData, getFields },
formData,
Expand Down Expand Up @@ -280,7 +286,7 @@ export const useField = <T>(
}

const validationResult = validator({
value: (valueToValidate as unknown) as string,
value: valueToValidate,
errors: validationErrors,
form: { getFormData, getFields },
formData,
Expand Down Expand Up @@ -388,9 +394,15 @@ export const useField = <T>(
*/
const setValue: FieldHook<T>['setValue'] = useCallback(
(newValue) => {
const formattedValue = formatInputValue<T>(newValue);
setStateValue(formattedValue);
return formattedValue;
setStateValue((prev) => {
let formattedValue: T;
if (typeof newValue === 'function') {
formattedValue = formatInputValue<T>((newValue as Function)(prev));
} else {
formattedValue = formatInputValue<T>(newValue);
}
return formattedValue;
});
},
[formatInputValue]
);
Expand Down Expand Up @@ -496,6 +508,7 @@ export const useField = <T>(
clearErrors,
validate,
reset,
__isIncludedInOutput: isIncludedInOutput,
__serializeValue: serializeValue,
};
}, [
Expand All @@ -511,6 +524,7 @@ export const useField = <T>(
isValidating,
isValidated,
isChangingValue,
isIncludedInOutput,
onChange,
getErrorsMessages,
setValue,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -95,19 +95,25 @@ export function useForm<T extends FormData = FormData>(

const fieldsToArray = useCallback(() => Object.values(fieldsRefs.current), []);

const stripEmptyFields = useCallback(
(fields: FieldsMap): FieldsMap => {
if (formOptions.stripEmptyFields) {
return Object.entries(fields).reduce((acc, [key, field]) => {
if (typeof field.value !== 'string' || field.value.trim() !== '') {
acc[key] = field;
}
const getFieldsForOutput = useCallback(
(fields: FieldsMap, opts: { stripEmptyFields: boolean }): FieldsMap => {
return Object.entries(fields).reduce((acc, [key, field]) => {
if (!field.__isIncludedInOutput) {
return acc;
}, {} as FieldsMap);
}
return fields;
}

if (opts.stripEmptyFields) {
const isFieldEmpty = typeof field.value === 'string' && field.value.trim() === '';
if (isFieldEmpty) {
return acc;
}
}

acc[key] = field;
return acc;
}, {} as FieldsMap);
},
[formOptions]
[]
);

const updateFormDataAt: FormHook<T>['__updateFormDataAt'] = useCallback(
Expand All @@ -133,8 +139,10 @@ export function useForm<T extends FormData = FormData>(
const getFormData: FormHook<T>['getFormData'] = useCallback(
(getDataOptions: Parameters<FormHook<T>['getFormData']>[0] = { unflatten: true }) => {
if (getDataOptions.unflatten) {
const nonEmptyFields = stripEmptyFields(fieldsRefs.current);
const fieldsValue = mapFormFields(nonEmptyFields, (field) => field.__serializeValue());
const fieldsToOutput = getFieldsForOutput(fieldsRefs.current, {
stripEmptyFields: formOptions.stripEmptyFields,
});
const fieldsValue = mapFormFields(fieldsToOutput, (field) => field.__serializeValue());
return serializer
? (serializer(unflattenObject(fieldsValue)) as T)
: (unflattenObject(fieldsValue) as T);
Expand All @@ -148,7 +156,7 @@ export function useForm<T extends FormData = FormData>(
{} as T
);
},
[stripEmptyFields, serializer]
[getFieldsForOutput, formOptions.stripEmptyFields, serializer]
);

const getErrors: FormHook['getErrors'] = useCallback(() => {
Expand Down
17 changes: 10 additions & 7 deletions src/plugins/es_ui_shared/static/forms/hook_form_lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,15 +108,18 @@ export interface FieldHook<T = unknown> {
errorCode?: string;
}) => string | null;
onChange: (event: ChangeEvent<{ name?: string; value: string; checked?: boolean }>) => void;
setValue: (value: T) => T;
setValue: (value: T | ((prevValue: T) => T)) => void;
setErrors: (errors: ValidationError[]) => void;
clearErrors: (type?: string | string[]) => void;
validate: (validateData?: {
formData?: any;
value?: unknown;
value?: T;
validationType?: string;
}) => FieldValidateResponse | Promise<FieldValidateResponse>;
reset: (options?: { resetValue?: boolean; defaultValue?: T }) => unknown | undefined;
// Flag to indicate if the field value will be included in the form data outputted
// when calling form.getFormData();
__isIncludedInOutput: boolean;
__serializeValue: (rawValue?: unknown) => unknown;
}

Expand All @@ -127,7 +130,7 @@ export interface FieldConfig<T extends FormData = any, ValueType = unknown> {
readonly helpText?: string | ReactNode;
readonly type?: HTMLInputElement['type'];
readonly defaultValue?: ValueType;
readonly validations?: Array<ValidationConfig<T>>;
readonly validations?: Array<ValidationConfig<T, string, ValueType>>;
readonly formatters?: FormatterFunc[];
readonly deserializer?: SerializerFunc;
readonly serializer?: SerializerFunc;
Expand Down Expand Up @@ -163,8 +166,8 @@ export interface ValidationFuncArg<T extends FormData, V = unknown> {
errors: readonly ValidationError[];
}

export type ValidationFunc<T extends FormData = any, E = string> = (
data: ValidationFuncArg<T>
export type ValidationFunc<T extends FormData = any, E = string, V = unknown> = (
data: ValidationFuncArg<T, V>
) => ValidationError<E> | void | undefined | Promise<ValidationError<E> | void | undefined>;

export interface FieldValidateResponse {
Expand All @@ -184,8 +187,8 @@ type FormatterFunc = (value: any, formData: FormData) => unknown;
// string | number | boolean | string[] ...
type FieldValue = unknown;

export interface ValidationConfig<T extends FormData = any> {
validator: ValidationFunc<T>;
export interface ValidationConfig<T extends FormData = any, E = string, V = unknown> {
validator: ValidationFunc<T, E, V>;
type?: string;
/**
* By default all validation are blockers, which means that if they fail, the field is invalid.
Expand Down
Loading

0 comments on commit 1ea58d9

Please sign in to comment.