fbredius/storybook

View on GitHub
addons/docs/src/lib/convert/convert.test.ts

Summary

Maintainability
F
5 days
Test Coverage
import 'jest-specific-snapshot';
import mapValues from 'lodash/mapValues';
import { transformSync } from '@babel/core';
import requireFromString from 'require-from-string';
import fs from 'fs';

import { convert } from './index';
import { normalizeNewlines } from '../utils';

expect.addSnapshotSerializer({
  print: (val: any) => JSON.stringify(val, null, 2),
  test: (val) => typeof val !== 'string',
});

describe('storybook type system', () => {
  describe('TypeScript', () => {
    it('scalars', () => {
      const input = readFixture('typescript/functions.tsx');
      expect(input).toMatchInlineSnapshot(`
        "import React, { FC } from 'react';

        interface ItemInterface {
          text: string;
          value: string;
        }
        interface Props {
          onClick?: () => void;
          voidFunc: () => void;
          funcWithArgsAndReturns: (a: string, b: string) => string;
          funcWithUnionArg: (a: string | number) => string;
          funcWithMultipleUnionReturns: () => string | ItemInterface;
        }
        export const Component: FC<Props> = (props: Props) => <>JSON.stringify(props)</>;
        "
      `);
      expect(convertTs(input)).toMatchInlineSnapshot(`
        {
          "onClick": {
            "raw": "() => void",
            "name": "function"
          },
          "voidFunc": {
            "raw": "() => void",
            "name": "function"
          },
          "funcWithArgsAndReturns": {
            "raw": "(a: string, b: string) => string",
            "name": "function"
          },
          "funcWithUnionArg": {
            "raw": "(a: string | number) => string",
            "name": "function"
          },
          "funcWithMultipleUnionReturns": {
            "raw": "() => string | ItemInterface",
            "name": "function"
          }
        }
      `);
    });
    it('functions', () => {
      const input = readFixture('typescript/functions.tsx');
      expect(input).toMatchInlineSnapshot(`
        "import React, { FC } from 'react';

        interface ItemInterface {
          text: string;
          value: string;
        }
        interface Props {
          onClick?: () => void;
          voidFunc: () => void;
          funcWithArgsAndReturns: (a: string, b: string) => string;
          funcWithUnionArg: (a: string | number) => string;
          funcWithMultipleUnionReturns: () => string | ItemInterface;
        }
        export const Component: FC<Props> = (props: Props) => <>JSON.stringify(props)</>;
        "
      `);
      expect(convertTs(input)).toMatchInlineSnapshot(`
        {
          "onClick": {
            "raw": "() => void",
            "name": "function"
          },
          "voidFunc": {
            "raw": "() => void",
            "name": "function"
          },
          "funcWithArgsAndReturns": {
            "raw": "(a: string, b: string) => string",
            "name": "function"
          },
          "funcWithUnionArg": {
            "raw": "(a: string | number) => string",
            "name": "function"
          },
          "funcWithMultipleUnionReturns": {
            "raw": "() => string | ItemInterface",
            "name": "function"
          }
        }
      `);
    });
    it('enums', () => {
      const input = readFixture('typescript/enums.tsx');
      expect(input).toMatchInlineSnapshot(`
        "import React, { FC } from 'react';

        enum DefaultEnum {
          TopLeft,
          TopRight,
          TopCenter,
        }
        enum NumericEnum {
          TopLeft = 0,
          TopRight,
          TopCenter,
        }
        enum StringEnum {
          TopLeft = 'top-left',
          TopRight = 'top-right',
          TopCenter = 'top-center',
        }
        interface Props {
          defaultEnum: DefaultEnum;
          numericEnum: NumericEnum;
          stringEnum: StringEnum;
        }
        export const Component: FC<Props> = (props: Props) => <>JSON.stringify(props)</>;
        "
      `);
      expect(convertTs(input)).toMatchInlineSnapshot(`
        {
          "defaultEnum": {
            "name": "other",
            "value": "DefaultEnum"
          },
          "numericEnum": {
            "name": "other",
            "value": "NumericEnum"
          },
          "stringEnum": {
            "name": "other",
            "value": "StringEnum"
          }
        }
      `);
    });
    it('unions', () => {
      const input = readFixture('typescript/unions.tsx');
      expect(input).toMatchInlineSnapshot(`
        "import React, { FC } from 'react';

        type Kind = 'default' | 'action';
        enum DefaultEnum {
          TopLeft,
          TopRight,
          TopCenter,
        }
        enum NumericEnum {
          TopLeft = 0,
          TopRight,
          TopCenter,
        }
        type EnumUnion = DefaultEnum | NumericEnum;
        interface Props {
          kind?: Kind;
          inlinedNumericLiteralUnion: 0 | 1;
          enumUnion: EnumUnion;
        }
        export const Component: FC<Props> = (props: Props) => <>JSON.stringify(props)</>;
        "
      `);
      expect(convertTs(input)).toMatchInlineSnapshot(`
        {
          "kind": {
            "raw": "'default' | 'action'",
            "name": "union",
            "value": [
              {
                "name": "other",
                "value": "literal"
              },
              {
                "name": "other",
                "value": "literal"
              }
            ]
          },
          "inlinedNumericLiteralUnion": {
            "raw": "0 | 1",
            "name": "union",
            "value": [
              {
                "name": "other",
                "value": "literal"
              },
              {
                "name": "other",
                "value": "literal"
              }
            ]
          },
          "enumUnion": {
            "raw": "DefaultEnum | NumericEnum",
            "name": "union",
            "value": [
              {
                "name": "other",
                "value": "DefaultEnum"
              },
              {
                "name": "other",
                "value": "NumericEnum"
              }
            ]
          }
        }
      `);
    });
    it('intersections', () => {
      const input = readFixture('typescript/intersections.tsx');
      expect(input).toMatchInlineSnapshot(`
        "import React, { FC } from 'react';

        interface ItemInterface {
          text: string;
          value: string;
        }
        interface PersonInterface {
          name: string;
        }
        type InterfaceIntersection = ItemInterface & PersonInterface;
        interface Props {
          intersectionType: InterfaceIntersection;
          intersectionWithInlineType: ItemInterface & { inlineValue: string };
        }
        export const Component: FC<Props> = (props: Props) => <>JSON.stringify(props)</>;
        "
      `);
      expect(convertTs(input)).toMatchInlineSnapshot(`
        {
          "intersectionType": {
            "raw": "ItemInterface & PersonInterface",
            "name": "intersection",
            "value": [
              {
                "name": "other",
                "value": "ItemInterface"
              },
              {
                "name": "other",
                "value": "PersonInterface"
              }
            ]
          },
          "intersectionWithInlineType": {
            "raw": "ItemInterface & { inlineValue: string }",
            "name": "intersection",
            "value": [
              {
                "name": "other",
                "value": "ItemInterface"
              },
              {
                "raw": "{ inlineValue: string }",
                "name": "object",
                "value": {
                  "inlineValue": {
                    "name": "string"
                  }
                }
              }
            ]
          }
        }
      `);
    });
    it('arrays', () => {
      const input = readFixture('typescript/arrays.tsx');
      expect(input).toMatchInlineSnapshot(`
        "import React, { FC } from 'react';

        interface ItemInterface {
          text: string;
          value: string;
        }
        interface Point {
          x: number;
          y: number;
        }
        interface Props {
          arrayOfPoints: Point[];
          arrayOfInlineObjects: { w: number; h: number }[];
          arrayOfPrimitive: string[];
          arrayOfComplexObject: ItemInterface[];
        }
        export const Component: FC<Props> = (props: Props) => <>JSON.stringify(props)</>;
        "
      `);
      expect(convertTs(input)).toMatchInlineSnapshot(`
        {
          "arrayOfPoints": {
            "raw": "Point[]",
            "name": "array",
            "value": [
              {
                "name": "other",
                "value": "Point"
              }
            ]
          },
          "arrayOfInlineObjects": {
            "raw": "{ w: number; h: number }[]",
            "name": "array",
            "value": [
              {
                "raw": "{ w: number; h: number }",
                "name": "object",
                "value": {
                  "w": {
                    "name": "number"
                  },
                  "h": {
                    "name": "number"
                  }
                }
              }
            ]
          },
          "arrayOfPrimitive": {
            "raw": "string[]",
            "name": "array",
            "value": [
              {
                "name": "string"
              }
            ]
          },
          "arrayOfComplexObject": {
            "raw": "ItemInterface[]",
            "name": "array",
            "value": [
              {
                "name": "other",
                "value": "ItemInterface"
              }
            ]
          }
        }
      `);
    });
    it('interfaces', () => {
      const input = readFixture('typescript/interfaces.tsx');
      expect(input).toMatchInlineSnapshot(`
        "import React, { FC } from 'react';

        interface ItemInterface {
          text: string;
          value: string;
        }
        interface GenericInterface<T> {
          value: T;
        }
        interface Props {
          interface: ItemInterface;
          genericInterface: GenericInterface<string>;
        }
        export const Component: FC<Props> = (props: Props) => <>JSON.stringify(props)</>;
        "
      `);
      expect(convertTs(input)).toMatchInlineSnapshot(`
        {
          "interface": {
            "name": "other",
            "value": "ItemInterface"
          },
          "genericInterface": {
            "raw": "GenericInterface<string>",
            "name": "other",
            "value": "GenericInterface"
          }
        }
      `);
    });
    it('records', () => {
      const input = readFixture('typescript/records.tsx');
      expect(input).toMatchInlineSnapshot(`
        "import React, { FC } from 'react';

        interface ItemInterface {
          text: string;
          value: string;
        }
        interface Props {
          recordOfPrimitive: Record<string, number>;
          recordOfComplexObject: Record<string, ItemInterface>;
        }
        export const Component: FC<Props> = (props: Props) => <>JSON.stringify(props)</>;
        "
      `);
      expect(convertTs(input)).toMatchInlineSnapshot(`
        {
          "recordOfPrimitive": {
            "raw": "Record<string, number>",
            "name": "other",
            "value": "Record"
          },
          "recordOfComplexObject": {
            "raw": "Record<string, ItemInterface>",
            "name": "other",
            "value": "Record"
          }
        }
      `);
    });
    it('aliases', () => {
      const input = readFixture('typescript/aliases.tsx');
      expect(input).toMatchInlineSnapshot(`
        "import React, { FC } from 'react';

        type StringAlias = string;
        type NumberAlias = number;
        type AliasesIntersection = StringAlias & NumberAlias;
        type AliasesUnion = StringAlias | NumberAlias;
        interface GenericAlias<T> {
          value: T;
        }
        interface Props {
          typeAlias: StringAlias;
          aliasesIntersection: AliasesIntersection;
          aliasesUnion: AliasesUnion;
          genericAlias: GenericAlias<string>;
        }
        export const Component: FC<Props> = (props: Props) => <>JSON.stringify(props)</>;
        "
      `);
      expect(convertTs(input)).toMatchInlineSnapshot(`
        {
          "typeAlias": {
            "name": "string"
          },
          "aliasesIntersection": {
            "raw": "StringAlias & NumberAlias",
            "name": "intersection",
            "value": [
              {
                "name": "string"
              },
              {
                "name": "number"
              }
            ]
          },
          "aliasesUnion": {
            "raw": "StringAlias | NumberAlias",
            "name": "union",
            "value": [
              {
                "name": "string"
              },
              {
                "name": "number"
              }
            ]
          },
          "genericAlias": {
            "raw": "GenericAlias<string>",
            "name": "other",
            "value": "GenericAlias"
          }
        }
      `);
    });
    it('tuples', () => {
      const input = readFixture('typescript/tuples.tsx');
      expect(input).toMatchInlineSnapshot(`
        "import React, { FC } from 'react';

        interface ItemInterface {
          text: string;
          value: string;
        }
        interface Props {
          tupleOfPrimitive: [string, number];
          tupleWithComplexType: [string, ItemInterface];
        }
        export const Component: FC<Props> = (props: Props) => <>JSON.stringify(props)</>;
        "
      `);
      expect(convertTs(input)).toMatchInlineSnapshot(`
        {
          "tupleOfPrimitive": {
            "raw": "[string, number]",
            "name": "other",
            "value": "tuple"
          },
          "tupleWithComplexType": {
            "raw": "[string, ItemInterface]",
            "name": "other",
            "value": "tuple"
          }
        }
      `);
    });
  });
  describe('PropTypes', () => {
    it('scalars', () => {
      const input = readFixture('proptypes/scalars.js');
      expect(input).toMatchInlineSnapshot(`
        "import React from 'react';
        import PropTypes from 'prop-types';

        export const Component = (props) => <>JSON.stringify(props)</>;
        Component.propTypes = {
          optionalBool: PropTypes.bool,
          optionalFunc: PropTypes.func,
          optionalNumber: PropTypes.number,
          optionalString: PropTypes.string,
          optionalSymbol: PropTypes.symbol,
        };
        "
      `);
      expect(convertJs(input)).toMatchInlineSnapshot(`
        {
          "optionalBool": {
            "name": "boolean"
          },
          "optionalFunc": {
            "name": "function"
          },
          "optionalNumber": {
            "name": "number"
          },
          "optionalString": {
            "name": "string"
          },
          "optionalSymbol": {
            "name": "symbol"
          }
        }
      `);
    });
    it('arrays', () => {
      const input = readFixture('proptypes/arrays.js');
      expect(input).toMatchInlineSnapshot(`
        "import React from 'react';
        import PropTypes from 'prop-types';

        export const Component = (props) => <>JSON.stringify(props)</>;
        Component.propTypes = {
          optionalArray: PropTypes.array,
          arrayOfStrings: PropTypes.arrayOf(PropTypes.string),
          arrayOfShape: PropTypes.arrayOf(
            PropTypes.shape({
              active: PropTypes.bool,
            })
          ),
        };
        "
      `);
      expect(convertJs(input)).toMatchInlineSnapshot(`
        {
          "optionalArray": {
            "name": "array"
          },
          "arrayOfStrings": {
            "name": "array",
            "value": {
              "name": "string"
            }
          },
          "arrayOfShape": {
            "name": "array",
            "value": {
              "name": "object",
              "value": {
                "active": {
                  "name": "boolean"
                }
              }
            }
          }
        }
      `);
    });
    it('enums', () => {
      const input = readFixture('proptypes/enums.js');
      expect(input).toMatchInlineSnapshot(`
        "import React from 'react';
        import PropTypes from 'prop-types';

        export const Component = (props) => <>JSON.stringify(props)</>;
        Component.propTypes = {
          oneOfNumber: PropTypes.oneOf([1, 2, 3]),
          oneOfMiscellaneous: PropTypes.oneOf([false, true, undefined]),
          oneOfStringNumber: PropTypes.oneOf(['1', '2', '3']),
          oneOfString: PropTypes.oneOf(['static', 'timed']),
        };
        "
      `);
      expect(convertJs(input)).toMatchInlineSnapshot(`
        {
          "oneOfNumber": {
            "name": "enum",
            "value": [
              1,
              2,
              3
            ]
          },
          "oneOfMiscellaneous": {
            "name": "enum",
            "value": [
              "false",
              "true",
              "undefined"
            ]
          },
          "oneOfStringNumber": {
            "name": "enum",
            "value": [
              "1",
              "2",
              "3"
            ]
          },
          "oneOfString": {
            "name": "enum",
            "value": [
              "static",
              "timed"
            ]
          }
        }
      `);
    });
    it('misc', () => {
      const input = readFixture('proptypes/misc.js');
      expect(input).toMatchInlineSnapshot(`
        "import React from 'react';
        import PropTypes from 'prop-types';

        export const Component = (props) => <>JSON.stringify(props)</>;
        Component.propTypes = {
          // An object that could be one of many types
          optionalUnion: PropTypes.oneOfType([
            PropTypes.string,
            PropTypes.number,
            PropTypes.instanceOf(Object),
          ]),
          optionalMessage: PropTypes.instanceOf(Object),
          // A value of any data type
          requiredAny: PropTypes.any.isRequired,
        };
        "
      `);
      expect(convertJs(input)).toMatchInlineSnapshot(`
        {
          "optionalUnion": {
            "name": "union",
            "value": [
              {
                "name": "string"
              },
              {
                "name": "number"
              },
              {
                "name": "other",
                "value": "instanceOf(Object)"
              }
            ]
          },
          "optionalMessage": {
            "name": "other",
            "value": "instanceOf(Object)"
          },
          "requiredAny": {
            "name": "other",
            "value": "any"
          }
        }
      `);
    });
    it('objects', () => {
      const input = readFixture('proptypes/objects.js');
      expect(input).toMatchInlineSnapshot(`
        "import React from 'react';
        import PropTypes from 'prop-types';

        export const Component = (props) => <>JSON.stringify(props)</>;
        Component.propTypes = {
          optionalObject: PropTypes.object,
          optionalObjectOf: PropTypes.objectOf(PropTypes.number),
          optionalObjectWithShape: PropTypes.shape({
            color: PropTypes.string,
            fontSize: PropTypes.number,
          }),
          optionalObjectWithStrictShape: PropTypes.exact({
            name: PropTypes.string,
            quantity: PropTypes.number,
          }),
        };
        "
      `);
      expect(convertJs(input)).toMatchInlineSnapshot(`
        {
          "optionalObject": {
            "name": "object"
          },
          "optionalObjectOf": {
            "name": "objectOf",
            "value": {
              "name": "number"
            }
          },
          "optionalObjectWithShape": {
            "name": "object",
            "value": {
              "color": {
                "name": "string"
              },
              "fontSize": {
                "name": "number"
              }
            }
          },
          "optionalObjectWithStrictShape": {
            "name": "object",
            "value": {
              "name": {
                "name": "string"
              },
              "quantity": {
                "name": "number"
              }
            }
          }
        }
      `);
    });
    it('react', () => {
      const input = readFixture('proptypes/react.js');
      expect(input).toMatchInlineSnapshot(`
        "import React from 'react';
        import PropTypes from 'prop-types';

        export const Component = (props) => <>JSON.stringify(props)</>;
        Component.propTypes = {
          // Anything that can be rendered: numbers, strings, elements or an array
          // (or fragment) containing these types.
          optionalNode: PropTypes.node,
          // A React element.
          optionalElement: PropTypes.element,
          // A React element type (ie. MyComponent).
          optionalElementType: PropTypes.elementType,
        };
        "
      `);
      expect(convertJs(input)).toMatchInlineSnapshot(`
        {
          "optionalNode": {
            "name": "other",
            "value": "node"
          },
          "optionalElement": {
            "name": "other",
            "value": "element"
          },
          "optionalElementType": {
            "name": "other",
            "value": "elementType"
          }
        }
      `);
    });
  });
});

