src/platform/testing/unit/schemaform-utils.jsx
/**
* Utilities for testing forms built with our schema based form library
*/
import Form from '@department-of-veterans-affairs/react-jsonschema-form';
import ReactTestUtils from 'react-dom/test-utils';
import sinon from 'sinon';
import React from 'react';
import { findDOMNode } from 'react-dom';
import SchemaForm from 'platform/forms-system/src/js/components/SchemaForm';
import {
replaceRefSchemas,
updateSchemasAndData,
} from 'platform/forms-system/src/js/state/helpers';
import { fireEvent } from '@testing-library/dom';
import { fillDate as oldFillDate } from './helpers';
import set from '../../utilities/data/set';
function getDefaultData(schema) {
if (schema.type === 'array') {
return [];
}
if (schema.type === 'object') {
return {};
}
return undefined;
}
/**
* A React component that takes in a schema, uiSchema, and formData
* and renders a form. We use this to test that definitions from form configs
* are correct without having to setup a whole form and handle state
* management.
*
* @extends {React.Component}
* @property {object} schema JSON Schema object for a form snippet
* @property {object} uiSchema Object with UI information for a form snippet
* @property {object} data Object with form data
* @property {string} arrayPath This is the array path in the schema that you want
* to render as a pagePerItem page
* @property {number} pagePerItemIndex When simulating a page per item form page,
* this is the index for the item to render.
* @property {boolean} reviewMode Renders the form in review mode if true
* @property {function} onSubmit Will be called if a form is submitted
* @property {function} onFileUpload Will be called if a file upload is triggered
* @property {function} updateFormData Will be called if form is updated
*/
export class DefinitionTester extends React.Component {
debouncedAutoSave = sinon.spy();
constructor(props) {
super(props);
const { data, uiSchema } = props;
const definitions = {
...(props.definitions || {}),
...props.schema.definitions,
};
const schema = replaceRefSchemas(props.schema, definitions);
const {
data: newData,
schema: newSchema,
uiSchema: newUiSchema,
} = updateSchemasAndData(schema, uiSchema, data || getDefaultData(schema));
this.state = {
formData: newData,
schema: newSchema,
uiSchema: newUiSchema,
};
}
handleChange = data => {
const { schema, uiSchema, formData } = this.state;
const { pagePerItemIndex, arrayPath, updateFormData } = this.props;
let fullData = data;
if (arrayPath) {
fullData = set([arrayPath, pagePerItemIndex], data, formData);
}
const newSchemaAndData = updateSchemasAndData(schema, uiSchema, fullData);
let newData = newSchemaAndData.data;
const newSchema = newSchemaAndData.schema;
const newUiSchema = newSchemaAndData.uiSchema;
if (typeof updateFormData === 'function') {
if (arrayPath && typeof pagePerItemIndex === 'undefined') {
// Adding this console message to help with troubleshooting
// eslint-disable-next-line no-console
console.error(
'pagePerItemIndex prop is required when arrayPath is specified',
);
}
newData = updateFormData(
arrayPath ? formData[arrayPath][pagePerItemIndex] : formData,
newData,
pagePerItemIndex,
);
}
this.setState({
formData: newData,
schema: newSchema,
uiSchema: newUiSchema,
});
};
render() {
let { schema, uiSchema, formData } = this.state;
const { pagePerItemIndex, arrayPath } = this.props;
if (arrayPath) {
schema = schema.properties[arrayPath].items[pagePerItemIndex];
uiSchema = uiSchema[arrayPath].items;
formData = formData ? formData[arrayPath][pagePerItemIndex] : formData;
}
return (
<SchemaForm
onBlur={this.debouncedAutoSave}
reviewMode={this.props.reviewMode}
name="test"
title={this.props.title || 'test'}
schema={schema}
uiSchema={uiSchema}
data={formData}
pagePerItemIndex={this.props.pagePerItemIndex}
onChange={this.handleChange}
uploadFile={this.props.uploadFile}
onSubmit={this.props.onSubmit}
appStateData={this.props.appStateData}
/>
);
}
}
/**
* Finds a form rendered into the DOM with ReactTestUtils
* and triggers a submit event on it.
*
* @param {Element} form The element containing the form
*/
export function submitForm(form) {
ReactTestUtils.findRenderedComponentWithType(form, Form).onSubmit({
preventDefault: f => f,
});
}
function getIdentifier(node) {
const tagName = node.tagName.toLowerCase();
const id = node.id ? `#${node.id}` : '';
const name = node.name ? `[name='${node.name}']` : '';
let classList = '';
const classes = node.getAttribute('class');
if (classes) {
// Make a dot-separated list of class names
classList = classes.split(' ').reduce((c, carry) => `${c}.${carry}`, '');
return `${tagName}${classList}`;
}
return `${tagName}${id}${name}${classList}`;
}
const bar = '\u2551';
const elbow = '\u2559';
const tee = '\u255F';
function printTree(node, level = 0, isLastChild = true, padding = '') {
const nextLevel = level + 1; // For tail call optimization...theoretically...
const lastPipe = isLastChild ? `${elbow} ` : `${tee} `;
console.log(`${padding}${lastPipe}${getIdentifier(node)}`); // eslint-disable-line no-console
// Recurse for each child
const newPadding = padding + (isLastChild ? ' ' : `${bar} `);
const children = Array.from(node.children);
children.forEach((child, index) => {
const isLast = index === children.length - 1;
return printTree(child, nextLevel, isLast, newPadding);
});
}
/**
* Gets the DOM node associated with the form tree passed in.
*
* @param {React.Element} form The root level of a rendered form
* @returns {object} An DOM node for the form, with added helper methods
*/
export function getFormDOM(form) {
const formDOM = form?.container || findDOMNode(form);
if (!formDOM) {
throw new Error(
'Could not find DOM node. Please make sure to pass a component returned from ReactTestUtils.renderIntoDocument(). If you are testing a stateless (function) component, be sure to wrap it in a <div>.',
);
}
/**
* Returns the element or throws a nicer error.
*
* @param {string} selector The css selector
* @return {element} The element returned from querySelctor()
*/
formDOM.getElement = function getElement(selector) {
const element = this.querySelector(selector);
if (!element) {
throw new Error(`Could not find element at ${selector}`);
}
return element;
};
formDOM.fillData = function fillDataFn(id, value) {
ReactTestUtils.Simulate.change(this.getElement(id), {
target: {
value,
},
});
ReactTestUtils.Simulate.input(this.getElement(id), {
target: {
value,
},
});
};
formDOM.files = function fillFiles(id, files) {
ReactTestUtils.Simulate.change(this.getElement(id), {
target: {
files,
},
});
};
formDOM.submitForm = () => {
if (form?.container) {
fireEvent.submit(formDOM.querySelector('form'), {
preventDefault: f => f,
});
} else {
submitForm(form);
}
};
formDOM.setCheckbox = function toggleCheckbox(selector, checked) {
ReactTestUtils.Simulate.change(this.getElement(selector), {
target: {
checked,
},
});
};
// Accepts 'Y', 'N', true, false
formDOM.setYesNo = function setYesNo(selector, value) {
const isYes =
typeof value === 'string' ? value.toLowerCase() === 'y' : !!value;
ReactTestUtils.Simulate.change(this.getElement(selector), {
target: {
value: isYes ? 'Y' : 'N',
},
});
};
formDOM.selectRadio = function selectRadioFn(fieldName, value) {
ReactTestUtils.Simulate.change(
this.getElement(`input[name*="${fieldName}"][value="${value}"]`),
{
target: { value },
},
);
};
formDOM.click = function click(selector) {
ReactTestUtils.Simulate.click(this.getElement(selector));
};
formDOM.fillDate = function populateDate(partialId, dateString) {
oldFillDate(this, partialId, dateString);
};
/**
* Prints the formDOM as a tree in the console for debugging purposes
* @return {void}
*/
formDOM.printTree = function print() {
printTree(this);
};
return formDOM;
}
/**
* Enzyme helper that fires a change event with a value for
* an element at the given selector
*
* @param {Enzyme} form The enzyme object that contains a form
* @param {string} selector The selector to find the input to fill
* @param {string} value The data to fill in the input
*/
export function fillData(form, selector, value) {
form.find(selector).simulate('change', {
target: {
value,
},
});
}
/**
* Enzyme helper that fires a change event with a value for
* a checkbox at the given name
*
* @param {Enzyme} form The enzyme object that contains a form
* @param {string} fieldName The input name of the checkbox
* @param {string} value The data to fill in the input
*/
export function selectCheckbox(form, fieldName, value) {
form.find(`input[name*="${fieldName}"]`).simulate('change', {
target: { checked: value },
});
}
/**
* Enzyme helper that fires a change event with a value for
* a radio button at the given name
*
* @param {Enzyme} form The enzyme object that contains a form
* @param {string} fieldName The input name of the radio button
* @param {string} value The data to fill in the input
*/
export function selectRadio(form, fieldName, value) {
form
.find(`input[name*="${fieldName}"][value="${value}"]`)
.simulate('change', {
target: { value },
});
}
/**
* Enzyme helper that fills in a date field with data from the
* given date string.
*
* @param {Enzyme} form The enzyme object that contains a form
* @param {string} partialName The name for the date field, without the month/day/year prefix.
* @param {string} dateString The date to fill in the input
*/
export function fillDate(form, partialName, dateString) {
const date = dateString.split('-');
const month = form.find(`select[name="${partialName}Month"]`);
const day = form.find(`select[name="${partialName}Day"]`);
const year = form.find(`input[name="${partialName}Year"]`);
month.simulate('change', {
target: { value: date[1] },
});
day.simulate('change', {
target: { value: date[2] },
});
year.simulate('change', {
target: { value: date[0] },
});
}