Skip to content

Commit

Permalink
feat: introduce rowRenderer (#40)
Browse files Browse the repository at this point in the history
* refactor: use inferType

* revert: "feat: introduce rowRendererRight "

This reverts commit dd89bc4.

* 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
  • Loading branch information
P0lip authored and marbemac committed Aug 7, 2019
1 parent 1a08e63 commit dcc8eea
Show file tree
Hide file tree
Showing 17 changed files with 320 additions and 224 deletions.
54 changes: 36 additions & 18 deletions src/__stories__/JsonSchemaViewer.tsx
Original file line number Diff line number Diff line change
@@ -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)
Expand Down Expand Up @@ -44,6 +45,7 @@ storiesOf('JsonSchemaViewer', module)
expanded={boolean('expanded', true)}
hideTopBar={boolean('hideTopBar', false)}
onGoToRef={action('onGoToRef')}
mergeAllOf={boolean('mergeAllOf', true)}
/>
</State>
);
Expand All @@ -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 (
<>
<SchemaRow node={node} rowOptions={rowOptions} />
<div className="flex h-full items-center">
<Button className="pl-1 mr-1" small minimal icon={<Icon color="grey" iconSize={12} icon="issue" />} />
<Checkbox className="mb-0" />
</div>
</>
);
};

return (
<JsonSchemaViewer
name={text('name', 'my schema')}
schema={object('schema', schema as JSONSchema4)}
expanded={boolean('expanded', true)}
hideTopBar={boolean('hideTopBar', false)}
onGoToRef={action('onGoToRef')}
maxRows={number('maxRows', 5)}
mergeAllOf={boolean('mergeAllOf', true)}
rowRenderer={customRowRenderer}
/>
);
})
.add('stress-test schema', () => (
<JsonSchemaViewer
name={text('name', 'my stress schema')}
Expand All @@ -67,6 +96,7 @@ storiesOf('JsonSchemaViewer', module)
hideTopBar={boolean('hideTopBar', false)}
onGoToRef={action('onGoToRef')}
maxRows={number('maxRows', 10)}
mergeAllOf={boolean('mergeAllOf', true)}
/>
))
.add('allOf-schema', () => (
Expand All @@ -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')}
/>
))
Expand All @@ -96,6 +127,7 @@ storiesOf('JsonSchemaViewer', module)
defaultExpandedDepth={number('defaultExpandedDepth', 2)}
hideTopBar={boolean('hideTopBar', false)}
onGoToRef={action('onGoToRef')}
mergeAllOf={boolean('mergeAllOf', true)}
/>
))
.add('dark', () => (
Expand All @@ -107,21 +139,7 @@ storiesOf('JsonSchemaViewer', module)
expanded={boolean('expanded', false)}
hideTopBar={boolean('hideTopBar', false)}
onGoToRef={action('onGoToRef')}
mergeAllOf={boolean('mergeAllOf', true)}
/>
</div>
))
.add('with rowRendererRight', () => (
<JsonSchemaViewer
rowRendererRight={() => (
<span style={{ position: 'relative', top: '5px' }}>
<Checkbox />
</span>
)}
name={text('name', 'my schema')}
schema={schema as JSONSchema4}
defaultExpandedDepth={number('defaultExpandedDepth', 2)}
expanded={boolean('expanded', false)}
hideTopBar={boolean('hideTopBar', false)}
onGoToRef={action('onGoToRef')}
/>
));
22 changes: 15 additions & 7 deletions src/components/JsonSchemaViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -24,6 +24,7 @@ export interface IJsonSchemaViewer extends IExtendableRenderers {
onGoToRef?: GoToRefHandler;
mergeAllOf?: boolean;
FallbackComponent?: FallbackComponent;
rowRenderer?: RowRenderer;
}

export class JsonSchemaViewerComponent extends React.PureComponent<IJsonSchemaViewer> {
Expand All @@ -39,9 +40,7 @@ export class JsonSchemaViewerComponent extends React.PureComponent<IJsonSchemaVi
props.dereferencedSchema || props.schema,
0,
{ path: [] },
{
mergeAllOf: props.mergeAllOf === undefined ? true : props.mergeAllOf,
},
{ mergeAllOf: props.mergeAllOf !== false },
),
),
});
Expand All @@ -66,10 +65,19 @@ export class JsonSchemaViewerComponent extends React.PureComponent<IJsonSchemaVi
});
}

