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

feat(injector): Add reconcilePropTypes #10

Merged
merged 9 commits into from
Apr 6, 2020
29 changes: 26 additions & 3 deletions src/generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,23 @@ export interface GenerateOptions {
*/
includeJSDoc?: boolean;

/**
* Previous source code of the validator for each prop type
*/
previousPropTypesSource?: Map<string, string>;

/**
* Given the `prop`, the `previous` source of the validator and the `generated` source:
* What source should be injected? `previous` is `undefined` if the validator
* didn't exist before
* @default Uses `generated` source
*/
reconcilePropTypes?(
proptype: t.PropTypeNode,
previous: string | undefined,
generated: string
): string;

/**
* Control which PropTypes are included in the final result
* @param proptype The current PropType about to be converted to text
Expand All @@ -46,6 +63,8 @@ export function generate(node: t.Node | t.PropTypeNode[], options: GenerateOptio
sortProptypes = true,
importedName = 'PropTypes',
includeJSDoc = true,
previousPropTypesSource = new Map<string, string>(),
reconcilePropTypes = (_prop: t.PropTypeNode, _previous: string, generated: string) => generated,
shouldInclude,
} = options;

Expand Down Expand Up @@ -118,9 +137,13 @@ export function generate(node: t.Node | t.PropTypeNode[], options: GenerateOptio
isOptional = true;
}

return `${jsDoc(node)}"${node.name}": ${generate(propType, options)}${
isOptional ? '' : '.isRequired'
},`;
const validatorSource = reconcilePropTypes(
node,
previousPropTypesSource.get(node.name),
`${generate(propType, options)}${isOptional ? '' : '.isRequired'}`
);

return `${jsDoc(node)}"${node.name}": ${validatorSource},`;
}

if (t.isInterfaceNode(node)) {
Expand Down
30 changes: 26 additions & 4 deletions src/injector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ export type InjectOptions = {
* Options passed to babel.transformSync
*/
babelOptions?: babel.TransformOptions;
} & Pick<GenerateOptions, 'sortProptypes' | 'includeJSDoc' | 'comment'>;
} & Pick<GenerateOptions, 'sortProptypes' | 'includeJSDoc' | 'comment' | 'reconcilePropTypes'>;

/**
* Injects the PropTypes from `parse` into the provided JavaScript code
Expand Down Expand Up @@ -84,8 +84,16 @@ function plugin(
options: InjectOptions = {},
mapOfPropTypes: Map<string, string>
): babel.PluginObj {
const { includeUnusedProps = false, removeExistingPropTypes = false, ...otherOptions } = options;

const {
includeUnusedProps = false,
reconcilePropTypes = (
_prop: t.PropTypeNode,
_previous: string | undefined,
generated: string
) => generated,
removeExistingPropTypes = false,
...otherOptions
} = options;
const shouldInclude: Exclude<InjectOptions['shouldInclude'], undefined> = (data) => {
if (options.shouldInclude) {
const result = options.shouldInclude(data);
Expand All @@ -101,11 +109,12 @@ function plugin(
let needImport = false;
let alreadyImported = false;
let originalPropTypesPath: null | babel.NodePath = null;
let previousPropTypesSource = new Map<string, string>();

return {
visitor: {
Program: {
enter(path) {
enter(path, state: any) {
if (
!path.node.body.some((n) => {
if (
Expand All @@ -131,6 +140,17 @@ function plugin(
babelTypes.isIdentifier(node.expression.left.property, { name: 'propTypes' })
) {
originalPropTypesPath = nodePath;

if (babelTypes.isObjectExpression(node.expression.right)) {
const { code } = state.file;

node.expression.right.properties.forEach((property) => {
if (babelTypes.isObjectProperty(property)) {
const validatorSource = code.slice(property.value.start, property.value.end);
previousPropTypesSource.set(property.key.name, validatorSource);
}
});
}
}
});
},
Expand Down Expand Up @@ -266,6 +286,8 @@ function plugin(
const source = generate(props, {
...otherOptions,
importedName: importName,
previousPropTypesSource,
reconcilePropTypes,
shouldInclude: (prop) => shouldInclude({ component: props, prop, usedProps }),
});

Expand Down
2 changes: 1 addition & 1 deletion test/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ for (const testCase of testCases) {
let inputSource = null;
if (testCase.endsWith('.d.ts')) {
try {
inputSource = fs.readFileSync(inputJS, { encoding: 'utf8' });
inputSource = fs.readFileSync(inputJS, 'utf8');
} catch (error) {}
} else {
inputSource = ttp.ts.transpileModule(fs.readFileSync(testCase, 'utf8'), {
Expand Down
7 changes: 7 additions & 0 deletions test/reconcile-prop-types/input.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import * as React from 'react';

interface Props {
children: React.ReactNode;
}

export default function Component(props: Props): JSX.Element;
29 changes: 29 additions & 0 deletions test/reconcile-prop-types/input.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import * as React from 'react';
import * as PropTypes from 'prop-types';
import { chainPropTypes } from 'some-utils-module';

function Component(props) {
const { children } = props;
return (
<button>
<span>{children}</span>
</button>
);
}

Component.propTypes = {
children: chainPropTypes(PropTypes.node.isRequired, (props) => {
const summary = React.Children.toArray(props.children)[0];
if (isFragment(summary)) {
return new Error('Not accepting Fragments');
}

if (!React.isValidElement(summary)) {
return new Error('First child must be an element');
}

return null;
}),
};

export default Component;
17 changes: 17 additions & 0 deletions test/reconcile-prop-types/options.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { TestOptions } from '../types';

const options: TestOptions = {
injector: {
removeExistingPropTypes: true,
reconcilePropTypes: (prop, previous: any, generated) => {
const isCustomValidator = previous !== undefined && !previous.startsWith('PropTypes');

if (isCustomValidator) {
return previous;
}
return generated;
},
},
};

export default options;
29 changes: 29 additions & 0 deletions test/reconcile-prop-types/output.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import * as React from 'react';
import * as PropTypes from 'prop-types';
import { chainPropTypes } from 'some-utils-module';

function Component(props) {
const { children } = props;
return (
<button>
<span>{children}</span>
</button>
);
}

Component.propTypes = {
children: chainPropTypes(PropTypes.node.isRequired, (props) => {
const summary = React.Children.toArray(props.children)[0];
if (isFragment(summary)) {
return new Error('Not accepting Fragments');
}

if (!React.isValidElement(summary)) {
return new Error('First child must be an element');
}

return null;
}),
};

export default Component;
19 changes: 19 additions & 0 deletions test/reconcile-prop-types/output.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
{
"type": "ProgramNode",
"body": [
{
"type": "ComponentNode",
"name": "Component",
"types": [
{
"type": "PropTypeNode",
"name": "children",
"propType": {
"type": "UnionNode",
"types": [{ "type": "ElementNode", "elementType": "node" }, { "type": "UndefinedNode" }]
}
}
]
}
]
}