Skip to content
Closed
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
11 changes: 11 additions & 0 deletions src/__stories__/JsonSchemaViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -108,4 +108,15 @@ storiesOf('JsonSchemaViewer', module)
onGoToRef={action('onGoToRef')}
/>
</div>
))
.add('with mask controls', () => (
<JsonSchemaViewer
maskControlsHandler={() => ['properties/name']}
name={text('name', 'my schema')}
schema={schema as JSONSchema4}
defaultExpandedDepth={number('defaultExpandedDepth', 2)}
expanded={boolean('expanded', false)}
hideTopBar={boolean('hideTopBar', false)}
onGoToRef={action('onGoToRef')}
/>
));
13 changes: 12 additions & 1 deletion src/components/JsonSchemaViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import * as React from 'react';
import { JSONSchema4 } from 'json-schema';
import { GoToRefHandler } from '../types';
import { isSchemaViewerEmpty, renderSchema } from '../utils';
import { SelectedPaths } from './MaskControls';
import { SchemaTree } from './SchemaTree';

export type FallbackComponent = React.ComponentType<{ error: Error | null }>;
Expand All @@ -22,6 +23,9 @@ export interface IJsonSchemaViewer {
hideTopBar?: boolean;
maxRows?: number;
onGoToRef?: GoToRefHandler;
maskControlsHandler?: (attrs: SelectedPaths) => string[];
maskUpdater?: () => React.ReactElement;
maskProps?: SelectedPaths;
FallbackComponent?: FallbackComponent;
}

Expand All @@ -33,7 +37,14 @@ export class JsonSchemaViewerComponent extends React.PureComponent<IJsonSchemaVi

this.treeStore = new TreeStore({
defaultExpandedDepth: this.expandedDepth,
nodes: Array.from(renderSchema(props.dereferencedSchema || props.schema, 0, { path: [] }, { mergeAllOf: true })),
nodes: Array.from(
renderSchema(
props.dereferencedSchema || props.schema,
0,
{ path: [] },
{ mergeAllOf: !props.maskControlsHandler },
),
),
});
}

Expand Down
176 changes: 176 additions & 0 deletions src/components/MaskControls.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
import { Button, Checkbox as UIKitCheckbox, Colors, Icon, Tooltip } from '@stoplight/ui-kit';
import * as React from 'react';
import { Dispatch, SetStateAction, useState } from 'react';

const ICON_SIZE = 12;

export type SelectedPaths = Array<{ path: string; required: boolean }>;

export type MaskingProps = Array<{ path: string; required: any }>;

interface IMaskGenericControls {
node: { level: number; metadata: { path: string[] } };
maskControlsHandler?: (attrs: SelectedPaths) => string[];
setSelectedProps: Dispatch<SetStateAction<Array<{ path: string; required: number }>>>;
}

interface IMaskControls extends IMaskGenericControls {
selectedProps: SelectedPaths;
}
interface ICheckbox extends IMaskGenericControls {
isChecked: boolean;
}

interface IRequired extends IMaskGenericControls {
idx: number;
setIdx: Dispatch<SetStateAction<number>>;
}

function addAttr(old: Array<{ path: string; required: number }>, path: string, required: number) {
const attr = old.find(oldAttr => oldAttr.path === path);

return attr
? old.map(oldAttr => {
return oldAttr.path === path ? Object.assign({}, oldAttr, { required }) : oldAttr;
})
: old.concat({ path, required });
}

function updateSelectedAttrs(
isChecked: boolean,
oldAttrs: Array<{ path: string; required: number }>,
path: string,
idx: number,
): Array<{ path: string; required: number }> {
return isChecked ? addAttr(oldAttrs, path, idx) : oldAttrs.filter(oldAttr => oldAttr.path !== path);
}

const toRequiredNumForm = (node: { required?: boolean } = {}) => {
if (node.required === true) {
return 1;
} else if (node.required === false) {
return 2;
} else {
return 0;
}
};

function toMaskAttrsWitReqAsBool(maskAttrs: Array<{ path: string; required: number }>, nodePath: string) {
return maskAttrs.map(maskAttr => {
const { path, required } = maskAttr;

const requiredState = {
0: {},
1: { required: true },
2: { required: false },
}[required || 0];

return nodePath === path ? Object.assign({}, { path }, requiredState) : maskAttr;
});
}

