From e86fbde6ef66c0f4853476ee662933177761db74 Mon Sep 17 00:00:00 2001 From: Marc MacLeod Date: Wed, 14 Sep 2022 14:11:06 -0500 Subject: [PATCH] feat: nodeHasChanged option + diff annotations (#204) --- package.json | 11 +- src/__fixtures__/diff/root-ref.json | 26 ++ src/__fixtures__/diff/simple-example.json | 97 ++++++ src/__stories__/Diff.tsx | 40 +++ .../__snapshots__/index.spec.tsx.snap | 84 ++--- src/__tests__/index.spec.tsx | 34 +- src/components/JsonSchemaViewer.tsx | 13 +- src/components/SchemaRow/SchemaRow.tsx | 327 ++++++++++-------- .../SchemaRow/TopLevelSchemaRow.tsx | 23 +- src/components/shared/ChildStack.tsx | 14 +- .../shared/__tests__/Property.spec.tsx | 16 +- src/contexts/jsvOptions.tsx | 2 + src/hash.ts | 35 ++ yarn.lock | 33 +- 14 files changed, 532 insertions(+), 223 deletions(-) create mode 100644 src/__fixtures__/diff/root-ref.json create mode 100644 src/__fixtures__/diff/simple-example.json create mode 100644 src/__stories__/Diff.tsx create mode 100644 src/hash.ts diff --git a/package.json b/package.json index a60c027f..6bf1c77c 100644 --- a/package.json +++ b/package.json @@ -39,8 +39,8 @@ }, "peerDependencies": { "@stoplight/markdown-viewer": "^5", - "@stoplight/mosaic": "^1", - "@stoplight/mosaic-code-viewer": "^1", + "@stoplight/mosaic": "^1.32", + "@stoplight/mosaic-code-viewer": "^1.32", "react": ">=16.8", "react-dom": ">=16.8" }, @@ -50,6 +50,7 @@ "@stoplight/react-error-boundary": "^2.0.0", "@types/json-schema": "^7.0.7", "classnames": "^2.2.6", + "fnv-plus": "^1.3.1", "jotai": "^1.4.5", "lodash": "^4.17.19" }, @@ -58,10 +59,10 @@ "@size-limit/preset-big-lib": "^4.11.0", "@stoplight/eslint-config": "3.0.0", "@stoplight/markdown-viewer": "^5.3.3", - "@stoplight/mosaic": "^1.24.2", - "@stoplight/mosaic-code-viewer": "^1.24.2", + "@stoplight/mosaic": "^1.32.0", + "@stoplight/mosaic-code-viewer": "^1.32.0", "@stoplight/scripts": "9.2.0", - "@stoplight/types": "^12.3.0", + "@stoplight/types": "^13.7.0", "@storybook/addon-essentials": "^6.4.14", "@storybook/builder-webpack5": "^6.4.14", "@storybook/core": "6.4.14", diff --git a/src/__fixtures__/diff/root-ref.json b/src/__fixtures__/diff/root-ref.json new file mode 100644 index 00000000..411ef00c --- /dev/null +++ b/src/__fixtures__/diff/root-ref.json @@ -0,0 +1,26 @@ +{ + "title": "User", + "type": "object", + "x-stoplight": { "id": "root-id" }, + "properties": { + "billing_address": { + "type": "string", + "title": "Billing Address", + "x-stoplight": { "id": "billing_address-id" }, + "$ref": "#/$defs/Address" + } + }, + "$defs": { + "Address": { + "type": "object", + "title": "Address", + "x-stoplight": { "id": "address-id" }, + "properties": { + "street": { + "type": "string", + "x-stoplight": { "id": "address-street-id" } + } + } + } + } +} diff --git a/src/__fixtures__/diff/simple-example.json b/src/__fixtures__/diff/simple-example.json new file mode 100644 index 00000000..d9042e71 --- /dev/null +++ b/src/__fixtures__/diff/simple-example.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__/Diff.tsx b/src/__stories__/Diff.tsx new file mode 100644 index 00000000..acb7b7f8 --- /dev/null +++ b/src/__stories__/Diff.tsx @@ -0,0 +1,40 @@ +import type { NodeHasChangedFn } from '@stoplight/types'; +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 simpleExample = require('../__fixtures__/diff/simple-example.json'); +const rootRefExample = require('../__fixtures__/diff/root-ref.json'); + +export default { + component: JsonSchemaViewer, + argTypes: {}, +}; + +const changed: Record> = { + 'age-id': { type: 'removed' }, + 'list-id': { type: 'modified', selfAffected: true }, + 'list-of-objects-id': { type: 'modified', isBreaking: true }, + 'list-of-objects-items-friend-id': { type: 'modified', isBreaking: true }, + 'list-of-objects-items-friend-name-id': { type: 'modified', selfAffected: true, isBreaking: true }, + 'list-of-objects-items-friend-name-last-id': { type: 'added', isBreaking: true }, + 'friend-id': { type: 'added', isBreaking: true }, + 'address-street-id': { type: 'added' }, +}; +const nodeHasChanged: NodeHasChangedFn = ({ nodeId }) => { + const change = changed[nodeId!]; + return change || false; +}; + +const Template: Story = ({ schema = defaultSchema as JSONSchema4, ...args }) => ( + +); + +export const SimpleAllOf = Template.bind({}); +SimpleAllOf.args = { schema: simpleExample as JSONSchema4 }; + +export const RootRef = Template.bind({}); +RootRef.args = { schema: rootRefExample as JSONSchema4 }; diff --git a/src/__tests__/__snapshots__/index.spec.tsx.snap b/src/__tests__/__snapshots__/index.spec.tsx.snap index e105dfa5..8d012f36 100644 --- a/src/__tests__/__snapshots__/index.spec.tsx.snap +++ b/src/__tests__/__snapshots__/index.spec.tsx.snap @@ -6,7 +6,7 @@ exports[`Expanded depth nested object static given initial level set to 0, shoul
-
+
@@ -17,7 +17,7 @@ exports[`Expanded depth nested object static given initial level set to 0, shoul
-
+
@@ -41,7 +41,7 @@ exports[`Expanded depth nested object static given initial level set to 1, shoul
-
+
@@ -53,7 +53,7 @@ exports[`Expanded depth nested object static given initial level set to 1, shoul
-
+
@@ -65,7 +65,7 @@ exports[`Expanded depth nested object static given initial level set to 1, shoul
-
+
@@ -76,7 +76,7 @@ exports[`Expanded depth nested object static given initial level set to 1, shoul
-
+
@@ -88,7 +88,7 @@ exports[`Expanded depth nested object static given initial level set to 1, shoul
-
+
@@ -100,7 +100,7 @@ exports[`Expanded depth nested object static given initial level set to 1, shoul
-
+
@@ -111,7 +111,7 @@ exports[`Expanded depth nested object static given initial level set to 1, shoul
-
+
@@ -136,7 +136,7 @@ exports[`Expanded depth nested object static given initial level set to 2, shoul
-
+
@@ -148,7 +148,7 @@ exports[`Expanded depth nested object static given initial level set to 2, shoul
-
+
@@ -161,7 +161,7 @@ exports[`Expanded depth nested object static given initial level set to 2, shoul
-
+
@@ -173,7 +173,7 @@ exports[`Expanded depth nested object static given initial level set to 2, shoul
-
+
@@ -184,7 +184,7 @@ exports[`Expanded depth nested object static given initial level set to 2, shoul
-
+
@@ -196,7 +196,7 @@ exports[`Expanded depth nested object static given initial level set to 2, shoul
-
+
@@ -208,7 +208,7 @@ exports[`Expanded depth nested object static given initial level set to 2, shoul
-
+
@@ -219,7 +219,7 @@ exports[`Expanded depth nested object static given initial level set to 2, shoul
-
+
@@ -251,7 +251,7 @@ exports[`HTML Output given anyOf combiner placed next to allOf given allOf mergi
(any of)
-
+
@@ -270,7 +270,7 @@ exports[`HTML Output given anyOf combiner placed next to allOf given allOf mergi
-
+
@@ -281,7 +281,7 @@ exports[`HTML Output given anyOf combiner placed next to allOf given allOf mergi

Is this account enabled

-
+
@@ -291,7 +291,7 @@ exports[`HTML Output given anyOf combiner placed next to allOf given allOf mergi
-
+
@@ -301,7 +301,7 @@ exports[`HTML Output given anyOf combiner placed next to allOf given allOf mergi
-
+
@@ -342,7 +342,7 @@ exports[`HTML Output given complex type that includes array and complex array su
-
+
@@ -374,7 +374,7 @@ exports[`HTML Output given multiple object and string type, should process prope
-
+
@@ -389,7 +389,7 @@ exports[`HTML Output given multiple object and string type, should process prope
-
+
@@ -419,7 +419,7 @@ exports[`HTML Output given oneOf combiner placed next to allOf given allOf mergi
(one of)
-
+
@@ -438,7 +438,7 @@ exports[`HTML Output given oneOf combiner placed next to allOf given allOf mergi
-
+
@@ -449,7 +449,7 @@ exports[`HTML Output given oneOf combiner placed next to allOf given allOf mergi

Is this account enabled

-
+
@@ -459,7 +459,7 @@ exports[`HTML Output given oneOf combiner placed next to allOf given allOf mergi
-
+
@@ -469,7 +469,7 @@ exports[`HTML Output given oneOf combiner placed next to allOf given allOf mergi
-
+
@@ -491,7 +491,7 @@ exports[`HTML Output given read mode, should populate proper nodes 1`] = `
-
+
@@ -506,7 +506,7 @@ exports[`HTML Output given read mode, should populate proper nodes 1`] = `
-
+
@@ -528,7 +528,7 @@ exports[`HTML Output given standalone mode, should populate proper nodes 1`] = `
-
+
@@ -543,7 +543,7 @@ exports[`HTML Output given standalone mode, should populate proper nodes 1`] = `
-
+
@@ -555,7 +555,7 @@ exports[`HTML Output given standalone mode, should populate proper nodes 1`] = `
-
+
@@ -580,7 +580,7 @@ exports[`HTML Output given visible $ref node, should try to inject the title imm
-
+
@@ -592,7 +592,7 @@ exports[`HTML Output given visible $ref node, should try to inject the title imm
-
+
@@ -604,7 +604,7 @@ exports[`HTML Output given visible $ref node, should try to inject the title imm
-
+
@@ -627,7 +627,7 @@ exports[`HTML Output given write mode, should populate proper nodes 1`] = `
-
+
@@ -642,7 +642,7 @@ exports[`HTML Output given write mode, should populate proper nodes 1`] = `
-
+
@@ -666,7 +666,7 @@ exports[`HTML Output should render top-level description on allOf 1`] = `

This is a description that should be rendered

-
+
@@ -676,7 +676,7 @@ exports[`HTML Output should render top-level description on allOf 1`] = `
-
+
diff --git a/src/__tests__/index.spec.tsx b/src/__tests__/index.spec.tsx index f25dc3bb..d6d28845 100644 --- a/src/__tests__/index.spec.tsx +++ b/src/__tests__/index.spec.tsx @@ -254,7 +254,7 @@ describe('Expanded depth', () => {
array of:
-
+
@@ -283,7 +283,7 @@ describe('Expanded depth', () => {
array of:
-
+
@@ -312,7 +312,7 @@ describe('Expanded depth', () => {
array of:
-
+
@@ -324,7 +324,7 @@ describe('Expanded depth', () => {
-
+
@@ -389,7 +389,7 @@ describe('Expanded depth', () => {
array of:
-
+
@@ -399,7 +399,7 @@ describe('Expanded depth', () => {
-
+
@@ -428,7 +428,7 @@ describe('Expanded depth', () => {
array of:
-
+
@@ -438,7 +438,7 @@ describe('Expanded depth', () => {
-
+
@@ -467,7 +467,7 @@ describe('Expanded depth', () => {
array of:
-
+
@@ -477,7 +477,7 @@ describe('Expanded depth', () => {
-
+
@@ -489,7 +489,7 @@ describe('Expanded depth', () => {
-
+
@@ -500,7 +500,7 @@ describe('Expanded depth', () => {
-
+
@@ -576,7 +576,7 @@ describe('Expanded depth', () => {
-
+
@@ -587,7 +587,7 @@ describe('Expanded depth', () => {
-
+
@@ -643,7 +643,7 @@ describe('$ref resolving', () => {
-
+
string
@@ -670,7 +670,7 @@ describe('$ref resolving', () => {
-
+
@@ -679,7 +679,7 @@ describe('$ref resolving', () => {
-
+
#/foo
diff --git a/src/components/JsonSchemaViewer.tsx b/src/components/JsonSchemaViewer.tsx index 4aa7b8c9..d4d99ae3 100644 --- a/src/components/JsonSchemaViewer.tsx +++ b/src/components/JsonSchemaViewer.tsx @@ -36,6 +36,7 @@ const JsonSchemaViewerComponent = ({ hideExamples, renderRootTreeLines, disableCrumbs, + nodeHasChanged, ...rest }: JsonSchemaProps & ErrorBoundaryForwardedProps) => { const options = React.useMemo( @@ -47,8 +48,18 @@ const JsonSchemaViewerComponent = ({ hideExamples, renderRootTreeLines, disableCrumbs, + nodeHasChanged, }), - [defaultExpandedDepth, viewMode, onGoToRef, renderRowAddon, hideExamples, renderRootTreeLines, disableCrumbs], + [ + defaultExpandedDepth, + viewMode, + onGoToRef, + renderRowAddon, + hideExamples, + renderRootTreeLines, + disableCrumbs, + nodeHasChanged, + ], ); return ( diff --git a/src/components/SchemaRow/SchemaRow.tsx b/src/components/SchemaRow/SchemaRow.tsx index 86e2c764..f6a0ea16 100644 --- a/src/components/SchemaRow/SchemaRow.tsx +++ b/src/components/SchemaRow/SchemaRow.tsx @@ -6,7 +6,8 @@ import { SchemaNode, SchemaNodeKind, } from '@stoplight/json-schema-tree'; -import { Box, Flex, Icon, Select, SpaceVals, VStack } from '@stoplight/mosaic'; +import { Box, Flex, Icon, NodeAnnotation, Select, SpaceVals, VStack } from '@stoplight/mosaic'; +import type { ChangeType } from '@stoplight/types'; import { Atom } from 'jotai'; import { useAtomValue, useUpdateAtom } from 'jotai/utils'; import last from 'lodash/last.js'; @@ -14,6 +15,7 @@ import * as React from 'react'; import { COMBINER_NAME_MAP } from '../../consts'; import { useJSVOptionsContext } from '../../contexts'; +import { getNodeId, getOriginalNodeId } from '../../hash'; import { calculateChildrenToShow, isFlattenableNode, isPropertyRequired } from '../../tree'; import { Caret, Description, getInternalSchemaError, getValidationsFromSchema, Types, Validations } from '../shared'; import { ChildStack } from '../shared/ChildStack'; @@ -25,154 +27,205 @@ export interface SchemaRowProps { schemaNode: SchemaNode; nestingLevel: number; pl?: SpaceVals; + parentNodeId?: string; + parentChangeType?: ChangeType; } -export const SchemaRow: React.FunctionComponent = React.memo(({ schemaNode, nestingLevel, pl }) => { - const { defaultExpandedDepth, renderRowAddon, onGoToRef, hideExamples, renderRootTreeLines } = useJSVOptionsContext(); - - const setHoveredNode = useUpdateAtom(hoveredNodeAtom); - - const [isExpanded, setExpanded] = React.useState( - !isMirroredNode(schemaNode) && nestingLevel <= defaultExpandedDepth, - ); - - const { selectedChoice, setSelectedChoice, choices } = useChoices(schemaNode); - const typeToShow = selectedChoice.type; - const description = isRegularNode(typeToShow) ? typeToShow.annotations.description : null; - - const refNode = React.useMemo(() => { - if (isReferenceNode(schemaNode)) { - return schemaNode; +export const SchemaRow: React.FunctionComponent = React.memo( + ({ schemaNode, nestingLevel, pl, parentNodeId, parentChangeType }) => { + const { + defaultExpandedDepth, + renderRowAddon, + onGoToRef, + hideExamples, + renderRootTreeLines, + nodeHasChanged, + viewMode, + } = useJSVOptionsContext(); + + const setHoveredNode = useUpdateAtom(hoveredNodeAtom); + + const nodeId = getNodeId(schemaNode, parentNodeId); + + // @ts-expect-error originalFragment does exist... + const originalNodeId = schemaNode.originalFragment?.$ref ? getOriginalNodeId(schemaNode, parentNodeId) : nodeId; + const mode = viewMode === 'standalone' ? undefined : viewMode; + const hasChanged = nodeHasChanged?.({ nodeId: originalNodeId, mode }); + + const [isExpanded, setExpanded] = React.useState( + !isMirroredNode(schemaNode) && nestingLevel <= defaultExpandedDepth, + ); + + const { selectedChoice, setSelectedChoice, choices } = useChoices(schemaNode); + const typeToShow = selectedChoice.type; + const description = isRegularNode(typeToShow) ? typeToShow.annotations.description : null; + + const refNode = React.useMemo(() => { + if (isReferenceNode(schemaNode)) { + return schemaNode; + } + + if ( + isRegularNode(schemaNode) && + (isFlattenableNode(schemaNode) || + (schemaNode.primaryType === SchemaNodeKind.Array && schemaNode.children?.length === 1)) + ) { + return (schemaNode.children?.find(isReferenceNode) as ReferenceNode | undefined) ?? null; + } + + return null; + }, [schemaNode]); + + const isBrokenRef = typeof refNode?.error === 'string'; + + const rootLevel = renderRootTreeLines ? 1 : 2; + const childNodes = React.useMemo(() => calculateChildrenToShow(typeToShow), [typeToShow]); + const combiner = isRegularNode(schemaNode) && schemaNode.combiners?.length ? schemaNode.combiners[0] : null; + 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 }); + + const internalSchemaError = getInternalSchemaError(schemaNode); + + const annotationRootOffset = renderRootTreeLines ? 0 : 8; + let annotationLeftOffset = -20 - annotationRootOffset; + if (nestingLevel > 1) { + // annotationLeftOffset -= 27; + annotationLeftOffset = + -1 * 29 * Math.max(nestingLevel - 1, 1) - Math.min(nestingLevel, 2) * 2 - 16 - annotationRootOffset; + + if (!renderRootTreeLines) { + annotationLeftOffset += 27; + } } - if ( - isRegularNode(schemaNode) && - (isFlattenableNode(schemaNode) || - (schemaNode.primaryType === SchemaNodeKind.Array && schemaNode.children?.length === 1)) - ) { - return (schemaNode.children?.find(isReferenceNode) as ReferenceNode | undefined) ?? null; + if (parentChangeType === 'added' && hasChanged && hasChanged.type === 'removed') { + return null; } - return null; - }, [schemaNode]); - - const isBrokenRef = typeof refNode?.error === 'string'; - - const rootLevel = renderRootTreeLines ? 1 : 2; - const childNodes = React.useMemo(() => calculateChildrenToShow(typeToShow), [typeToShow]); - const combiner = isRegularNode(schemaNode) && schemaNode.combiners?.length ? schemaNode.combiners[0] : null; - 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 }); - - const internalSchemaError = getInternalSchemaError(schemaNode); + if (parentChangeType === 'removed' && hasChanged && hasChanged.type === 'added') { + return null; + } - return ( - <> - { - e.stopPropagation(); - setHoveredNode(selectedChoice.type); - }} - > - {!isRootLevel && } - - - setExpanded(!isExpanded) : undefined} - cursor={isCollapsible ? 'pointer' : undefined} - > - {isCollapsible ? : null} - - - {schemaNode.subpath.length > 0 && shouldShowPropertyName(schemaNode) && ( - - {last(schemaNode.subpath)} - - )} - - {choices.length === 1 && } - - {onGoToRef && isReferenceNode(schemaNode) && schemaNode.external ? ( - { - e.preventDefault(); - e.stopPropagation(); - onGoToRef(schemaNode); - }} - > - (go to ref) - - ) : null} - - {schemaNode.subpath.length > 1 && schemaNode.subpath[0] === 'patternProperties' ? ( - - (pattern property) - - ) : null} - - {choices.length > 1 && ( - ({ + value: String(index), + label: choice.title, + }))} + value={ + String(choices.indexOf(selectedChoice)) + /* String to work around https://github.com/stoplightio/mosaic/issues/162 */ + } + onChange={selectedIndex => setSelectedChoice(choices[selectedIndex as number])} + /> + )} + + + {hasProperties && } + + - {hasProperties && } - - - + {typeof description === 'string' && description.length > 0 && } - {typeof description === 'string' && description.length > 0 && } - - - - {(isBrokenRef || internalSchemaError.hasError) && ( - - )} - - - {renderRowAddon ? {renderRowAddon({ schemaNode, nestingLevel })} : null} - - {isCollapsible && isExpanded ? ( - - ) : null} - - ); -}); + {(isBrokenRef || internalSchemaError.hasError) && ( + + )} + + + {renderRowAddon ? {renderRowAddon({ schemaNode, nestingLevel })} : null} + + + {isCollapsible && isExpanded ? ( + + ) : null} + + ); + }, +); const Divider = ({ atom }: { atom: Atom }) => { const isHovering = useAtomValue(atom); diff --git a/src/components/SchemaRow/TopLevelSchemaRow.tsx b/src/components/SchemaRow/TopLevelSchemaRow.tsx index dbf82db3..dc567026 100644 --- a/src/components/SchemaRow/TopLevelSchemaRow.tsx +++ b/src/components/SchemaRow/TopLevelSchemaRow.tsx @@ -18,6 +18,8 @@ export const TopLevelSchemaRow = ({ schemaNode }: Pick calculateChildrenToShow(selectedChoice.type), [selectedChoice.type]); const nestingLevel = 0; + const nodeId = schemaNode.fragment?.['x-stoplight']?.id; + const internalSchemaError = getInternalSchemaError(schemaNode); // regular objects are flattened at the top level @@ -26,7 +28,12 @@ export const TopLevelSchemaRow = ({ schemaNode }: Pick - + {internalSchemaError.hasError && ( )} @@ -72,7 +79,12 @@ export const TopLevelSchemaRow = ({ schemaNode }: Pick {childNodes.length > 0 ? ( - + ) : null} ); @@ -89,7 +101,12 @@ export const TopLevelSchemaRow = ({ schemaNode }: Pick {childNodes.length > 0 ? ( - + ) : null} ); diff --git a/src/components/shared/ChildStack.tsx b/src/components/shared/ChildStack.tsx index bc186b8c..57544528 100644 --- a/src/components/shared/ChildStack.tsx +++ b/src/components/shared/ChildStack.tsx @@ -1,5 +1,6 @@ import { SchemaNode } from '@stoplight/json-schema-tree'; import { Box, SpaceVals } from '@stoplight/mosaic'; +import type { ChangeType } from '@stoplight/types'; import * as React from 'react'; import { NESTING_OFFSET } from '../../consts'; @@ -11,11 +12,20 @@ type ChildStackProps = { childNodes: readonly SchemaNode[]; currentNestingLevel: number; className?: string; + parentNodeId?: string; RowComponent?: React.FC; + parentChangeType?: ChangeType; }; export const ChildStack = React.memo( - ({ childNodes, currentNestingLevel, className, RowComponent = SchemaRow }: ChildStackProps) => { + ({ + childNodes, + currentNestingLevel, + className, + RowComponent = SchemaRow, + parentNodeId, + parentChangeType, + }: ChildStackProps) => { const { renderRootTreeLines } = useJSVOptionsContext(); const rootLevel = renderRootTreeLines ? 0 : 1; const isRootLevel = currentNestingLevel < rootLevel; @@ -39,6 +49,8 @@ export const ChildStack = React.memo( schemaNode={childNode} nestingLevel={currentNestingLevel + 1} pl={isRootLevel ? undefined : NESTING_OFFSET} + parentNodeId={parentNodeId} + parentChangeType={parentChangeType} /> ))} diff --git a/src/components/shared/__tests__/Property.spec.tsx b/src/components/shared/__tests__/Property.spec.tsx index c16eec15..bc19a88a 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(); }); diff --git a/src/contexts/jsvOptions.tsx b/src/contexts/jsvOptions.tsx index b3c48d17..c6d88da7 100644 --- a/src/contexts/jsvOptions.tsx +++ b/src/contexts/jsvOptions.tsx @@ -1,3 +1,4 @@ +import type { NodeHasChangedFn } from '@stoplight/types'; import * as React from 'react'; import { GoToRefHandler, RowAddonRenderer, ViewMode } from '../types'; @@ -10,6 +11,7 @@ export type JSVOptions = { hideExamples?: boolean; renderRootTreeLines?: boolean; disableCrumbs?: boolean; + nodeHasChanged?: NodeHasChangedFn; }; const JSVOptionsContext = React.createContext({ diff --git a/src/hash.ts b/src/hash.ts new file mode 100644 index 00000000..00fc83b5 --- /dev/null +++ b/src/hash.ts @@ -0,0 +1,35 @@ +import type { SchemaNode } from '@stoplight/json-schema-tree'; +// @ts-expect-error: no types +import * as fnv from 'fnv-plus'; + +// for easier debugging the values going into hash +let SKIP_HASHING = false; + +export const setSkipHashing = (skip: boolean) => { + SKIP_HASHING = skip; +}; + +export const hash = (value: string, skipHashing: boolean = SKIP_HASHING): string => { + // Never change this, as it would affect how the default stable id is generated, and cause mismatches with whatever + // we already have stored in our DB etc. + return skipHashing ? value : fnv.fast1a52hex(value); +}; + +export const getNodeId = (node: SchemaNode, parentId?: string): string => { + const nodeId = node.fragment?.['x-stoplight']?.id; + if (nodeId) return nodeId; + + const key = node.path[node.path.length - 1]; + + return hash(['schema_property', parentId, String(key)].join('-')); +}; + +export const getOriginalNodeId = (node: SchemaNode, parentId?: string): string => { + // @ts-expect-error originalFragment does exist... + const nodeId = node.originalFragment?.['x-stoplight']?.id; + if (nodeId) return nodeId; + + const key = node.path[node.path.length - 1]; + + return hash(['schema_property', parentId, String(key)].join('-')); +}; diff --git a/yarn.lock b/yarn.lock index 46f1a742..bbd91070 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2417,10 +2417,10 @@ unist-util-select "^4.0.0" unist-util-visit "^3.1.0" -"@stoplight/mosaic-code-viewer@^1.24.2": - version "1.24.2" - resolved "https://registry.yarnpkg.com/@stoplight/mosaic-code-viewer/-/mosaic-code-viewer-1.24.2.tgz#3e3a283ab4ad8a8161240de63ad3904a38e8f580" - integrity sha512-anPzzyCgcU+aFV+6qZB4YmWnbymgRgB70g5T2gvavZZ2r9tw8g3eG/U6d1+38i1it3IadJPpF6yzqwC5kUs04Q== +"@stoplight/mosaic-code-viewer@^1.32.0": + version "1.32.0" + resolved "https://registry.yarnpkg.com/@stoplight/mosaic-code-viewer/-/mosaic-code-viewer-1.32.0.tgz#07f517c5179cdc59d968146cf03e910650adf3ca" + integrity sha512-QO7bTKNBIrkXN6fAbb75BvEjQHKfYv6xPFWHmh0DkkrmANveF/wUYXQdIxV2KdjAUT6BaDXr3yt0dEwb15IevQ== dependencies: "@fortawesome/fontawesome-svg-core" "^6.1.1" "@fortawesome/react-fontawesome" "^0.2.0" @@ -2429,7 +2429,8 @@ "@react-types/radio" "3.1.2" "@react-types/shared" "3.9.0" "@react-types/switch" "3.1.2" - "@stoplight/mosaic" "1.24.2" + "@stoplight/mosaic" "1.32.0" + "@stoplight/types" "^13.7.0" clsx "^1.1.1" copy-to-clipboard "^3.3.1" dom-helpers "^3.3.1" @@ -2445,10 +2446,10 @@ use-resize-observer "^9.0.2" zustand "^3.5.2" -"@stoplight/mosaic@1.24.2", "@stoplight/mosaic@^1.24.2": - version "1.24.2" - resolved "https://registry.yarnpkg.com/@stoplight/mosaic/-/mosaic-1.24.2.tgz#802596c2702264dc32f2b979a4bdacd36eeb0f24" - integrity sha512-0wYIjhDk0YMTPpOWyOwiy+Z0efT0W9bXhE3uhK4zEHKtTahhHaGtVauPq2y4EwwNTdRYE5ACr9use/tGKiMKig== +"@stoplight/mosaic@1.32.0", "@stoplight/mosaic@^1.32.0": + version "1.32.0" + resolved "https://registry.yarnpkg.com/@stoplight/mosaic/-/mosaic-1.32.0.tgz#ab1bce0a7360fe61730c3f4c762a25c739ea50a7" + integrity sha512-1ZHacNpmta3Hu3JQK1w+UuH5NawuWc4TdY6sPXJAAgZ9zNodkbYzdKsJVdfH2r/ni337ue5r3T15kwhKqFeL0A== dependencies: "@fortawesome/fontawesome-svg-core" "^6.1.1" "@fortawesome/react-fontawesome" "^0.2.0" @@ -2459,6 +2460,7 @@ "@react-types/shared" "3.9.0" "@react-types/switch" "3.1.2" "@react-types/textfield" "3.3.0" + "@stoplight/types" "^13.7.0" "@types/react" "^17.0.3" "@types/react-dom" "^17.0.3" clsx "^1.1.1" @@ -2528,6 +2530,14 @@ shelljs "0.8.x" tslib "^2.2.0" +"@stoplight/types@13.7.0", "@stoplight/types@^13.7.0": + version "13.7.0" + resolved "https://registry.yarnpkg.com/@stoplight/types/-/types-13.7.0.tgz#717d9e3ef068e8f143d045e9bc89b97da06933c4" + integrity sha512-7ePIccfTxjEhruv8VrkDv5whP5qd9ijRzAWEbjYpUYnDfaqPTfq8/wMMjMCAKIecboxsAVD9LZy/3puXddGsDQ== + dependencies: + "@types/json-schema" "^7.0.4" + utility-types "^3.10.0" + "@stoplight/types@^12.0.0", "@stoplight/types@^12.3.0": version "12.3.0" resolved "https://registry.yarnpkg.com/@stoplight/types/-/types-12.3.0.tgz#ac71d295319f26abb279e3d89d1c1774857d20b4" @@ -8386,6 +8396,11 @@ flush-write-stream@^1.0.0: inherits "^2.0.3" readable-stream "^2.3.6" +fnv-plus@^1.3.1: + version "1.3.1" + resolved "https://registry.yarnpkg.com/fnv-plus/-/fnv-plus-1.3.1.tgz#c34cb4572565434acb08ba257e4044ce2b006d67" + integrity sha512-Gz1EvfOneuFfk4yG458dJ3TLJ7gV19q3OM/vVvvHf7eT02Hm1DleB4edsia6ahbKgAYxO9gvyQ1ioWZR+a00Yw== + for-in@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/for-in/-/for-in-1.0.2.tgz#81068d295a8142ec0ac726c6e2200c30fb6d5e80"