Skip to content
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

Opt out sanitizeEmptyValues with SimpleForm and TabbedForm #5077

Merged
merged 2 commits into from
Aug 10, 2020

Conversation

Kmaschta
Copy link
Contributor

@Kmaschta Kmaschta commented Jul 22, 2020

Because final-form removes empty / null attributes, SimpleForm needs to recreate them with a null value from its initial values.
This is a good default as most REST APIs requires all the fields.

That said, in particular cases, it might conflicts with expected attributes removal, for example:

  • A HTTP PUT query that expects field absence to remove them
  • A JSON input, that requires to remove a field in the JSON

Moreover, GraphQL APIs don't require all nullable fields to be present in the query.

A workaround would be to create your own form and coping the behavior of <FormWithRedirect> (that's what I did in the meanwhile), but I think this is a common enough case, and I this is a good opportunity to document this behavior.

Feel free to edit the PR!

Closes #5043 and #4942

@Kmaschta Kmaschta force-pushed the opt-out-sanitize-empty-values branch 2 times, most recently from a7bc1a8 to 2d35f6c Compare July 22, 2020 18:14
@Kmaschta Kmaschta force-pushed the opt-out-sanitize-empty-values branch from 2d35f6c to 11296e4 Compare July 23, 2020 08:38
@Kmaschta Kmaschta force-pushed the opt-out-sanitize-empty-values branch from 11296e4 to c4a4389 Compare July 23, 2020 08:39
@Kmaschta Kmaschta changed the title Opt out sanitizeEmptyValues with SimpleForm Opt out sanitizeEmptyValues with SimpleForm and TabbedForm Jul 23, 2020
@Luwangel Luwangel added enhancement RFR Ready For Review labels Jul 23, 2020
@djhi
Copy link
Contributor

djhi commented Aug 10, 2020

It looks to me we would add yet another prop for something you can already handle at either the dataProvider level or by using the transform prop available on the Create, Edit and SaveButton components.

Am I missing something?

@Kmaschta
Copy link
Contributor Author

Kmaschta commented Aug 10, 2020

Indeed, we could remove the null values with a transform or at the dataProvider level.
But I have an issue with that, because how would you discriminate null values explicitly added by the user from the one guessed by React Admin?

My problem is that I want to keep user-defined null values, but remove the one guessed by React Admin.
For now, the only way to do so is to copy React Admin's SimpleForm and FormWithRedirect, and it's really cumbersome and difficult to maintain.

EDIT: I would agree open a new PR if there is a simplest and/or more elegant way to resolve this problem!

Copy link
Member

@fzaninotto fzaninotto left a comment

Choose a reason for hiding this comment

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

Looking good to me!

docs/CreateEdit.md Outdated Show resolved Hide resolved
Co-authored-by: Francois Zaninotto <fzaninotto@gmail.com>
@fzaninotto fzaninotto merged commit a075b95 into marmelab:master Aug 10, 2020
@fzaninotto
Copy link
Member

Thanks!

@wmwart
Copy link

wmwart commented Aug 11, 2020

This is a good step towards supporting JSON fields, but there is another problem that I already wrote about and not solved.
This concerns ra-data-graphql-simple
Problem in file ...\ra-data-graphql-simple\src\getResponseParser.js , which makes eg

{
  metaVariable: [
    {
      alias: 'SLAVE',
    },
    {
      alias: 'PASSWORD',
    }
  ],
  metaVariableIds: [
    null,
    null
  ]
}

instead

{
  metaVariable: [
    {
      alias: 'SLAVE',
    },
    {
      alias: 'PASSWORD',
    }
  ]
}

please add changes to the file

import { GET_LIST, GET_MANY, GET_MANY_REFERENCE } from 'ra-core';

const sanitizeResource = data => {
    const result = Object.keys(data).reduce((acc, key) => {
        if (key.startsWith('_')) {
            return acc;
        }

        const dataKey = data[key];

        if (dataKey === null || dataKey === undefined) {
            return acc;
        }

        if (Array.isArray(dataKey)) {
            if (typeof dataKey[0] === 'object' && dataKey[0] !== null) {
                return {
                    ...acc,
                    [key]: dataKey.map(sanitizeResource),
                    [`${key}Ids`]: dataKey.map(d => d.id),
                };
            } else {
                return { ...acc, [key]: dataKey };
            }
        }

        if (typeof dataKey === 'object' && dataKey !== null) {
            return {
                ...acc,
                ...(dataKey &&
                    dataKey.id && {
                        [`${key}.id`]: dataKey.id,
                    }),
                [key]: (dataKey.__typename) ? sanitizeResource(dataKey) : dataKey,
				// do not apply sanitizeResource for scalar types (for example JSON)
            };
        }

        return { ...acc, [key]: dataKey };
    }, {});

    return result;
};

export default introspectionResults => aorFetchType => response => {
    const data = response.data;

    if (
        aorFetchType === GET_LIST ||
        aorFetchType === GET_MANY ||
        aorFetchType === GET_MANY_REFERENCE
    ) {
        return {
            data: response.data.items.map(sanitizeResource),
            total: response.data.total.count,
        };
    }

    return { data: sanitizeResource(data.data) };
};

@wmwart
Copy link

wmwart commented Aug 12, 2020

I think the best solution would be to allow using

const mySanitizeEmptyValues = ( finalInitialValues, values ) => values  
<SimpleForm sanitizeEmptyValues={mySanitizeEmptyValues}> 

instead of
<SimpleForm sanitizeEmptyValues={false}>

That would be as flexible as possible. your decision reserves other restrictions.
replace:

import * as React from 'react';
import { FC, useRef, useCallback, useMemo } from 'react';
import { Form, FormProps } from 'react-final-form';
import arrayMutators from 'final-form-arrays';

import useInitializeFormWithRecord from './useInitializeFormWithRecord';
import useWarnWhenUnsavedChanges from './useWarnWhenUnsavedChanges';
import sanitizeEmptyValues as defaultSanitizeEmptyValues from './sanitizeEmptyValues';
import getFormInitialValues from './getFormInitialValues';
import FormContext from './FormContext';
import { Record } from '../types';
import { RedirectionSideEffect } from '../sideEffect';

/**
 * Wrapper around react-final-form's Form to handle redirection on submit,
 * legacy defaultValue prop, and array inputs.
 *
 * Requires a render function, just like react-final-form
 *
 * @example
 *
 * const SimpleForm = props => (
 *    <FormWithRedirect
 *        {...props}
 *        render={formProps => <SimpleFormView {...formProps} />}
 *    />
 * );
 *
 * @typedef {Object} Props the props you can use (other props are injected by Create or Edit)
 * @prop {Object} initialValues
 * @prop {Function} validate
 * @prop {Function} save
 * @prop {boolean} submitOnEnter
 * @prop {string} redirect
 * @prop {boolean} sanitizeEmptyValues
 *
 * @param {Prop} props
 */
const FormWithRedirect: FC<FormWithRedirectOwnProps & FormProps> = ({
    debug,
    decorators,
    defaultValue,
    destroyOnUnregister,
    form,
    initialValues,
    initialValuesEqual,
    keepDirtyOnReinitialize = true,
    mutators = arrayMutators as any, // FIXME see https://github.com/final-form/react-final-form/issues/704 and https://github.com/microsoft/TypeScript/issues/35771
    record,
    render,
    save,
    saving,
    subscription = defaultSubscription,
    validate,
    validateOnBlur,
    version,
    warnWhenUnsavedChanges,
	sanitizeEmptyValues = defaultSanitizeEmptyValues,
    ...props
}) => {
    let redirect = useRef(props.redirect);
    let onSave = useRef(save);

    // We don't use state here for two reasons:
    // 1. There no way to execute code only after the state has been updated
    // 2. We don't want the form to rerender when redirect is changed
    const setRedirect = newRedirect => {
        redirect.current = newRedirect;
    };

    /**
     * A form can have several Save buttons. In case the user clicks on
     * a Save button with a custom onSave handler, then on a second Save button
     * without custom onSave handler, the user expects the default save
     * handler (the one of the Form) to be called.
     * That's why the SaveButton onClick calls setOnSave() with no parameters
     * if it has no custom onSave, and why this function forces a default to
     * save.
     */
    const setOnSave = useCallback(
        newOnSave => {
            typeof newOnSave === 'function'
                ? (onSave.current = newOnSave)
                : (onSave.current = save);
        },
        [save]
    );

    const formContextValue = useMemo(() => ({ setOnSave }), [setOnSave]);

    const finalInitialValues = getFormInitialValues(
        initialValues,
        defaultValue,
        record
    );

    const submit = values => {
        const finalRedirect =
            typeof redirect.current === undefined
                ? props.redirect
                : redirect.current;


		const finalValues = sanitizeEmptyValues(finalInitialValues, values);

        onSave.current(finalValues, finalRedirect);
        // const shouldSanitizeEmptyValues =
            // typeof props.sanitizeEmptyValues === 'undefined'
                // ? true
                // : props.sanitizeEmptyValues;

        // if (shouldSanitizeEmptyValues) {
            // const sanitizedValues = sanitizeEmptyValues(
                // finalInitialValues,
                // values
            // );
            // onSave.current(sanitizedValues, finalRedirect);
        // } else {
            // onSave.current(values, finalRedirect);
        }
    };

    return (
        <FormContext.Provider value={formContextValue}>
            <Form
                key={version} // support for refresh button
                debug={debug}
                decorators={decorators}
                destroyOnUnregister={destroyOnUnregister}
                form={form}
                initialValues={finalInitialValues}
                initialValuesEqual={initialValuesEqual}
                keepDirtyOnReinitialize={keepDirtyOnReinitialize}
                mutators={mutators} // necessary for ArrayInput
                onSubmit={submit}
                subscription={subscription} // don't redraw entire form each time one field changes
                validate={validate}
                validateOnBlur={validateOnBlur}
            >
                {formProps => (
                    <FormView
                        {...props}
                        {...formProps}
                        record={record}
                        setRedirect={setRedirect}
                        saving={formProps.submitting || saving}
                        render={render}
                        save={save}
                        warnWhenUnsavedChanges={warnWhenUnsavedChanges}
                    />
                )}
            </Form>
        </FormContext.Provider>
    );
};

