Skip to content

Commit

Permalink
feat: add support for vendor extensions (#245)
Browse files Browse the repository at this point in the history
---------

Co-authored-by: Weyert de Boer <weyert@innerfuse.biz>
Co-authored-by: Jakub Rożek <jakub@stoplight.io>
  • Loading branch information
3 people committed Mar 11, 2024
1 parent 0737d66 commit 129d05c
Show file tree
Hide file tree
Showing 10 changed files with 207 additions and 12 deletions.
97 changes: 97 additions & 0 deletions src/__fixtures__/extensions/simple.json
Original file line number Diff line number Diff line change
@@ -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" }
}
}
}
}
}
7 changes: 7 additions & 0 deletions src/__stories__/Default.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 };

Expand Down
40 changes: 40 additions & 0 deletions src/__stories__/VendorExtensions.tsx
Original file line number Diff line number Diff line change
@@ -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<JsonSchemaProps> = ({ schema = defaultSchema as JSONSchema4, ...args }) => (
<JsonSchemaViewer schema={schema} {...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 (
<Flex h="full" alignItems="center">
<strong>{JSON.stringify(vendorExtensions['x-stoplight'], null, 2)}</strong>
</Flex>
);
},
};
3 changes: 3 additions & 0 deletions src/components/JsonSchemaViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ const JsonSchemaViewerComponent = ({
defaultExpandedDepth = 1,
onGoToRef,
renderRowAddon,
renderExtensionAddon,
hideExamples,
renderRootTreeLines,
disableCrumbs,
Expand All @@ -49,6 +50,7 @@ const JsonSchemaViewerComponent = ({
viewMode,
onGoToRef,
renderRowAddon,
renderExtensionAddon,
hideExamples,
renderRootTreeLines,
disableCrumbs,
Expand All @@ -59,6 +61,7 @@ const JsonSchemaViewerComponent = ({
viewMode,
onGoToRef,
renderRowAddon,
renderExtensionAddon,
hideExamples,
renderRootTreeLines,
disableCrumbs,
Expand Down
20 changes: 11 additions & 9 deletions src/components/SchemaRow/SchemaRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -30,6 +31,7 @@ export const SchemaRow: React.FunctionComponent<SchemaRowProps> = React.memo(
const {
defaultExpandedDepth,
renderRowAddon,
renderExtensionAddon,
onGoToRef,
hideExamples,
renderRootTreeLines,
Expand Down Expand Up @@ -65,6 +67,12 @@ export const SchemaRow: React.FunctionComponent<SchemaRowProps> = 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) {
Expand Down Expand Up @@ -100,11 +108,9 @@ export const SchemaRow: React.FunctionComponent<SchemaRowProps> = React.memo(
}}
>
{!isRootLevel && <Box borderT w={isCollapsible ? 1 : 3} ml={-3} mr={3} mt={2} />}

{parentChangeType !== 'added' && parentChangeType !== 'removed' ? (
<NodeAnnotation change={hasChanged} style={{ left: annotationLeftOffset }} />
) : null}

<VStack spacing={1} maxW="full" flex={1} ml={isCollapsible && !isRootLevel ? 2 : undefined}>
<Flex
alignItems="center"
Expand All @@ -113,7 +119,6 @@ export const SchemaRow: React.FunctionComponent<SchemaRowProps> = React.memo(
cursor={isCollapsible ? 'pointer' : undefined}
>
{isCollapsible ? <Caret isExpanded={isExpanded} /> : null}

<Flex alignItems="baseline" fontSize="base">
{schemaNode.subpath.length > 0 && shouldShowPropertyName(schemaNode) && (
<Box
Expand Down Expand Up @@ -167,22 +172,19 @@ export const SchemaRow: React.FunctionComponent<SchemaRowProps> = React.memo(
/>
)}
</Flex>

{hasProperties && <Divider atom={isNodeHoveredAtom(schemaNode)} />}

<Properties required={required} deprecated={deprecated} validations={validations} />
</Flex>

{typeof description === 'string' && description.length > 0 && <Description value={description} />}

<Validations
validations={isRegularNode(schemaNode) ? getValidationsFromSchema(schemaNode) : {}}
hideExamples={hideExamples}
/>
{hasVendorProperties && renderExtensionAddon ? (
<Box>{renderExtensionAddon({ schemaNode, nestingLevel, vendorExtensions })}</Box>
) : null}
</VStack>

<Error schemaNode={schemaNode} />

{renderRowAddon ? <Box>{renderRowAddon({ schemaNode, nestingLevel })}</Box> : null}
</Flex>
{isCollapsible && isExpanded ? (
Expand Down
14 changes: 12 additions & 2 deletions src/components/SchemaRow/TopLevelSchemaRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -18,18 +20,28 @@ export const TopLevelSchemaRow = ({
schemaNode,
skipDescription,
}: Pick<SchemaRowProps, 'schemaNode'> & { 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)) {
return (
<>
<ScrollCheck />
{!skipDescription ? <Description value={schemaNode.annotations.description} /> : null}
{hasVendorProperties && renderExtensionAddon
? renderExtensionAddon({ schemaNode, nestingLevel, vendorExtensions })
: null}
<ChildStack
schemaNode={schemaNode}
childNodes={childNodes}
Expand All @@ -48,7 +60,6 @@ export const TopLevelSchemaRow = ({
<>
<ScrollCheck />
<Description value={schemaNode.annotations.description} />

<HStack spacing={3} pb={4}>
<Menu
aria-label="Pick a type"
Expand Down Expand Up @@ -77,7 +88,6 @@ export const TopLevelSchemaRow = ({
</Flex>
) : null}
</HStack>

{childNodes.length > 0 ? (
<ChildStack
schemaNode={schemaNode}
Expand Down
3 changes: 2 additions & 1 deletion src/contexts/jsvOptions.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import type { NodeHasChangedFn } from '@stoplight/types';
import * as React from 'react';

import { GoToRefHandler, RowAddonRenderer, ViewMode } from '../types';
import { ExtensionAddonRenderer, GoToRefHandler, RowAddonRenderer, ViewMode } from '../types';

export type JSVOptions = {
defaultExpandedDepth: number;
viewMode: ViewMode;
onGoToRef?: GoToRefHandler;
renderRowAddon?: RowAddonRenderer;
renderExtensionAddon?: ExtensionAddonRenderer;
hideExamples?: boolean;
renderRootTreeLines?: boolean;
disableCrumbs?: boolean;
Expand Down
8 changes: 8 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,14 @@ export interface SchemaRowProps {

export type RowAddonRenderer = (props: SchemaRowProps) => React.ReactNode;

export interface ExtensionRowProps {
schemaNode: SchemaNode;
nestingLevel: number;
vendorExtensions: Record<string, unknown>;
}

export type ExtensionAddonRenderer = (props: ExtensionRowProps) => React.ReactNode;

export type ViewMode = 'read' | 'write' | 'standalone';

export type JSONSchema = JSONSchema4 | JSONSchema6 | JSONSchema7;
26 changes: 26 additions & 0 deletions src/utils/extractVendorExtensions.ts
Original file line number Diff line number Diff line change
@@ -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];
}
1 change: 1 addition & 0 deletions src/utils/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export * from './extractVendorExtensions';
export * from './printName';

0 comments on commit 129d05c

Please sign in to comment.