const toNodePath = (node: { metadata: { path: string[] } }) => node.metadata.path.join('/');

function updateMaskAttrs(
setSelectedProps: Dispatch<SetStateAction<Array<{ path: string; required: number }>>>,
isChecked: boolean,
node: { metadata: { path: string[] } },
idx: number,
maskControlsHandler?: (attrs: SelectedPaths) => string[],
) {
setSelectedProps(oldProps => {
const nodePath = toNodePath(node);

const maskAttrs = updateSelectedAttrs(isChecked, oldProps, nodePath, idx);
const maskAttrsWithBools = toMaskAttrsWitReqAsBool(maskAttrs, nodePath);

if (maskControlsHandler) {
maskControlsHandler(maskAttrsWithBools);
}

return maskAttrsWithBools;
});
}

const Required: React.FunctionComponent<IRequired> = ({
idx,
setIdx,
setSelectedProps,
maskControlsHandler,
node,
}: IRequired) => {
return (
<Tooltip boundary="window" position="top">
<Button
style={{ paddingRight: '1em' }}
small
minimal
title={['No Change', 'Required', 'Not Required'][idx]}
icon={<Icon color={[Colors.GRAY1, Colors.RED3, Colors.GREEN2][idx]} iconSize={ICON_SIZE} icon="issue" />}
onClick={(evt: { stopPropagation: () => void }) => {
evt.stopPropagation();

setIdx((prev: number) => {
return prev >= 2 ? 0 : prev + 1;
});

updateMaskAttrs(setSelectedProps, true, node, idx + 1, maskControlsHandler);
}}
/>
</Tooltip>
);
};

const Checkbox: React.FunctionComponent<ICheckbox> = ({
isChecked,
setSelectedProps,
maskControlsHandler,
node,
}: ICheckbox) => {
return (
<UIKitCheckbox
className="m-0 self-center"
checked={isChecked}
onChange={(evt: any) => {
evt.persist();

updateMaskAttrs(setSelectedProps, evt.target.checked, node, 0, maskControlsHandler);
}}
/>
);
};

const MaskControls: React.FunctionComponent<IMaskControls> = ({
node,
maskControlsHandler,
setSelectedProps,
selectedProps,
}: IMaskControls) => {
const theNode = selectedProps.find(({ path }: { path: string }) => path === toNodePath(node));
const isChecked = !!theNode;
const [idx, setIdx] = useState(toRequiredNumForm(theNode));

return (
<div className="flex">
{node.level && maskControlsHandler ? (
<>
<Checkbox
isChecked={isChecked}
setSelectedProps={setSelectedProps}
maskControlsHandler={maskControlsHandler}
node={node}
/>
<Required
idx={idx}
setIdx={setIdx}
setSelectedProps={setSelectedProps}
maskControlsHandler={maskControlsHandler}
node={node}
/>
</>
) : null}
</div>
);
};

export default MaskControls;
16 changes: 13 additions & 3 deletions src/components/SchemaRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,21 @@ export interface ISchemaRow {
node: SchemaTreeListNode;
rowOptions: IRowRendererOptions;
onGoToRef?: GoToRefHandler;
maskControls?: () => React.ReactElement;
toggleExpand: () => void;
}

const ICON_SIZE = 12;
const ICON_DIMENSION = 20;
const ROW_OFFSET = 7;

