Skip to content

Commit

Permalink
feat: menu items from tags + md extension for Schema Definition (#681)
Browse files Browse the repository at this point in the history
* add section menus for tags and object description

* bundle and test

* add depth calculation

* add object descriptions to test

* enable operations spacing for operations as well

* bring back section rule, as this could be solved better

* update read/writeonly filter rule to be able to filter both

* add showReadOnly and showWriteOnly options to object-description

* update demo to show use cases

* remove forgotten console.log

* adjust demo test with newly added items

* do the right match with the menu items :/

* chore: refactor + jsxify md tags

* chore: simplify demo spec

* fix: dropdown fixes related to object description
  • Loading branch information
RomanHotsiy authored Jul 29, 2019
2 parents e4c3af6 + 9bf45d8 commit ac41f0b
Show file tree
Hide file tree
Showing 11 changed files with 183 additions and 31 deletions.
26 changes: 25 additions & 1 deletion demo/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ info:
OAuth2 - an open protocol to allow secure authorization in a simple
and standard method from web, mobile and desktop applications.
<security-definitions />
<SecurityDefinitions />
version: 1.0.0
title: Swagger Petstore
Expand All @@ -63,6 +63,14 @@ tags:
description: Access to Petstore orders
- name: user
description: Operations about user
- name: pet_model
x-displayName: The Pet Model
description: |
<ObjectDescription schemaRef="#/components/schemas/Pet" />
- name: store_model
x-displayName: The Order Model
description: |
<ObjectDescription schemaRef="#/components/schemas/Order" exampleRef="#/components/examples/Order" showReadOnly={true} showWriteOnly={true} />
x-tagGroups:
- name: General
tags:
Expand All @@ -71,6 +79,10 @@ x-tagGroups:
- name: User Management
tags:
- user
- name: Models
tags:
- pet_model
- store_model
paths:
/pet:
parameters:
Expand Down Expand Up @@ -754,6 +766,11 @@ components:
description: Indicates whenever order was completed or not
type: boolean
default: false
readOnly: true
rqeuestId:
description: Unique Request Id
type: string
writeOnly: true
xml:
name: Order
Pet:
Expand Down Expand Up @@ -926,3 +943,10 @@ components:
type: apiKey
name: api_key
in: header
examples:
Order:
value:
quantity: 1,
shipDate: 2018-10-19T16:46:45Z,
status: placed,
complete: false
2 changes: 1 addition & 1 deletion e2e/integration/menu.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ describe('Menu', () => {
it('should have valid items count', () => {
cy.get('.menu-content')
.find('li')
.should('have.length', 6 + (2 + 8 + 4) + (1 + 8));
.should('have.length', 6 + (2 + 8 + 1 + 4 + 2) + (1 + 8));
});

it('should sync active menu items while scroll', () => {
Expand Down
14 changes: 10 additions & 4 deletions src/components/PayloadSamples/MediaTypeSamples.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import * as React from 'react';

import styled from '../../styled-components';

import { DropdownProps } from '../../common-elements';
import { MediaTypeModel } from '../../services/models';
import { Markdown } from '../Markdown/Markdown';
Expand Down Expand Up @@ -48,7 +50,7 @@ export class MediaTypeSamples extends React.Component<PayloadSamplesProps, Media
const description = example.description;

return (
<>
<SamplesWrapper>
<DropdownWrapper>
<DropdownLabel>Example</DropdownLabel>
{this.props.renderDropdown({
Expand All @@ -61,16 +63,20 @@ export class MediaTypeSamples extends React.Component<PayloadSamplesProps, Media
{description && <Markdown source={description} />}
<Example example={example} mimeType={mimeType} />
</div>
</>
</SamplesWrapper>
);
} else {
const example = examples[examplesNames[0]];
return (
<div>
<SamplesWrapper>
{example.description && <Markdown source={example.description} />}
<Example example={example} mimeType={mimeType} />
</div>
</SamplesWrapper>
);
}
}
}

const SamplesWrapper = styled.div`
margin-top: 15px;
`;
20 changes: 6 additions & 14 deletions src/components/PayloadSamples/PayloadSamples.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,9 @@ import { observer } from 'mobx-react';
import * as React from 'react';
import { MediaTypeSamples } from './MediaTypeSamples';

import { MediaTypesSwitch } from '../MediaTypeSwitch/MediaTypesSwitch';

import styled from '../../../src/styled-components';
import { MediaContentModel } from '../../services/models';
import { DropdownOrLabel } from '../DropdownOrLabel/DropdownOrLabel';
import { MediaTypesSwitch } from '../MediaTypeSwitch/MediaTypesSwitch';
import { InvertedSimpleDropdown, MimeLabel } from './styled.elements';

export interface PayloadSamplesProps {
Expand All @@ -24,13 +22,11 @@ export class PayloadSamples extends React.Component<PayloadSamplesProps> {
return (
<MediaTypesSwitch content={mimeContent} renderDropdown={this.renderDropdown} withLabel={true}>
{mediaType => (
<SamplesWrapper>
<MediaTypeSamples
key="samples"
mediaType={mediaType}
renderDropdown={this.renderDropdown}
/>
</SamplesWrapper>
<MediaTypeSamples
key="samples"
mediaType={mediaType}
renderDropdown={this.renderDropdown}
/>
)}
</MediaTypesSwitch>
);
Expand All @@ -40,7 +36,3 @@ export class PayloadSamples extends React.Component<PayloadSamplesProps> {
return <DropdownOrLabel Label={MimeLabel} Dropdown={InvertedSimpleDropdown} {...props} />;
};
}

const SamplesWrapper = styled.div`
margin-top: 15px;
`;
2 changes: 1 addition & 1 deletion src/components/PayloadSamples/styled.elements.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ export const InvertedSimpleDropdown = styled(StyledDropdown)`
}
.Dropdown-menu {
margin: 0;
margin-top: 10px;
margin-top: 2px;
}
`;

Expand Down
6 changes: 3 additions & 3 deletions src/components/Schema/ObjectSchema.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,9 @@ export class ObjectSchema extends React.Component<ObjectSchemaProps> {

const filteredFields = needFilter
? fields.filter(item => {
return (
(this.props.skipReadOnly && !item.schema.readOnly) ||
(this.props.skipWriteOnly && !item.schema.writeOnly)
return !(
(this.props.skipReadOnly && item.schema.readOnly) ||
(this.props.skipWriteOnly && item.schema.writeOnly)
);
})
: fields;
Expand Down
93 changes: 93 additions & 0 deletions src/components/SchemaDefinition/SchemaDefinition.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import * as React from 'react';

import { DarkRightPanel, MiddlePanel, MimeLabel, Row, Section } from '../../common-elements';
import { MediaTypeModel, OpenAPIParser, RedocNormalizedOptions } from '../../services';
import styled from '../../styled-components';
import { OpenAPIMediaType } from '../../types';
import { DropdownOrLabel } from '../DropdownOrLabel/DropdownOrLabel';
import { MediaTypeSamples } from '../PayloadSamples/MediaTypeSamples';
import { InvertedSimpleDropdown } from '../PayloadSamples/styled.elements';
import { Schema } from '../Schema';

export interface ObjectDescriptionProps {
schemaRef: string;
exampleRef?: string;
showReadOnly?: boolean;
showWriteOnly?: boolean;
parser: OpenAPIParser;
options: RedocNormalizedOptions;
}

export class SchemaDefinition extends React.PureComponent<ObjectDescriptionProps> {
private static getMediaType(schemaRef: string, exampleRef?: string): OpenAPIMediaType {
if (!schemaRef) {
return {};
}

const info: OpenAPIMediaType = {
schema: { $ref: schemaRef },
};

if (exampleRef) {
info.examples = { example: { $ref: exampleRef } };
}

return info;
}

private _mediaModel: MediaTypeModel;

private get mediaModel() {
const { parser, schemaRef, exampleRef, options } = this.props;
if (!this._mediaModel) {
this._mediaModel = new MediaTypeModel(
parser,
'json',
false,
SchemaDefinition.getMediaType(schemaRef, exampleRef),
options,
);
}

return this._mediaModel;
}

render() {
const { showReadOnly = true, showWriteOnly = false } = this.props;
return (
<Section>
<Row>
<MiddlePanel>
<Schema
skipWriteOnly={!showWriteOnly}
skipReadOnly={!showReadOnly}
schema={this.mediaModel.schema}
/>
</MiddlePanel>
<DarkRightPanel>
<MediaSamplesWrap>
<MediaTypeSamples renderDropdown={this.renderDropdown} mediaType={this.mediaModel} />
</MediaSamplesWrap>
</DarkRightPanel>
</Row>
</Section>
);
}

private renderDropdown = props => {
return <DropdownOrLabel Label={MimeLabel} Dropdown={InvertedSimpleDropdown} {...props} />;
};
}

const MediaSamplesWrap = styled.div`
background: ${({ theme }) => theme.codeSample.backgroundColor};
& > div,
& > pre {
padding: ${props => props.theme.spacing.unit * 4}px;
margin: 0;
}
& > div > pre {
padding: 0;
}
`;
20 changes: 19 additions & 1 deletion src/services/AppStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,13 @@ import { RedocNormalizedOptions, RedocRawOptions } from './RedocNormalizedOption
import { ScrollService } from './ScrollService';
import { SearchStore } from './SearchStore';

import { SchemaDefinition } from '../components/SchemaDefinition/SchemaDefinition';
import { SecurityDefs } from '../components/SecuritySchemes/SecuritySchemes';
import { SECURITY_DEFINITIONS_COMPONENT_NAME } from '../utils/openapi';
import {
SCHEMA_DEFINITION_JSX_NAME,
SECURITY_DEFINITIONS_COMPONENT_NAME,
SECURITY_DEFINITIONS_JSX_NAME,
} from '../utils/openapi';

export interface StoreState {
menu: {
Expand Down Expand Up @@ -151,5 +156,18 @@ const DEFAULT_OPTIONS: RedocRawOptions = {
securitySchemes: store.spec.securitySchemes,
}),
},
[SECURITY_DEFINITIONS_JSX_NAME]: {
component: SecurityDefs,
propsSelector: (store: AppStore) => ({
securitySchemes: store.spec.securitySchemes,
}),
},
[SCHEMA_DEFINITION_JSX_NAME]: {
component: SchemaDefinition,
propsSelector: (store: AppStore) => ({
parser: store.spec.parser,
options: store.options,
}),
},
},
};
21 changes: 15 additions & 6 deletions src/services/MenuBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ export class MenuBuilder {

const items: ContentItemModel[] = [];
const tagsMap = MenuBuilder.getTagsWithOperations(spec);
items.push(...MenuBuilder.addMarkdownItems(spec.info.description || '', options));
items.push(...MenuBuilder.addMarkdownItems(spec.info.description || '', undefined, 1, options));
if (spec['x-tagGroups'] && spec['x-tagGroups'].length > 0) {
items.push(
...MenuBuilder.getTagGroupsItems(parser, undefined, spec['x-tagGroups'], tagsMap, options),
Expand All @@ -59,14 +59,16 @@ export class MenuBuilder {
*/
static addMarkdownItems(
description: string,
parent: GroupModel | undefined,
initialDepth: number,
options: RedocNormalizedOptions,
): ContentItemModel[] {
const renderer = new MarkdownRenderer(options);
const headings = renderer.extractHeadings(description || '');

const mapHeadingsDeep = (parent, items, depth = 1) =>
const mapHeadingsDeep = (_parent, items, depth = 1) =>
items.map(heading => {
const group = new GroupModel('section', heading, parent);
const group = new GroupModel('section', heading, _parent);
group.depth = depth;
if (heading.items) {
group.items = mapHeadingsDeep(group, heading.items, depth + 1);
Expand All @@ -82,7 +84,7 @@ export class MenuBuilder {
return group;
});

return mapHeadingsDeep(undefined, headings);
return mapHeadingsDeep(parent, headings, initialDepth);
}

/**
Expand Down Expand Up @@ -144,15 +146,22 @@ export class MenuBuilder {
}
const item = new GroupModel('tag', tag, parent);
item.depth = GROUP_DEPTH + 1;
item.items = this.getOperationsItems(parser, item, tag, item.depth + 1, options);

// don't put empty tag into content, instead put its operations
if (tag.name === '') {
const items = this.getOperationsItems(parser, undefined, tag, item.depth + 1, options);
const items = [
...MenuBuilder.addMarkdownItems(tag.description || '', item, item.depth + 1, options),
...this.getOperationsItems(parser, undefined, tag, item.depth + 1, options),
];
res.push(...items);
continue;
}

item.items = [
...MenuBuilder.addMarkdownItems(tag.description || '', item, item.depth + 1, options),
...this.getOperationsItems(parser, item, tag, item.depth + 1, options),
];

res.push(item);
}
return res;
Expand Down
7 changes: 7 additions & 0 deletions src/services/models/Group.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,14 @@ export class GroupModel implements IMenuItem {
this.type = type;
this.name = tagOrGroup['x-displayName'] || tagOrGroup.name;
this.level = (tagOrGroup as MarkdownHeading).level || 1;

// remove sections from markdown, same as in ApiInfo
this.description = tagOrGroup.description || '';
const firstHeadingLinePos = this.description.search(/^##?\s+/m);
if (firstHeadingLinePos > -1) {
this.description = this.description.substring(0, firstHeadingLinePos);
}

this.parent = parent;
this.externalDocs = (tagOrGroup as OpenAPITag).externalDocs;

Expand Down
3 changes: 3 additions & 0 deletions src/utils/openapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -496,6 +496,9 @@ export function normalizeServers(
}

export const SECURITY_DEFINITIONS_COMPONENT_NAME = 'security-definitions';
export const SECURITY_DEFINITIONS_JSX_NAME = 'SecurityDefinitions';
export const SCHEMA_DEFINITION_JSX_NAME = 'ObjectDescription';

export let SECURITY_SCHEMES_SECTION_PREFIX = 'section/Authentication/';
export function setSecuritySchemePrefix(prefix: string) {
SECURITY_SCHEMES_SECTION_PREFIX = prefix;
Expand Down

0 comments on commit ac41f0b

Please sign in to comment.