import CheckboxTree from 'react-checkbox-tree';
import ContextMenu from 'context-menu';
import './Translation.css';
import 'react-checkbox-tree/lib/react-checkbox-tree.css';
import PropTypes from 'prop-types';
import React from 'react';
import deepcopy from 'deepcopy';
// import { graphqlSync, introspectionQuery, printSchema } from 'graphql';
import { getGraphqlSchema } from 'translation-to-graphql';
import EndpointPopup from '../Popup/EndpointPopup';
import LinkPopup from '../Popup/LinkPopup';
import DescriptionPopup from '../Popup/DescriptionPopup';
/**
* @file Translation
* Handles the translation process, checkbox tree, saving and opening translation file
* @module Translation
* @extends React.Component
*/
export default class Translation extends React.Component {
constructor(props) {
super(props);
this.endpoints = {};
this.state = {
translationFile: null,
nodes: [],
checked: [],
expanded: [],
openLinkModal: false,
openDescriptionModal: false,
editingPart: '',
pathParts: [],
};
}
/**
* Called after this component is mounted
* @method
*/
componentDidMount() {
const checkboxTree = document.querySelector('.translation-checkbox-tree');
// parent handles all contextmenu events (https://www.kirupa.com/html5/handling_events_for_many_elements.htm)
checkboxTree.addEventListener(
'contextmenu',
this.contextMenuListener,
false
);
// handle clicking away from context menu
checkboxTree.addEventListener('click', this.textAreaListener, false);
}
/**
* Handle creation of a new translation file
* @method
*/
newTranslationFile = () => {
const { swaggerFile } = this.props;
this.setState({
translationFile: {
title: swaggerFile.info.title,
version: swaggerFile.info.version,
},
});
};
/**
* Read in existing translation file and intialize the checked nodes in the checkbox tree
* based off translation file
* @method
* @param {Object} translationFile - the translation file
* @param {Array} leafNodes - array of checked leaf nodes
* @param {string} path - string value stored in leafNodes array
*/
initCheckedFromTranslation = (translationFile, leafNodes, path) => {
Object.keys(translationFile).forEach((node) => {
// ignore any names that start with '/' since they are not part of the checkbox tree
// (e.g. /subreddits/hot, /labs/2/users, etc. )
if (node.startsWith('/')) {
this.initCheckedFromTranslation(translationFile[node], leafNodes, path);
}
// check if translationFile[node] is not an Object type
if (
!(
Object.prototype.toString.call(translationFile[node]) ===
'[object Object]'
)
) {
// at a leaf so push to leafNodes
leafNodes.push(`${path}/${node}: ${translationFile[node]}`);
} else {
// recursing on level down so append to end of path
this.initCheckedFromTranslation(
translationFile[node],
leafNodes,
path === '' ? `${node}` : `${path}/${node}`
);
}
});
};
/**
* Handling opening of swagger file button
* @method
* @param {ChangeEvent<HTMLInputElement>} event - event containing name of the swagger file
*/
onOpenTranslation = (event) => {
const { onTranslationChange, swaggerFile } = this.props;
const fileReader = new FileReader();
fileReader.addEventListener('load', () => {
const translationFile = JSON.parse(fileReader.result);
// intialize the checked array for the checkbox tree from the opened translation file
const checked = [];
this.initCheckedFromTranslation(translationFile.endpoints, checked, '');
this.setState(
{
nodes: [],
checked,
expanded: [],
translationFile,
},
() => {
// iterate through all endpoints the translation file and add that to the checkbox tree
Object.keys(translationFile.endpoints).forEach((realEndpoint) => {
Object.keys(translationFile.endpoints[realEndpoint]).forEach(
(endpointView) => {
this.handleNewEndpoint(
realEndpoint,
endpointView,
swaggerFile.paths[realEndpoint]
);
}
);
});
}
);
// alert App componenet that the translation file has changed so the preview can be updated
onTranslationChange(translationFile);
});
fileReader.readAsText(event.target.files[0]);
};
/**
* Save the translation file to file system
* @method
*/
saveTranslation = async () => {
const { translationFile } = this.state;
const { swaggerFile } = this.props;
// create the graphql schema
const schema = await getGraphqlSchema(swaggerFile, translationFile);
// const resul = graphqlSync(schema, introspectionQuery).data;
// console.log(result);
// const data = {
// variables: {
// schema: schemaConfig,
// accessToken: '352480127796-UoShgNdvB_zlnJ7oYbHIyDxF9iI',
// },
// query: '{ top (subreddit: "uiuc") { data { children { data { title } } } } }',
// };
// fetch('http://localhost:4000/', {
// method: 'POST',
// body: JSON.stringify(data),
// headers: {
// 'Content-Type': 'application/json',
// },
// }).then((response) => {
// console.log(response);
// });
// save as JSON file
const blob = new Blob([JSON.stringify(translationFile, null, 4)], {
type: 'application/json',
});
// saveAs(blob);
};
/**
* Create new node in the checkbox tree after user adds a new endpoint
* @method
* @param {Object} elements - object containing data for the endpoint
* @param {Array} nodes - array containing the nodes for the checkbox tree
* @param {String} value - name of the path
*/
getNewEndpointCheckbox = (elements, nodes, value) => {
if (Object.prototype.toString.call(elements) === '[object Object]') {
Object.keys(elements).forEach((el) => {
if (
Object.prototype.toString.call(elements[el]) !== '[object Object]' &&
!(elements[el] instanceof Array)
) {
if (el !== 'operationId') {
// add leaf nodes of checkbox tree
nodes.push({
value: `${value}/${el
.replace(/:/g, '%3A')
.replace(/\//g, '%2F')}:${
typeof elements[el] === 'string'
? elements[el].replace(/\//g, '%2F')
: elements[el]
}-type:${typeof elements[el]}`,
label: `${el}: ${elements[el]}`,
});
}
} else if (el !== 'security') {
nodes.push({
// add parent node that can contain children nodes
value: `${value}/${
elements[el] instanceof Array
? `${el.replace(/\//g, '%2F')}-array`
: el.replace(/\//g, '%2F')
}`,
label: `${el}`,
children: [],
});
// add nodes to newly created parent's children array
const responseNodeLen = nodes.length - 1;
this.getNewEndpointCheckbox(
elements[el],
nodes[responseNodeLen].children,
`${value}/${
elements[el] instanceof Array
? `${el.replace(/\//g, '%2F')}-array`
: el.replace(/\//g, '%2F')
}`
);
}
});
} else if (elements instanceof Array) {
elements.forEach((arrayEl) => {
if (
Object.prototype.toString.call(arrayEl) === '[object Object]' &&
'name' in arrayEl
) {
// update name field to become the parent:
/* e.g.
{
name: foo,
type: string,
required: false,
...
}
to
foo: {
type: string,
required: false,
...
}
*/
const { name } = arrayEl;
nodes.push({
value: `${value}/${name.replace(/\//g, '%2F')}`,
label: `${name}`,
children: [],
});
// delete the name field from the property
const noName = { ...arrayEl };
delete noName.name;
// add nodes to the newly created parent's children array
const responseNodeLen = nodes.length - 1;
this.getNewEndpointCheckbox(
noName,
nodes[responseNodeLen].children,
`${value}/${name.replace(/\//g, '%2F')}`
);
} else {
this.getNewEndpointCheckbox(arrayEl, nodes, `${value}`);
}
});
} else {
nodes.push({
value: `${value}/${elements.replace(/\//g, '%2F')}`,
label: `${elements}`,
});
}
};
/**
* Event listener for context menu
* @method
* @param {Event} event - context menu listener
*/
contextMenuListener = (event) => {
if (event.target !== event.currentTarget) {
this.handleOnContextMenu(this.getNodeElement(event.target));
}
event.stopPropagation();
};
/**
* Converts the node's label to a value
* @method
* @param {Array} path - array containing every node
* @returns array containing every node's value
*/
labelToValue = (path) => {
let { nodes } = this.state;
const newPath = [];
for (let i = 0; i < path.length; i += 1) {
// search for the node in the nodes state value
const node = nodes.find((x) => x.label.startsWith(path[i]));
newPath.push(node.value.substring(node.value.lastIndexOf('/') + 1));
nodes = node.children;
}
return newPath;
};
/**
* Get the array of nodes that lead to the selected element
* @method
* @param {HTMLElement} node - HTML element that user has just edited
* @returns array of nodes
*/
getCheckboxPath = (node) => {
let el = node;
const pathParts = [];
while (el.className !== 'translation-checkbox-tree') {
el = el.parentNode;
if (el.tagName === 'LI') {
const nodeTitle = el.querySelector('.rct-title').innerHTML;
// add to beginning of array
pathParts.unshift(nodeTitle);
}
}
return this.labelToValue(pathParts);
};
/**
* Search for the checkbox node recursively
* @method
* @param {Array} path - array containing nodes that lead to targeted checked node
* @param {Array} nodes - array of all checkbox nodes
* @param {String} searchValue - search for the checkbox through the searchValue
*/
searchCheckboxNode = (path, nodes, searchValue) => {
if (path.length === 0) {
return nodes.some((node) => node.value.includes(searchValue));
}
// find the next nodes to recurse down
const nextNodes = nodes.find(
(node) =>
node.value.substring(node.value.lastIndexOf('/') + 1) === path[0]
);
// remove the first element from path array
const newPath = path.slice(1);
return this.searchCheckboxNode(newPath, nextNodes.children, searchValue);
};
/**
* Update the checkbox
* @method
* @param {Array} path - array containing nodes that lead to targeted checked node
* @param {Array} nodes - array of all checkbox nodes
* @param {String} value - search for the checkbox through the value
*/
updateCheckboxNode = (path, nodes, value) => {
if (path.length !== 0) {
const nextNodes = nodes.find(
(node) =>
node.value.substring(node.value.lastIndexOf('/') + 1) === path[0]
);
if (path.length === 1) {
if (!('children' in nextNodes)) {
if (value.substring(value.indexOf(':') + 2)) {
// update nodes's value
const key = nextNodes.value.substring(
0,
nextNodes.value.indexOf(':')
);
const type = nextNodes.value.substring(
nextNodes.value.indexOf('-type')
);
nextNodes.value = `${key}:${value.substring(
value.indexOf(':') + 2
)}${type}`;
}
// update the node's label
nextNodes.label = value;
} else {
nextNodes.children.unshift({
label: value,
value: `${nextNodes.value}/${value}-type:string`,
});
}
} else {
const newPath = path.slice(1);
this.updateCheckboxNode(newPath, nextNodes.children, value);
}
}
};
/**
* Listener for text area events
* @method
* @param {Event} event - event for text area
*/
textAreaListener = (event) => {
if (
event.target !== event.currentTarget &&
event.target.tagName !== 'TEXTAREA'
) {
const text = document.querySelector('textarea');
if (text !== null) {
const { nodes, checked, editingPart } = this.state;
const nodeCopy = deepcopy(nodes);
let checkedCopy = [...checked];
const pathParts = this.getCheckboxPath(text);
const path = pathParts.join('/');
if (editingPart === 'endpoint') {
checkedCopy = checkedCopy.map((node) => {
if (node.startsWith(path.trim())) {
return node;
}
return node;
});
} else {
const checkedIndex = checkedCopy.findIndex((node) =>
node.startsWith(path)
);
if (checkedIndex !== -1) {
const colonIndex = checkedCopy[checkedIndex].indexOf(':');
const previous = checkedCopy[checkedIndex].substring(0, colonIndex);
checkedCopy[checkedIndex] = `${previous}:${text.value}-type:string`;
this.onCheckHandler(checkedCopy);
}
}
this.updateCheckboxNode(
pathParts,
nodeCopy,
`${editingPart}${text.value}`
);
this.setState({
nodes: nodeCopy,
editingPart: '',
});
text.parentNode.removeChild(text);
}
}
};
/**
* Handle context menu interaction
* @method
* @param {HTMLElement} nodeElement - HTML element node
*/
handleOnContextMenu = (nodeElement) => {
const possibleOperations = [
'get',
'post',
'put',
'patch',
'delete',
'head',
'options',
];
const data = [[]];
if (nodeElement.innerHTML.startsWith('description:')) {
data[0].push({
label: 'Edit Description',
onClick: () => {
const { editingPart } = this.state;
if (!editingPart) {
// create new textarea
const newText = document.createElement('textarea');
newText.value = nodeElement.innerHTML.substring(
nodeElement.innerHTML.indexOf(':') + 2
);
// set the size of the new text area
newText.rows = 4;
newText.cols = 75;
const { nodes } = this.state;
const nodeCopy = deepcopy(nodes);
const checkboxPath = this.getCheckboxPath(nodeElement);
this.updateCheckboxNode(checkboxPath, nodeCopy, 'description: ');
// insert the text area into the DOM
nodeElement.parentNode.insertBefore(
newText,
nodeElement.nextSibling
);
this.setState({
nodes: nodeCopy,
editingPart: 'description: ',
});
}
},
});
} else if (nodeElement.innerHTML.startsWith('required:')) {
const requiredValue = nodeElement.innerHTML.substring(
nodeElement.innerHTML.indexOf(':') + 2
);
data[0].push({
label: `Change to ${requiredValue === 'true' ? 'not' : ''} required`,
onClick: () => {
const { nodes, checked } = this.state;
const nodeCopy = deepcopy(nodes);
const checkboxPath = this.getCheckboxPath(nodeElement);
const path = checkboxPath.join('/');
const checkedCopy = [...checked];
const checkedIndex = checkedCopy.findIndex((node) =>
node.startsWith(path)
);
if (checkedIndex !== -1) {
const key = checkedCopy[checkedIndex].substring(
0,
checkedCopy[checkedIndex].indexOf(':')
);
const type = checkedCopy[checkedIndex].substring(
checkedCopy[checkedIndex].indexOf('-type')
);
checkedCopy[checkedIndex] = `${key}:${
requiredValue === 'true' ? 'false' : 'true'
}${type}`;
this.onCheckHandler(checkedCopy);
}
this.updateCheckboxNode(
checkboxPath,
nodeCopy,
`required: ${requiredValue === 'true' ? 'false' : 'true'}`
);
this.setState({
nodes: nodeCopy,
editingPart: '',
});
},
});
} else {
const checkboxPath = this.getCheckboxPath(nodeElement);
const escaped = nodeElement.innerHTML.replace(/\//g, '%2F');
const { nodes } = this.state;
if (
escaped in this.endpoints ||
checkboxPath[checkboxPath.length - 2] === 'parameters-array' ||
checkboxPath[checkboxPath.length - 2] === 'properties'
) {
data[0].push({
label: 'Rename',
onClick: () => {
const { editingPart } = this.state;
if (!editingPart) {
const newText = document.createElement('textarea'); // create new textarea
newText.value = nodeElement.innerHTML;
newText.rows = 1;
newText.cols = 25;
const nodeCopy = deepcopy(nodes);
this.updateCheckboxNode(checkboxPath, nodeCopy, '');
this.setState({
nodes: nodeCopy,
editingPart: 'endpoint',
});
nodeElement.parentNode.insertBefore(
newText,
nodeElement.nextSibling
);
}
},
});
}
if (
(possibleOperations.includes(nodeElement.innerHTML) ||
checkboxPath[checkboxPath.length - 2] === 'parameters-array' ||
checkboxPath[checkboxPath.length - 2] === 'properties' ||
checkboxPath[checkboxPath.length - 1] === '200' ||
checkboxPath[checkboxPath.length - 1] === 'items-array') &&
!this.searchCheckboxNode(checkboxPath, nodes, 'description:')
) {
data[0].push({
label: 'Add Description',
onClick: () => {
this.setState({
openDescriptionModal: true,
pathParts: checkboxPath,
});
},
});
}
if (
possibleOperations.includes(nodeElement.innerHTML) ||
checkboxPath.includes('responses')
) {
data[0].push({
label: 'Add Link',
onClick: () => {
this.setState({ openLinkModal: true, pathParts: checkboxPath });
},
});
}
}
const handle = ContextMenu.showMenu(data);
// Optional operations
handle.onShow(() => {
/** impl */
});
handle.onClose(() => {
/** impl */
});
handle.close();
};
/**
* Get the node
* @method
* @param {HTMLElement} el - HTML element node
* @returns HTML element node
*/
getNodeElement = (el) => {
let node = el;
while (node.className !== 'rct-text') {
node = node.parentNode;
}
return node.querySelector('.rct-title');
};
/**
* Handle a new endpoint
* @method
* @param {String} endpoint - Endpoint from the swagger
* @param {String} endpointName - User created endpoint name
* @param {Object} path - nodes that lead to endpoint
*/
handleNewEndpoint = (endpoint, endpointName, path) => {
const escapedName = endpointName.replace(/\//g, '%2F');
const newNode = {
value: `${escapedName}`,
label: endpointName,
children: [],
};
this.getNewEndpointCheckbox(path, newNode.children, `${escapedName}`);
this.endpoints = { ...this.endpoints, [escapedName]: endpoint };
this.setState((prevState) => ({
nodes: [...prevState.nodes, newNode],
}));
};
/**
* Get the node's parent
* @method
* @param {Array} path - array containing nodes leading to the targeted checkbox
* @param {Object} nodes - all checkbox nodes
* @returns the parent
*/
getParent = (path, nodes) => {
if (path.length === 0) {
return nodes;
}
const nextNodes = nodes.find(
(node) =>
node.value.substring(node.value.lastIndexOf('/') + 1) === path[0]
);
const newPath = path.slice(1);
return this.getParent(newPath, nextNodes.children);
};
/**
* Handle a new description specified from the text area
* @method
* @param {String} description - Description
*/
handleNewDescription = (description) => {
const { pathParts, nodes } = this.state;
const newNodes = deepcopy(nodes);
const parent = this.getParent(pathParts, newNodes);
const path = pathParts.join('/');
parent.unshift({
value: `${path}/description:${description.replace(
/\//g,
'%2F'
)}-type:string`,
label: `description: ${description}`,
});
this.setState({ nodes: newNodes });
};
/**
* Handle adding a new link
* @method
* @param {String} newLinkName - Name of new link
* @param {String} targetEndpoint - Name of the target endpoint
* @param {String} parameterName - Name of parameter
* @param {String} value - Name of the value
*/
handleNewLink = (newLinkName, targetEndpoint, parameterName, value) => {
const { nodes, pathParts } = this.state;
const [endpoint, operation] = [...pathParts];
const newNodes = deepcopy(nodes);
const endpointIndex = newNodes.findIndex((node) => node.label === endpoint);
const operationIndex = newNodes[endpointIndex].children.findIndex(
(node) => node.label === operation
);
const responsesIndex = newNodes[endpointIndex].children[
operationIndex
].children.findIndex((node) => node.label === 'responses');
newNodes[endpointIndex].children[operationIndex].children[
responsesIndex
].children.push({
value: `${endpoint}/${operation}/responses/links`,
label: 'links',
children: [
{
value: `${endpoint}/${operation}/responses/links/${newLinkName.replace(
/\//g,
'%2F'
)}`,
label: newLinkName,
children: [
{
value: `${endpoint}/${operation}/responses/links/${newLinkName.replace(
/\//g,
'%2F'
)}/target: ${targetEndpoint.replace(/\//g, '%2F')}-type:string`,
label: `target: ${targetEndpoint}`,
},
{
value: `${endpoint}/${operation}/responses/links/${newLinkName.replace(
/\//g,
'%2F'
)}/parameters`,
label: 'parameters',
children: [
{
value: `${endpoint}/${operation}/responses/links/${newLinkName.replace(
/\//g,
'%2F'
)}/parameters/${parameterName.replace(
/\//g,
'%2F'
)}: ${value.replace(/\//g, '%2F')}-type:string`,
label: `${parameterName}: ${value}`,
},
],
},
],
},
],
});
this.setState({ nodes: newNodes });
};
/**
* Convert the checkbox tree checked nodes to the translation file
* @method
* @param {Array} parts - Array containing strings that leads to this node
* @param {Object} translationFile - object representing translation file
*/
checkedToTranslation = (parts, translationFile) => {
if (parts.length !== 0) {
const desanitized = parts[0].replace(/%2F/gi, '/').trim();
if (parts.length === 1) {
if (!desanitized.includes('-type:')) {
translationFile.push(desanitized);
} else {
const type = desanitized.substring(desanitized.lastIndexOf(':') + 1);
const field = desanitized
.substring(0, desanitized.indexOf(':'))
.replace(/%3A/gi, ':');
let value = desanitized.substring(
desanitized.indexOf(':') + 1,
desanitized.lastIndexOf('-')
);
if (type === 'number') {
value = parseInt(value, 10);
}
translationFile[field] =
value === 'true' || (value === 'false' ? false : value);
}
} else if (desanitized.includes('array')) {
const cleaned = desanitized.substring(0, desanitized.indexOf('-array'));
translationFile[cleaned] = translationFile[cleaned] || [];
if (parts.length === 2) {
this.checkedToTranslation(parts.splice(1), translationFile[cleaned]);
} else {
let index = translationFile[cleaned].findIndex(
(el) => el.name === parts[1].replace(/%2F/gi, '/').trim()
);
if (index === -1) {
translationFile[cleaned].push({
name: parts[1].replace(/%2F/gi, '/').trim(),
});
index = translationFile[cleaned].length - 1;
}
this.checkedToTranslation(
parts.splice(2),
translationFile[cleaned][index]
);
}
} else {
translationFile[desanitized] = translationFile[desanitized] || {};
this.checkedToTranslation(
parts.splice(1),
translationFile[desanitized]
);
}
}
};
/**
* Handle new checked nodes
* @method
* @param {Array} newChecked - array of the newly checked nodes
*/
onCheckHandler = (newChecked) => {
const { onTranslationChange, swaggerFile } = this.props;
const translationFile = {
title: swaggerFile.info.title,
version: swaggerFile.info.version,
endpoints: {},
};
newChecked.forEach((node) => {
const parts = node.split('/');
translationFile.endpoints[this.endpoints[parts[0]]] =
translationFile.endpoints[this.endpoints[parts[0]]] || {};
this.checkedToTranslation(
parts,
translationFile.endpoints[this.endpoints[parts[0]]]
);
});
this.setState({ checked: newChecked, translationFile }, () => {
onTranslationChange(translationFile);
});
};
onExpandHandler = (newExpanded) => {
this.setState({ expanded: newExpanded });
};
render() {
const { swaggerFile } = this.props;
const {
translationFile,
nodes,
checked,
expanded,
openLinkModal,
openDescriptionModal,
pathParts,
} = this.state;
return (
<div className="translation">
<h2>Translation</h2>
<div className="translation-btn">
<div className="new">
<input
id="new-translation"
type="button"
onClick={this.newTranslationFile}
disabled={!swaggerFile}
className="custom-file-action"
/>
<label
htmlFor="new-translation"
className={`custom-file-action ${!swaggerFile ? 'disabled' : ''}`}
>
Create New Translation
</label>
</div>
<div className="existing">
<input
id="existing-translation"
type="file"
name="file"
onChange={this.onOpenTranslation}
accept="application/JSON"
disabled={!swaggerFile}
/>
<label
htmlFor="existing-translation"
className={`custom-file-action ${!swaggerFile ? 'disabled' : ''}`}
>
Open Existing Translation
</label>
</div>
</div>
<div className="save">
<input
id="save-translation"
type="submit"
onClick={this.saveTranslation}
disabled={!swaggerFile}
className="custom-file-action"
accept="application/JSON"
/>
<label
htmlFor="save-translation"
className={`custom-file-action ${!swaggerFile ? 'disabled' : ''}`}
>
Save Translation
</label>
</div>
<div className="add">
{translationFile && (
<EndpointPopup
endpoints={Object.keys(swaggerFile.paths)}
onNewEndpoint={(endpoint, endpointName) =>
this.handleNewEndpoint(
endpoint,
endpointName,
swaggerFile.paths[endpoint]
)
}
/>
)}
</div>
<div className="translation-checkbox-tree">
<CheckboxTree
nodes={nodes}
checkModel="leaf"
checked={checked}
expanded={expanded}
onCheck={(newChecked) => this.onCheckHandler(newChecked)}
onExpand={(newExpanded, targetNode) =>
this.onExpandHandler(newExpanded, targetNode)
}
/>
<LinkPopup
openModal={openLinkModal}
pathParts={pathParts}
onNewLink={(newLinkName, targetEndpoint, parameterName, value) =>
this.handleNewLink(
newLinkName,
targetEndpoint,
parameterName,
value
)
}
handleClose={() => this.setState({ openLinkModal: false })}
/>
<DescriptionPopup
openModal={openDescriptionModal}
pathParts={pathParts}
onNewDescription={(description) =>
this.handleNewDescription(description)
}
handleClose={() => this.setState({ openDescriptionModal: false })}
/>
</div>
</div>
);
}
}
Translation.defaultProps = {
swaggerFile: PropTypes.objectOf(PropTypes.object),
};
Translation.propTypes = {
onTranslationChange: PropTypes.func.isRequired,
swaggerFile: PropTypes.oneOfType([PropTypes.object, PropTypes.string]),
};