From dcc8eea3e2d1b1a43bcb25e227b56862f85d45d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Ro=C5=BCek?= Date: Wed, 7 Aug 2019 10:08:11 +0200 Subject: [PATCH] feat: introduce rowRenderer (#40) * refactor: use inferType * revert: "feat: introduce rowRendererRight " This reverts commit dd89bc4f0cd278653b6528feb040eaab48ce4a88. * feat: make allOf merging optional * refactor: split components * fix: minor tweaks * chore: rexport components + add a story * feat: expose treeStore * feat: make SchemaRow more flexible * chore: improve storybook example * fix: lint --fix --- src/__stories__/JsonSchemaViewer.tsx | 54 ++++-- src/components/JsonSchemaViewer.tsx | 22 ++- src/components/SchemaRow.tsx | 185 ++++---------------- src/components/SchemaTree.tsx | 55 +++--- src/components/__tests__/SchemaRow.spec.tsx | 9 +- src/components/__tests__/Type.spec.tsx | 2 +- src/components/index.tsx | 2 +- src/components/shared/Caret.tsx | 22 +++ src/components/shared/Description.tsx | 18 ++ src/components/shared/Divider.tsx | 8 + src/components/shared/Property.tsx | 69 ++++++++ src/components/{ => shared}/Types.tsx | 2 +- src/components/shared/Validations.tsx | 66 +++++++ src/components/shared/index.ts | 6 + src/index.ts | 3 +- src/types.ts | 16 +- src/utils/renderSchema.ts | 5 +- 17 files changed, 320 insertions(+), 224 deletions(-) create mode 100644 src/components/shared/Caret.tsx create mode 100644 src/components/shared/Description.tsx create mode 100644 src/components/shared/Divider.tsx create mode 100644 src/components/shared/Property.tsx rename src/components/{ => shared}/Types.tsx (96%) create mode 100644 src/components/shared/Validations.tsx create mode 100644 src/components/shared/index.ts diff --git a/src/__stories__/JsonSchemaViewer.tsx b/src/__stories__/JsonSchemaViewer.tsx index cde18580..205aa808 100644 --- a/src/__stories__/JsonSchemaViewer.tsx +++ b/src/__stories__/JsonSchemaViewer.tsx @@ -1,19 +1,20 @@ import * as React from 'react'; import { State, Store } from '@sambego/storybook-state'; +import { Button, Checkbox, Icon } from '@stoplight/ui-kit'; import { action } from '@storybook/addon-actions'; import { boolean, number, object, select, text, withKnobs } from '@storybook/addon-knobs'; import { storiesOf } from '@storybook/react'; -import { JsonSchemaViewer } from '../components'; - -import { Checkbox } from '@stoplight/ui-kit'; import { JSONSchema4 } from 'json-schema'; +import { JsonSchemaViewer, SchemaRow } from '../components'; + import * as allOfSchemaResolved from '../__fixtures__/allOf/allOf-resolved.json'; import * as allOfSchema from '../__fixtures__/allOf/allOf-schema.json'; import * as schema from '../__fixtures__/default-schema.json'; import * as schemaWithRefs from '../__fixtures__/ref/original.json'; import * as dereferencedSchema from '../__fixtures__/ref/resolved.json'; import * as stressSchema from '../__fixtures__/stress-schema.json'; +import { RowRenderer } from '../types'; import { Wrapper } from './utils/Wrapper'; storiesOf('JsonSchemaViewer', module) @@ -44,6 +45,7 @@ storiesOf('JsonSchemaViewer', module) expanded={boolean('expanded', true)} hideTopBar={boolean('hideTopBar', false)} onGoToRef={action('onGoToRef')} + mergeAllOf={boolean('mergeAllOf', true)} /> ); @@ -56,8 +58,35 @@ storiesOf('JsonSchemaViewer', module) hideTopBar={boolean('hideTopBar', false)} onGoToRef={action('onGoToRef')} maxRows={number('maxRows', 5)} + mergeAllOf={boolean('mergeAllOf', true)} /> )) + .add('custom row renderer', () => { + const customRowRenderer: RowRenderer = (node, rowOptions) => { + return ( + <> + +
+
+ + ); + }; + + return ( + + ); + }) .add('stress-test schema', () => ( )) .add('allOf-schema', () => ( @@ -76,6 +106,7 @@ storiesOf('JsonSchemaViewer', module) defaultExpandedDepth={number('defaultExpandedDepth', 2)} expanded={boolean('expanded', false)} hideTopBar={boolean('hideTopBar', false)} + mergeAllOf={boolean('mergeAllOf', true)} onGoToRef={action('onGoToRef')} /> )) @@ -96,6 +127,7 @@ storiesOf('JsonSchemaViewer', module) defaultExpandedDepth={number('defaultExpandedDepth', 2)} hideTopBar={boolean('hideTopBar', false)} onGoToRef={action('onGoToRef')} + mergeAllOf={boolean('mergeAllOf', true)} /> )) .add('dark', () => ( @@ -107,21 +139,7 @@ storiesOf('JsonSchemaViewer', module) expanded={boolean('expanded', false)} hideTopBar={boolean('hideTopBar', false)} onGoToRef={action('onGoToRef')} + mergeAllOf={boolean('mergeAllOf', true)} /> - )) - .add('with rowRendererRight', () => ( - ( - - - - )} - name={text('name', 'my schema')} - schema={schema as JSONSchema4} - defaultExpandedDepth={number('defaultExpandedDepth', 2)} - expanded={boolean('expanded', false)} - hideTopBar={boolean('hideTopBar', false)} - onGoToRef={action('onGoToRef')} - /> )); diff --git a/src/components/JsonSchemaViewer.tsx b/src/components/JsonSchemaViewer.tsx index 562ac176..cbbd968a 100644 --- a/src/components/JsonSchemaViewer.tsx +++ b/src/components/JsonSchemaViewer.tsx @@ -4,13 +4,13 @@ import { runInAction } from 'mobx'; import * as React from 'react'; import { JSONSchema4 } from 'json-schema'; -import { GoToRefHandler, IExtendableRenderers } from '../types'; +import { GoToRefHandler, RowRenderer } from '../types'; import { isSchemaViewerEmpty, renderSchema } from '../utils'; import { SchemaTree } from './SchemaTree'; export type FallbackComponent = React.ComponentType<{ error: Error | null }>; -export interface IJsonSchemaViewer extends IExtendableRenderers { +export interface IJsonSchemaViewer { schema: JSONSchema4; dereferencedSchema?: JSONSchema4; style?: object; @@ -24,6 +24,7 @@ export interface IJsonSchemaViewer extends IExtendableRenderers { onGoToRef?: GoToRefHandler; mergeAllOf?: boolean; FallbackComponent?: FallbackComponent; + rowRenderer?: RowRenderer; } export class JsonSchemaViewerComponent extends React.PureComponent { @@ -39,9 +40,7 @@ export class JsonSchemaViewerComponent extends React.PureComponent { this.treeStore.nodes = Array.from( - renderSchema(this.props.dereferencedSchema || this.props.schema, 0, { path: [] }, { mergeAllOf: true }), + renderSchema( + this.props.dereferencedSchema || this.props.schema, + 0, + { path: [] }, + { mergeAllOf: this.props.mergeAllOf !== false }, + ), ); }); } diff --git a/src/components/SchemaRow.tsx b/src/components/SchemaRow.tsx index 7930bf72..4bef0040 100644 --- a/src/components/SchemaRow.tsx +++ b/src/components/SchemaRow.tsx @@ -1,192 +1,67 @@ -import { MarkdownViewer } from '@stoplight/markdown-viewer'; import { IRowRendererOptions } from '@stoplight/tree-list'; -import { Icon, Popover } from '@stoplight/ui-kit'; -import * as cn from 'classnames'; +import cn from 'classnames'; import * as React from 'react'; +import { Divider } from './shared/Divider'; import get = require('lodash/get'); -import map = require('lodash/map'); -import size = require('lodash/size'); -import { GoToRefHandler, IExtendableRenderers, SchemaNodeWithMeta, SchemaTreeListNode } from '../types'; -import { isCombiner, isRef } from '../utils'; -import { Types } from './'; +import { GoToRefHandler, SchemaNodeWithMeta, SchemaTreeListNode } from '../types'; +import { Caret } from './shared/Caret'; +import { Description } from './shared/Description'; +import { Property } from './shared/Property'; +import { Validations } from './shared/Validations'; -export interface ISchemaRow extends IExtendableRenderers { +export interface ISchemaRow { + className?: string; node: SchemaTreeListNode; rowOptions: IRowRendererOptions; onGoToRef?: GoToRefHandler; - toggleExpand: () => void; } const ICON_SIZE = 12; const ICON_DIMENSION = 20; +const ROW_OFFSET = 7; -export const SchemaRow: React.FunctionComponent = ({ - node, - rowOptions, - onGoToRef, - rowRendererRight, - toggleExpand, -}) => { +export const SchemaRow: React.FunctionComponent = ({ className, node, rowOptions, onGoToRef }) => { const schemaNode = node.metadata as SchemaNodeWithMeta; - const { name, $ref, subtype, required } = schemaNode; - - const type = isRef(schemaNode) ? '$ref' : isCombiner(schemaNode) ? schemaNode.combiner : schemaNode.type; const description = get(schemaNode, 'annotations.description'); - const childrenCount = - type === 'object' - ? size(get(schemaNode, 'properties')) - : subtype === 'object' - ? size(get(schemaNode, 'items.properties')) - : size(get(schemaNode, 'items')); - - const nodeValidations = { - ...('annotations' in schemaNode && schemaNode.annotations.default - ? { default: schemaNode.annotations.default } - : {}), - ...get(schemaNode, 'validations', {}), - }; - const validationCount = Object.keys(nodeValidations).length; - const handleGoToRef = React.useCallback( - () => { - if (onGoToRef) { - onGoToRef($ref!, node); - } - }, - [onGoToRef, node, $ref], - ); - - const requiredElem = ( -
- {required ? 'required' : 'optional'} - {validationCount ? `+${validationCount}` : ''} -
- ); - const combinerOffset = ICON_DIMENSION * node.level; return ( -
- {/* Do not set position: relative. Divider must be relative to the parent container in order to avoid bugs related to this container calculated height changes. */} +
{node.canHaveChildren && node.level > 0 && ( -
- -
+ size={ICON_SIZE} + /> )} - {schemaNode.divider && ( -
-
{schemaNode.divider}
-
-
- )} + {schemaNode.divider && {schemaNode.divider}}
- {name &&
{name}
} - - - {type === '$ref' ? `[${$ref}]` : null} - - - {type === '$ref' && onGoToRef ? ( - - (go to ref) - - ) : null} - - {node.canHaveChildren &&
{`{${childrenCount}}`}
} - - {'pattern' in schemaNode && schemaNode.pattern ? ( -
(pattern property)
- ) : null} - - {description && ( - {description}
} - targetClassName="text-darken-7 dark:text-lighten-6 w-full truncate" - content={ -
- -
- } - /> - )} + + {description && }
- {validationCount ? ( - - {map(Object.keys(nodeValidations), (key, index) => { - const validation = nodeValidations[key]; - - let elem = null; - if (Array.isArray(validation)) { - elem = validation.map((v, i) => ( -
-
{String(v)}
- {i < validation.length - 1 ?
,
: null} -
- )); - } else if (typeof validation === 'object') { - elem = ( -
- {'{...}'} -
- ); - } else { - elem = ( -
- {JSON.stringify(validation)} -
- ); - } - - return ( -
-
{key}:
-
{elem}
-
- ); - })} -
- } - target={requiredElem} - /> - ) : ( - requiredElem - )} - {rowRendererRight &&
{rowRendererRight(node)}
} +
); diff --git a/src/components/SchemaTree.tsx b/src/components/SchemaTree.tsx index b62a1486..f38ed05c 100644 --- a/src/components/SchemaTree.tsx +++ b/src/components/SchemaTree.tsx @@ -1,12 +1,13 @@ -import { TreeList, TreeStore } from '@stoplight/tree-list'; +import { TreeList, TreeListEvents, TreeStore } from '@stoplight/tree-list'; import * as cn from 'classnames'; import { JSONSchema4 } from 'json-schema'; import { observer } from 'mobx-react-lite'; import * as React from 'react'; -import { GoToRefHandler, IExtendableRenderers, SchemaTreeListNode } from '../types'; + +import { GoToRefHandler, RowRenderer } from '../types'; import { SchemaRow } from './'; -export interface ISchemaTree extends IExtendableRenderers { +export interface ISchemaTree { treeStore: TreeStore; schema: JSONSchema4; className?: string; @@ -16,18 +17,37 @@ export interface ISchemaTree extends IExtendableRenderers { expanded?: boolean; maxRows?: number; onGoToRef?: GoToRefHandler; + rowRenderer?: RowRenderer; } const canDrag = () => false; export const SchemaTree = observer(props => { - const { hideTopBar, name, treeStore, maxRows, className, onGoToRef } = props; + const { hideTopBar, name, treeStore, maxRows, className, onGoToRef, rowRenderer: customRowRenderer } = props; + + React.useEffect( + () => { + treeStore.on(TreeListEvents.NodeClick, (e, node) => { + treeStore.toggleExpand(node); + }); + + return () => { + treeStore.dispose(); + }; + }, + [treeStore], + ); - const itemData = { - treeStore, - count: treeStore.nodes.length, - onGoToRef, - }; + const rowRenderer = React.useCallback( + (node, rowOptions) => { + if (customRowRenderer !== undefined) { + return customRowRenderer(node, rowOptions, treeStore); + } + + return ; + }, + [onGoToRef, customRowRenderer, treeStore], + ); return (
@@ -42,24 +62,9 @@ export const SchemaTree = observer(props => { striped maxRows={maxRows !== undefined ? maxRows + 0.5 : maxRows} store={treeStore} - rowRenderer={(node, rowOptions) => { - // TODO: add a React.useCallback to rerender only when either itemData.count or maskProps (to be found in studio) change - - return ( - { - treeStore.toggleExpand(node); - }} - rowRendererRight={props.rowRendererRight} - node={node as SchemaTreeListNode} - rowOptions={rowOptions} - {...itemData} - /> - ); - }} + rowRenderer={rowRenderer} canDrag={canDrag} /> - {props.schemaControlsRenderer && props.schemaControlsRenderer()}
); }); diff --git a/src/components/__tests__/SchemaRow.spec.tsx b/src/components/__tests__/SchemaRow.spec.tsx index 14aa4d76..cf42f3de 100644 --- a/src/components/__tests__/SchemaRow.spec.tsx +++ b/src/components/__tests__/SchemaRow.spec.tsx @@ -1,9 +1,9 @@ -import { Popover } from '@stoplight/ui-kit'; import { shallow } from 'enzyme'; import 'jest-enzyme'; import * as React from 'react'; import { SchemaTreeListNode } from '../../types'; import { SchemaRow } from '../SchemaRow'; +import { Validations } from '../shared/Validations'; describe('SchemaRow component', () => { test('should render falsy validations', () => { @@ -27,10 +27,9 @@ describe('SchemaRow component', () => { isExpanded: true, }; - const wrapper = shallow(shallow( - null} node={node as SchemaTreeListNode} rowOptions={rowOptions} />, - ) - .find(Popover) + const wrapper = shallow(shallow() + .find(Validations) + .shallow() .prop('content') as React.ReactElement); expect(wrapper).toHaveText('enum:null,0,false'); diff --git a/src/components/__tests__/Type.spec.tsx b/src/components/__tests__/Type.spec.tsx index b0ac77b2..b1cce12a 100644 --- a/src/components/__tests__/Type.spec.tsx +++ b/src/components/__tests__/Type.spec.tsx @@ -1,7 +1,7 @@ import { shallow } from 'enzyme'; import 'jest-enzyme'; import * as React from 'react'; -import { IType, PropertyTypeColors, Type } from '../Types'; +import { IType, PropertyTypeColors, Type } from '../shared/Types'; describe('Type component', () => { it.each(Object.keys(PropertyTypeColors))('should handle $s type', type => { diff --git a/src/components/index.tsx b/src/components/index.tsx index 01cc319c..f77b5926 100644 --- a/src/components/index.tsx +++ b/src/components/index.tsx @@ -1,4 +1,4 @@ export * from './JsonSchemaViewer'; export * from './SchemaRow'; export * from './SchemaTree'; -export * from './Types'; +export * from './shared'; diff --git a/src/components/shared/Caret.tsx b/src/components/shared/Caret.tsx new file mode 100644 index 00000000..704c3cc7 --- /dev/null +++ b/src/components/shared/Caret.tsx @@ -0,0 +1,22 @@ +import { Icon, IIconProps } from '@stoplight/ui-kit'; +import * as React from 'react'; + +export interface ICaret { + isExpanded: boolean; + style?: React.CSSProperties; + size?: IIconProps['iconSize']; +} + +export const Caret: React.FunctionComponent = ({ style, size, isExpanded }) => ( + + + +); diff --git a/src/components/shared/Description.tsx b/src/components/shared/Description.tsx new file mode 100644 index 00000000..1f486f6d --- /dev/null +++ b/src/components/shared/Description.tsx @@ -0,0 +1,18 @@ +import { MarkdownViewer } from '@stoplight/markdown-viewer'; +import { Popover } from '@stoplight/ui-kit'; +import * as React from 'react'; + +export const Description: React.FunctionComponent<{ value: string }> = ({ value }) => ( + {value}} + targetClassName="text-darken-7 dark:text-lighten-6 w-full truncate" + content={ +
+ +
+ } + /> +); diff --git a/src/components/shared/Divider.tsx b/src/components/shared/Divider.tsx new file mode 100644 index 00000000..7800e0d1 --- /dev/null +++ b/src/components/shared/Divider.tsx @@ -0,0 +1,8 @@ +import * as React from 'react'; + +export const Divider: React.FunctionComponent = ({ children }) => ( +
+
{children}
+
+
+); diff --git a/src/components/shared/Property.tsx b/src/components/shared/Property.tsx new file mode 100644 index 00000000..851d8528 --- /dev/null +++ b/src/components/shared/Property.tsx @@ -0,0 +1,69 @@ +import { size } from 'lodash'; +import * as React from 'react'; +import { GoToRefHandler, IArrayNode, IObjectNode, SchemaKind, SchemaNodeWithMeta } from '../../types'; +import { isCombiner, isRef } from '../../utils'; +import { inferType } from '../../utils/inferType'; +import { Types } from './Types'; + +export interface IProperty { + node: SchemaNodeWithMeta; + onGoToRef?: GoToRefHandler; +} + +export const Property: React.FunctionComponent = ({ node, onGoToRef }) => { + const type = isRef(node) ? '$ref' : isCombiner(node) ? node.combiner : node.type; + const subtype = + type === SchemaKind.Array && (node as IArrayNode).items !== undefined + ? inferType((node as IArrayNode).items!) + : undefined; + + const childrenCount = React.useMemo( + () => { + if (type === SchemaKind.Object) { + return size((node as IObjectNode).properties); + } + + if (subtype === SchemaKind.Object) { + return size(((node as IArrayNode).items as IObjectNode).properties); + } + + if (subtype === SchemaKind.Array) { + return size((node as IArrayNode).items as IArrayNode); + } + + return null; + }, + [node], + ); + + const handleGoToRef = React.useCallback( + () => { + if (onGoToRef) { + onGoToRef(node.$ref!, node); + } + }, + [onGoToRef, node], + ); + + return ( + <> + {node.name &&
{node.name}
} + + + {type === '$ref' ? `[${node.$ref}]` : null} + + + {type === '$ref' && onGoToRef ? ( + + (go to ref) + + ) : null} + + {childrenCount !== null &&
{`{${childrenCount}}`}
} + + {'pattern' in node && node.pattern ? ( +
(pattern property)
+ ) : null} + + ); +}; diff --git a/src/components/Types.tsx b/src/components/shared/Types.tsx similarity index 96% rename from src/components/Types.tsx rename to src/components/shared/Types.tsx index 5910d5b4..d7b0494d 100644 --- a/src/components/Types.tsx +++ b/src/components/shared/Types.tsx @@ -3,7 +3,7 @@ import cn from 'classnames'; import { JSONSchema4TypeName } from 'json-schema'; import * as React from 'react'; -import { ITreeNodeMeta, JSONSchema4CombinerName } from '../types'; +import { ITreeNodeMeta, JSONSchema4CombinerName } from '../../types'; /** * TYPE diff --git a/src/components/shared/Validations.tsx b/src/components/shared/Validations.tsx new file mode 100644 index 00000000..c6dccae8 --- /dev/null +++ b/src/components/shared/Validations.tsx @@ -0,0 +1,66 @@ +import { Dictionary } from '@stoplight/types'; +import { Popover } from '@stoplight/ui-kit'; +import cn from 'classnames'; +import * as React from 'react'; + +export interface IValidations { + required: boolean; + validations: Dictionary; +} + +export const Validations: React.FunctionComponent = ({ required, validations }) => { + const validationCount = Object.keys(validations).length; + + const requiredElem = ( +
+ {required ? 'required' : 'optional'} + {validationCount ? `+${validationCount}` : ''} +
+ ); + + return validationCount ? ( + + {Object.keys(validations).map((key, index) => { + const validation = validations[key]; + + let elem = null; + if (Array.isArray(validation)) { + elem = validation.map((v, i) => ( +
+
{String(v)}
+ {i < validation.length - 1 ?
,
: null} +
+ )); + } else if (typeof validation === 'object') { + elem = ( +
+ {'{...}'} +
+ ); + } else { + elem = ( +
+ {JSON.stringify(validation)} +
+ ); + } + + return ( +
+
{key}:
+
{elem}
+
+ ); + })} +
+ } + target={requiredElem} + /> + ) : ( + requiredElem + ); +}; diff --git a/src/components/shared/index.ts b/src/components/shared/index.ts new file mode 100644 index 00000000..2fb6949c --- /dev/null +++ b/src/components/shared/index.ts @@ -0,0 +1,6 @@ +export * from './Caret'; +export * from './Description'; +export * from './Description'; +export * from './Property'; +export * from './Types'; +export * from './Validations'; diff --git a/src/index.ts b/src/index.ts index bfc7ee1f..195d95f5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1 +1,2 @@ -export * from './components/JsonSchemaViewer'; +export * from './components'; +export * from './types'; diff --git a/src/types.ts b/src/types.ts index 8bd7adbe..169ddf5c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,11 +1,7 @@ -import { TreeListNode } from '@stoplight/tree-list'; +import { IRowRendererOptions, TreeListNode, TreeStore } from '@stoplight/tree-list'; import { Dictionary, JsonPath } from '@stoplight/types'; import { JSONSchema4, JSONSchema4TypeName } from 'json-schema'; - -export interface IExtendableRenderers { - rowRendererRight?: (node: SchemaTreeListNode) => React.ReactElement; - schemaControlsRenderer?: () => React.ReactElement; -} +import * as React from 'react'; export const enum SchemaKind { Any = 'any', @@ -73,4 +69,10 @@ export type SchemaNodeWithMeta = SchemaNode & ITreeNodeMeta; export type SchemaTreeListNode = TreeListNode; -export type GoToRefHandler = (path: string, node: SchemaTreeListNode) => void; +export type GoToRefHandler = (path: string, node: SchemaNodeWithMeta) => void; + +export type RowRenderer = ( + node: SchemaTreeListNode, + rowOptions: IRowRendererOptions, + treeStore: TreeStore, +) => React.ReactNode; diff --git a/src/utils/renderSchema.ts b/src/utils/renderSchema.ts index 40d0f7c1..f6e16e53 100644 --- a/src/utils/renderSchema.ts +++ b/src/utils/renderSchema.ts @@ -11,6 +11,7 @@ import { walk } from './walk'; // @ts-ignore no typings import * as resolveAllOf from 'json-schema-merge-allof'; +import { inferType } from './inferType'; type Walker = ( schema: JSONSchema4, @@ -80,9 +81,7 @@ export const renderSchema: Walker = function*(schema, level = 0, meta = { path: subtype: '$ref' in parsedSchema.items ? `$ref( ${parsedSchema.items.$ref} )` - : parsedSchema.items.type || - (parsedSchema.items.properties && 'object') || - (parsedSchema.items.items && 'array'), + : parsedSchema.items.type || inferType(parsedSchema.items), }), path, },