export interface FormWithRedirectOwnProps {
    defaultValue?: any;
    record?: Record;
    save: (
        data: Partial<Record>,
        redirectTo: RedirectionSideEffect,
        options?: {
            onSuccess?: (data?: any) => void;
            onFailure?: (error: any) => void;
        }
    ) => void;
    redirect: RedirectionSideEffect;
    saving: boolean;
    version: number;
    warnWhenUnsavedChanges?: boolean;
    sanitizeEmptyValues?: boolean;
}

const defaultSubscription = {
    submitting: true,
    pristine: true,
    valid: true,
    invalid: true,
};

const FormView = ({ render, warnWhenUnsavedChanges, ...props }) => {
    // if record changes (after a getOne success or a refresh), the form must be updated
    useInitializeFormWithRecord(props.record);
    useWarnWhenUnsavedChanges(warnWhenUnsavedChanges);

    const { redirect, setRedirect, handleSubmit } = props;

    /**
     * We want to let developers define the redirection target from inside the form,
     * e.g. in a <SaveButton redirect="list" />.
     * This callback does two things: handle submit, and change the redirection target.
     * The actual redirection is done in save(), passed by the main controller.
     *
     * If the redirection target doesn't depend on the button clicked, it's a
     * better option to define it directly on the Form component. In that case,
     * using handleSubmit() instead of handleSubmitWithRedirect is fine.
     *
     * @example
     *
     * <Button onClick={() => handleSubmitWithRedirect('edit')}>
     *     Save and edit
     * </Button>
     */
    const handleSubmitWithRedirect = useCallback(
        (redirectTo = redirect) => {
            setRedirect(redirectTo);
            handleSubmit();
        },
        [setRedirect, redirect, handleSubmit]
    );

    return render({
        ...props,
        handleSubmitWithRedirect,
    });
};

export default FormWithRedirect;

@wmwart
Copy link

wmwart commented Aug 12, 2020

When using warning
image

@Kmaschta
Copy link
Contributor Author

@wmwart Thanks for reporting the warning! It should be fixed with #5143

About make the sanitation even more customizable, the argument above is that, after opted-out from the default sanitation, you can come with your custom one on a "transform", if I understand well.
In any case, feel free to open a new issue / PR for that

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
enhancement RFR Ready For Review
Projects
None yet
Development

Successfully merging this pull request may close these issues.

sanitizeEmptyValues should not touch properties that do not belong to form fields
6 participants