Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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),

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@P0lip it might be just me but I find it a bit intimidating to read. Would you mind wrapping this with a small helper function? E.g. getAnnotations() + getValidations()?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds good to me.

}}
/>
</div>
</div>
);
Expand Down
Loading