diff --git a/src/__tests__/__snapshots__/index.spec.tsx.snap b/src/__tests__/__snapshots__/index.spec.tsx.snap index cec9e77e..6837c649 100644 --- a/src/__tests__/__snapshots__/index.spec.tsx.snap +++ b/src/__tests__/__snapshots__/index.spec.tsx.snap @@ -258,6 +258,7 @@ exports[`HTML Output given anyOf combiner placed next to allOf given allOf mergi
type
string +
required
@@ -425,6 +426,7 @@ exports[`HTML Output given oneOf combiner placed next to allOf given allOf mergi
type
string
+
required
@@ -548,6 +550,7 @@ exports[`HTML Output given standalone mode, should populate proper nodes 1`] = `
id
string
+
read-only @@ -559,6 +562,7 @@ exports[`HTML Output given standalone mode, should populate proper nodes 1`] = `
description
string +
write-only diff --git a/src/components/JsonSchemaViewer.tsx b/src/components/JsonSchemaViewer.tsx index 9247d425..b35409e6 100644 --- a/src/components/JsonSchemaViewer.tsx +++ b/src/components/JsonSchemaViewer.tsx @@ -14,8 +14,9 @@ import * as React from 'react'; import { JSVOptions, JSVOptionsContextProvider } from '../contexts'; import type { JSONSchema } from '../types'; -import { PathCrumbs, pathCrumbsAtom } from './PathCrumbs'; +import { PathCrumbs } from './PathCrumbs'; import { TopLevelSchemaRow } from './SchemaRow'; +import { hoveredNodeAtom } from './SchemaRow/state'; export type JsonSchemaProps = Partial & { schema: JSONSchema; @@ -74,10 +75,10 @@ const JsonSchemaViewerInner = ({ JsonSchemaProps, 'schema' | 'viewMode' | 'className' | 'resolveRef' | 'emptyText' | 'onTreePopulated' | 'maxHeight' | 'parentCrumbs' >) => { - const setPathCrumbs = useUpdateAtom(pathCrumbsAtom); + const setHoveredNode = useUpdateAtom(hoveredNodeAtom); const onMouseLeave = React.useCallback(() => { - setPathCrumbs([]); - }, [setPathCrumbs]); + setHoveredNode(null); + }, [setHoveredNode]); const { jsonSchemaTreeRoot, nodeCount } = React.useMemo(() => { const jsonSchemaTree = new JsonSchemaTree(schema, { diff --git a/src/components/PathCrumbs/index.tsx b/src/components/PathCrumbs/index.tsx index 8d5f2590..6930ddf3 100644 --- a/src/components/PathCrumbs/index.tsx +++ b/src/components/PathCrumbs/index.tsx @@ -1,15 +1,9 @@ -import { isRegularNode, isRootNode, SchemaNode } from '@stoplight/json-schema-tree'; import { Box, HStack } from '@stoplight/mosaic'; -import { atom, useAtom } from 'jotai'; +import { useAtom } from 'jotai'; import * as React from 'react'; import { useJSVOptionsContext } from '../../contexts'; - -export const showPathCrumbsAtom = atom(false); - -export const pathCrumbsAtom = atom([], (_get, set, node) => { - set(pathCrumbsAtom, propertyPathToObjectPath(node as SchemaNode)); -}); +import { pathCrumbsAtom, showPathCrumbsAtom } from './state'; export const PathCrumbs = ({ parentCrumbs = [] }: { parentCrumbs?: string[] }) => { const [showPathCrumbs] = useAtom(showPathCrumbsAtom); @@ -67,32 +61,3 @@ export const PathCrumbs = ({ parentCrumbs = [] }: { parentCrumbs?: string[] }) = ); }; - -function propertyPathToObjectPath(node: SchemaNode) { - const objectPath: string[] = []; - - let currentNode: SchemaNode | null = node; - while (currentNode && !isRootNode(currentNode)) { - if (isRegularNode(currentNode)) { - const pathPart = currentNode.subpath[currentNode.subpath.length - 1]; - - if (currentNode.primaryType === 'array') { - const key = `${pathPart || ''}[]`; - if (objectPath[objectPath.length - 1]) { - objectPath[objectPath.length - 1] = key; - } else { - objectPath.push(key); - } - } else if ( - pathPart && - (currentNode.subpath.length !== 2 || !['allOf', 'oneOf', 'anyOf'].includes(currentNode.subpath[0])) - ) { - objectPath.push(currentNode.subpath[currentNode.subpath.length - 1]); - } - } - - currentNode = currentNode.parent; - } - - return objectPath.reverse(); -} diff --git a/src/components/PathCrumbs/state.ts b/src/components/PathCrumbs/state.ts new file mode 100644 index 00000000..8545b9e5 --- /dev/null +++ b/src/components/PathCrumbs/state.ts @@ -0,0 +1,43 @@ +import { isRegularNode, isRootNode, SchemaNode } from '@stoplight/json-schema-tree'; +import { atom } from 'jotai'; + +import { hoveredNodeAtom } from '../SchemaRow/state'; + +export const showPathCrumbsAtom = atom(false); + +export const pathCrumbsAtom = atom(get => { + const node = get(hoveredNodeAtom); + + if (!node) return []; + + return propertyPathToObjectPath(node as SchemaNode); +}); + +function propertyPathToObjectPath(node: SchemaNode) { + const objectPath: string[] = []; + + let currentNode: SchemaNode | null = node; + while (currentNode && !isRootNode(currentNode)) { + if (isRegularNode(currentNode)) { + const pathPart = currentNode.subpath[currentNode.subpath.length - 1]; + + if (currentNode.primaryType === 'array') { + const key = `${pathPart || ''}[]`; + if (objectPath[objectPath.length - 1]) { + objectPath[objectPath.length - 1] = key; + } else { + objectPath.push(key); + } + } else if ( + pathPart && + (currentNode.subpath.length !== 2 || !['allOf', 'oneOf', 'anyOf'].includes(currentNode.subpath[0])) + ) { + objectPath.push(currentNode.subpath[currentNode.subpath.length - 1]); + } + } + + currentNode = currentNode.parent; + } + + return objectPath.reverse(); +} diff --git a/src/components/SchemaRow/SchemaRow.tsx b/src/components/SchemaRow/SchemaRow.tsx index 770545d2..180ed8d2 100644 --- a/src/components/SchemaRow/SchemaRow.tsx +++ b/src/components/SchemaRow/SchemaRow.tsx @@ -7,29 +7,32 @@ import { SchemaNode, SchemaNodeKind, } from '@stoplight/json-schema-tree'; -import { Box, Flex, Icon, Select, VStack } from '@stoplight/mosaic'; -import { useUpdateAtom } from 'jotai/utils'; +import { Box, Flex, Icon, Select, SpaceVals, VStack } from '@stoplight/mosaic'; +import { useAtomValue, useUpdateAtom } from 'jotai/utils'; import last from 'lodash/last.js'; import * as React from 'react'; import { COMBINER_NAME_MAP } from '../../consts'; import { useJSVOptionsContext } from '../../contexts'; import { calculateChildrenToShow, isFlattenableNode, isPropertyRequired } from '../../tree'; -import { pathCrumbsAtom } from '../PathCrumbs'; import { Caret, Description, Format, getValidationsFromSchema, Types, Validations } from '../shared'; import { ChildStack } from '../shared/ChildStack'; -import { Properties } from '../shared/Properties'; +import { Properties, useHasProperties } from '../shared/Properties'; +import { hoveredNodeAtom, isNodeHoveredAtom } from './state'; import { useChoices } from './useChoices'; export interface SchemaRowProps { schemaNode: SchemaNode; nestingLevel: number; + pl?: SpaceVals; } -export const SchemaRow: React.FunctionComponent = ({ schemaNode, nestingLevel }) => { +export const SchemaRow: React.FunctionComponent = React.memo(({ schemaNode, nestingLevel, pl }) => { const { defaultExpandedDepth, renderRowAddon, onGoToRef, hideExamples, renderRootTreeLines } = useJSVOptionsContext(); - const setPathCrumbs = useUpdateAtom(pathCrumbsAtom); + const setHoveredNode = useUpdateAtom(hoveredNodeAtom); + const isHovering = useAtomValue(isNodeHoveredAtom(schemaNode)); + const [isExpanded, setExpanded] = React.useState( !isMirroredNode(schemaNode) && nestingLevel <= defaultExpandedDepth, ); @@ -62,13 +65,20 @@ export const SchemaRow: React.FunctionComponent = ({ schemaNode, const isCollapsible = childNodes.length > 0; const isRootLevel = nestingLevel < rootLevel; + const required = isPropertyRequired(schemaNode); + const deprecated = isRegularNode(schemaNode) && schemaNode.deprecated; + const validations = isRegularNode(schemaNode) ? schemaNode.validations : {}; + const hasProperties = useHasProperties({ required, deprecated, validations }); + return ( <> { e.stopPropagation(); - setPathCrumbs(selectedChoice.type); + setHoveredNode(selectedChoice.type); }} > {!isRootLevel && } @@ -82,7 +92,7 @@ export const SchemaRow: React.FunctionComponent = ({ schemaNode, > {isCollapsible ? : null} - + {schemaNode.subpath.length > 0 && shouldShowPropertyName(schemaNode) && ( {last(schemaNode.subpath)} @@ -136,11 +146,9 @@ export const SchemaRow: React.FunctionComponent = ({ schemaNode, )} - + {hasProperties && } + + {typeof description === 'string' && description.length > 0 && } @@ -159,10 +167,12 @@ export const SchemaRow: React.FunctionComponent = ({ schemaNode, {renderRowAddon ? {renderRowAddon({ schemaNode, nestingLevel })} : null} - {isCollapsible && isExpanded ? : null} + {isCollapsible && isExpanded ? ( + + ) : null} ); -}; +}); function shouldShowPropertyName(schemaNode: SchemaNode) { return ( diff --git a/src/components/SchemaRow/TopLevelSchemaRow.tsx b/src/components/SchemaRow/TopLevelSchemaRow.tsx index 29455c65..ef1b2a1a 100644 --- a/src/components/SchemaRow/TopLevelSchemaRow.tsx +++ b/src/components/SchemaRow/TopLevelSchemaRow.tsx @@ -7,7 +7,7 @@ import * as React from 'react'; import { COMBINER_NAME_MAP } from '../../consts'; import { useIsOnScreen } from '../../hooks/useIsOnScreen'; import { calculateChildrenToShow, isComplexArray } from '../../tree'; -import { showPathCrumbsAtom } from '../PathCrumbs'; +import { showPathCrumbsAtom } from '../PathCrumbs/state'; import { ChildStack } from '../shared/ChildStack'; import { SchemaRow, SchemaRowProps } from './SchemaRow'; import { useChoices } from './useChoices'; @@ -22,7 +22,7 @@ export const TopLevelSchemaRow = ({ schemaNode }: Pick - + ); } @@ -63,7 +63,9 @@ export const TopLevelSchemaRow = ({ schemaNode }: Pick - {childNodes.length > 0 ? : null} + {childNodes.length > 0 ? ( + + ) : null} ); } @@ -77,7 +79,9 @@ export const TopLevelSchemaRow = ({ schemaNode }: Pick - {childNodes.length > 0 ? : null} + {childNodes.length > 0 ? ( + + ) : null} ); } diff --git a/src/components/SchemaRow/state.ts b/src/components/SchemaRow/state.ts new file mode 100644 index 00000000..6a389649 --- /dev/null +++ b/src/components/SchemaRow/state.ts @@ -0,0 +1,15 @@ +import { SchemaNode } from '@stoplight/json-schema-tree'; +import { atom } from 'jotai'; +import { atomFamily } from 'jotai/utils'; + +export const hoveredNodeAtom = atom(null); +export const isNodeHoveredAtom = atomFamily((node: SchemaNode) => atom(get => node === get(hoveredNodeAtom))); +export const isChildNodeHoveredAtom = atomFamily((parent: SchemaNode) => + atom(get => { + const hoveredNode = get(hoveredNodeAtom); + + if (!hoveredNode || hoveredNode === parent) return false; + + return hoveredNode.parent === parent; + }), +); diff --git a/src/components/shared/ChildStack.tsx b/src/components/shared/ChildStack.tsx index e72403f0..bc186b8c 100644 --- a/src/components/shared/ChildStack.tsx +++ b/src/components/shared/ChildStack.tsx @@ -1,5 +1,5 @@ import { SchemaNode } from '@stoplight/json-schema-tree'; -import { SpaceVals, VStack } from '@stoplight/mosaic'; +import { Box, SpaceVals } from '@stoplight/mosaic'; import * as React from 'react'; import { NESTING_OFFSET } from '../../consts'; @@ -7,40 +7,41 @@ import { useJSVOptionsContext } from '../../contexts'; import { SchemaRow, SchemaRowProps } from '../SchemaRow'; type ChildStackProps = { + schemaNode: SchemaNode; childNodes: readonly SchemaNode[]; currentNestingLevel: number; className?: string; RowComponent?: React.FC; }; -export const ChildStack = ({ - childNodes, - currentNestingLevel, - className, - RowComponent = SchemaRow, -}: ChildStackProps) => { - const { renderRootTreeLines } = useJSVOptionsContext(); - const rootLevel = renderRootTreeLines ? 0 : 1; - const isRootLevel = currentNestingLevel < rootLevel; +export const ChildStack = React.memo( + ({ childNodes, currentNestingLevel, className, RowComponent = SchemaRow }: ChildStackProps) => { + const { renderRootTreeLines } = useJSVOptionsContext(); + const rootLevel = renderRootTreeLines ? 0 : 1; + const isRootLevel = currentNestingLevel < rootLevel; - let ml: SpaceVals | undefined; - if (!isRootLevel) { - ml = currentNestingLevel === rootLevel ? 'px' : 4; - } + let ml: SpaceVals | undefined; + if (!isRootLevel) { + ml = currentNestingLevel === rootLevel ? 'px' : 7; + } - return ( - - {childNodes.map((childNode: SchemaNode) => ( - - ))} - - ); -}; + return ( + + {childNodes.map((childNode: SchemaNode) => ( + + ))} + + ); + }, +); diff --git a/src/components/shared/Properties.tsx b/src/components/shared/Properties.tsx index 6e594eb5..e012e97a 100644 --- a/src/components/shared/Properties.tsx +++ b/src/components/shared/Properties.tsx @@ -10,6 +10,14 @@ export interface IProperties { validations: Dictionary; } +export const useHasProperties = ({ required, deprecated, validations: { readOnly, writeOnly } }: IProperties) => { + const { viewMode } = useJSVOptionsContext(); + + const showVisibilityValidations = viewMode === 'standalone' && !!readOnly !== !!writeOnly; + + return deprecated || showVisibilityValidations || required; +}; + export const Properties: React.FunctionComponent = ({ required, deprecated, diff --git a/src/components/shared/__tests__/Property.spec.tsx b/src/components/shared/__tests__/Property.spec.tsx index cc652d21..1cf7860b 100644 --- a/src/components/shared/__tests__/Property.spec.tsx +++ b/src/components/shared/__tests__/Property.spec.tsx @@ -83,7 +83,7 @@ describe('Property component', () => { const wrapper = render(schema, ['properties', 'foo']); expect(wrapper.find(SchemaRow).html()).toMatchInlineSnapshot( - `"
foo
string
"`, + `"
foo
string
"`, ); }); @@ -102,7 +102,7 @@ describe('Property component', () => { const wrapper = render(schema, ['properties', 'foo']); expect(wrapper.find(SchemaRow).html()).toMatchInlineSnapshot( - `"
foo
array[integer]
"`, + `"
foo
array[integer]
"`, ); }); @@ -120,7 +120,7 @@ describe('Property component', () => { const wrapper = render(schema, ['items', 'properties', 'foo']); expect(wrapper.html()).toMatchInlineSnapshot( - `"
foo
string
"`, + `"
foo
string
"`, ); }); @@ -141,7 +141,7 @@ describe('Property component', () => { const wrapper = render(schema); expect(wrapper.html()).toMatchInlineSnapshot( - `"
"`, + `"
"`, ); }); @@ -164,7 +164,7 @@ describe('Property component', () => { const wrapper = render(schema); expect(wrapper.html()).toMatchInlineSnapshot( - `"
array[object]
foo
bar
baz
"`, + `"
array[object]
foo
bar
baz
"`, ); }); @@ -199,12 +199,12 @@ describe('Property component', () => { let wrapper = render(schema, ['properties', 'array-all-objects', 'items', 'properties', 'foo']); expect(wrapper.html()).toMatchInlineSnapshot( - `"
foo
string
"`, + `"
foo
string
"`, ); wrapper = render(schema, ['properties', 'array-all-objects', 'items', 'properties', 'bar']); expect(wrapper.html()).toMatchInlineSnapshot( - `"
bar
string
"`, + `"
bar
string
"`, ); }); @@ -224,7 +224,7 @@ describe('Property component', () => { const wrapper = mount(); expect(wrapper.html()).toMatchInlineSnapshot( - `"
foo
object
"`, + `"
foo
object
"`, ); wrapper.unmount(); });