diff --git a/plugins/gatsby-source-newrelic-sdk/src/__tests__/propTypeInfo.js b/plugins/gatsby-source-newrelic-sdk/src/__tests__/propTypeInfo.js new file mode 100644 index 000000000..38260fd40 --- /dev/null +++ b/plugins/gatsby-source-newrelic-sdk/src/__tests__/propTypeInfo.js @@ -0,0 +1,661 @@ +const { + getNormalizedTypeName, + getDefaultValue, + getTypeMeta, +} = require('../propTypeInfo'); + +const createDocs = ( + type, + {description, deprecation, returnValue, params} = {} +) => { + const docs = { + text: description, + }; + + if (deprecation) { + docs.tags = { + deprecated: [deprecation], + }; + } + + switch (type) { + case 'func': + docs.tags = docs.tags || {}; + + if (returnValue) { + docs.tags.returns = returnValue; + } + + if (params) { + docs.tags.param = params; + } + break; + } + + return docs; +}; + +const createPropType = (name, args, {isRequired = false, docs} = {}) => { + const propType = [{name: 'PropTypes'}, {name}]; + + if (args) { + propType.push({args}); + } + + if (isRequired) { + propType.push({name: 'isRequired'}); + } + + return {__reflect__: propType, __docs__: createDocs(name, docs)}; +}; + +describe('getNormalizedTypeName', () => { + [ + 'any', + 'array', + 'element', + 'elementType', + 'number', + 'node', + 'object', + 'shape', + 'string', + 'symbol', + ].forEach((type) => { + test(`returns "${type}" for ${type} types`, () => { + const propType = createPropType(type); + + expect(getNormalizedTypeName(propType)).toEqual(type); + }); + }); + + test('returns "boolean" for bool types', () => { + const propType = createPropType('bool'); + + expect(getNormalizedTypeName(propType)).toEqual('boolean'); + }); + + test('returns "function" for func types', () => { + const propType = createPropType('func'); + + expect(getNormalizedTypeName(propType)).toEqual('function'); + }); + + test('returns "enum" for enum types', () => { + const propType = createPropType('oneOf'); + + expect(getNormalizedTypeName(propType)).toEqual('enum'); + }); + + test('returns type of first argment for union types with a single argument', () => { + const propType = createPropType('oneOfType', [[createPropType('string')]]); + + expect(getNormalizedTypeName(propType)).toEqual('string'); + }); + + test('pipe delimits union types with multiple types', () => { + const propType = createPropType('oneOfType', [ + [ + createPropType('string'), + createPropType('object'), + createPropType('number'), + ], + ]); + + expect(getNormalizedTypeName(propType)).toEqual('string|object|number'); + }); + + test('handles union types that are required', () => { + const propType = createPropType( + 'oneOfType', + [ + [ + createPropType('string'), + createPropType('object'), + createPropType('number'), + ], + ], + {isRequired: true} + ); + + expect(getNormalizedTypeName(propType)).toEqual('string|object|number'); + }); + + test('returns array representation for arrayOf types', () => { + const propType = createPropType('arrayOf', [createPropType('string')]); + + expect(getNormalizedTypeName(propType)).toEqual('string[]'); + }); + + test('handles arrayOf enum types', () => { + const propType = createPropType('arrayOf', [createPropType('oneOf')]); + + expect(getNormalizedTypeName(propType)).toEqual('enum[]'); + }); + + test('handles array of union types', () => { + const propType = createPropType('arrayOf', [ + createPropType('oneOfType', [ + [createPropType('string'), createPropType('number')], + ]), + ]); + + expect(getNormalizedTypeName(propType)).toEqual('(string|number)[]'); + }); +}); + +describe('getDefaultValue', () => { + test('returns value for primitive types', () => { + const component = { + propTypes: { + message: createPropType('string'), + }, + defaultProps: { + message: 'Hello', + }, + }; + + expect(getDefaultValue(component, 'message')).toEqual('Hello'); + }); + + test('returns undefined for undefined default values', () => { + const component = { + propTypes: {}, + defaultProps: {}, + }; + + expect(getDefaultValue(component, 'name')).toBeUndefined(); + }); + + test('returns null for default values set to null', () => { + const component = { + propTypes: { + name: createPropType('string'), + }, + defaultProps: { + name: null, + }, + }; + + expect(getDefaultValue(component, 'name')).toBeNull(); + }); + + test('returns stringified boolean value when it is a boolean', () => { + const component = { + propTypes: { + disabled: createPropType('bool'), + }, + defaultProps: { + disabled: false, + }, + }; + + expect(getDefaultValue(component, 'disabled')).toEqual('false'); + }); + + test('returns a number if the default value is a number', () => { + const component = { + propTypes: { + count: createPropType('number'), + }, + defaultProps: { + count: 5, + }, + }; + + expect(getDefaultValue(component, 'count')).toEqual(5); + }); + + test('returns special number names if the default is a special number', () => { + const component = { + propTypes: { + bytes: createPropType('number'), + }, + defaultProps: { + bytes: Number.MAX_SAFE_INTEGER, + }, + }; + + expect(getDefaultValue(component, 'bytes')).toEqual( + 'Number.MAX_SAFE_INTEGER' + ); + }); + + test('returns undefined if the default value is an arbitrary object', () => { + const component = { + propTypes: { + location: createPropType('object'), + }, + defaultProps: { + location: {state: '1234'}, + }, + }; + + expect(getDefaultValue(component, 'location')).toBeUndefined(); + }); + + test('returns stringfied representation of array if the default value is an array', () => { + const component = { + propTypes: { + sizes: createPropType('array'), + }, + defaultProps: { + sizes: [1, 2, 3], + }, + }; + + expect(getDefaultValue(component, 'sizes')).toEqual('[1,2,3]'); + }); + + test('returns static constant if the default value is a union prop', () => { + const GAP = { + SMALL: 1, + MEDIUM: 2, + LARGE: 3, + }; + + const component = { + name: 'Grid', + propTypes: { + gap: createPropType('oneOf', [[GAP.SMALL, GAP.MEDIUM, GAP.LARGE]]), + }, + defaultProps: { + gap: GAP.SMALL, + }, + GAP, + }; + + expect(getDefaultValue(component, 'gap')).toEqual('Grid.GAP.SMALL'); + }); +}); + +describe('getTypeMeta', () => { + [ + 'any', + 'array', + 'element', + 'elementType', + 'number', + 'node', + 'object', + 'string', + 'symbol', + ].forEach((type) => { + test(`returns null for ${type} types`, () => { + const propType = createPropType(type); + const component = { + propTypes: { + [type]: propType, + }, + }; + + expect(getTypeMeta(type, propType, {component})).toBeNull(); + }); + }); + + test('returns function information for func types', () => { + const propType = createPropType('func', undefined, { + docs: { + description: 'A click handler', + params: [{description: '', name: 'event', type: 'Event'}], + }, + }); + + const component = { + propTypes: { + onClick: propType, + }, + }; + + expect(getTypeMeta('onClick', propType, {component})).toEqual({ + returnValue: {type: 'undefined'}, + params: [{description: '', name: 'event', type: 'Event'}], + }); + }); + + test('returns nested prop type info for shape types', () => { + const propType = createPropType('shape', [ + { + pathname: createPropType('string', undefined, {isRequired: true}), + search: createPropType('string'), + hash: createPropType('string'), + }, + ]); + + const component = { + propTypes: { + to: propType, + }, + }; + + expect(getTypeMeta('to', propType, {component})).toEqual({ + types: [ + { + name: 'pathname', + defaultValue: undefined, + description: undefined, + deprecation: null, + isRequired: true, + examples: [], + type: { + meta: null, + raw: 'string', + name: 'string', + }, + }, + { + name: 'search', + defaultValue: undefined, + description: undefined, + deprecation: null, + isRequired: false, + examples: [], + type: { + meta: null, + raw: 'string', + name: 'string', + }, + }, + { + name: 'hash', + defaultValue: undefined, + description: undefined, + deprecation: null, + isRequired: false, + examples: [], + type: { + meta: null, + raw: 'string', + name: 'string', + }, + }, + ], + }); + }); + + test('returns constants for enum types', () => { + const SIZE = { + SMALL: 'sm', + MEDIUM: 'md', + LARGE: 'lg', + }; + + const propType = createPropType('oneOf', [Object.values(SIZE)]); + + const component = { + name: 'Button', + propTypes: { + size: propType, + }, + SIZE, + }; + + expect(getTypeMeta('size', propType, {component})).toEqual({ + constants: [ + 'Button.SIZE.SMALL', + 'Button.SIZE.MEDIUM', + 'Button.SIZE.LARGE', + ], + }); + }); + + test('returns nested type info for arrayOf types', () => { + const propType = createPropType('arrayOf', [createPropType('string')]); + + const component = { + propTypes: { + names: propType, + }, + }; + + expect(getTypeMeta('names', propType, {component})).toEqual({ + itemTypes: { + meta: null, + raw: 'string', + name: 'string', + }, + }); + }); + + test('returns nested type info for union types', () => { + const propType = createPropType('oneOfType', [ + [ + createPropType('string'), + createPropType('shape', [ + { + pathname: createPropType('string', undefined, { + docs: {description: 'The name of the path to link to'}, + }), + search: createPropType('string'), + }, + ]), + ], + ]); + + const component = { + propTypes: { + to: propType, + }, + }; + + expect(getTypeMeta('to', propType, {component})).toEqual({ + types: [ + { + name: 'string', + raw: 'string', + meta: null, + }, + { + name: 'shape', + raw: 'shape', + meta: { + types: [ + { + name: 'pathname', + defaultValue: undefined, + description: 'The name of the path to link to', + deprecation: null, + isRequired: false, + examples: [], + type: { + meta: null, + raw: 'string', + name: 'string', + }, + }, + { + name: 'search', + defaultValue: undefined, + description: undefined, + deprecation: null, + isRequired: false, + examples: [], + type: { + meta: null, + raw: 'string', + name: 'string', + }, + }, + ], + }, + }, + ], + }); + }); + + test('handles arrayOf enum types', () => { + const SPACE = { + SMALL: 'sm', + MEDIUM: 'md', + LARGE: 'lg', + }; + const propType = createPropType('arrayOf', [ + createPropType('oneOf', [Object.values(SPACE)]), + ]); + + const component = { + name: 'Button', + propTypes: { + space: propType, + }, + SPACE, + }; + + expect(getTypeMeta('space', propType, {component})).toEqual({ + itemTypes: { + meta: { + constants: [ + 'Button.SPACE.SMALL', + 'Button.SPACE.MEDIUM', + 'Button.SPACE.LARGE', + ], + }, + raw: 'oneOf', + name: 'enum', + }, + }); + }); + + test('handles enums nested in shape prop types', () => { + const OUTER_SPACE = { + SMALL: 'sm', + MEDIUM: 'md', + LARGE: 'lg', + }; + const enumPropType = createPropType('oneOf', [Object.values(OUTER_SPACE)]); + const propType = createPropType('shape', [{space: enumPropType}]); + + const component = { + name: 'Button', + propTypes: { + outer: propType, + }, + OUTER_SPACE, + }; + + expect(getTypeMeta('space', enumPropType, {component})).toEqual({ + constants: [ + 'Button.OUTER_SPACE.SMALL', + 'Button.OUTER_SPACE.MEDIUM', + 'Button.OUTER_SPACE.LARGE', + ], + }); + }); + + test('handles advanced case', () => { + const CRAZY = { + ONE: 1, + TWO: 2, + }; + + const propType = createPropType('oneOfType', [ + [ + createPropType('string'), + createPropType('arrayOf', [ + createPropType('shape', [ + { + name: createPropType('string', undefined, {isRequired: true}), + onHide: createPropType('func'), + onHidden: createPropType('func', undefined, { + docs: { + deprecation: { + date: 1591519180477, + description: 'Use onHide', + }, + }, + }), + }, + ]), + ]), + createPropType('oneOf', [Object.values(CRAZY)]), + ], + ]); + + const component = { + name: 'Wacky', + propTypes: { + crazy: propType, + }, + CRAZY, + }; + + expect(getTypeMeta('crazy', propType, {component})).toEqual({ + types: [ + { + meta: null, + raw: 'string', + name: 'string', + }, + { + raw: 'arrayOf', + name: 'shape[]', + meta: { + itemTypes: { + meta: { + types: [ + { + name: 'name', + defaultValue: undefined, + description: undefined, + deprecation: null, + isRequired: true, + examples: [], + type: { + meta: null, + raw: 'string', + name: 'string', + }, + }, + { + name: 'onHide', + defaultValue: undefined, + description: undefined, + deprecation: null, + isRequired: false, + examples: [], + type: { + meta: { + returnValue: {type: 'undefined'}, + params: [], + }, + raw: 'func', + name: 'function', + }, + }, + { + name: 'onHidden', + defaultValue: undefined, + description: undefined, + deprecation: null, + isRequired: false, + examples: [], + deprecation: { + date: 1591519180477, + description: 'Use onHide', + }, + type: { + meta: { + returnValue: {type: 'undefined'}, + params: [], + }, + raw: 'func', + name: 'function', + }, + }, + ], + }, + raw: 'shape', + name: 'shape', + }, + }, + }, + { + name: 'enum', + raw: 'oneOf', + meta: {constants: ['Wacky.CRAZY.ONE', 'Wacky.CRAZY.TWO']}, + }, + ], + }); + }); +}); + diff --git a/plugins/gatsby-source-newrelic-sdk/src/docInfo.js b/plugins/gatsby-source-newrelic-sdk/src/docInfo.js index 7f7c070f2..54f315b9c 100644 --- a/plugins/gatsby-source-newrelic-sdk/src/docInfo.js +++ b/plugins/gatsby-source-newrelic-sdk/src/docInfo.js @@ -1,5 +1,6 @@ const { getExamples } = require('./exampleInfo'); const { getTypeDefs } = require('./typeDefInfo'); +const { getPropTypes } = require('./propTypeInfo'); exports.getComponentDoc = (name, sdk) => { const component = sdk[name]; @@ -21,6 +22,7 @@ exports.getComponentDoc = (name, sdk) => { usage: `import { ${name} } from 'nr1'`, description: component.__docs__.text, examples: getExamples(component), + propTypes: getPropTypes(component), typeDefs: getTypeDefs(properties.concat(propTypes), sdk), }; }; diff --git a/plugins/gatsby-source-newrelic-sdk/src/propTypeInfo.js b/plugins/gatsby-source-newrelic-sdk/src/propTypeInfo.js new file mode 100644 index 000000000..33199d9e8 --- /dev/null +++ b/plugins/gatsby-source-newrelic-sdk/src/propTypeInfo.js @@ -0,0 +1,206 @@ +const UNION_DELIMITER = '|'; + +const SPECIAL_NUMBERS = [ + 'MAX_VALUE', + 'MIN_VALUE', + 'NEGATIVE_INFINITY', + 'POSITIVE_INFINITY', + 'MAX_SAFE_INTEGER', + 'MIN_SAFE_INTEGER', + 'EPSILON', +]; + +const IGNORED_PROPERTIES = [ + 'prototype', + 'length', + 'name', + 'propTypes', + 'getDerivedStateFromProps', + 'defaultProps', +]; + +const getArgs = (propType) => + (propType.__reflect__.find(({ args }) => args) || {}).args; + +const isEnum = (propType) => getRawTypeName(propType) === 'oneOf'; +const isUnion = (propType) => getRawTypeName(propType) === 'oneOfType'; + +const matchesEnum = (property, enums) => { + const values = Object.values(property); + + return ( + values.length === enums.length && + enums.every((value) => values.includes(value)) + ); +}; + +const findSpecialNumber = (number) => + SPECIAL_NUMBERS.find((property) => Number[property] === number); + +const toStaticPropertyName = (name) => + name + .replace(/(.+?)(?=[A-Z])/g, '$1_') + .replace('.', '_') + .toUpperCase(); + +const getRawTypeName = (propType) => propType.__reflect__[1].name; + +const getNormalizedTypeName = (propType) => { + const name = getRawTypeName(propType); + + switch (name) { + case 'bool': + return 'boolean'; + case 'func': + return 'function'; + case 'oneOf': + return 'enum'; + case 'oneOfType': { + const [propTypes] = getArgs(propType); + + return propTypes + .map((propType) => getNormalizedTypeName(propType)) + .join(UNION_DELIMITER); + } + case 'arrayOf': { + const [arrayOfPropType] = getArgs(propType); + const typeName = getNormalizedTypeName(arrayOfPropType); + + return isUnion(arrayOfPropType) ? `(${typeName})[]` : `${typeName}[]`; + } + default: + return name; + } +}; + +const getDefaultValue = (component, propTypeName) => { + const defaultValue = (component.defaultProps || {})[propTypeName]; + + if (defaultValue == null) { + return defaultValue; + } + + if (isEnum(component.propTypes[propTypeName])) { + const staticProperty = toStaticPropertyName(propTypeName); + const property = Object.entries(component[staticProperty]).find( + ([_, value]) => value === defaultValue + )[0]; + + return `${component.name}.${staticProperty}.${property}`; + } + + if (typeof defaultValue === 'boolean') { + return defaultValue.toString(); + } + + if (Array.isArray(defaultValue)) { + return JSON.stringify(defaultValue); + } + + if (typeof defaultValue === 'object') { + return undefined; + } + + if (typeof defaultValue === 'number') { + const specialNumber = findSpecialNumber(defaultValue); + + return specialNumber ? `Number.${specialNumber}` : defaultValue; + } + + return defaultValue; +}; + +const getTypeMeta = (name, propType, { component }) => { + const propTypeDocs = propType.__docs__ || {}; + + switch (getRawTypeName(propType)) { + case 'func': + return { + returnValue: (propTypeDocs.tags || {}).returnValue || { + type: 'undefined', + }, + params: (propTypeDocs.tags || {}).param || [], + }; + case 'shape': { + const [shape] = getArgs(propType); + + return { + types: Object.entries(shape).map(([shapePropName, propType]) => + getPropTypeDefinition(component, shapePropName, propType) + ), + }; + } + case 'oneOf': { + const [enums] = getArgs(propType); + const staticProperty = Object.getOwnPropertyNames(component) + .filter( + (member) => + !IGNORED_PROPERTIES.includes(member) && + typeof component[member] === 'object' + ) + .find((member) => matchesEnum(component[member], enums)); + + return { + constants: Object.keys(component[staticProperty] || {}).map( + (name) => `${component.name}.${staticProperty}.${name}` + ), + }; + } + case 'arrayOf': { + const [arrayOfPropType] = getArgs(propType); + + return { + itemTypes: { + meta: getTypeMeta(name, arrayOfPropType, { component }), + raw: getRawTypeName(arrayOfPropType), + name: getNormalizedTypeName(arrayOfPropType), + }, + }; + } + case 'oneOfType': { + const [types] = getArgs(propType); + + return { + types: types.map((propType) => ({ + name: getNormalizedTypeName(propType), + raw: getRawTypeName(propType), + meta: getTypeMeta(name, propType, { component }), + })), + }; + } + default: + return null; + } +}; + +const getPropTypeDefinition = (component, name, propType) => { + const propDocs = propType.__docs__ || {}; + const propMeta = propType.__reflect__ || {}; + const tags = propDocs.tags || {}; + + return { + name, + defaultValue: getDefaultValue(component, name), + description: propDocs.text, + deprecation: (tags.deprecated || [])[0] || null, + isRequired: propMeta.some((item) => item.name === 'isRequired') || false, + examples: tags.examples || [], + type: { + meta: getTypeMeta(name, propType, { component }), + raw: getRawTypeName(propType), + name: getNormalizedTypeName(propType), + }, + }; +}; + +const getPropTypes = (component) => { + return Object.entries(component.propTypes || {}).map(([name, propType]) => + getPropTypeDefinition(component, name, propType) + ); +}; + +exports.getRawTypeName = getRawTypeName; +exports.getNormalizedTypeName = getNormalizedTypeName; +exports.getDefaultValue = getDefaultValue; +exports.getTypeMeta = getTypeMeta; +exports.getPropTypes = getPropTypes;