Skip to content
Snippets Groups Projects
inputBehaviors.tsx 19.67 KiB
import { useEffect, useCallback, useState } from 'react';
import type * as React from 'react';
import { selectLatestUndoRedoActionId } from '@/features/ui/uiSlice';
import {
  getDefaultValue,
  validateProp,
  toPropName,
  getPropSchemas,
  shouldSkipPropValidation,
  getPropsValues,
} from '@/components/form/formUtil';
import { useAppDispatch, useAppSelector } from '@/app/hooks';
import type {
  ResolvedValues,
  Sources,
} from '@/features/layout/layoutModelSlice';
import { setUpdatePreview } from '@/features/layout/layoutModelSlice';
import {
  isEvaluatedComponentModel,
  selectLayout,
  selectModel,
} from '@/features/layout/layoutModelSlice';
import { parseValue, SELECT_ITEM_EMPTY_STRING } from '@/utils/function-utils';
import { debounce } from 'lodash';
import { useGetComponentsQuery } from '@/services/componentAndLayout';
import { findComponentByUuid } from '@/features/layout/layoutUtils';
import './InputBehaviors.css';
import type { PropsValues, InputUIData } from '@/types/Form';

import type { Attributes } from '@/types/DrupalAttribute';
import Ajv from 'ajv';
// @ts-ignore
import addDraft2019 from 'ajv-formats-draft2019';
import { selectPageData, setPageData } from '@/features/pageData/pageDataSlice';
import type { FormId } from '@/features/form/formStateSlice';
import { selectCurrentComponent } from '@/features/form/formStateSlice';
import {
  selectFieldError,
  selectFormValues,
  setFieldError,
  setFieldValue,
  clearFieldError,
} from '@/features/form/formStateSlice';
import type { ErrorObject } from 'ajv/dist/types';
import type { XBComponent } from '@/types/Component';
import { componentHasFieldData } from '@/types/Component';
import { FORM_TYPES } from '@/features/form/constants';
import { useUpdateComponentMutation } from '@/services/preview';
import type { AjaxUpdateFormBuildIdEvent } from '@/types/Ajax';
import { AJAX_UPDATE_FORM_BUILD_ID_EVENT } from '@/types/Ajax';
import { useComponentTransforms } from '@/components/DummyPropsEditForm';

const ajv = new Ajv();
addDraft2019(ajv);

type ValidationResult = {
  valid: boolean;
  errors: null | ErrorObject[];
};

type InputBehaviorsForm = (
  OriginalInput: React.FC,
  props: React.ComponentProps<any>,
) => React.ReactElement;

interface InputProps {
  attributes: Attributes & {
    onChange: (e: React.ChangeEvent) => void;
    onBlur: (e: React.FocusEvent) => void;
  };
  options?: { [key: string]: string }[];
}

