Skip to content

Commit e620432

Browse files
authored
feat: show internal schema errors in a nicer way (#227)
1 parent 78aaa58 commit e620432

File tree

8 files changed

+105
-92
lines changed

8 files changed

+105
-92
lines changed

src/__tests__/index.spec.tsx

+2
Original file line numberDiff line numberDiff line change
@@ -685,6 +685,7 @@ describe('$ref resolving', () => {
685685
<div><span>$ref(#/foo)[]</span></div>
686686
</div>
687687
</div>
688+
<span></span>
688689
</div>
689690
<div data-level=\\"0\\">
690691
<div data-id=\\"98538b996305d\\">
@@ -693,6 +694,7 @@ describe('$ref resolving', () => {
693694
<div><span>#/foo</span></div>
694695
</div>
695696
</div>
697+
<span></span>
696698
</div>
697699
</div>
698700
</div>

src/components/SchemaRow/SchemaRow.tsx

+7-41
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,5 @@
1-
import {
2-
isMirroredNode,
3-
isReferenceNode,
4-
isRegularNode,
5-
ReferenceNode,
6-
SchemaNode,
7-
SchemaNodeKind,
8-
} from '@stoplight/json-schema-tree';
9-
import { Box, Flex, Icon, NodeAnnotation, Select, SpaceVals, VStack } from '@stoplight/mosaic';
1+
import { isMirroredNode, isReferenceNode, isRegularNode, SchemaNode } from '@stoplight/json-schema-tree';
2+
import { Box, Flex, NodeAnnotation, Select, SpaceVals, VStack } from '@stoplight/mosaic';
103
import type { ChangeType } from '@stoplight/types';
114
import { Atom } from 'jotai';
125
import { useAtomValue, useUpdateAtom } from 'jotai/utils';
@@ -16,9 +9,10 @@ import * as React from 'react';
169
import { COMBINER_NAME_MAP } from '../../consts';
1710
import { useJSVOptionsContext } from '../../contexts';
1811
import { getNodeId, getOriginalNodeId } from '../../hash';
19-
import { calculateChildrenToShow, isFlattenableNode, isPropertyRequired } from '../../tree';
20-
import { Caret, Description, getInternalSchemaError, getValidationsFromSchema, Types, Validations } from '../shared';
12+
import { calculateChildrenToShow, isPropertyRequired } from '../../tree';
13+
import { Caret, Description, getValidationsFromSchema, Types, Validations } from '../shared';
2114
import { ChildStack } from '../shared/ChildStack';
15+
import { Error } from '../shared/Error';
2216
import { Properties, useHasProperties } from '../shared/Properties';
2317
import { hoveredNodeAtom, isNodeHoveredAtom } from './state';
2418
import { useChoices } from './useChoices';
@@ -60,24 +54,6 @@ export const SchemaRow: React.FunctionComponent<SchemaRowProps> = React.memo(
6054
const typeToShow = selectedChoice.type;
6155
const description = isRegularNode(typeToShow) ? typeToShow.annotations.description : null;
6256

63-
const refNode = React.useMemo<ReferenceNode | null>(() => {
64-
if (isReferenceNode(schemaNode)) {
65-
return schemaNode;
66-
}
67-
68-
if (
69-
isRegularNode(schemaNode) &&
70-
(isFlattenableNode(schemaNode) ||
71-
(schemaNode.primaryType === SchemaNodeKind.Array && schemaNode.children?.length === 1))
72-
) {
73-
return (schemaNode.children?.find(isReferenceNode) as ReferenceNode | undefined) ?? null;
74-
}
75-
76-
return null;
77-
}, [schemaNode]);
78-
79-
const isBrokenRef = typeof refNode?.error === 'string';
80-
8157
const rootLevel = renderRootTreeLines ? 1 : 2;
8258
const childNodes = React.useMemo(() => calculateChildrenToShow(typeToShow), [typeToShow]);
8359
const combiner = isRegularNode(schemaNode) && schemaNode.combiners?.length ? schemaNode.combiners[0] : null;
@@ -89,8 +65,6 @@ export const SchemaRow: React.FunctionComponent<SchemaRowProps> = React.memo(
8965
const validations = isRegularNode(schemaNode) ? schemaNode.validations : {};
9066
const hasProperties = useHasProperties({ required, deprecated, validations });
9167

92-
const internalSchemaError = getInternalSchemaError(schemaNode);
93-
9468
const annotationRootOffset = renderRootTreeLines ? 0 : 8;
9569
let annotationLeftOffset = -20 - annotationRootOffset;
9670
if (nestingLevel > 1) {
@@ -199,20 +173,12 @@ export const SchemaRow: React.FunctionComponent<SchemaRowProps> = React.memo(
199173
validations={isRegularNode(schemaNode) ? getValidationsFromSchema(schemaNode) : {}}
200174
hideExamples={hideExamples}
201175
/>
202-
203-
{(isBrokenRef || internalSchemaError.hasError) && (
204-
<Icon
205-
title={refNode?.error! || internalSchemaError.error}
206-
color="danger"
207-
icon={['fas', 'exclamation-triangle']}
208-
size="sm"
209-
/>
210-
)}
211176
</VStack>
212177

178+
<Error schemaNode={schemaNode} />
179+
213180
{renderRowAddon ? <Box>{renderRowAddon({ schemaNode, nestingLevel })}</Box> : null}
214181
</Flex>
215-
216182
{isCollapsible && isExpanded ? (
217183
<ChildStack
218184
schemaNode={schemaNode}

src/components/SchemaRow/TopLevelSchemaRow.tsx

+2-6
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import { calculateChildrenToShow, isComplexArray } from '../../tree';
99
import { showPathCrumbsAtom } from '../PathCrumbs/state';
1010
import { Description } from '../shared';
1111
import { ChildStack } from '../shared/ChildStack';
12-
import { getInternalSchemaError } from '../shared/Validations';
12+
import { Error } from '../shared/Error';
1313
import { SchemaRow, SchemaRowProps } from './SchemaRow';
1414
import { useChoices } from './useChoices';
1515

@@ -23,8 +23,6 @@ export const TopLevelSchemaRow = ({
2323

2424
const nodeId = schemaNode.fragment?.['x-stoplight']?.id;
2525

26-
const internalSchemaError = getInternalSchemaError(schemaNode);
27-
2826
// regular objects are flattened at the top level
2927
if (isRegularNode(schemaNode) && isPureObjectNode(schemaNode)) {
3028
return (
@@ -37,9 +35,7 @@ export const TopLevelSchemaRow = ({
3735
currentNestingLevel={nestingLevel}
3836
parentNodeId={nodeId}
3937
/>
40-
{internalSchemaError.hasError && (
41-
<Icon title={internalSchemaError.error} color="danger" icon={['fas', 'exclamation-triangle']} size="sm" />
42-
)}
38+
<Error schemaNode={schemaNode} />
4339
</>
4440
);
4541
}

src/components/__tests__/SchemaRow.spec.tsx

+9-9
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ describe('SchemaRow component', () => {
3030

3131
it('given no custom resolver, should render a generic error message', () => {
3232
const wrapper = mount(<SchemaRow schemaNode={tree.children[0]!} nestingLevel={0} />);
33-
expect(wrapper.find(Icon).at(1)).toHaveProp('title', `Could not resolve '#/properties/foo'`);
33+
expect(wrapper.find(Icon).at(1)).toHaveProp('aria-label', `Could not resolve '#/properties/foo'`);
3434
wrapper.unmount();
3535
});
3636

@@ -44,7 +44,7 @@ describe('SchemaRow component', () => {
4444
});
4545

4646
const wrapper = mount(<SchemaRow schemaNode={tree.children[0]!} nestingLevel={0} />);
47-
expect(wrapper.find(Icon).at(1)).toHaveProp('title', message);
47+
expect(wrapper.find(Icon).at(1)).toHaveProp('aria-label', message);
4848
wrapper.unmount();
4949
});
5050
});
@@ -53,15 +53,15 @@ describe('SchemaRow component', () => {
5353
let tree: RootNode;
5454
let schema: JSONSchema4;
5555

56-
it('given an object schema is marked as internal, a permission denied error messsage should be shown', () => {
56+
it('given an object schema is marked as internal, a permission denied error message should be shown', () => {
5757
schema = {
5858
type: 'object',
5959
'x-sl-internally-excluded': true,
6060
'x-sl-error-message': 'You do not have permission to view this reference',
6161
};
6262
tree = buildTree(schema);
6363
const wrapper = mount(<SchemaRow schemaNode={tree.children[0]!} nestingLevel={0} />);
64-
expect(wrapper.find(Icon).at(0)).toHaveProp('title', `You do not have permission to view this reference`);
64+
expect(wrapper.find(Icon).at(0)).toHaveProp('aria-label', `You do not have permission to view this reference`);
6565
wrapper.unmount();
6666
});
6767

@@ -73,7 +73,7 @@ describe('SchemaRow component', () => {
7373
};
7474
tree = buildTree(schema);
7575
const wrapper = mount(<SchemaRow schemaNode={tree.children[0]!} nestingLevel={0} />);
76-
expect(wrapper.find(Icon).at(0)).toHaveProp('title', `You do not have permission to view this reference`);
76+
expect(wrapper.find(Icon).at(0)).toHaveProp('aria-label', `You do not have permission to view this reference`);
7777
wrapper.unmount();
7878
});
7979

@@ -85,7 +85,7 @@ describe('SchemaRow component', () => {
8585
};
8686
tree = buildTree(schema);
8787
const wrapper = mount(<SchemaRow schemaNode={tree.children[0]!} nestingLevel={0} />);
88-
expect(wrapper.find(Icon).at(0)).toHaveProp('title', `You do not have permission to view this reference`);
88+
expect(wrapper.find(Icon).at(0)).toHaveProp('aria-label', `You do not have permission to view this reference`);
8989
wrapper.unmount();
9090
});
9191

@@ -97,7 +97,7 @@ describe('SchemaRow component', () => {
9797
};
9898
tree = buildTree(schema);
9999
const wrapper = mount(<SchemaRow schemaNode={tree.children[0]!} nestingLevel={0} />);
100-
expect(wrapper.find(Icon).at(0)).toHaveProp('title', `You do not have permission to view this reference`);
100+
expect(wrapper.find(Icon).at(0)).toHaveProp('aria-label', `You do not have permission to view this reference`);
101101
wrapper.unmount();
102102
});
103103

@@ -109,7 +109,7 @@ describe('SchemaRow component', () => {
109109
};
110110
tree = buildTree(schema);
111111
const wrapper = mount(<SchemaRow schemaNode={tree.children[0]!} nestingLevel={0} />);
112-
expect(wrapper.find(Icon).at(0)).toHaveProp('title', `You do not have permission to view this reference`);
112+
expect(wrapper.find(Icon).at(0)).toHaveProp('aria-label', `You do not have permission to view this reference`);
113113
wrapper.unmount();
114114
});
115115

@@ -125,7 +125,7 @@ describe('SchemaRow component', () => {
125125
};
126126
tree = buildTree(schema);
127127
const wrapper = mount(<SchemaRow schemaNode={tree.children[0]!} nestingLevel={0} />);
128-
expect(wrapper.find(Icon).at(0)).toHaveProp('title', `You do not have permission to view this reference`);
128+
expect(wrapper.find(Icon).at(0)).toHaveProp('aria-label', `You do not have permission to view this reference`);
129129
wrapper.unmount();
130130
});
131131
});

src/components/__tests__/TopLevelSchemaRow.spec.tsx

+12-12
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ describe('resolving permission error', () => {
1313
let tree: RootNode;
1414
let schema: JSONSchema4;
1515

16-
it('given an object schema has a mixture of properties with and without x-sl-error-message, a permission denied error messsage should be shown on properties with x-sl-error-message', () => {
16+
it('given an object schema has a mixture of properties with and without x-sl-error-message, a permission denied error message should be shown on properties with x-sl-error-message', () => {
1717
schema = {
1818
title: 'User',
1919
type: 'object',
@@ -41,14 +41,14 @@ describe('resolving permission error', () => {
4141

4242
tree = buildTree(schema);
4343
const wrapper = mount(<TopLevelSchemaRow schemaNode={tree.children[0]!} />);
44-
expect(wrapper.find(Icon).at(0)).toHaveProp('title', `You do not have permission to view this reference`);
45-
expect(wrapper.find(Icon).at(1)).toHaveProp('title', `You do not have permission to view this reference`);
46-
expect(wrapper.find(Icon).at(2)).not.toHaveProp('title', `You do not have permission to view this reference`);
47-
expect(wrapper.find(Icon).at(3)).not.toHaveProp('title', `You do not have permission to view this reference`);
44+
expect(wrapper.find(Icon).at(0)).toHaveProp('aria-label', `You do not have permission to view this reference`);
45+
expect(wrapper.find(Icon).at(1)).toHaveProp('aria-label', `You do not have permission to view this reference`);
46+
expect(wrapper.find(Icon).at(2)).not.toHaveProp('aria-label', `You do not have permission to view this reference`);
47+
expect(wrapper.find(Icon).at(3)).not.toHaveProp('aria-label', `You do not have permission to view this reference`);
4848
wrapper.unmount();
4949
});
5050

51-
it('given an object schema with all properties containing x-sl-error-message, a permission denied error messsage should be shown for each', () => {
51+
it('given an object schema with all properties containing x-sl-error-message, a permission denied error message should be shown for each', () => {
5252
schema = {
5353
title: 'User',
5454
type: 'object',
@@ -74,25 +74,25 @@ describe('resolving permission error', () => {
7474

7575
tree = buildTree(schema);
7676
const wrapper = mount(<TopLevelSchemaRow schemaNode={tree.children[0]!} />);
77-
expect(wrapper.find(Icon).at(0)).toHaveProp('title', `You do not have permission to view this reference`);
78-
expect(wrapper.find(Icon).at(1)).toHaveProp('title', `You do not have permission to view this reference`);
79-
expect(wrapper.find(Icon).at(2)).toHaveProp('title', `You do not have permission to view this reference`);
77+
expect(wrapper.find(Icon).at(0)).toHaveProp('aria-label', `You do not have permission to view this reference`);
78+
expect(wrapper.find(Icon).at(1)).toHaveProp('aria-label', `You do not have permission to view this reference`);
79+
expect(wrapper.find(Icon).at(2)).toHaveProp('aria-label', `You do not have permission to view this reference`);
8080
wrapper.unmount();
8181
});
8282

83-
it('given an object schema where the toplevel contains x-sl-error-message, a permission denied error messsage should be shown', () => {
83+
it('given an object schema where the toplevel contains x-sl-error-message, a permission denied error message should be shown', () => {
8484
schema = {
8585
type: 'object',
8686
'x-sl-error-message': 'You do not have permission to view this reference',
8787
'x-sl-internally-excluded': true,
8888
};
8989
tree = buildTree(schema);
9090
const wrapper = mount(<TopLevelSchemaRow schemaNode={tree.children[0]!} />);
91-
expect(wrapper.find(Icon).at(0)).toHaveProp('title', `You do not have permission to view this reference`);
91+
expect(wrapper.find(Icon).at(0)).toHaveProp('aria-label', `You do not have permission to view this reference`);
9292
wrapper.unmount();
9393
});
9494

95-
it('given an object schema has properties without ax-sl-error-message, a permission denied error messsage should not be shown', () => {
95+
it('given an object schema has properties without ax-sl-error-message, a permission denied error message should not be shown', () => {
9696
schema = {
9797
title: 'User',
9898
type: 'object',

src/components/shared/Error.tsx

+43
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { isReferenceNode, isRegularNode, ReferenceNode, SchemaNode, SchemaNodeKind } from '@stoplight/json-schema-tree';
2+
import { Box, Icon, Tooltip } from '@stoplight/mosaic';
3+
import * as React from 'react';
4+
5+
import { isFlattenableNode } from '../../tree';
6+
import { getInternalSchemaError } from '../../utils/getInternalSchemaError';
7+
8+
function useRefNode(schemaNode: SchemaNode) {
9+
return React.useMemo<ReferenceNode | null>(() => {
10+
if (isReferenceNode(schemaNode)) {
11+
return schemaNode;
12+
}
13+
14+
if (
15+
isRegularNode(schemaNode) &&
16+
(isFlattenableNode(schemaNode) ||
17+
(schemaNode.primaryType === SchemaNodeKind.Array && schemaNode.children?.length === 1))
18+
) {
19+
return (schemaNode.children?.find(isReferenceNode) as ReferenceNode | undefined) ?? null;
20+
}
21+
22+
return null;
23+
}, [schemaNode]);
24+
}
25+
26+
export const Error: React.FC<{ schemaNode: SchemaNode }> = ({ schemaNode }) => {
27+
const refNode = useRefNode(schemaNode);
28+
const error = getInternalSchemaError(schemaNode) ?? refNode?.error;
29+
30+
if (typeof error !== 'string') return null;
31+
32+
return (
33+
<Tooltip
34+
renderTrigger={
35+
<Box as="span" display="inline-block" ml={1.5}>
36+
<Icon aria-label={error} color="var(--color-danger)" icon={['fas', 'exclamation-triangle']} size="1x" />
37+
</Box>
38+
}
39+
>
40+
{error}
41+
</Tooltip>
42+
);
43+
};

src/components/shared/Validations.tsx

+1-24
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { isRegularNode, RegularNode, SchemaNode } from '@stoplight/json-schema-tree';
1+
import { isRegularNode, RegularNode } from '@stoplight/json-schema-tree';
22
import { Flex, HStack, Text } from '@stoplight/mosaic';
33
import { Dictionary } from '@stoplight/types';
44
import capitalize from 'lodash/capitalize.js';
@@ -211,26 +211,3 @@ function getFilteredValidations(schemaNode: RegularNode) {
211211

212212
return schemaNode.validations;
213213
}
214-
215-
export function getInternalSchemaError(schemaNode: SchemaNode, defaultErrorMessage?: string) {
216-
let errorMessage: string | undefined;
217-
const fragment: unknown = schemaNode.fragment;
218-
if (typeof fragment === 'object' && fragment !== null) {
219-
const fragmentErrorMessage = fragment['x-sl-error-message'];
220-
if (typeof fragmentErrorMessage === 'string') {
221-
errorMessage = fragmentErrorMessage ?? defaultErrorMessage;
222-
} else {
223-
const items: unknown = fragment['items'];
224-
if (typeof items === 'object' && items !== null) {
225-
const itemsErrorMessage = items['x-sl-error-message'];
226-
if (typeof itemsErrorMessage === 'string') {
227-
errorMessage = itemsErrorMessage ?? defaultErrorMessage;
228-
}
229-
}
230-
}
231-
}
232-
return {
233-
hasError: !!errorMessage,
234-
error: errorMessage,
235-
};
236-
}

src/utils/getInternalSchemaError.ts

+29
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { isPlainObject } from '@stoplight/json';
2+
import type { SchemaNode } from '@stoplight/json-schema-tree';
3+
4+
export function getInternalSchemaError(schemaNode: SchemaNode): string | undefined {
5+
let errorMessage;
6+
const fragment: unknown = schemaNode.fragment;
7+
if (!isPlainObject(fragment)) return;
8+
9+
const xStoplight = fragment['x-stoplight'];
10+
11+
if (isPlainObject(xStoplight) && typeof xStoplight['error-message'] === 'string') {
12+
errorMessage = xStoplight['error-message'];
13+
} else {
14+
const fragmentErrorMessage = fragment['x-sl-error-message'];
15+
if (typeof fragmentErrorMessage === 'string') {
16+
errorMessage = fragmentErrorMessage;
17+
} else {
18+
const items: unknown = fragment['items'];
19+
if (typeof items === 'object' && items !== null) {
20+
const itemsErrorMessage = items['x-sl-error-message'];
21+
if (typeof itemsErrorMessage === 'string') {
22+
errorMessage = itemsErrorMessage;
23+
}
24+
}
25+
}
26+
}
27+
28+
return errorMessage;
29+
}

0 commit comments

Comments
 (0)