Extended search

This commit is contained in:
thisismo 2019-10-14 14:45:05 +02:00
parent ffacdc09be
commit 9e6005ff72
11 changed files with 251 additions and 24 deletions

View File

@ -36,6 +36,8 @@ export class ContentItem extends React.Component<ContentItemProps> {
case 'group': case 'group':
content = null; content = null;
break; break;
case 'field':
return null;
case 'tag': case 'tag':
case 'section': case 'section':
content = <SectionItem {...this.props} />; content = <SectionItem {...this.props} />;

View File

@ -61,7 +61,7 @@ export class Field extends React.Component<FieldProps> {
<> <>
<tr className={isLast ? 'last ' + className : className}> <tr className={isLast ? 'last ' + className : className}>
{paramName} {paramName}
<PropertyDetailsCell> <PropertyDetailsCell data-section-id={this.props.field.id}>
<FieldDetails {...this.props} /> <FieldDetails {...this.props} />
</PropertyDetailsCell> </PropertyDetailsCell>
</tr> </tr>

View File

@ -42,6 +42,9 @@ export class MenuItem extends React.Component<MenuItemProps> {
render() { render() {
const { item, withoutChildren } = this.props; const { item, withoutChildren } = this.props;
if (item.type === 'field' && this.props['data-role'] !== 'search:result') {
return null;
}
return ( return (
<MenuItemLi <MenuItemLi
onClick={this.activate} onClick={this.activate}

View File

@ -6,14 +6,17 @@ exports[`Components SchemaView discriminator should correctly render discriminat
<Field <Field
field={ field={
FieldModel { FieldModel {
"active": false,
"deprecated": false, "deprecated": false,
"description": "", "description": "",
"example": undefined, "example": undefined,
"expanded": false, "expanded": false,
"explode": false, "explode": false,
"in": undefined, "in": undefined,
"items": Array [],
"kind": "field", "kind": "field",
"name": "packSize", "name": "packSize",
"ready": true,
"required": false, "required": false,
"schema": SchemaModel { "schema": SchemaModel {
"activeOneOf": 0, "activeOneOf": 0,
@ -47,6 +50,7 @@ exports[`Components SchemaView discriminator should correctly render discriminat
"typePrefix": "", "typePrefix": "",
"writeOnly": false, "writeOnly": false,
}, },
"type": "field",
} }
} }
isLast={false} isLast={false}
@ -56,14 +60,17 @@ exports[`Components SchemaView discriminator should correctly render discriminat
<Field <Field
field={ field={
FieldModel { FieldModel {
"active": false,
"deprecated": false, "deprecated": false,
"description": "", "description": "",
"example": undefined, "example": undefined,
"expanded": false, "expanded": false,
"explode": false, "explode": false,
"in": undefined, "in": undefined,
"items": Array [],
"kind": "field", "kind": "field",
"name": "type", "name": "type",
"ready": true,
"required": true, "required": true,
"schema": SchemaModel { "schema": SchemaModel {
"activeOneOf": 0, "activeOneOf": 0,
@ -97,6 +104,7 @@ exports[`Components SchemaView discriminator should correctly render discriminat
"typePrefix": "", "typePrefix": "",
"writeOnly": false, "writeOnly": false,
}, },
"type": "field",
} }
} }
isLast={true} isLast={true}

View File

