diff --git a/src/common-elements/fields-layout.ts b/src/common-elements/fields-layout.ts index a1fd214b..9e7fce0b 100644 --- a/src/common-elements/fields-layout.ts +++ b/src/common-elements/fields-layout.ts @@ -1,4 +1,4 @@ -import styled, { extensionsHook, media } from '../styled-components'; +import styled, { extensionsHook, media, css } from '../styled-components'; import { deprecatedCss } from './mixins'; export const PropertiesTableCaption = styled.caption` @@ -72,7 +72,26 @@ export const PropertyNameCell = styled(PropertyCell)` ${deprecatedCss}; } - ${({ kind }) => (kind !== 'field' ? 'font-style: italic' : '')}; + ${({ kind }) => + kind === 'patternProperties' && + css` + > span.property-name { + display: inline-table; + white-space: break-spaces; + margin-right: 20px; + + ::before, + ::after { + content: '/'; + filter: opacity(0.2); + } + } + `} + + ${({ kind = '' }) => + ['field', 'additionalProperties', 'patternProperties'].includes(kind) + ? '' + : 'font-style: italic'}; ${extensionsHook('PropertyNameCell')}; `; diff --git a/src/common-elements/fields.ts b/src/common-elements/fields.ts index 0a7275cf..d894e9a0 100644 --- a/src/common-elements/fields.ts +++ b/src/common-elements/fields.ts @@ -1,6 +1,6 @@ import { transparentize } from 'polished'; -import styled, { extensionsHook } from '../styled-components'; +import styled, { extensionsHook, css } from '../styled-components'; import { PropertyNameCell } from './fields-layout'; import { ShelfIcon } from './shelfs'; @@ -17,6 +17,27 @@ export const ClickablePropertyNameCell = styled(PropertyNameCell)` &:focus { font-weight: ${({ theme }) => theme.typography.fontWeightBold}; } + ${({ kind }) => + kind === 'patternProperties' && + css` + display: inline-flex; + margin-right: 20px; + + > span.property-name { + white-space: break-spaces; + text-align: left; + + ::before, + ::after { + content: '/'; + filter: opacity(0.2); + } + } + + > svg { + align-self: center; + } + `} } ${ShelfIcon} { height: ${({ theme }) => theme.schema.arrow.size}; @@ -56,6 +77,10 @@ export const RequiredLabel = styled(FieldLabel.withComponent('div'))` line-height: 1; `; +export const PropertyLabel = styled(RequiredLabel)` + color: ${props => props.theme.colors.primary.light}; +`; + export const RecursiveLabel = styled(FieldLabel)` color: ${({ theme }) => theme.colors.warning.main}; font-size: 13px; diff --git a/src/common-elements/shelfs.tsx b/src/common-elements/shelfs.tsx index f07c6bfc..18c26ebd 100644 --- a/src/common-elements/shelfs.tsx +++ b/src/common-elements/shelfs.tsx @@ -37,6 +37,7 @@ class IntShelfIcon extends React.PureComponent<{ export const ShelfIcon = styled(IntShelfIcon)` height: ${props => props.size || '18px'}; width: ${props => props.size || '18px'}; + min-width: ${props => props.size || '18px'}; vertical-align: middle; float: ${props => props.float || ''}; transition: transform 0.2s ease-out; diff --git a/src/components/Fields/Field.tsx b/src/components/Fields/Field.tsx index 2fb5865f..0f0d1226 100644 --- a/src/components/Fields/Field.tsx +++ b/src/components/Fields/Field.tsx @@ -1,9 +1,12 @@ import { observer } from 'mobx-react'; import * as React from 'react'; -import { ClickablePropertyNameCell, RequiredLabel } from '../../common-elements/fields'; +import { + ClickablePropertyNameCell, + PropertyLabel, + RequiredLabel, +} from '../../common-elements/fields'; import { FieldDetails } from './FieldDetails'; - import { InnerPropertiesWrap, PropertyBullet, @@ -11,11 +14,10 @@ import { PropertyDetailsCell, PropertyNameCell, } from '../../common-elements/fields-layout'; - import { ShelfIcon } from '../../common-elements/'; - -import { FieldModel } from '../../services/models'; -import { Schema, SchemaOptions } from '../Schema/Schema'; +import { Schema } from '../Schema/Schema'; +import type { SchemaOptions } from '../Schema/Schema'; +import type { FieldModel } from '../../services/models'; export interface FieldProps extends SchemaOptions { className?: string; @@ -52,6 +54,14 @@ export class Field extends React.Component { const expanded = field.expanded === undefined ? expandByDefault : field.expanded; + const labels = ( + <> + {kind === 'additionalProperties' && additional property} + {kind === 'patternProperties' && pattern property} + {required && required} + + ); + const paramName = withSubSchema ? ( { onKeyPress={this.handleKeyPress} aria-label="expand properties" > - {name} + {name} - {required && required } + {labels} ) : ( - {name} - {required && required } + {name} + {labels} ); diff --git a/src/services/__tests__/fixtures/3.1/patternProperties.json b/src/services/__tests__/fixtures/3.1/patternProperties.json new file mode 100644 index 00000000..ec686421 --- /dev/null +++ b/src/services/__tests__/fixtures/3.1/patternProperties.json @@ -0,0 +1,32 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "Schema definition with unevaluatedProperties", + "version": "1.0.0" + }, + "servers": [ + { + "url": "example.com" + } + ], + "components": { + "schemas": { + "Patterns": { + "type": "object", + "patternProperties": { + "^S_\\w+\\.[1-9]{2,4}$": { + "type": "string" + }, + "^O_\\w+\\.[1-9]{2,4}$": { + "type": "object", + "properties": { + "x-nestedProperty": { + "type": "string" + } + } + } + } + } + } + } +} diff --git a/src/services/__tests__/models/Schema.test.ts b/src/services/__tests__/models/Schema.test.ts index 966de1af..b4e94d95 100644 --- a/src/services/__tests__/models/Schema.test.ts +++ b/src/services/__tests__/models/Schema.test.ts @@ -76,5 +76,16 @@ describe('Models', () => { expect(schema.fields![1].kind).toEqual('additionalProperties'); expect(schema.fields![1].schema.type).toEqual('boolean'); }); + + test('schemaDefinition should resolve patternProperties', () => { + const spec = require('../fixtures/3.1/patternProperties.json'); + parser = new OpenAPIParser(spec, undefined, opts); + const schema = new SchemaModel(parser, spec.components.schemas.Patterns, '', opts); + expect(schema.fields).toHaveLength(2); + expect(schema.fields![0].kind).toEqual('patternProperties'); + expect(schema.fields![0].schema.type).toEqual('string'); + expect(schema.fields![1].kind).toEqual('patternProperties'); + expect(schema.fields![1].schema.type).toEqual('object'); + }); }); }); diff --git a/src/services/models/Schema.ts b/src/services/models/Schema.ts index c0754b4a..0b1f233f 100644 --- a/src/services/models/Schema.ts +++ b/src/services/models/Schema.ts @@ -364,6 +364,7 @@ function buildFields( options: RedocNormalizedOptions, ): FieldModel[] { const props = schema.properties || {}; + const patternProps = schema.patternProperties || {}; const additionalProps = schema.additionalProperties || schema.unevaluatedProperties; const defaults = schema.default; let fields = Object.keys(props || []).map(fieldName => { @@ -402,6 +403,31 @@ function buildFields( fields = sortByRequired(fields, !options.sortPropsAlphabetically ? schema.required : undefined); } + fields.push( + ...Object.keys(patternProps).map(fieldName => { + let field = patternProps[fieldName]; + + if (!field) { + console.warn( + `Field "${fieldName}" is invalid, skipping.\n Field must be an object but got ${typeof field} at "${$ref}"`, + ); + field = {}; + } + + return new FieldModel( + parser, + { + name: fieldName, + required: false, + schema: field, + kind: 'patternProperties', + }, + `${$ref}/patternProperties/${fieldName}`, + options, + ); + }), + ); + if (typeof additionalProps === 'object' || additionalProps === true) { fields.push( new FieldModel( diff --git a/src/types/open-api.ts b/src/types/open-api.ts index 98ce1f9e..41a65916 100644 --- a/src/types/open-api.ts +++ b/src/types/open-api.ts @@ -113,6 +113,7 @@ export interface OpenAPISchema { $ref?: string; type?: string | string[]; properties?: { [name: string]: OpenAPISchema }; + patternProperties?: { [name: string]: OpenAPISchema }; additionalProperties?: boolean | OpenAPISchema; unevaluatedProperties?: boolean | OpenAPISchema; description?: string; diff --git a/src/utils/openapi.ts b/src/utils/openapi.ts index e8a0cc05..0540cecc 100644 --- a/src/utils/openapi.ts +++ b/src/utils/openapi.ts @@ -99,6 +99,7 @@ const schemaKeywordTypes = { additionalProperties: 'object', unevaluatedProperties: 'object', properties: 'object', + patternProperties: 'object', }; export function detectType(schema: OpenAPISchema): string {