Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Tags side menu with markdown and object description #681

Merged
merged 16 commits into from
Jul 29, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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