@ -84,7 +84,7 @@ export class AppStore {
if (!this.options.disableSearch) { if (!this.options.disableSearch) {
this.search = new SearchStore(); this.search = new SearchStore();
if (createSearchIndex) { if (createSearchIndex) {
this.search.indexItems(this.menu.items); this.search.indexItems(this.menu.flatItems);
} }
this.disposer = observe(this.menu, 'activeItemIdx', change => { this.disposer = observe(this.menu, 'activeItemIdx', change => {

View File

@ -1,11 +1,11 @@
import { OpenAPIOperation, OpenAPIParameter, OpenAPISpec, OpenAPITag, Referenced } from '../types'; import { OpenAPIOperation, OpenAPIParameter, OpenAPISpec, OpenAPITag, Referenced } from '../types';
import { import {
isOperationName, isOperationName, safeSlugify,
SECURITY_DEFINITIONS_COMPONENT_NAME, SECURITY_DEFINITIONS_COMPONENT_NAME,
setSecuritySchemePrefix, setSecuritySchemePrefix,
} from '../utils'; } from '../utils';
import { MarkdownRenderer } from './MarkdownRenderer'; import { MarkdownRenderer } from './MarkdownRenderer';
import { GroupModel, OperationModel } from './models'; import { FieldModel, GroupModel, OperationModel } from './models';
import { OpenAPIParser } from './OpenAPIParser'; import { OpenAPIParser } from './OpenAPIParser';
import { RedocNormalizedOptions } from './RedocNormalizedOptions'; import { RedocNormalizedOptions } from './RedocNormalizedOptions';
@ -28,12 +28,13 @@ export interface TagGroup {
} }
export const GROUP_DEPTH = 0; export const GROUP_DEPTH = 0;
export type ContentItemModel = GroupModel | OperationModel; export type ContentItemModel = GroupModel | OperationModel | FieldModel;
export class MenuBuilder { export class MenuBuilder {
/** /**
* Builds page content structure based on tags * Builds page content structure based on tags
*/ */
static buildStructure( static buildStructure(
parser: OpenAPIParser, parser: OpenAPIParser,
options: RedocNormalizedOptions, options: RedocNormalizedOptions,
@ -139,7 +140,7 @@ export class MenuBuilder {
return tagsMap[tagName]; return tagsMap[tagName];
}); });
const res: Array<GroupModel | OperationModel> = []; const res: Array<GroupModel | OperationModel | FieldModel> = [];
for (const tag of tags) { for (const tag of tags) {
if (!tag) { if (!tag) {
continue; continue;
@ -179,20 +180,144 @@ export class MenuBuilder {
tag: TagInfo, tag: TagInfo,
depth: number, depth: number,
options: RedocNormalizedOptions, options: RedocNormalizedOptions,
): OperationModel[] { ): ContentItemModel[] {
if (tag.operations.length === 0) { if (tag.operations.length === 0) {
return []; return [];
} }
const res: OperationModel[] = []; const res: ContentItemModel[] = [];
for (const operationInfo of tag.operations) { for (const operationInfo of tag.operations) {
const operation = new OperationModel(parser, operationInfo, parent, options); const operation = new OperationModel(parser, operationInfo, parent, options);
operation.depth = depth; operation.depth = depth;
res.push(operation); res.push(operation);
res.push(...this.getOperationFields(operation, depth + 1));
} }
return res; 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 * collects tags and maps each tag to list of operations belonging to this tag
*/ */

View File

@ -1,6 +1,6 @@
import { action, observable } from 'mobx'; import { action, observable } from 'mobx';
import { querySelector } from '../utils/dom'; import { querySelector } from '../utils/dom';
import { SpecStore } from './models'; import { FieldModel, SpecStore } from './models';
import { history as historyInst, HistoryService } from './HistoryService'; import { history as historyInst, HistoryService } from './HistoryService';
import { ScrollService } from './ScrollService'; import { ScrollService } from './ScrollService';
@ -9,7 +9,7 @@ import { flattenByProp, SECURITY_SCHEMES_SECTION_PREFIX } from '../utils';
import { GROUP_DEPTH } from './MenuBuilder'; import { GROUP_DEPTH } from './MenuBuilder';
export type MenuItemGroupType = 'group' | 'tag' | 'section'; export type MenuItemGroupType = 'group' | 'tag' | 'section';
export type MenuItemType = MenuItemGroupType | 'operation'; export type MenuItemType = MenuItemGroupType | 'operation' | 'field';
/** Generic interface for MenuItems */ /** Generic interface for MenuItems */
export interface IMenuItem { export interface IMenuItem {
@ -64,6 +64,8 @@ export class MenuStore {
items: IMenuItem[]; items: IMenuItem[];
flatItems: IMenuItem[]; flatItems: IMenuItem[];
imagesLoaded: boolean = false;
/** /**
* cached flattened menu items to support absolute indexing * cached flattened menu items to support absolute indexing
*/ */
@ -129,7 +131,16 @@ export class MenuStore {
itemIdx += step; 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; 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); item = this.flatItems.find(i => i.id === id);
if (item) { if (item) {
this.activateAndScroll(item, false); this.activateAndScroll(item, false);
@ -178,7 +195,7 @@ export class MenuStore {
* activate menu item * activate menu item
* @param item item to activate * @param item item to activate
* @param updateLocation [true] whether to update location * @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 @action
activate( activate(
@ -220,7 +237,9 @@ export class MenuStore {
} }
item.deactivate(); item.deactivate();
while (item !== undefined) { while (item !== undefined) {
item.collapse(); if (item.type !== 'field') {
item.collapse();
}
item = item.parent; item = item.parent;
} }
} }
@ -248,7 +267,17 @@ export class MenuStore {
* scrolls to active section * scrolls to active section
*/ */
scrollToActive(): void { 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() { dispose() {

View File

@ -21,16 +21,13 @@ export class SearchStore<T> {
searchWorker = new worker(); searchWorker = new worker();
indexItems(groups: Array<IMenuItem | OperationModel>) { indexItems(groups: Array<IMenuItem | OperationModel>) {
const recurse = items => { groups.forEach(group => {
items.forEach(group => { if (group.type !== 'group') {
if (group.type !== 'group') { // @ts-ignore
this.add(group.name, group.description || '', group.id); this.add(group.name, group.description || '', group.id);
} }
recurse(group.items); });
});
};
recurse(groups);
this.searchWorker.done(); this.searchWorker.done();
} }

View File

@ -6,10 +6,13 @@ import {
OpenAPIParameterStyle, OpenAPIParameterStyle,
Referenced, Referenced,
} from '../../types'; } from '../../types';
import { IMenuItem } from '../MenuStore';
import { RedocNormalizedOptions } from '../RedocNormalizedOptions'; import { RedocNormalizedOptions } from '../RedocNormalizedOptions';
import { extractExtensions } from '../../utils/openapi'; import { extractExtensions } from '../../utils/openapi';
import { OpenAPIParser } from '../OpenAPIParser'; import { OpenAPIParser } from '../OpenAPIParser';
import { MediaContentModel } from './MediaContent';
import { ResponseModel } from './Response';
import { SchemaModel } from './Schema'; import { SchemaModel } from './Schema';
function getDefaultStyleValue(parameterLocation: OpenAPIParameterLocation): OpenAPIParameterStyle { function getDefaultStyleValue(parameterLocation: OpenAPIParameterLocation): OpenAPIParameterStyle {
@ -28,10 +31,28 @@ function getDefaultStyleValue(parameterLocation: OpenAPIParameterLocation): Open
/** /**
* Field or Parameter model ready to be used by components * Field or Parameter model ready to be used by components
*/ */
export class FieldModel { export class FieldModel implements IMenuItem {
@observable @observable
expanded: boolean = false; 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; schema: SchemaModel;
name: string; name: string;
required: boolean; required: boolean;
@ -92,4 +113,41 @@ export class FieldModel {
toggle() { toggle() {
this.expanded = !this.expanded; 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
}
} }

View File

@ -126,6 +126,7 @@ export class OperationModel implements IMenuItem {
this.active = false; this.active = false;
} }
@action
expand() { expand() {
if (this.parent) { if (this.parent) {
this.parent.expand(); this.parent.expand();

View File

@ -58,4 +58,8 @@ export class ResponseModel {
toggle() { toggle() {
this.expanded = !this.expanded; this.expanded = !this.expanded;
} }
expand() {
this.expanded = true;
}
} }