From 9e6005ff72dba957cff2bf0faa3e0cd68f002b95 Mon Sep 17 00:00:00 2001 From: thisismo Date: Mon, 14 Oct 2019 14:45:05 +0200 Subject: [PATCH] Extended search --- src/components/ContentItems/ContentItems.tsx | 2 + src/components/Fields/Field.tsx | 2 +- src/components/SideMenu/MenuItem.tsx | 3 + .../DiscriminatorDropdown.test.tsx.snap | 8 + src/services/AppStore.ts | 2 +- src/services/MenuBuilder.ts | 137 +++++++++++++++++- src/services/MenuStore.ts | 41 +++++- src/services/SearchStore.ts | 15 +- src/services/models/Field.ts | 60 +++++++- src/services/models/Operation.ts | 1 + src/services/models/Response.ts | 4 + 11 files changed, 251 insertions(+), 24 deletions(-) diff --git a/src/components/ContentItems/ContentItems.tsx b/src/components/ContentItems/ContentItems.tsx index 3b638896..068689a1 100644 --- a/src/components/ContentItems/ContentItems.tsx +++ b/src/components/ContentItems/ContentItems.tsx @@ -36,6 +36,8 @@ export class ContentItem extends React.Component { case 'group': content = null; break; + case 'field': + return null; case 'tag': case 'section': content = ; diff --git a/src/components/Fields/Field.tsx b/src/components/Fields/Field.tsx index ef969b2d..bfa337c1 100644 --- a/src/components/Fields/Field.tsx +++ b/src/components/Fields/Field.tsx @@ -61,7 +61,7 @@ export class Field extends React.Component { <> {paramName} - + diff --git a/src/components/SideMenu/MenuItem.tsx b/src/components/SideMenu/MenuItem.tsx index 2a219dfc..b2a74859 100644 --- a/src/components/SideMenu/MenuItem.tsx +++ b/src/components/SideMenu/MenuItem.tsx @@ -42,6 +42,9 @@ export class MenuItem extends React.Component { render() { const { item, withoutChildren } = this.props; + if (item.type === 'field' && this.props['data-role'] !== 'search:result') { + return null; + } return ( { diff --git a/src/services/MenuBuilder.ts b/src/services/MenuBuilder.ts index 1987ba10..a656e770 100644 --- a/src/services/MenuBuilder.ts +++ b/src/services/MenuBuilder.ts @@ -1,11 +1,11 @@ import { OpenAPIOperation, OpenAPIParameter, OpenAPISpec, OpenAPITag, Referenced } from '../types'; import { - isOperationName, + isOperationName, safeSlugify, SECURITY_DEFINITIONS_COMPONENT_NAME, setSecuritySchemePrefix, } from '../utils'; import { MarkdownRenderer } from './MarkdownRenderer'; -import { GroupModel, OperationModel } from './models'; +import { FieldModel, GroupModel, OperationModel } from './models'; import { OpenAPIParser } from './OpenAPIParser'; import { RedocNormalizedOptions } from './RedocNormalizedOptions'; @@ -28,12 +28,13 @@ export interface TagGroup { } export const GROUP_DEPTH = 0; -export type ContentItemModel = GroupModel | OperationModel; +export type ContentItemModel = GroupModel | OperationModel | FieldModel; export class MenuBuilder { /** * Builds page content structure based on tags */ + static buildStructure( parser: OpenAPIParser, options: RedocNormalizedOptions, @@ -139,7 +140,7 @@ export class MenuBuilder { return tagsMap[tagName]; }); - const res: Array = []; + const res: Array = []; for (const tag of tags) { if (!tag) { continue; @@ -179,20 +180,144 @@ export class MenuBuilder { tag: TagInfo, depth: number, options: RedocNormalizedOptions, - ): OperationModel[] { + ): ContentItemModel[] { if (tag.operations.length === 0) { return []; } - const res: OperationModel[] = []; + const res: ContentItemModel[] = []; for (const operationInfo of tag.operations) { const operation = new OperationModel(parser, operationInfo, parent, options); operation.depth = depth; res.push(operation); + res.push(...this.getOperationFields(operation, depth + 1)); } return res; } + static getOperationFields( + parent: OperationModel, + depth: number, + ): FieldModel[] { + + const fields: FieldModel[] = []; + + if (parent.parameters !== undefined) { + const parameters = parent.parameters; + fields.push(...this.getFields(parameters, parent, 'parameters', depth)); + } + + if (parent.requestBody !== undefined) { + const body = parent.requestBody; + const bodyFields: FieldModel[] = []; + + if (body.content) { + const mediaTypes = body.content.mediaTypes; + mediaTypes.forEach(mediaType => { + const type = mediaType.name.split('/')[1]; + const schema = mediaType.schema; + if (schema && schema.oneOf) { // One of + let active = 0; + schema.oneOf.forEach(s => { + bodyFields.push(...this.getFields(s.fields, parent, 'body/' + type + '/' + s.title, depth).map(f => { + f.containerContentModel = body.content; + f.activeContentModel = mediaTypes.indexOf(mediaType); + f.containerOneOf = schema; + f.activeOneOf = active; + return f; + })); + active++; + }); + } else if (schema && schema.fields) { + bodyFields.push(...this.getFields(schema.fields, parent, 'body/' + type, depth).map(f => { + f.containerContentModel = body.content; + f.activeContentModel = mediaTypes.indexOf(mediaType); + return f; + })); + } + }); + fields.push(...bodyFields); + } + } + + if (parent.responses !== undefined) { + const responses = parent.responses; + const responseFields: FieldModel[] = []; + + responses.forEach(response => { + responseFields.push(...this.getFields(response.headers, parent, 'responses/' + response.code + '/headers', depth).map(r => { + r.responseContainer = response; + return r; + })); + + if (response.content) { + const mediaTypes = response.content.mediaTypes; + mediaTypes.forEach(mediaType => { + const type = mediaType.name.split('/')[1]; + const schema = mediaType.schema; + if (schema && schema.oneOf) { // One of + let active = 0; + schema.oneOf.forEach(s => { + responseFields.push(...this.getFields(s.fields, parent, 'responses/' + response.code + '/' + type + '/' + s.title, depth).map(f => { + f.responseContainer = response; + f.containerContentModel = response.content; + f.activeContentModel = mediaTypes.indexOf(mediaType); + f.containerOneOf = schema; + f.activeOneOf = active; + return f; + })); + active++; + }); + } else if (schema && schema.fields) { + responseFields.push(...this.getFields(schema.fields, parent, 'responses/' + response.code + '/' + type, depth).map(f => { + f.responseContainer = response; + f.containerContentModel = response.content; + f.activeContentModel = mediaTypes.indexOf(mediaType); + return f; + })); + } + }); + } + }); + fields.push(...responseFields); + } + + return fields; + } + + static getFields(fields, parent, section, depth): FieldModel[] { + const temp: FieldModel[] = []; + fields.forEach(field => { + temp.push(...this.getDeepFields(field, parent, section, depth)); + }); + return temp.filter((field, index, self) => { + return index === self.findIndex(f => { + return f.id === field.id; + }); + }); + } + + static getDeepFields(field: FieldModel, parent: ContentItemModel, section: string, depth: number): FieldModel[] { + const temp: FieldModel[] = []; + + field.id = parent.id.includes(section) ? parent.id + '/' + safeSlugify(field.name) : parent.id + '/' + section + '/' + safeSlugify(field.name); + field.parent = parent; + temp.push(field); + + if (field.schema.fields !== undefined) { + field.schema.fields.forEach(fieldInner => { + temp.push(...this.getDeepFields(fieldInner, field, section, depth + 1)); + }); + } + if (field.schema.items !== undefined && field.schema.items.fields !== undefined) { + field.schema.items.fields.forEach(fieldInner => { + temp.push(...this.getDeepFields(fieldInner, field, section, depth + 1)); + }); + } + + return temp; + } + /** * collects tags and maps each tag to list of operations belonging to this tag */ diff --git a/src/services/MenuStore.ts b/src/services/MenuStore.ts index f775070b..264870e6 100644 --- a/src/services/MenuStore.ts +++ b/src/services/MenuStore.ts @@ -1,6 +1,6 @@ import { action, observable } from 'mobx'; import { querySelector } from '../utils/dom'; -import { SpecStore } from './models'; +import { FieldModel, SpecStore } from './models'; import { history as historyInst, HistoryService } from './HistoryService'; import { ScrollService } from './ScrollService'; @@ -9,7 +9,7 @@ import { flattenByProp, SECURITY_SCHEMES_SECTION_PREFIX } from '../utils'; import { GROUP_DEPTH } from './MenuBuilder'; export type MenuItemGroupType = 'group' | 'tag' | 'section'; -export type MenuItemType = MenuItemGroupType | 'operation'; +export type MenuItemType = MenuItemGroupType | 'operation' | 'field'; /** Generic interface for MenuItems */ export interface IMenuItem { @@ -64,6 +64,8 @@ export class MenuStore { items: IMenuItem[]; flatItems: IMenuItem[]; + imagesLoaded: boolean = false; + /** * cached flattened menu items to support absolute indexing */ @@ -129,7 +131,16 @@ export class MenuStore { itemIdx += step; } - this.activate(this.flatItems[itemIdx], true, true); + let item: FieldModel | undefined = this.flatItems[itemIdx] as FieldModel; + while (item && item.type === 'field' && item.parent && item.parent.type === 'field' && !item.parent.expanded || + item && item.containerContentModel !== undefined && item.containerContentModel.activeMimeIdx !== item.activeContentModel || + item && item.responseContainer && !item.responseContainer.expanded || + item && item.containerOneOf !== undefined && item.containerOneOf.activeOneOf !== item.activeOneOf) { + itemIdx += step; + item = this.flatItems[itemIdx] as FieldModel; + } + + this.activate(item, true, true); }; /** @@ -142,6 +153,12 @@ export class MenuStore { } let item: IMenuItem | undefined; + // Make jumps possible even if last char in URL is '/' + if (id[id.length - 1] === '/') { + id = id.slice(0, -1); + this.history.replace(id, false); + } + item = this.flatItems.find(i => i.id === id); if (item) { this.activateAndScroll(item, false); @@ -178,7 +195,7 @@ export class MenuStore { * activate menu item * @param item item to activate * @param updateLocation [true] whether to update location - * @param rewriteHistory [false] whether to rewrite browser history (do not create new enrty) + * @param rewriteHistory [false] whether to rewrite browser history (do not create new entry) */ @action activate( @@ -220,7 +237,9 @@ export class MenuStore { } item.deactivate(); while (item !== undefined) { - item.collapse(); + if (item.type !== 'field') { + item.collapse(); + } item = item.parent; } } @@ -248,7 +267,17 @@ export class MenuStore { * scrolls to active section */ scrollToActive(): void { - this.scroll.scrollIntoView(this.getElementAt(this.activeItemIdx)); + const active = this.activeItemIdx; + const element = this.getElementAt(active); + if (element === null || !this.imagesLoaded) { + setTimeout(() => { + this.activeItemIdx = active; + this.scrollToActive(); + this.imagesLoaded = true; + }, this.imagesLoaded ? 500 : 100); + } else { + this.scroll.scrollIntoView(element); + } } dispose() { diff --git a/src/services/SearchStore.ts b/src/services/SearchStore.ts index 669d8d05..d4e39937 100644 --- a/src/services/SearchStore.ts +++ b/src/services/SearchStore.ts @@ -21,16 +21,13 @@ export class SearchStore { searchWorker = new worker(); indexItems(groups: Array) { - const recurse = items => { - items.forEach(group => { - if (group.type !== 'group') { - this.add(group.name, group.description || '', group.id); - } - recurse(group.items); - }); - }; + groups.forEach(group => { + if (group.type !== 'group') { + // @ts-ignore + this.add(group.name, group.description || '', group.id); + } + }); - recurse(groups); this.searchWorker.done(); } diff --git a/src/services/models/Field.ts b/src/services/models/Field.ts index 5302a09f..5d0d395e 100644 --- a/src/services/models/Field.ts +++ b/src/services/models/Field.ts @@ -6,10 +6,13 @@ import { OpenAPIParameterStyle, Referenced, } from '../../types'; +import { IMenuItem } from '../MenuStore'; import { RedocNormalizedOptions } from '../RedocNormalizedOptions'; import { extractExtensions } from '../../utils/openapi'; import { OpenAPIParser } from '../OpenAPIParser'; +import { MediaContentModel } from './MediaContent'; +import { ResponseModel } from './Response'; import { SchemaModel } from './Schema'; function getDefaultStyleValue(parameterLocation: OpenAPIParameterLocation): OpenAPIParameterStyle { @@ -28,10 +31,28 @@ function getDefaultStyleValue(parameterLocation: OpenAPIParameterLocation): Open /** * Field or Parameter model ready to be used by components */ -export class FieldModel { +export class FieldModel implements IMenuItem { @observable expanded: boolean = false; + depth: number; + items = []; + + ready?: boolean = true; + active: boolean = false; + + id: string; + absoluteIdx?: number; + parent?: IMenuItem; + + containerContentModel?: MediaContentModel; + containerOneOf?: SchemaModel; + activeContentModel?: number; + activeOneOf?: number; + responseContainer?: ResponseModel; + + type = 'field' as 'field'; + schema: SchemaModel; name: string; required: boolean; @@ -92,4 +113,41 @@ export class FieldModel { toggle() { this.expanded = !this.expanded; } + + @action + activate() { + if (this.parent) { + this.parent.activate(); + if (this.responseContainer !== undefined) { + this.responseContainer.expand(); + } + if (this.containerContentModel !== undefined && this.activeContentModel !== undefined) { + this.containerContentModel.activate(this.activeContentModel); + } + if (this.containerOneOf !== undefined && this.activeOneOf !== undefined) { + this.containerOneOf.activateOneOf(this.activeOneOf); + } + } + } + + @action + deactivate() { + if (this.parent) { + this.parent.deactivate(); + } + } + + @action + expand() { + if (this.parent) { + if (this.parent.type === 'field') { + this.parent.expanded = true; + } + this.parent.expand(); + } + } + + collapse() { + // Do nothing + } } diff --git a/src/services/models/Operation.ts b/src/services/models/Operation.ts index 88640dc5..1b5c9ad3 100644 --- a/src/services/models/Operation.ts +++ b/src/services/models/Operation.ts @@ -126,6 +126,7 @@ export class OperationModel implements IMenuItem { this.active = false; } + @action expand() { if (this.parent) { this.parent.expand(); diff --git a/src/services/models/Response.ts b/src/services/models/Response.ts index 3fc4d06c..10e16074 100644 --- a/src/services/models/Response.ts +++ b/src/services/models/Response.ts @@ -58,4 +58,8 @@ export class ResponseModel { toggle() { this.expanded = !this.expanded; } + + expand() { + this.expanded = true; + } }