// Wraps all form elements to provide common functionality and handle committing
// the form state, parsing and validation of values.
const InputBehaviorsCommon = ({
  OriginalInput,
  props,
  callbacks,
}: {
  OriginalInput: React.FC<InputProps>;
  props: {
    value: any;
    options?: { [key: string]: string }[];
    attributes: Attributes & {
      onChange: (e: React.ChangeEvent) => void;
      onBlur: (e: React.FocusEvent) => void;
    };
  };
  callbacks: {
    commitFormState: (newFormState: PropsValues) => void;
    parseNewValue: (newValue: React.ChangeEvent) => any;
    validateNewValue: (e: React.ChangeEvent, newValue: any) => ValidationResult;
  };
}) => {
  const { attributes, options, value, ...passProps } = props;
  const { commitFormState, parseNewValue, validateNewValue } = callbacks;
  const dispatch = useAppDispatch();
  const defaultValue = getDefaultValue(options, attributes, value);
  const [inputValue, setInputValue] = useState(defaultValue || '');

  const formValues = useAppSelector((state) =>
    selectFormValues(state, attributes['data-form-id'] as FormId),
  );

  const formId = attributes['data-form-id'] as FormId;
  const fieldName = (attributes.name || attributes['data-xb-name']) as string;
  const fieldIdentifier = {
    formId,
    fieldName,
  };
  const fieldError = useAppSelector((state) =>
    selectFieldError(state, fieldIdentifier),
  );

  // Include the input's default value in the form state on init - including
  // when an element is added via AJAX.
  const elementType = attributes.type || attributes['data-xb-type'];
  useEffect(() => {
    if (
      // Ignore radios in indeterminate (initial unset) state.
      // @see https://developer.mozilla.org/en-US/docs/Web/API/HTMLInputElement/indeterminate
      (elementType === 'radios' && inputValue === '') ||
      // Every individual radio element has a value, but it isn't
      // the value of the field unless it is checked. The value of the field is
      // managed by the radios group, not the individual radio elements.
      elementType === 'radio'
    ) {
      return;
    }
    if (fieldName && formId) {
      dispatch(
        setFieldValue({
          formId,
          fieldName,
          value: elementType === 'checkbox' ? !!inputValue : inputValue,
        }),
      );
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  useEffect(() => {
    // Special handling for the form_build_id which can be updated by an ajax
    // callback without using hyperscriptify to render a new React component.
    if (fieldName !== 'form_build_id') {
      return;
    }
    // Listen for changes to the form build ID so we can update that in
    // our form state and value.
    const formBuildIdListener = (e: AjaxUpdateFormBuildIdEvent) => {
      if (e.detail.formId === formId) {
        dispatch(
          setFieldValue({
            formId,
            fieldName,
            value: e.detail.newFormBuildId,
          }),
        );
        setInputValue(e.detail.newFormBuildId);
      }
    };
    document.addEventListener(
      AJAX_UPDATE_FORM_BUILD_ID_EVENT,
      formBuildIdListener as unknown as EventListener,
    );
    return () => {
      document.removeEventListener(
        AJAX_UPDATE_FORM_BUILD_ID_EVENT,
        formBuildIdListener as unknown as EventListener,
      );
    };
  }, [dispatch, fieldName, formId, setInputValue]);

  // Use debounce to prevent excessive repaints of the layout.
  const debounceStoreUpdate = debounce(
    commitFormState,
    ['checkbox', 'radio'].includes(elementType as string) ? 0 : 400,
  );

  // Register the debounced store function as a callback so debouncing is
  // preserved between renders.
  const storeUpdateCallback = useCallback(
    (value: PropsValues) => debounceStoreUpdate(value),
    // eslint-disable-next-line react-hooks/exhaustive-deps
    [],
  );

  // Don't track the value of hidden fields except for form_build_id.
  if (
    ['hidden', 'submit'].includes(elementType as string) &&
    fieldName !== 'form_build_id'
  ) {
    attributes.readOnly = '';
  } else if (!attributes['data-drupal-uncontrolled']) {
    // If the input is not explicitly set as uncontrolled, its state should
    // be managed by React.
    attributes.value = inputValue;

    attributes.onChange = (e: React.ChangeEvent) => {
      delete attributes['data-invalid-prop-value'];

      const formId = attributes['data-form-id'] as FormId;
      if (formId) {
        dispatch(
          clearFieldError({
            formId,
            fieldName,
          }),
        );
      }

      const newValue = parseNewValue(e);
      // Update the value of the input in the local state.
      setInputValue(newValue);

      // The data-xb-no-update indicates we should return early and not update the
      // store.
      if (
        typeof e?.target?.hasAttribute === 'function' &&
        e.target.hasAttribute('data-xb-no-update')
      ) {
        return;
      }
      // Update the value of the input in the Redux store.
      if (formId) {
        dispatch(
          setFieldValue({
            formId,
            fieldName,
            value: newValue,
          }),
        );
      }

      // Check if the input is valid before continuing.
      if (e.target instanceof HTMLInputElement && !e.target.reportValidity()) {
        return;
      }

      if (
        fieldName &&
        newValue &&
        e.target instanceof HTMLInputElement &&
        e.target.form instanceof HTMLFormElement
      ) {
        if (!validateNewValue(e, newValue).valid) {
          return;
        }
      }

      storeUpdateCallback({ ...formValues, [fieldName]: newValue });
    };

    attributes.onBlur = (e: React.FocusEvent) => {
      const validationResult = validateNewValue(e, inputValue);
      if (!validationResult.valid) {
        if (formId) {
          attributes['data-invalid-prop-value'] = 'true';
          dispatch(
            setFieldError({
              type: 'error',
              message: ajv.errorsText(validationResult.errors),
              formId,
              fieldName,
            }),
          );
        }
      }
    };
  }

  // React objects to inputs with the value attribute set if there are no
  // event handlers added via on* attributes.
  const hasListener = Object.keys(attributes).some((key) =>
    /^on[A-Z]/.test(key),
  );

  // The value attribute can remain for hidden and submit inputs, but
  // otherwise dispose of `value`.
  if (!hasListener && !['hidden', 'submit'].includes(elementType as string)) {
    delete attributes.value;
  }

  return (
    <>
      <OriginalInput {...passProps} attributes={attributes} options={options} />
      {fieldError && (
        <span data-prop-message>
          {`${fieldError.type === 'error' ? '' : ''}${fieldError.message}`}
        </span>
      )}
    </>
  );
};

// Provides a higher order component to wrap a form element that is part of the
// component inputs form.
const InputBehaviorsComponentPropsForm = (
  OriginalInput: React.FC,
  props: React.ComponentProps<any>,
): React.ReactElement => {
  /**
   * @todo #3502484 useParams() should be used here to replace getting the value from currentComponent in the formStateSlice
   * Hyperscriptify re-creates the React component for the media library when Drupal ajax completes does not wrap the
   * rendering in the correct React Router context so we can't get the selected component ID from the url in inputBehaviors.tsx.
   * We already have a workaround for this for the Redux provider, could we do the same for the React Router context?
   */
  const currentComponent = useAppSelector(selectCurrentComponent);
  const selectedComponent = currentComponent || 'noop';
  const model = useAppSelector(selectModel);
  const { attributes } = props;
  const { data: components } = useGetComponentsQuery();
  const transforms = useComponentTransforms();
  const layout = useAppSelector(selectLayout);
  const node = findComponentByUuid(layout, selectedComponent);
  const selectedComponentType = node ? (node.type as string) : 'noop';
  const inputAndUiData: InputUIData = {
    selectedComponent,
    components,
    selectedComponentType,
    layout,
    node,
    model,
  };
  const component = components?.[selectedComponentType];

  const [patchComponent] = useUpdateComponentMutation({
    fixedCacheKey: selectedComponent,
  });

  const formStateToStore = (newFormState: PropsValues) => {
    // Apply (client-side) transforms for form state.
    const { propsValues: values, selectedModel } = getPropsValues(
      newFormState,
      inputAndUiData,
      transforms,
    );

    // And then send data to backend - this will:
    // a) Trigger server-side validation/transformation (massaging of widget values)
    // b) Update both the preview and the model - see the pessimistic update
    //    in onQueryStarted in preview.ts
    // @see \Drupal\Core\Field\WidgetInterface::massageFormValues()
    const resolved = { ...selectedModel.resolved, ...values };
    if (isEvaluatedComponentModel(selectedModel) && component) {
      patchComponent({
        componentInstanceUuid: selectedComponent,
        componentType: selectedComponentType,
        model: {
          source: syncPropSourcesToResolvedValues(
            selectedModel.source,
            component,
            resolved,
          ),
          resolved,
        },
      });
      return;
    }
    patchComponent({
      componentInstanceUuid: selectedComponent,
      componentType: selectedComponentType,
      model: {
        ...selectedModel,
        resolved,
      },
    });
  };

  const fieldName = attributes.name || attributes['data-xb-name'];
  const parseNewValue = (e: React.ChangeEvent) => {
    const schemas = getPropSchemas(inputAndUiData);
    const propName = toPropName(fieldName, selectedComponent);
    return parseValue(
      (e.target as HTMLInputElement | HTMLSelectElement).value,
      e.target as HTMLInputElement,
      schemas?.[propName],
    );
  };

  const validateNewValue = (e: React.ChangeEvent, newValue: any) => {
    const target = e.target as HTMLInputElement;
    if (!shouldSkipPropValidation(fieldName, target, inputAndUiData)) {
      const [valid, validate] = validateProp(
        toPropName(fieldName, selectedComponent),
        newValue,
        inputAndUiData,
      );
      return {
        valid,
        errors: validate?.errors || null,
      };
    }
    return { valid: true, errors: null };
  };

  return (
    <InputBehaviorsCommon
      OriginalInput={OriginalInput}
      props={props}
      callbacks={{
        commitFormState: formStateToStore,
        parseNewValue,
        validateNewValue,
      }}
    />
  );
};

// Provides a higher order component to wrap a form element that is part of the
// entity fields form.
const InputBehaviorsEntityForm = (
  OriginalInput: React.FC,
  props: React.ComponentProps<any>,
): React.ReactElement => {
  const dispatch = useAppDispatch();
  const pageData = useAppSelector(selectPageData);
  const latestUndoRedoActionId = useAppSelector(selectLatestUndoRedoActionId);
  const formState = useAppSelector((state) =>
    selectFormValues(state, FORM_TYPES.ENTITY_FORM),
  );

  // Determine if page data is actually available.
  // Were we pulling this data *directly* from an API, doing this would be best
  // accomplished by the isLoading property provided by RTK. This serves the
  // same purpose without adding complexity to our reducers.
  const pageDataExists = !!Object.keys(pageData).length;
  const { attributes } = props;

  const fieldName = attributes.name || attributes['data-xb-name'];
  if (!['changed'].includes(fieldName)) {
    let newValue = pageData[fieldName] || null;

    if (attributes.name === 'form_build_id' && 'form_build_id' in formState) {
      // We always take the latest form_build_id value from form state.
      // We have an event listener in the generic inputBehaviors to react to
      // the update_build_id Ajax command, but that event can fire while the
      // input is not yet mounted, which can result in a stale form_build_id
      // being used.
      newValue = formState.form_build_id;
    }

    // @todo Handle the revision form elements on nodes.
    // @todo Handle `date` and `time` inputs.

    const elementType = attributes.type || attributes['data-xb-type'];
    if (!['radio', 'hidden', 'submit'].includes(elementType as string)) {
      attributes.value = newValue;
    }
    if (elementType === 'checkbox') {
      attributes.checked = Boolean(Number(newValue));
    }
  }

  const formStateToStore = (newFormState: PropsValues) => {
    const values = Object.keys(newFormState).reduce(
      (acc: Record<string, any>, key) => {
        if (!['changed', 'formId', 'formType'].includes(key)) {
          return { ...acc, [key]: newFormState[key] };
        }
        return acc;
      },
      {},
    );
    // Flag that we need to update the preview.
    dispatch(setUpdatePreview(true));
    dispatch(setPageData(values));
  };

  const parseNewValue = (e: React.ChangeEvent) => {
    const target = e.target as HTMLInputElement;
    // If the target is an input element, return its value
    if (target.value !== undefined) {
      return target.value === SELECT_ITEM_EMPTY_STRING ? '' : target.value;
    }
    // If the target is a checkbox or radio button, return its checked
    if ('checked' in target) {
      return target.checked;
    }
    // If the target is neither an input element nor a checkbox/radio button, return null
    return null;
  };

  const validateNewValue = (e: React.ChangeEvent, newValue: any) => {
    // @todo Implement this.
    return { valid: true, errors: null };
  };

  if (pageDataExists) {
    return (
      <InputBehaviorsCommon
        key={`${attributes?.name}-${latestUndoRedoActionId}`}
        OriginalInput={OriginalInput}
        props={props}
        callbacks={{
          commitFormState: formStateToStore,
          parseNewValue,
          validateNewValue,
        }}
      />
    );
  }
  return <></>;
};

// Provides a higher order component to wrap a form element that will map to
// a more specific higher order component depending on the element's form ID.
const InputBehaviors = (OriginalInput: React.FC) => {
  const InputBehaviorsWrapper: React.FC<React.ComponentProps<any>> = (
    props,
  ) => {
    const { attributes } = props;
    const formId = attributes['data-form-id'] as FormId;
    const FORM_INPUT_BEHAVIORS: Record<FormId, InputBehaviorsForm> = {
      [FORM_TYPES.COMPONENT_INPUTS_FORM]: InputBehaviorsComponentPropsForm,
      [FORM_TYPES.ENTITY_FORM]: InputBehaviorsEntityForm,
    };

    if (formId === undefined) {
      // This is not one of the forms we manage, e.g. the media library form
      // popup.
      return <OriginalInput {...props} />;
    }
    if (!(formId in FORM_INPUT_BEHAVIORS)) {
      throw new Error(`No input behavior defined for form ID: ${formId}`);
    }
    return FORM_INPUT_BEHAVIORS[formId](OriginalInput, props);
  };

  return InputBehaviorsWrapper;
};

export default InputBehaviors;

export const syncPropSourcesToResolvedValues = (
  sources: Sources,
  component: XBComponent,
  resolvedValues: ResolvedValues,
): Sources => {
  if (!componentHasFieldData(component)) {
    return sources;
  }
  const fieldData = component.propSources;

  // We need to include a source entry for any props with a resolved value.
  // We don't store a source entry for empty values, so once the value is no
  // longer empty we need to populate the source data for it from the
  // prop source defaults for this component.
  const missingProps = Object.keys(fieldData).filter(
    (key) => !(key in sources) && Object.keys(resolvedValues).includes(key),
  );

  // Likewise, if a resolved value is now empty, we need to remove it from
  // the source data so it is not evaluated server side.
  const emptyProps = Object.keys(fieldData).filter(
    (key) => !Object.keys(resolvedValues).includes(key) && key in sources,
  );

  return missingProps.reduce(
    (carry: Sources, propName: string) => ({
      ...carry,
      // Add in the missing source.
      [propName]: fieldData[propName],
    }),
    Object.entries(sources).reduce((carry: Sources, [propName, source]) => {
      if (emptyProps.includes(propName)) {
        // Ignore this source as the value is now empty.
        return carry;
      }
      return {
        ...carry,
        [propName]: {
          ...source,
          // Set the value from resolved values. This might duplicate the value
          // in the resolved key for components where the source and resolved
          // values are the same, however this method is generally called before
          // a patchComponent request to Drupal which will remove values from
          // the source key if it duplicates the resolved value. So for a simple
          // component with e.g. a string property, we would have duplication
          // here but this would be removed from the model returned from Drupal
          // during patchComponent and hence the model stored in the redux store
          // after this request. For a component with an expression such as an
          // image component - at this point both resolved and source may be a
          // media entity ID. When patchComponent is called in that instance,
          // Drupal will retain the media entity ID in the source value, but
          // return the evaluated expression for the resolved values - e.g. this
          // might be the src, alt, height and width for the media entity.
          value: resolvedValues[propName],
        },
      };
    }, {}),
  );
};