if (prevProps.schema !== this.props.schema || prevProps.dereferencedSchema !== this.props.dereferencedSchema) {
if (
prevProps.schema !== this.props.schema ||
prevProps.dereferencedSchema !== this.props.dereferencedSchema ||
prevProps.mergeAllOf !== this.props.mergeAllOf
) {
runInAction(() => {
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 },
),
);
});
}
Expand Down
185 changes: 30 additions & 155 deletions src/components/SchemaRow.tsx
Original file line number Diff line number Diff line change
@@ -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<ISchemaRow> = ({
node,
rowOptions,
onGoToRef,
rowRendererRight,
toggleExpand,
}) => {
export const SchemaRow: React.FunctionComponent<ISchemaRow> = ({ 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<React.MouseEventHandler>(
() => {
if (onGoToRef) {
onGoToRef($ref!, node);
}
},
[onGoToRef, node, $ref],
);

const requiredElem = (
<div className={cn('ml-2', required ? 'font-medium' : 'text-darken-7 dark:text-lighten-6')}>
{required ? 'required' : 'optional'}
{validationCount ? `+${validationCount}` : ''}
</div>
);

const combinerOffset = ICON_DIMENSION * node.level;
return (
<div onClick={toggleExpand} className="px-6 flex-1 w-full">
{/* 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. */}
<div className={cn('px-2 flex-1 w-full', className)}>
<div
className="flex items-center text-sm"
className="flex items-center text-sm relative"
style={{
marginLeft: combinerOffset,
marginLeft: ICON_DIMENSION * node.level, // offset for spacing
}}
>
{node.canHaveChildren &&
node.level > 0 && (
<div
className="absolute flex justify-center cursor-pointer p-1 rounded hover:bg-darken-3"
<Caret
isExpanded={!!rowOptions.isExpanded}
style={{
left: combinerOffset,
left: ICON_DIMENSION * -1 + ROW_OFFSET / -2,
width: ICON_DIMENSION,
height: ICON_DIMENSION,
}}
>
<Icon
iconSize={ICON_SIZE}
icon={rowOptions.isExpanded ? 'caret-down' : 'caret-right'}
className="text-darken-9 dark:text-lighten-9"
/>
</div>
size={ICON_SIZE}
/>
)}

{schemaNode.divider && (
<div
className="flex items-center absolute"
style={{
top: 0,
height: 1,
width: `calc(100% - ${combinerOffset}px - 1.5rem)`,
}}
>
<div className="text-darken-7 dark:text-lighten-8 uppercase text-xs pr-2 -ml-4">{schemaNode.divider}</div>
<div className="flex-1 bg-darken-5 dark:bg-lighten-5" style={{ height: 1 }} />
</div>
)}
{schemaNode.divider && <Divider>{schemaNode.divider}</Divider>}

<div className="flex-1 flex truncate">
{name && <div className="mr-2">{name}</div>}

<Types type={type} subtype={subtype}>
{type === '$ref' ? `[${$ref}]` : null}
</Types>

{type === '$ref' && onGoToRef ? (
<a role="button" className="text-blue-4 ml-2" onClick={handleGoToRef}>
(go to ref)
</a>
) : null}

{node.canHaveChildren && <div className="ml-2 text-darken-7 dark:text-lighten-7">{`{${childrenCount}}`}</div>}

{'pattern' in schemaNode && schemaNode.pattern ? (
<div className="ml-2 text-darken-7 dark:text-lighten-7 truncate">(pattern property)</div>
) : null}

{description && (
<Popover
boundary="window"
interactionKind="hover"
className="ml-2 flex-1 truncate flex items-baseline"
target={<div className="text-darken-7 dark:text-lighten-7 w-full truncate">{description}</div>}
targetClassName="text-darken-7 dark:text-lighten-6 w-full truncate"
content={
<div className="p-5" style={{ maxHeight: 500, maxWidth: 400 }}>
<MarkdownViewer markdown={description} />
</div>
}
/>
)}
<Property node={schemaNode} onGoToRef={onGoToRef} />
{description && <Description value={description} />}
</div>

{validationCount ? (
<Popover
boundary="window"
interactionKind="hover"
content={
<div className="p-5" style={{ maxHeight: 500, maxWidth: 400 }}>
{map(Object.keys(nodeValidations), (key, index) => {
const validation = nodeValidations[key];

let elem = null;
if (Array.isArray(validation)) {
elem = validation.map((v, i) => (
<div key={i} className="mt-1 mr-1 flex items-center">
<div className="px-1 bg-gray-2 dark:bg-gray-8 font-bold text-sm rounded">{String(v)}</div>
{i < validation.length - 1 ? <div>,</div> : null}
</div>
));
} else if (typeof validation === 'object') {
elem = (
<div className="m-1 px-1 bg-gray-2 dark:bg-gray-8 font-bold text-sm rounded" key={index}>
{'{...}'}
</div>
);
} else {
elem = (
<div className="m-1 px-1 bg-gray-2 dark:bg-gray-8 font-bold text-sm rounded" key={index}>
{JSON.stringify(validation)}
</div>
);
}

return (
<div key={index} className="py-1 flex items-baseline">
<div className="font-medium pr-2 w-24">{key}:</div>
<div className="flex-1 flex flex-wrap text-center">{elem}</div>
</div>
);
})}
</div>
}
target={requiredElem}
/>
) : (
requiredElem
)}
{rowRendererRight && <div className="ml-2">{rowRendererRight(node)}</div>}
<Validations
required={!!schemaNode.required}
validations={{
...('annotations' in schemaNode &&
schemaNode.annotations.default && { default: schemaNode.annotations.default }),
...('validations' in schemaNode && schemaNode.validations),
}}
/>
</div>
</div>
);
Expand Down
Loading

0 comments on commit dcc8eea

Please sign in to comment.