export const SchemaRow: React.FunctionComponent<ISchemaRow> = ({ node, rowOptions, onGoToRef }) => {
export const SchemaRow: React.FunctionComponent<ISchemaRow> = ({
node,
rowOptions,
onGoToRef,
maskControls,
toggleExpand,
}) => {
const schemaNode = node.metadata as SchemaNodeWithMeta;
const { name, $ref, subtype, required } = schemaNode;

Expand Down Expand Up @@ -59,13 +67,15 @@ export const SchemaRow: React.FunctionComponent<ISchemaRow> = ({ node, rowOption
);

return (
<div className="px-2 flex-1 w-full">
<div onClick={toggleExpand} className="px-6 flex-1 w-full">
<div
className="flex items-center text-sm relative"
style={{
marginLeft: ICON_DIMENSION * node.level, // offset for spacing
}}
>
{maskControls && maskControls()}

{node.canHaveChildren &&
node.level > 0 && (
<div
Expand All @@ -85,7 +95,7 @@ export const SchemaRow: React.FunctionComponent<ISchemaRow> = ({ node, rowOption
)}

{schemaNode.divider && (
<div className="flex items-center w-full absolute" style={{ top: -9, height: 1 }}>
<div className="flex items-center w-full absolute" style={{ top: maskControls ? -3 : -9, height: 1 }}>
<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>
Expand Down
61 changes: 55 additions & 6 deletions src/components/SchemaTree.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { TreeList, TreeListEvents, TreeStore } from '@stoplight/tree-list';
import { TreeList, TreeStore } from '@stoplight/tree-list';
import { Button } from '@stoplight/ui-kit';
import * as cn from 'classnames';
import { JSONSchema4 } from 'json-schema';
import { observer } from 'mobx-react-lite';
import * as React from 'react';

import { GoToRefHandler } from '../types';
import { SchemaRow } from './';
import MaskControls, { MaskingProps, SelectedPaths } from './MaskControls';

export interface ISchemaTree {
treeStore: TreeStore;
Expand All @@ -17,24 +18,52 @@ export interface ISchemaTree {
expanded?: boolean;
maxRows?: number;
onGoToRef?: GoToRefHandler;
maskControlsHandler?: (attrs: SelectedPaths) => string[];
maskUpdater?: () => React.ReactElement;
maskProps?: SelectedPaths;
}

const canDrag = () => false;

export const SchemaTree = observer<ISchemaTree>(props => {
const { hideTopBar, name, treeStore, maxRows, className, onGoToRef } = props;

treeStore.on(TreeListEvents.NodeClick, (e, node) => treeStore.toggleExpand(node));

const itemData = {
treeStore,
count: treeStore.nodes.length,
onGoToRef,
};

const [selectedProps, setSelectedProps] = React.useState<MaskingProps>((props.maskProps || []) as MaskingProps);

const rowRenderer = React.useCallback(
(node, rowOptions) => <SchemaRow node={node} rowOptions={rowOptions} {...itemData} />,
[itemData.count],
(node, rowOptions) => {
const possibleProps = props.maskControlsHandler
Copy link
Contributor

@P0lip P0lip Jul 5, 2019

Choose a reason for hiding this comment

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

Suggest passing custom rowRenderer rather than having this weird logic here.
You can refer to https://github.com/stoplightio/tree-list/blob/master/src/components/TreeListItem.tsx#L39 to have a better understanding.

? {
maskControls: () => (
<MaskControls
node={node}
maskControlsHandler={props.maskControlsHandler}
setSelectedProps={setSelectedProps}
selectedProps={selectedProps}
/>
),
}
: {};

return (
<SchemaRow
toggleExpand={() => {
treeStore.toggleExpand(node);
}}
{...possibleProps}
node={node}
rowOptions={rowOptions}
{...itemData}
/>
);
},
[itemData.count, selectedProps],
);

return (
Expand All @@ -53,6 +82,26 @@ export const SchemaTree = observer<ISchemaTree>(props => {
rowRenderer={rowRenderer}
canDrag={canDrag}
/>

{props.maskControlsHandler && (
<div className="pt-4 flex self-end justify-between">
{props.maskUpdater && props.maskUpdater()}

<Button
intent="none"
title="clear selection"
onClick={() => {
setSelectedProps([]);

if (props.maskControlsHandler) {
props.maskControlsHandler([]);
}
}}
>
Clear Selection
</Button>
</div>
)}
</div>
);
});
Expand Down
4 changes: 3 additions & 1 deletion src/components/__tests__/SchemaRow.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,9 @@ describe('SchemaRow component', () => {
isExpanded: true,
};

const wrapper = shallow(shallow(<SchemaRow node={node as SchemaTreeListNode} rowOptions={rowOptions} />)
const wrapper = shallow(shallow(
<SchemaRow toggleExpand={() => null} node={node as SchemaTreeListNode} rowOptions={rowOptions} />,
)
.find(Popover)
.prop('content') as React.ReactElement);

Expand Down
2 changes: 1 addition & 1 deletion src/utils/renderSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ export const renderSchema: Walker = function*(schema, level = 0, meta = { path:

const { path } = meta;

for (const node of walk(parsedSchema)) {
for (const node of walk(resolvedSchema)) {
const baseNode: SchemaTreeListNode = {
id: node.id,
level,
Expand Down