diff --git a/src/__fixtures__/extensions/simple.json b/src/__fixtures__/extensions/simple.json new file mode 100644 index 00000000..d9042e71 --- /dev/null +++ b/src/__fixtures__/extensions/simple.json @@ -0,0 +1,97 @@ +{ + "title": "User", + "type": "object", + "x-stoplight": { "id": "root-id" }, + "properties": { + "name": { + "type": "string", + "const": "Constant name", + "examples": ["Example name", "Different name"], + "x-stoplight": { "id": "name-id" } + }, + "age": { + "type": "number", + "minimum": 10, + "maximum": 40, + "x-stoplight": { "id": "age-id" } + }, + "completed_at": { + "type": "string", + "format": "date-time", + "x-stoplight": { "id": "completed_at-id" } + }, + "list": { + "type": ["null", "array"], + "items": { + "type": ["string", "number"], + "x-stoplight": { "id": "list-items-id" } + }, + "minItems": 1, + "maxItems": 4, + "x-stoplight": { "id": "list-id" } + }, + "email": { + "type": "string", + "format": "email", + "examples": ["one@email.com", "two@email.com"], + "deprecated": true, + "default": "default@email.com", + "minLength": 2, + "x-stoplight": { "id": "email-id" } + }, + "list-of-objects": { + "type": "array", + "items": { + "type": "object", + "x-stoplight": { "id": "list-of-objects-items-id" }, + "properties": { + "id": { + "type": "string", + "x-stoplight": { "id": "list-of-objects-items-id-id" } + }, + "friend": { + "type": "object", + "x-stoplight": { "id": "list-of-objects-items-friend-id" }, + "properties": { + "id": { + "type": "string", + "x-stoplight": { "id": "list-of-objects-items-friend-id-id" } + }, + "name": { + "type": "object", + "x-stoplight": { "id": "list-of-objects-items-friend-name-id" }, + "properties": { + "first": { + "type": "string", + "x-stoplight": { "id": "list-of-objects-items-friend-name-first-id" } + }, + "last": { + "type": "string", + "x-stoplight": { "id": "list-of-objects-items-friend-name-last-id" } + } + } + } + } + } + } + }, + "minItems": 1, + "maxItems": 4, + "x-stoplight": { "id": "list-of-objects-id" } + }, + "friend": { + "type": "object", + "x-stoplight": { "id": "friend-id" }, + "properties": { + "id": { + "type": "string", + "x-stoplight": { "id": "friend-id-id" } + }, + "name": { + "type": "string", + "x-stoplight": { "id": "friend-name-id" } + } + } + } + } +} diff --git a/src/__stories__/Default.tsx b/src/__stories__/Default.tsx index 1f4bfffd..0f1d1d4e 100644 --- a/src/__stories__/Default.tsx +++ b/src/__stories__/Default.tsx @@ -30,6 +30,13 @@ CustomRowAddon.args = { ), }; +export const Expansions = Template.bind({}); +Expansions.args = { + schema: arrayOfComplexObjects as JSONSchema4, + renderRootTreeLines: true, + defaultExpandedDepth: 0, +}; + export const ArrayOfObjects = Template.bind({}); ArrayOfObjects.args = { schema: arrayOfComplexObjects as JSONSchema4, renderRootTreeLines: true }; diff --git a/src/__stories__/VendorExtensions.tsx b/src/__stories__/VendorExtensions.tsx new file mode 100644 index 00000000..d91e1afd --- /dev/null +++ b/src/__stories__/VendorExtensions.tsx @@ -0,0 +1,40 @@ +import { Flex } from '@stoplight/mosaic'; +import { Story } from '@storybook/react'; +import { JSONSchema4 } from 'json-schema'; +import React from 'react'; + +import { JsonSchemaProps, JsonSchemaViewer } from '../components/JsonSchemaViewer'; + +const defaultSchema = require('../__fixtures__/default-schema.json'); +const extensionsSchema = require('../__fixtures__/extensions/simple.json'); + +export default { + component: JsonSchemaViewer, + argTypes: {}, +}; + +const Template: Story = ({ schema = defaultSchema as JSONSchema4, ...args }) => ( + +); + +export const ExtensionRowSchema = Template.bind({}); +ExtensionRowSchema.args = { + schema: extensionsSchema as JSONSchema4, + defaultExpandedDepth: Infinity, + renderRootTreeLines: true, + renderExtensionAddon: ({ nestingLevel, vendorExtensions }) => { + if (nestingLevel < 1) { + return null; + } + + if (typeof vendorExtensions['x-stoplight'] === 'undefined') { + return null; + } + + return ( + + {JSON.stringify(vendorExtensions['x-stoplight'], null, 2)} + + ); + }, +}; diff --git a/src/components/JsonSchemaViewer.tsx b/src/components/JsonSchemaViewer.tsx index e7f51457..4056a711 100644 --- a/src/components/JsonSchemaViewer.tsx +++ b/src/components/JsonSchemaViewer.tsx @@ -36,6 +36,7 @@ const JsonSchemaViewerComponent = ({ defaultExpandedDepth = 1, onGoToRef, renderRowAddon, + renderExtensionAddon, hideExamples, renderRootTreeLines, disableCrumbs, @@ -49,6 +50,7 @@ const JsonSchemaViewerComponent = ({ viewMode, onGoToRef, renderRowAddon, + renderExtensionAddon, hideExamples, renderRootTreeLines, disableCrumbs, @@ -59,6 +61,7 @@ const JsonSchemaViewerComponent = ({ viewMode, onGoToRef, renderRowAddon, + renderExtensionAddon, hideExamples, renderRootTreeLines, disableCrumbs, diff --git a/src/components/SchemaRow/SchemaRow.tsx b/src/components/SchemaRow/SchemaRow.tsx index e9b8b51f..4703b2a0 100644 --- a/src/components/SchemaRow/SchemaRow.tsx +++ b/src/components/SchemaRow/SchemaRow.tsx @@ -10,6 +10,7 @@ import { COMBINER_NAME_MAP } from '../../consts'; import { useJSVOptionsContext } from '../../contexts'; import { getNodeId, getOriginalNodeId } from '../../hash'; import { isPropertyRequired, visibleChildren } from '../../tree'; +import { extractVendorExtensions } from '../../utils/extractVendorExtensions'; import { Caret, Description, getValidationsFromSchema, Types, Validations } from '../shared'; import { ChildStack } from '../shared/ChildStack'; import { Error } from '../shared/Error'; @@ -30,6 +31,7 @@ export const SchemaRow: React.FunctionComponent = React.memo( const { defaultExpandedDepth, renderRowAddon, + renderExtensionAddon, onGoToRef, hideExamples, renderRootTreeLines, @@ -65,6 +67,12 @@ export const SchemaRow: React.FunctionComponent = React.memo( const validations = isRegularNode(schemaNode) ? schemaNode.validations : {}; const hasProperties = useHasProperties({ required, deprecated, validations }); + const [totalVendorExtensions, vendorExtensions] = React.useMemo( + () => extractVendorExtensions(schemaNode.fragment), + [schemaNode.fragment], + ); + const hasVendorProperties = totalVendorExtensions > 0; + const annotationRootOffset = renderRootTreeLines ? 0 : 8; let annotationLeftOffset = -20 - annotationRootOffset; if (nestingLevel > 1) { @@ -100,11 +108,9 @@ export const SchemaRow: React.FunctionComponent = React.memo( }} > {!isRootLevel && } - {parentChangeType !== 'added' && parentChangeType !== 'removed' ? ( ) : null} - = React.memo( cursor={isCollapsible ? 'pointer' : undefined} > {isCollapsible ? : null} - {schemaNode.subpath.length > 0 && shouldShowPropertyName(schemaNode) && ( = React.memo( /> )} - {hasProperties && } - - {typeof description === 'string' && description.length > 0 && } - + {hasVendorProperties && renderExtensionAddon ? ( + {renderExtensionAddon({ schemaNode, nestingLevel, vendorExtensions })} + ) : null} - - {renderRowAddon ? {renderRowAddon({ schemaNode, nestingLevel })} : null} {isCollapsible && isExpanded ? ( diff --git a/src/components/SchemaRow/TopLevelSchemaRow.tsx b/src/components/SchemaRow/TopLevelSchemaRow.tsx index 5f46c78f..5ccd4999 100644 --- a/src/components/SchemaRow/TopLevelSchemaRow.tsx +++ b/src/components/SchemaRow/TopLevelSchemaRow.tsx @@ -5,8 +5,10 @@ import { isEmpty } from 'lodash'; import * as React from 'react'; import { COMBINER_NAME_MAP } from '../../consts'; +import { useJSVOptionsContext } from '../../contexts'; import { useIsOnScreen } from '../../hooks/useIsOnScreen'; import { isComplexArray, isDictionaryNode, visibleChildren } from '../../tree'; +import { extractVendorExtensions } from '../../utils/extractVendorExtensions'; import { showPathCrumbsAtom } from '../PathCrumbs/state'; import { Description, getValidationsFromSchema, Validations } from '../shared'; import { ChildStack } from '../shared/ChildStack'; @@ -18,11 +20,18 @@ export const TopLevelSchemaRow = ({ schemaNode, skipDescription, }: Pick & { skipDescription?: boolean }) => { + const { renderExtensionAddon } = useJSVOptionsContext(); + const { selectedChoice, setSelectedChoice, choices } = useChoices(schemaNode); const childNodes = React.useMemo(() => visibleChildren(selectedChoice.type), [selectedChoice.type]); const nestingLevel = 0; const nodeId = schemaNode.fragment?.['x-stoplight']?.id; + const [totalVendorExtensions, vendorExtensions] = React.useMemo( + () => extractVendorExtensions(schemaNode.fragment), + [schemaNode.fragment], + ); + const hasVendorProperties = totalVendorExtensions > 0; // regular objects are flattened at the top level if (isRegularNode(schemaNode) && isPureObjectNode(schemaNode)) { @@ -30,6 +39,9 @@ export const TopLevelSchemaRow = ({ <> {!skipDescription ? : null} + {hasVendorProperties && renderExtensionAddon + ? renderExtensionAddon({ schemaNode, nestingLevel, vendorExtensions }) + : null} - ) : null} - {childNodes.length > 0 ? ( React.ReactNode; +export interface ExtensionRowProps { + schemaNode: SchemaNode; + nestingLevel: number; + vendorExtensions: Record; +} + +export type ExtensionAddonRenderer = (props: ExtensionRowProps) => React.ReactNode; + export type ViewMode = 'read' | 'write' | 'standalone'; export type JSONSchema = JSONSchema4 | JSONSchema6 | JSONSchema7; diff --git a/src/utils/extractVendorExtensions.ts b/src/utils/extractVendorExtensions.ts new file mode 100644 index 00000000..db3069b8 --- /dev/null +++ b/src/utils/extractVendorExtensions.ts @@ -0,0 +1,26 @@ +import { SchemaFragment } from '@stoplight/json-schema-tree'; + +export type VendorExtensionsList = { + [keyof: string]: unknown; +}; + +export type VendorExtensionsResult = [number, VendorExtensionsList]; + +/** + * Extract all vendor extensions or properties prefix with 'x-' from the schema definition + * @param fragment The fragment to extract the vendor extensions from + * @returns VendorExtensionsResult + */ +export function extractVendorExtensions(fragment: SchemaFragment | boolean): VendorExtensionsResult { + if (typeof fragment === 'boolean') { + return [0, {}]; + } + + const extensionKeys = Object.keys(fragment).filter(key => key.startsWith('x-')); + let vendorExtensions = {}; + extensionKeys.forEach(key => { + vendorExtensions[key] = fragment[key]; + }); + + return [extensionKeys.length, vendorExtensions]; +} diff --git a/src/utils/index.ts b/src/utils/index.ts index 29ec0754..3c291299 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1 +1,2 @@ +export * from './extractVendorExtensions'; export * from './printName';