import React from 'react'; import { JsonSchema, JsonSchema1 } from './schema'; import { getSchemaFromResult, Lookup } from './lookup'; import { ParameterView } from './Parameter'; import styled from 'styled-components'; import Button from '@atlaskit/button'; import ChevronLeftIcon from '@atlaskit/icon/glyph/chevron-left'; import LinkIcon from '@atlaskit/icon/glyph/link'; import { Markdown } from './markdown'; import { BreadcrumbsStateless, BreadcrumbsItem } from '@atlaskit/breadcrumbs'; import Tabs from '@atlaskit/tabs'; import { TabData, OnSelectCallback } from '@atlaskit/tabs/types'; import { CodeBlockWithCopy } from './code-block-with-copy'; import { generateJsonExampleFor, isExample } from './example'; import { Stage, shouldShowInStage } from './stage'; import { externalLinkTo, linkTo, PathElement } from './route-path'; import { ClickElement, Type, Anything } from './Type'; import { Link, LinkProps, useHistory, useLocation } from 'react-router-dom'; import { getTitle, findTitle } from './title'; import { LinkPreservingSearch, NavLinkPreservingSearch } from './search-preserving-link'; import { dump } from 'js-yaml'; import { isExternalReference } from './type-inference'; interface SEPHeadProps { basePathSegments: Array<string>; path: PathElement[]; pathExpanded: boolean; onExpandClick: () => void; } const Head = styled.div` display: flex; flex-direction: row; justify-content: flex-start; align-items: center; margin: 0; padding: 0; `; const Path = styled.div` padding-left: 20px; a { color: inherit; text-decoration: none; } a.active { color: #0057d8; } `; function getObjectPath(basePathSegments: Array<string>, path: PathElement[]): JSX.Element[] { return path.map((pe, i) => ( <BreadcrumbsItem key={`${pe.title}-${i}`} text={pe.title} component={() => ( <NavLinkPreservingSearch to={linkTo(basePathSegments, path.slice(0, i+1).map(p => p.reference))} exact={true}> {getTitle(pe.reference, { title: pe.title !== 'object' ? pe.title : undefined })} </NavLinkPreservingSearch> )} /> )); } const BackButton: React.FC<LinkProps> = props => { const history = useHistory(); return ( <Button key="backButton" iconBefore={<ChevronLeftIcon label="Back" />} href={props.href} onClick={e => { e.preventDefault(); history.push(props.href || ''); }} >Back </Button> ); } function init<A>(arr: Array<A>): Array<A> { if (arr.length === 0) { return arr; } return arr.slice(0, arr.length - 1); } const SEPHead: React.FC<SEPHeadProps> = (props) => { const onExpandClick = () => { props.onExpandClick(); }; const ActionButton = props.path.length <= 1 ? <h1>Root</h1> : ( <LinkPreservingSearch to={linkTo(props.basePathSegments, init(props.path.map(p => p.reference)))} component={BackButton} /> ); return ( <Head> <div>{ActionButton}</div> <Path> <BreadcrumbsStateless isExpanded={props.pathExpanded} onExpand={onExpandClick} > {getObjectPath(props.basePathSegments, props.path)} </BreadcrumbsStateless> </Path> </Head> ); }; const Permalink: React.FC = () => { const location = useLocation(); return ( <Button appearance="link" href={location.pathname + location.search} iconBefore={<LinkIcon label="permalink" />} >Permalink </Button> ); }; type ExpandProps = { onOpen: string; onClosed: string; }; type ExpandState = { open: boolean; }; class Expand extends React.PureComponent<ExpandProps, ExpandState> { UNSAFE_componentWillMount() { this.setState({ open: false }); } render() { const onClick = (e: React.SyntheticEvent<HTMLAnchorElement>) => { e.preventDefault(); this.setState(s => ({ open: !s.open })); }; return ( <> <a href="#" onClick={onClick}>{this.state.open ? this.props.onOpen : this.props.onClosed}</a> {this.state.open && this.props.children} </> ); } } type SchemaExplorerExampleProps = { schema: JsonSchema; lookup: Lookup; stage: Stage; format: 'json' | 'yaml'; }; const FullWidth = styled.div` width: 100%; `; const ErrorHeading = styled.h3` font-size: 14px; padding: 8px 0; `; const SchemaExplorerExample: React.FC<SchemaExplorerExampleProps> = props => { const potentialExample = generateJsonExampleFor(props.schema, props.lookup, props.stage); if (isExample(potentialExample)) { const renderedOutput = props.format === 'json' ? JSON.stringify(potentialExample.value, null, 2) : dump(potentialExample.value); return ( <FullWidth> <CodeBlockWithCopy text={renderedOutput} language={props.format} /> </FullWidth> ); } const messages = new Set(potentialExample.errors.map(e => e.message)); return ( <div> <ErrorHeading>An example could not be generated.</ErrorHeading> <Expand onOpen="(Collapse advanced view)" onClosed="(Expand advanced view)"> <div> This example could not be automatically generated because: <ul> {Array.from(messages.values()).map(m => <li key={m}>{m}</li>)} </ul> For more information please download the JSON Schema. </div> </Expand> </div> ); }; export type SchemaExplorerDetailsProps = { schema: JsonSchema1; reference: string; lookup: Lookup; stage: Stage; clickElement: ClickElement; }; const DescriptionContainer = styled.div` margin-top: 8px; margin-bottom: 10px; `; function getDescriptionForSchema(schema: JsonSchema): string | undefined { if (typeof schema === 'boolean') { return schema ? 'Anything is allowed here.' : 'There is no valid value for this property.'; } if (isExternalReference(schema)) { return 'This is an external reference. Click on the reference to try and view this external JSON Schema. Use the browser back button to return here.' } if (Object.keys(schema).length === 0) { return 'Anything is allowed here.'; } return schema.description; } export const SchemaExplorerDetails: React.FC<SchemaExplorerDetailsProps> = props => { const { schema, reference, clickElement, lookup, stage } = props; const properties = schema.properties || {}; const renderedProps = Object.keys(properties) .map(propertyName => { const propertySchema = properties[propertyName]; const lookupResult = lookup.getSchema(propertySchema); return ({ propertyName, initialSchema: propertySchema, lookupResult, propertyReference: lookupResult?.baseReference || `${reference}/properties/${propertyName}` }); }) .filter(p => { if (p.lookupResult === undefined) { return true; } return shouldShowInStage(stage, p.lookupResult.schema); }) .map(p => { const isRequired = typeof schema.required !== 'undefined' && !!schema.required.find(n => n === p.propertyName); if (p.lookupResult) { return ( <ParameterView key={p.propertyName} name={p.propertyName} description={getDescriptionForSchema(p.lookupResult.schema)} required={isRequired} deprecated={false} schema={p.lookupResult.schema} reference={p.propertyReference} lookup={lookup} clickElement={clickElement} /> ); } else { return ( <ParameterView key={p.propertyName} name={p.propertyName} description={getDescriptionForSchema(p.initialSchema)} required={isRequired} schema={p.initialSchema} reference={p.propertyReference} lookup={lookup} clickElement={clickElement} /> ); } }); const additionalProperties = new Array<JSX.Element>(); if (typeof schema.additionalProperties === 'boolean') { if (schema.additionalProperties) { additionalProperties.push(( <ParameterView key="dac__schema-additional-properties" name="Additional Properties" description="Extra properties of any type may be provided to this object." required={false} schema={{}} reference={`${reference}/additionalProperties`} lookup={lookup} clickElement={clickElement} /> )); } } else if (schema.additionalProperties !== undefined) { const additionalPropertiesResult = lookup.getSchema(schema.additionalProperties); if (additionalPropertiesResult !== undefined) { const resolvedReference = additionalPropertiesResult.baseReference || `${reference}/additionalProperties`; additionalProperties.push(( <ParameterView key="dac__schema-additional-properties" name="Additional Properties" description={getDescriptionForSchema(additionalPropertiesResult)} required={false} schema={additionalPropertiesResult.schema} reference={resolvedReference} lookup={lookup} clickElement={clickElement} /> )); } } const patternProperties = schema.patternProperties || {}; const renderedPatternProperties = Object.keys(patternProperties).map((pattern, i) => { const lookupResult = lookup.getSchema(patternProperties[pattern]); return ( <ParameterView key={`pattern-properties-${i}`} name={`/${pattern}/ (keys of pattern)`} description={getDescriptionForSchema(schema)} required={false} schema={getSchemaFromResult(lookupResult) || patternProperties[pattern]} reference={lookupResult?.baseReference || `${reference}/patternProperties/${pattern}`} lookup={lookup} clickElement={clickElement} /> ) }) const hasProperties = renderedProps.length > 0 || renderedPatternProperties.length > 0 || additionalProperties.length > 0; const { anyOf, allOf, oneOf, not } = schema; const compositeOnlyType: JsonSchema1 = { anyOf, allOf, oneOf, not }; let mixinProps = <></>; if (Object.keys(compositeOnlyType).some(key => compositeOnlyType[key] !== undefined)) { mixinProps = ( <> <h3 key="mixins-header">Mixins</h3> {hasProperties ? <p key="mixins-description">This type has all of the properties below, but must also match this type:</p> : <p key="mixins-description">This object must match the following conditions:</p> } <Type key="mixins-type" s={compositeOnlyType} clickElement={clickElement} lookup={lookup} reference={reference} /> </> ); } let allRenderedProperties = <></>; if (hasProperties) { allRenderedProperties = ( <> <h3 key="properties-header">Properties</h3> {renderedProps} {renderedPatternProperties} {additionalProperties} </> ) } return ( <div> <DescriptionContainer> {schema.description && <Markdown source={schema.description} />} </DescriptionContainer> {mixinProps} {allRenderedProperties} </div> ); }; type JsonSchemaObjectClickProps = { basePathSegments: Array<string>; path: Array<PathElement>; }; function createClickElement(details: JsonSchemaObjectClickProps): ClickElement { return (props) => { if (isExternalReference(props.schema) && props.schema.$ref !== undefined) { const externalUrl = externalLinkTo(details.basePathSegments, props.schema.$ref); if (externalUrl === null) { return <Anything />; } else { return <Link to={externalUrl}>$ref: {props.schema.$ref}</Link>; } } const references = [...details.path.map(p => p.reference), props.reference]; return ( <LinkPreservingSearch to={linkTo(details.basePathSegments, references)}> {findTitle(props.reference, props.schema) || props.fallbackTitle} </LinkPreservingSearch> ); }; } export type SchemaExplorerProps = { basePathSegments: Array<string>; path: PathElement[]; schema: JsonSchema1; stage: Stage; lookup: Lookup; }; export type ViewType = 'details' | 'example-json' | 'example-yaml'; export type SchemaExplorerState = { pathExpanded: boolean; view: ViewType; }; const LabelToViewType: { [label: string]: ViewType } = { 'Details': 'details', 'Example (JSON)': 'example-json', 'Example (YAML)': 'example-yaml' }; const ViewTypeToTab: { [viewType: string]: number } = { 'details': 0, 'example-json': 1, 'example-yaml': 2 }; export class SchemaExplorer extends React.PureComponent<SchemaExplorerProps, SchemaExplorerState> { private static Container = styled.section` display: flex; flex-direction: column; flex-grow: 1; padding: 24px 20px; margin: 0; max-width: 100%; `; private static HeadingContainer = styled.div` display: flex; flex-direction: row; justify-content: space-between; `; private static Heading = styled.h1` font-size: 16px; font-weight: 600; padding-top: 24px; margin: 5px 8px; `; UNSAFE_componentWillMount() { this.setState({ pathExpanded: false, view: 'details' }); } render() { const { path, schema, lookup, stage, basePathSegments } = this.props; const { pathExpanded } = this.state; if (path.length === 0) { return <div>TODO What do we do when the reference could not be found? Error maybe?</div>; } const currentPathElement = path[path.length - 1]; const tabData: TabData[] = [{ label: 'Details', content: ( <SchemaExplorerDetails schema={schema} reference={currentPathElement.reference} lookup={lookup} stage={stage} clickElement={createClickElement({ basePathSegments, path })} /> ) }, { label: 'Example (JSON)', content: ( <SchemaExplorerExample schema={schema} lookup={lookup} stage={stage} format="json" /> ) }, { label: 'Example (YAML)', content: ( <SchemaExplorerExample schema={schema} lookup={lookup} stage={stage} format="yaml" /> ) }]; const onTabSelect: OnSelectCallback = tab => { this.setState({ view: LabelToViewType[tab.label || 'Details'] }); }; return ( <SchemaExplorer.Container> <SEPHead basePathSegments={basePathSegments} path={path} pathExpanded={pathExpanded} onExpandClick={() => this.onExpandClick()} /> <SchemaExplorer.HeadingContainer> <SchemaExplorer.Heading>{getTitle(currentPathElement.reference, schema)}</SchemaExplorer.Heading> <Permalink /> </SchemaExplorer.HeadingContainer> <Tabs tabs={tabData} selected={ViewTypeToTab[this.state.view || 'details']} onSelect={onTabSelect} /> </SchemaExplorer.Container> ); } private onExpandClick(): void { this.setState({ pathExpanded: true }); } }