diff --git a/src/utils/__tests__/__snapshots__/getPropType-test.js.snap b/src/utils/__tests__/__snapshots__/getPropType-test.js.snap index a54a5256d24..0e2af1e794a 100644 --- a/src/utils/__tests__/__snapshots__/getPropType-test.js.snap +++ b/src/utils/__tests__/__snapshots__/getPropType-test.js.snap @@ -134,7 +134,15 @@ Object { } `; -exports[`getPropType resolve identifier to their values does not resolve computed values 1`] = ` +exports[`getPropType resolve identifier to their values does not resolve external values 1`] = ` +Object { + "computed": true, + "name": "enum", + "value": "TYPES", +} +`; + +exports[`getPropType resolve identifier to their values does resolve object keys values 1`] = ` Object { "name": "enum", "value": Array [ @@ -150,11 +158,19 @@ Object { } `; -exports[`getPropType resolve identifier to their values does not resolve external values 1`] = ` +exports[`getPropType resolve identifier to their values does resolve object values 1`] = ` Object { - "computed": true, "name": "enum", - "value": "TYPES", + "value": Array [ + Object { + "computed": false, + "value": "\\"bar\\"", + }, + Object { + "computed": false, + "value": "\\"foo\\"", + }, + ], } `; diff --git a/src/utils/__tests__/getPropType-test.js b/src/utils/__tests__/getPropType-test.js index 43e544dcc64..5571c009279 100644 --- a/src/utils/__tests__/getPropType-test.js +++ b/src/utils/__tests__/getPropType-test.js @@ -222,7 +222,7 @@ describe('getPropType', () => { expect(getPropType(propTypeExpression)).toMatchSnapshot(); }); - it('does not resolve computed values', () => { + it('does resolve object keys values', () => { const propTypeExpression = statement(` PropTypes.oneOf(Object.keys(TYPES)); var TYPES = { FOO: "foo", BAR: "bar" }; @@ -231,6 +231,15 @@ describe('getPropType', () => { expect(getPropType(propTypeExpression)).toMatchSnapshot(); }); + it('does resolve object values', () => { + const propTypeExpression = statement(` + PropTypes.oneOf(Object.values(TYPES)); + var TYPES = { FOO: "foo", BAR: "bar" }; + `).get('expression'); + + expect(getPropType(propTypeExpression)).toMatchSnapshot(); + }); + it('does not resolve external values', () => { const propTypeExpression = statement(` PropTypes.oneOf(TYPES); diff --git a/src/utils/__tests__/resolveObjectValuesToArray-test.js b/src/utils/__tests__/resolveObjectValuesToArray-test.js new file mode 100644 index 00000000000..a0d1112fb45 --- /dev/null +++ b/src/utils/__tests__/resolveObjectValuesToArray-test.js @@ -0,0 +1,156 @@ +/* + * Copyright (c) 2015, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + * + */ + +/* eslint-env jest */ + +import recast from 'recast'; + +const builders = recast.types.builders; +import resolveObjectValuesToArray from '../resolveObjectValuesToArray'; +import * as utils from '../../../tests/utils'; + +describe('resolveObjectValuesToArray', () => { + function parse(src) { + const root = utils.parse(src); + return root.get('body', root.node.body.length - 1, 'expression'); + } + + it('resolves Object.values with strings', () => { + const path = parse( + ['var foo = { 1: "bar", 2: "foo" };', 'Object.values(foo);'].join('\n'), + ); + + expect(resolveObjectValuesToArray(path)).toEqualASTNode( + builders.arrayExpression([ + builders.literal('bar'), + builders.literal('foo'), + ]), + ); + }); + + it('resolves Object.values with numbers', () => { + const path = parse( + ['var foo = { 1: 0, 2: 5 };', 'Object.values(foo);'].join('\n'), + ); + + expect(resolveObjectValuesToArray(path)).toEqualASTNode( + builders.arrayExpression([builders.literal(0), builders.literal(5)]), + ); + }); + + it('resolves Object.values with undefined or null', () => { + const path = parse( + ['var foo = { 1: null, 2: undefined };', 'Object.values(foo);'].join( + '\n', + ), + ); + + expect(resolveObjectValuesToArray(path)).toEqualASTNode( + builders.arrayExpression([ + builders.literal(null), + builders.literal(null), + ]), + ); + }); + + it('resolves Object.values with literals as computed key', () => { + const path = parse( + ['var foo = { ["bar"]: 1, [5]: 2};', 'Object.values(foo);'].join('\n'), + ); + + expect(resolveObjectValuesToArray(path)).toEqualASTNode( + builders.arrayExpression([builders.literal(2), builders.literal(1)]), + ); + }); + + it('resolves Object.values when using resolvable spread', () => { + const path = parse( + [ + 'var bar = { doo: 4 }', + 'var foo = { boo: 1, foo: 2, ...bar };', + 'Object.values(foo);', + ].join('\n'), + ); + + expect(resolveObjectValuesToArray(path)).toEqualASTNode( + builders.arrayExpression([ + builders.literal(1), + builders.literal(4), + builders.literal(2), + ]), + ); + }); + + it('resolves Object.values when using getters', () => { + const path = parse( + [ + 'var foo = { boo: 1, foo: 2, get bar() {} };', + 'Object.values(foo);', + ].join('\n'), + ); + + expect(resolveObjectValuesToArray(path)).toEqualASTNode( + builders.arrayExpression([builders.literal(1), builders.literal(2)]), + ); + }); + + it('resolves Object.values when using setters', () => { + const path = parse( + [ + 'var foo = { boo: 1, foo: 2, set bar(e) {} };', + 'Object.values(foo);', + ].join('\n'), + ); + + expect(resolveObjectValuesToArray(path)).toEqualASTNode( + builders.arrayExpression([builders.literal(1), builders.literal(2)]), + ); + }); + + it('resolves Object.values but ignores duplicates', () => { + const path = parse( + [ + 'var bar = { doo: 4, doo: 5 }', + 'var foo = { boo: 1, foo: 2, doo: 1, ...bar };', + 'Object.values(foo);', + ].join('\n'), + ); + + expect(resolveObjectValuesToArray(path)).toEqualASTNode( + builders.arrayExpression([ + builders.literal(1), + builders.literal(5), + builders.literal(2), + ]), + ); + }); + + it('resolves Object.values but ignores duplicates with getter and setter', () => { + const path = parse( + ['var foo = { get x() {}, set x(a) {} };', 'Object.values(foo);'].join( + '\n', + ), + ); + + expect(resolveObjectValuesToArray(path)).toEqualASTNode( + builders.arrayExpression([]), + ); + }); + + it('does not resolve Object.values when using unresolvable spread', () => { + const path = parse( + ['var foo = { bar: 1, foo: 2, ...bar };', 'Object.values(foo);'].join( + '\n', + ), + ); + + expect(resolveObjectValuesToArray(path)).toBeNull(); + }); +}); diff --git a/src/utils/getPropType.js b/src/utils/getPropType.js index caf8294577d..9a658755a70 100644 --- a/src/utils/getPropType.js +++ b/src/utils/getPropType.js @@ -20,6 +20,7 @@ import printValue from './printValue'; import recast from 'recast'; import resolveToValue from './resolveToValue'; import resolveObjectKeysToArray from './resolveObjectKeysToArray'; +import resolveObjectValuesToArray from './resolveObjectValuesToArray'; import type { PropTypeDescriptor, PropDescriptor } from '../types'; const { @@ -60,7 +61,8 @@ function getPropTypeOneOf(argumentPath) { const type: PropTypeDescriptor = { name: 'enum' }; let value = resolveToValue(argumentPath); if (!types.ArrayExpression.check(value.node)) { - value = resolveObjectKeysToArray(value); + value = + resolveObjectKeysToArray(value) || resolveObjectValuesToArray(value); if (value) { type.value = getEnumValues(value); } else { diff --git a/src/utils/resolveObjectValuesToArray.js b/src/utils/resolveObjectValuesToArray.js new file mode 100644 index 00000000000..c1aab89d504 --- /dev/null +++ b/src/utils/resolveObjectValuesToArray.js @@ -0,0 +1,149 @@ +/* + * Copyright (c) 2017, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + * + * @flow + * + */ + +import recast from 'recast'; +import resolveToValue from './resolveToValue'; + +type ObjectPropMap = { + properties: Array, + values: Object, +}; + +const { + types: { ASTNode, NodePath, builders, namedTypes: types }, +} = recast; + +function isObjectValuesCall(node: ASTNode): boolean { + return ( + types.CallExpression.check(node) && + node.arguments.length === 1 && + types.MemberExpression.check(node.callee) && + types.Identifier.check(node.callee.object) && + node.callee.object.name === 'Object' && + types.Identifier.check(node.callee.property) && + node.callee.property.name === 'values' + ); +} + +function isWhitelistedObjectProperty(prop) { + return ( + (types.Property.check(prop) && + ((types.Identifier.check(prop.key) && !prop.computed) || + types.Literal.check(prop.key))) || + types.SpreadElement.check(prop) + ); +} + +function isWhiteListedObjectTypeProperty(prop) { + return ( + types.ObjectTypeProperty.check(prop) || + types.ObjectTypeSpreadProperty.check(prop) + ); +} + +// Resolves an ObjectExpression or an ObjectTypeAnnotation +export function resolveObjectToPropMap( + object: NodePath, + raw: boolean = false, +): ?ObjectPropMap { + if ( + (types.ObjectExpression.check(object.value) && + object.value.properties.every(isWhitelistedObjectProperty)) || + (types.ObjectTypeAnnotation.check(object.value) && + object.value.properties.every(isWhiteListedObjectTypeProperty)) + ) { + const properties = []; + let values = {}; + let error = false; + object.get('properties').each(propPath => { + if (error) return; + const prop = propPath.value; + + if (prop.kind === 'get' || prop.kind === 'set') return; + + if (types.Property.check(prop) || types.ObjectTypeProperty.check(prop)) { + // Key is either Identifier or Literal + const name = prop.key.name || (raw ? prop.key.raw : prop.key.value); + const propValue = propPath.get(name).parentPath.value; + const value = + propValue.value.value || + (raw ? propValue.value.raw : propValue.value.value); + + if (properties.indexOf(name) === -1) { + properties.push(name); + } + values[name] = value; + } else if ( + types.SpreadElement.check(prop) || + types.ObjectTypeSpreadProperty.check(prop) + ) { + let spreadObject = resolveToValue(propPath.get('argument')); + if (types.GenericTypeAnnotation.check(spreadObject.value)) { + const typeAlias = resolveToValue(spreadObject.get('id')); + if (types.ObjectTypeAnnotation.check(typeAlias.get('right').value)) { + spreadObject = resolveToValue(typeAlias.get('right')); + } + } + + const spreadValues = resolveObjectToPropMap(spreadObject); + if (!spreadValues) { + error = true; + return; + } + spreadValues.properties.forEach(spreadProp => { + if (properties.indexOf(spreadProp) === -1) { + properties.push(spreadProp); + } + }); + values = { ...values, ...spreadValues.values }; + } + }); + + if (!error) { + return { properties: properties.sort(), values }; + } + } + + return null; +} + +/** + * Returns an ArrayExpression which contains all the keys resolved from an object + * + * Ignores setters in objects + * + * Returns null in case of + * unresolvable spreads + * computed identifier values + */ +export default function resolveObjectValuesToArray(path: NodePath): ?NodePath { + const node = path.node; + + if (isObjectValuesCall(node)) { + const objectExpression = resolveToValue(path.get('arguments').get(0)); + const propMap = resolveObjectToPropMap(objectExpression); + + if (propMap) { + const nodes = propMap.properties.map(prop => { + const value = propMap.values[prop]; + + return typeof value === 'undefined' + ? builders.literal(null) + : builders.literal(value); + }); + + return new NodePath(builders.arrayExpression(nodes)); + } + } + + return null; +}