const readFixture = (fixture: string) =>
  fs.readFileSync(`${__dirname}/__testfixtures__/${fixture}`).toString();

const transformToModule = (inputCode: string) => {
  const options = {
    presets: [
      [
        '@babel/preset-env',
        {
          targets: {
            esmodules: true,
          },
        },
      ],
    ],
  };
  const { code } = transformSync(inputCode, options);
  return normalizeNewlines(code);
};

const annotateWithDocgen = (inputCode: string, filename: string) => {
  const options = {
    presets: ['@babel/typescript', '@babel/react'],
    plugins: ['babel-plugin-react-docgen', '@babel/plugin-proposal-class-properties'],
    babelrc: false,
    filename,
  };
  const { code } = transformSync(inputCode, options);
  return normalizeNewlines(code);
};

const convertCommon = (code: string, fileExt: string) => {
  const docgenPretty = annotateWithDocgen(code, `temp.${fileExt}`);
  const { Component } = requireFromString(transformToModule(docgenPretty));
  // eslint-disable-next-line no-underscore-dangle
  const { props = {} } = Component.__docgenInfo || {};
  const types = mapValues(props, (prop) => convert(prop));
  return types;
};

const convertTs = (code: string) => convertCommon(code, 'tsx');

const convertJs = (code: string) => convertCommon(code, 'js');