diff --git a/README.md b/README.md index 3bf8cdad..a3655408 100644 --- a/README.md +++ b/README.md @@ -113,6 +113,7 @@ ReDoc makes use of the following [vendor extensions](http://swagger.io/specifica * [`x-code-samples`](docs/redoc-vendor-extensions.md#x-code-samples) - specify operation code samples * [`x-nullable`](docs/redoc-vendor-extensions.md#nullable) - mark schema param as a nullable * [`x-displayName`](docs/redoc-vendor-extensions.md#x-displayname) - specify human-friendly names for the menu categories +* [`x-tagGroups`](docs/redoc-vendor-extensions.md#x-tagGroups) - group tags by categories in the side menu ### `` tag attributes * `spec-url` - relative or absolute url to your spec file; diff --git a/build/webpack.test.js b/build/webpack.test.js index 12eedc31..3b76fa98 100644 --- a/build/webpack.test.js +++ b/build/webpack.test.js @@ -7,6 +7,7 @@ const VERSION = JSON.stringify(require('../package.json').version); module.exports = { devtool: 'inline-source-map', + performance: { hints: false }, resolve: { extensions: ['.ts', '.js', '.json', '.css'], alias: { diff --git a/demo/swagger.yaml b/demo/swagger.yaml index 17d45c4c..126d919c 100644 --- a/demo/swagger.yaml +++ b/demo/swagger.yaml @@ -54,6 +54,14 @@ tags: description: Access to Petstore orders - name: user description: Operations about user +x-tagGroups: + - name: General + tags: + - pet + - store + - name: User Management + tags: + - user securityDefinitions: petstore_auth: description: | diff --git a/docs/redoc-vendor-extensions.md b/docs/redoc-vendor-extensions.md index e68858c7..452c74d4 100644 --- a/docs/redoc-vendor-extensions.md +++ b/docs/redoc-vendor-extensions.md @@ -1,6 +1,57 @@ # ReDoc vendor extensions ReDoc makes use of the following [vendor extensions](http://swagger.io/specification/#vendorExtensions) +### Swagger Object vendor extensions +Extend OpenAPI root [Swagger Object](http://swagger.io/specification/#swaggerObject) +#### x-tagGroups + +| Field Name | Type | Description | +| :------------- | :-----------: | :---------- | +| x-tagGroups | [ [Tag Group Object](#tagGroupObject) ] | A list of tag groups | + +###### Usage in Redoc +`x-tagGroups` is used to group tags in the side menu + +#### Tag Group Object +Information about tags group +###### Fixed fields +| Field Name | Type | Description | +| :---------- | :--------: | :---------- | +| name | string | The group name | +| tags | [ string ] | List of tags to include in this group + +###### x-tagGroups example +json +```json +{ + "x-tagGroups": [ + { + "name": "User Management", + "tags": ["Users", "API keys", "Admin"] + }, + { + "name": "Statistics", + "tags": ["Main Stats", "Secondary Stats"] + } + ] +} +``` +yaml +```yaml +x-tagGroups: + - name: User Management + tags: + - Users + - API keys + - Admin + - name: Statistics + tags: + - Main Stats + - Secondary Stats +``` + +#### Logo Object + ### Info Object vendor extensions Extends OpenAPI [Info Object](http://swagger.io/specification/#infoObject) #### x-logo diff --git a/lib/components/Method/method.ts b/lib/components/Method/method.ts index 82332307..0590c1d1 100644 --- a/lib/components/Method/method.ts +++ b/lib/components/Method/method.ts @@ -1,10 +1,24 @@ 'use strict'; -import { Input, Component, OnInit, ChangeDetectionStrategy, ElementRef } from '@angular/core'; +import { Input, HostBinding, Component, OnInit, ChangeDetectionStrategy, ElementRef } from '@angular/core'; import JsonPointer from '../../utils/JsonPointer'; import { BaseComponent, SpecManager } from '../base'; import { SchemaHelper } from '../../services/schema-helper.service'; import { OptionsService } from '../../services/'; + +interface MethodInfo { + apiUrl: string; + httpMethod: string; + path: string; + info: { + tags: string[]; + description: string; + }; + bodyParam: any; + summary: any; + anchor: any; +} + @Component({ selector: 'method', templateUrl: './method.html', @@ -12,35 +26,47 @@ import { OptionsService } from '../../services/'; changeDetection: ChangeDetectionStrategy.OnPush }) export class Method extends BaseComponent implements OnInit { - @Input() pointer:string; - @Input() tag:string; - @Input() posInfo: any; + @Input() pointer :string; + @Input() parentTagId :string; - hidden = true; + @HostBinding('attr.operation-id') operationId; - method:any; + method: MethodInfo; - constructor(specMgr:SpecManager, private optionsService: OptionsService, private el: ElementRef) { + constructor(specMgr:SpecManager, private optionsService: OptionsService) { super(specMgr); } init() { - this.method = {}; - if (this.optionsService.options.hideHostname) { - this.method.apiUrl = this.specMgr.basePath; + this.operationId = this.componentSchema.operationId; + + this.method = { + httpMethod: JsonPointer.baseName(this.pointer), + path: JsonPointer.baseName(this.pointer, 2), + info: { + description: this.componentSchema.description, + tags: this.filterMainTags(this.componentSchema.tags) + }, + bodyParam: this.findBodyParam(), + summary: SchemaHelper.methodSummary(this.componentSchema), + apiUrl: this.getBaseUrl(), + anchor: this.buildAnchor() + }; + } + + buildAnchor() { + if (this.operationId) { + return 'operation/' + encodeURIComponent(this.componentSchema.operationId); } else { - this.method.apiUrl = this.specMgr.apiUrl; + return this.parentTagId + encodeURIComponent(this.pointer); } - this.method.httpMethod = JsonPointer.baseName(this.pointer); - this.method.path = JsonPointer.baseName(this.pointer, 2); - this.method.info = this.componentSchema; - this.method.info.tags = this.filterMainTags(this.method.info.tags); - this.method.bodyParam = this.findBodyParam(); - this.method.summary = SchemaHelper.methodSummary(this.componentSchema); - if (this.componentSchema.operationId) { - this.method.anchor = 'operation/' + encodeURIComponent(this.componentSchema.operationId); + } + + getBaseUrl():string { + if (this.optionsService.options.hideHostname) { + return this.specMgr.basePath; } else { - this.method.anchor = this.tag + encodeURIComponent(this.pointer); + return this.specMgr.apiUrl; } } @@ -56,14 +82,6 @@ export class Method extends BaseComponent implements OnInit { return bodyParam; } - show(res) { - if (res) { - this.el.nativeElement.firstElementChild.removeAttribute('hidden'); - } else { - this.el.nativeElement.firstElementChild.setAttribute('hidden', 'hidden'); - } - } - ngOnInit() { this.preinit(); } diff --git a/lib/components/MethodsList/methods-list.html b/lib/components/MethodsList/methods-list.html index 61caa17f..18a833c8 100644 --- a/lib/components/MethodsList/methods-list.html +++ b/lib/components/MethodsList/methods-list.html @@ -1,10 +1,11 @@
-
-
+
+

{{tag.name}}

- +
diff --git a/lib/components/MethodsList/methods-list.spec.ts b/lib/components/MethodsList/methods-list.spec.ts index 330cb754..6d2d56da 100644 --- a/lib/components/MethodsList/methods-list.spec.ts +++ b/lib/components/MethodsList/methods-list.spec.ts @@ -13,7 +13,7 @@ import { getChildDebugElement } from '../../../tests/helpers'; import { MethodsList } from './methods-list'; import { SpecManager } from '../../utils/spec-manager'; -describe('Redoc components', () => { +describe('Redoc components', () => { beforeEach(() => { TestBed.configureTestingModule({ declarations: [ TestAppComponent ] }); }); @@ -42,13 +42,13 @@ describe('Redoc components', () => { expect(component).not.toBeNull(); }); - it('should get correct tags list', () => { + it('should build correct tags list', () => { expect(component.tags).not.toBeNull(); component.tags.should.have.lengthOf(2); component.tags[0].name.should.be.equal('traitTag'); - component.tags[0].methods.should.be.empty(); + should.not.exist(component.tags[0].items); component.tags[1].name.should.be.equal('tag1'); - component.tags[1].methods.should.have.lengthOf(2); + component.tags[1].items.should.have.lengthOf(2); }); }); }); diff --git a/lib/components/MethodsList/methods-list.ts b/lib/components/MethodsList/methods-list.ts index ec130328..ba434ec5 100644 --- a/lib/components/MethodsList/methods-list.ts +++ b/lib/components/MethodsList/methods-list.ts @@ -1,7 +1,7 @@ 'use strict'; import { Component, Input, OnInit, ChangeDetectionStrategy } from '@angular/core'; import { BaseComponent, SpecManager } from '../base'; -import { SchemaHelper } from '../../services/index'; +import { MenuService } from '../../services/index'; @Component({ selector: 'methods-list', @@ -14,20 +14,29 @@ export class MethodsList extends BaseComponent implements OnInit { tags:Array = []; - constructor(specMgr:SpecManager) { + constructor(specMgr:SpecManager, private menu: MenuService) { super(specMgr); } init() { - let tags = SchemaHelper.buildMenuTree(this.specMgr.schema); - this.tags = tags.filter(tagInfo => !tagInfo.virtual); - this.tags.forEach(tagInfo => { - // inject tag name into method info - tagInfo.methods = tagInfo.methods || []; - tagInfo.methods.forEach(method => { - method.tag = tagInfo.id; - }); + let flatMenuItems = this.menu.flatItems; + this.tags = []; + let emptyTag = { + name: '', + items: [] + }; + flatMenuItems.forEach(menuItem => { + // skip items that are not bound to swagger tags/methods + if (!menuItem.metadata) return; + + if (menuItem.metadata.type === 'tag') { + this.tags.push(menuItem); + } + if (menuItem.metadata.type === 'method' && !menuItem.parent) { + emptyTag.items.push(menuItem); + } }); + if (emptyTag.items.length) this.tags.push(emptyTag); } trackByTagName(_, el) { diff --git a/lib/components/Redoc/redoc.scss b/lib/components/Redoc/redoc.scss index 7934a0b7..19fb0a3a 100644 --- a/lib/components/Redoc/redoc.scss +++ b/lib/components/Redoc/redoc.scss @@ -70,7 +70,7 @@ } .background { - position: fixed; + position: absolute; top: 0; bottom: 0; right: 0; diff --git a/lib/components/ResponsesList/responses-list.spec.ts b/lib/components/ResponsesList/responses-list.spec.ts new file mode 100644 index 00000000..9cec70f2 --- /dev/null +++ b/lib/components/ResponsesList/responses-list.spec.ts @@ -0,0 +1,57 @@ +'use strict'; + +import { Component } from '@angular/core'; +import { + inject, + async, + TestBed, + ComponentFixture +} from '@angular/core/testing'; + +import { getChildDebugElement } from '../../../tests/helpers'; + + +import { ResponsesList } from './responses-list'; +import { SpecManager } from '../../utils/spec-manager'; + +describe('Redoc components', () => { + + describe('MethodsList Component', () => { + let builder; + let component: ResponsesList; + let fixture: ComponentFixture + let specMgr; + + beforeEach(async(inject([SpecManager], (_specMgr) => { + specMgr = _specMgr; + }))); + + beforeEach(done => { + specMgr.load('/tests/schemas/responses-list-component.json').then(done, done.fail); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(ResponsesList); + component = fixture.componentInstance; + }); + + it('should instantiate without errors', () => { + should.exist(component); + }); + + it('should init repsonses list', () => { + component.pointer = '#/paths/~1test1/get/responses'; + fixture.detectChanges(); + should.exist(component.responses); + component.responses.should.be.lengthOf(2); + }); + + it('should not overwrite codes for shared schemas', () => { + component.pointer = '#/paths/~1test1/get/responses'; + fixture.detectChanges(); + let resp1 = component.responses[0]; + let resp2 = component.responses[1]; + resp1.code.should.not.be.equal(resp2.code); + }); + }); +}); diff --git a/lib/components/ResponsesList/responses-list.ts b/lib/components/ResponsesList/responses-list.ts index f7744041..83fe2e0e 100644 --- a/lib/components/ResponsesList/responses-list.ts +++ b/lib/components/ResponsesList/responses-list.ts @@ -42,7 +42,7 @@ export class ResponsesList extends BaseComponent implements OnInit { resp.pointer = JsonPointer.join(this.pointer, respCode); if (resp.$ref) { let ref = resp.$ref; - resp = this.specMgr.byPointer(resp.$ref); + resp = Object.assign({}, this.specMgr.byPointer(resp.$ref)); resp.pointer = ref; } diff --git a/lib/components/SideMenu/side-menu-items.html b/lib/components/SideMenu/side-menu-items.html new file mode 100644 index 00000000..0db93069 --- /dev/null +++ b/lib/components/SideMenu/side-menu-items.html @@ -0,0 +1,7 @@ + diff --git a/lib/components/SideMenu/side-menu-items.scss b/lib/components/SideMenu/side-menu-items.scss new file mode 100644 index 00000000..e5220905 --- /dev/null +++ b/lib/components/SideMenu/side-menu-items.scss @@ -0,0 +1,110 @@ +@import '../../shared/styles/variables'; + +.menu-item-header { + cursor: pointer; + color: rgba($text-color, .9); + -webkit-transition: all .15s ease-in-out; + -moz-transition: all .15s ease-in-out; + -ms-transition: all .15s ease-in-out; + -o-transition: all .15s ease-in-out; + transition: all .15s ease-in-out; + display: block; + padding: $side-menu-item-vpadding*2.5 $side-menu-item-hpadding; + + &[hidden] { + display: none; + } + + &.disabled, &.disabled:hover { + cursor: default; + color: lighten($text-color, 60%); + } +} + +.menu-item { + -webkit-transition: all .15s ease-in-out; + -moz-transition: all .15s ease-in-out; + -ms-transition: all .15s ease-in-out; + -o-transition: all .15s ease-in-out; + transition: all .15s ease-in-out; + list-style: none inside none; + overflow: hidden; + text-overflow: ellipsis; + padding: 0; +} + +.menu-item:hover, +.menu-item.active { + //background: darken($side-menu-active-bg-color, 6%); +} + +.menu-subitems { + margin: 0; + font-size: 0.929em; + line-height: 1.2em; + font-weight: $light; + color: rgba($text-color, .9); + padding: 0; + overflow: hidden; + height: 0; + + .active > & { + height: auto; + } +} + +.menu-item-depth-1 { + > .menu-item-header { + font-family: $headers-font, $headers-font-family; + font-weight: $light; + font-size: $h5; + text-transform: uppercase; + } + + // do not capitalize method summuary in level-1 menu + &.menu-item-for-method > .menu-item-header { + text-transform: none; + } + + > .menu-item-header:not(.disabled):hover, + &.active > .menu-item-header { + color: $primary-color; + background: $side-menu-active-bg-color; + } + &.active { + //background: $side-menu-active-bg-color; + } +} + +.menu-item-depth-2 { + > .menu-item-header { + padding-left: 2*$side-menu-item-hpadding; + } + + > .menu-item-header:hover, + &.active > .menu-item-header { + background: darken($side-menu-active-bg-color, 6%); + } +} + +// group items +.menu-item-depth-0 { + margin-top: 15px; + + > .menu-subitems { + height: auto; + } + + > .menu-item-header { + font-family: $headers-font, $headers-font-family; + color: rgba($text-color, .4); + text-transform: uppercase; + font-size: 0.8em; + padding-bottom: 0; + cursor: default; + } + &:hover, + &.active { + //background: none; + } +} diff --git a/lib/components/SideMenu/side-menu.html b/lib/components/SideMenu/side-menu.html index 3328bd3b..638ff025 100644 --- a/lib/components/SideMenu/side-menu.html +++ b/lib/components/SideMenu/side-menu.html @@ -7,17 +7,7 @@
- +
diff --git a/lib/components/SideMenu/side-menu.scss b/lib/components/SideMenu/side-menu.scss index 22144b21..2cd849c2 100644 --- a/lib/components/SideMenu/side-menu.scss +++ b/lib/components/SideMenu/side-menu.scss @@ -6,6 +6,11 @@ $mobile-menu-compact-breakpoint: 550px; box-sizing: border-box; } +ul.menu-root { + margin: 0; + padding: 0; +} + .menu-header { text-transform: uppercase; color: $headers-color; @@ -13,81 +18,6 @@ $mobile-menu-compact-breakpoint: 550px; margin: 10px 0; } -.menu-cat-header { - font-size: $h5; - font-family: $headers-font, $headers-font-family; - font-weight: $light; - cursor: pointer; - color: rgba($text-color, .6); - text-transform: uppercase; - background-color: $side-bar-bg-color; - -webkit-transition: all .15s ease-in-out; - -moz-transition: all .15s ease-in-out; - -ms-transition: all .15s ease-in-out; - -o-transition: all .15s ease-in-out; - transition: all .15s ease-in-out; - display: block; - padding: $side-menu-item-vpadding*2.5 $side-menu-item-hpadding; - - &:hover, - &.active { - color: $primary-color; - background-color: $side-menu-active-bg-color; - } - - &[hidden] { - display: none; - } - - &.disabled, &.disabled:hover { - cursor: default; - color: lighten($text-color, 60%); - } -} - -.menu-subitems { - margin: 0; - font-size: 0.929em; - line-height: 1.2em; - font-weight: $light; - color: rgba($text-color, .9); - padding: 0; - overflow: hidden; - - &.active { - height: auto; - } - - & li { - -webkit-transition: all .15s ease-in-out; - -moz-transition: all .15s ease-in-out; - -ms-transition: all .15s ease-in-out; - -o-transition: all .15s ease-in-out; - transition: all .15s ease-in-out; - list-style: none inside none; - cursor: pointer; - background-color: $side-menu-active-bg-color; - padding: $side-menu-item-vpadding*2 $side-menu-item-hpadding*2; - padding-left: $side-menu-item-hpadding*2; - overflow: hidden; - text-overflow: ellipsis; - } - - & li:hover, - & li.active { - background: darken($side-menu-active-bg-color, 6%); - } - - &.disabled, &.disabled:hover { - cursor: default; - color: lighten($text-color, 60%); - } -} - - -.menu-subitems li.active { -} - .mobile-nav { display: none; height: 3em; diff --git a/lib/components/SideMenu/side-menu.ts b/lib/components/SideMenu/side-menu.ts index 63a36c59..27d30c1c 100644 --- a/lib/components/SideMenu/side-menu.ts +++ b/lib/components/SideMenu/side-menu.ts @@ -1,20 +1,19 @@ 'use strict'; -import { Component, ElementRef, ChangeDetectorRef, OnInit, OnDestroy } from '@angular/core'; +import { Component, EventEmitter, Input, Output, ElementRef, ChangeDetectorRef, OnInit, OnDestroy } from '@angular/core'; //import { global } from '@angular/core/src/facade/lang'; import { trigger, state, animate, transition, style } from '@angular/core'; import { BaseComponent, SpecManager } from '../base'; -import { ScrollService, MenuService, OptionsService } from '../../services/index'; +import { ScrollService, MenuService, OptionsService, MenuItem } from '../../services/'; import { BrowserDomAdapter as DOM } from '../../utils/browser-adapter'; -import { MenuCategory } from '../../services/schema-helper.service'; const global = window; @Component({ - selector: 'side-menu', - templateUrl: './side-menu.html', - styleUrls: ['./side-menu.css'], + selector: 'side-menu-items', + templateUrl: './side-menu-items.html', + styleUrls: ['./side-menu-items.css'], animations: [ trigger('itemAnimation', [ state('collapsed, void', @@ -25,12 +24,26 @@ const global = window; animate('200ms ease') ]) ]) - ], + ] +}) +export class SideMenuItems { + @Input() items: MenuItem[]; + @Output() activate = new EventEmitter(); + + activateItem(item) { + this.activate.next(item); + } +} + +@Component({ + selector: 'side-menu', + templateUrl: './side-menu.html', + styleUrls: ['./side-menu.css'] }) export class SideMenu extends BaseComponent implements OnInit, OnDestroy { activeCatCaption: string; activeItemCaption: string; - categories: Array; + menuItems: Array; private options: any; private $element: any; @@ -54,11 +67,18 @@ export class SideMenu extends BaseComponent implements OnInit, OnDestroy { this.menuService.changed.subscribe((evt) => this.changed(evt)); } - changed(newItem) { - if (newItem) { - let {cat, item} = newItem; - this.activeCatCaption = cat.name || ''; - this.activeItemCaption = item && item.summary || ''; + changed(item) { + if (!item) { + this.activeCatCaption = ''; + this.activeItemCaption = ''; + return; + } + if (item.parent) { + this.activeItemCaption = item.name; + this.activeCatCaption = item.parent.name; + } else { + this.activeCatCaption = item.name; + this.activeItemCaption = ''; } //safari doesn't update bindings if not run changeDetector manually :( @@ -74,22 +94,17 @@ export class SideMenu extends BaseComponent implements OnInit, OnDestroy { if ($item) $item.scrollIntoView(); } - activateAndScroll(catIdx, methodIdx) { - if (this.mobileMode()) { + activateAndScroll(item) { + if (this.mobileMode) { this.toggleMobileNav(); } - let menu = this.categories; - if (!menu[catIdx].ready) return; - if (menu[catIdx].methods && menu[catIdx].methods.length && (methodIdx >= 0) && - !menu[catIdx].methods[methodIdx].ready) return; - - this.menuService.activate(catIdx, methodIdx); + this.menuService.activate(item.flatIdx); this.menuService.scrollToActive(); } init() { - this.categories = this.menuService.categories; + this.menuItems = this.menuService.items; this.$mobileNav = DOM.querySelector(this.$element, '.mobile-nav'); this.$resourcesNav = DOM.querySelector(this.$element, '#resources-nav'); @@ -101,7 +116,7 @@ export class SideMenu extends BaseComponent implements OnInit, OnDestroy { }; } - mobileMode() { + get mobileMode() { return this.$mobileNav.clientHeight > 0; } diff --git a/lib/components/index.ts b/lib/components/index.ts index ed445ecb..4b4ffc32 100644 --- a/lib/components/index.ts +++ b/lib/components/index.ts @@ -9,7 +9,7 @@ import { RequestSamples } from './RequestSamples/request-samples'; import { ResponsesList } from './ResponsesList/responses-list'; import { ResponsesSamples } from './ResponsesSamples/responses-samples'; import { SchemaSample } from './SchemaSample/schema-sample'; -import { SideMenu } from './SideMenu/side-menu'; +import { SideMenu, SideMenuItems } from './SideMenu/side-menu'; import { MethodsList } from './MethodsList/methods-list'; import { Method } from './Method/method'; import { Warnings } from './Warnings/warnings'; @@ -21,9 +21,9 @@ import { Redoc } from './Redoc/redoc'; export const REDOC_DIRECTIVES = [ ApiInfo, ApiLogo, JsonSchema, JsonSchemaLazy, ParamsList, RequestSamples, ResponsesList, ResponsesSamples, SchemaSample, SideMenu, MethodsList, Method, Warnings, Redoc, SecurityDefinitions, - LoadingBar + LoadingBar, SideMenuItems ]; export { ApiInfo, ApiLogo, JsonSchema, JsonSchemaLazy, ParamsList, RequestSamples, ResponsesList, ResponsesSamples, SchemaSample, SideMenu, MethodsList, Method, Warnings, Redoc, SecurityDefinitions, -LoadingBar } +LoadingBar, SideMenuItems } diff --git a/lib/services/menu.service.spec.ts b/lib/services/menu.service.spec.ts index c34b7f57..3596fede 100644 --- a/lib/services/menu.service.spec.ts +++ b/lib/services/menu.service.spec.ts @@ -6,7 +6,7 @@ import { } from '@angular/core/testing'; import { MethodsList } from '../components/MethodsList/methods-list'; -import { MenuService } from './menu.service'; +import { MenuService, MenuItem } from './menu.service'; import { Hash } from './hash.service'; import { LazyTasksService } from '../shared/components/LazyFor/lazy-for'; import { ScrollService } from './scroll.service'; @@ -18,7 +18,7 @@ describe('Menu service', () => { TestBed.configureTestingModule({ declarations: [ TestAppComponent, MethodsList ] }); }); - let menu, hashService, scroll, tasks; + let menu:MenuService, hashService, scroll, tasks; let specMgr; beforeEach(inject([SpecManager, Hash, ScrollService, LazyTasksService], @@ -70,22 +70,113 @@ describe('Menu service', () => { }); it('should select next/prev menu item when scrolled down/up', () => { - scroll.$scrollParent = document.querySelector('#parent'); - menu.activeCatIdx.should.be.equal(0); - menu.activeMethodIdx.should.be.equal(-1); - let nextElTop = menu.getRelativeCatOrItem(1).getBoundingClientRect().top; + // enable all items + menu.items.forEach(item => item.ready = true); + scroll.$scrollParent = document.querySelector('#parent'); + menu.activeIdx.should.be.equal(-1); + + let nextElTop = menu.getEl(1).getBoundingClientRect().top; scroll.$scrollParent.scrollTop = nextElTop + 1; //simulate scroll down spyOn(scroll, 'scrollY').and.returnValue(nextElTop + 10); - menu.scrollUpdate(true); - menu.activeCatIdx.should.be.equal(1); + menu.onScroll(true); + menu.activeIdx.should.be.equal(1); scroll.scrollY.and.returnValue(nextElTop - 2); scroll.$scrollParent.scrollTop = nextElTop - 1; - menu.scrollUpdate(false); - menu.activeCatIdx.should.be.equal(0); + menu.onScroll(false); + menu.activeIdx.should.be.equal(0); + }); + + describe('buildMenu method', () => { + let suitSchema = { + tags: [ + {name: 'tag1', description: 'info1', 'x-traitTag': true}, + {name: 'tag2', description: 'info2'}, + {name: 'tag4', description: 'info2', 'x-displayName': 'Tag Four'} + ], + paths: { + test: { + put: { + tags: ['tag1', 'tag3'], + summary: 'test put' + }, + get: { + tags: ['tag1', 'tag2'], + summary: 'test get' + }, + delete: { + tags: ['tag4'], + summary: 'test delete' + }, + // no tags + post: { + summary: 'test post' + } + } + } + }; + + let items:MenuItem[]; + beforeEach(() => { + menu.items = null; + specMgr._schema = suitSchema; + menu.buildMenu(); + items = menu.items; + }); + + it('should return instance of Array', () => { + items.should.be.instanceof(Array); + }); + + it('should return Array with correct number of items', () => { + // 3 - defined tags, 1 - tag3 and 1 method item for method without tag + items.length.should.be.equal(3 + 1 + 1); + }); + + it('should append not defined tags to the end of list', () => { + let item = items[3]; + item.name.should.be.equal('tag3'); + item.items.length.should.be.equal(1); + item.items[0].name.should.be.equal('test put'); + }); + + it('should append method items without tags to the end of list', () => { + let methodItem = items[4]; + methodItem.name.should.be.equal('test post'); + methodItem.metadata.type.should.be.equal('method'); + should.not.exist(methodItem.items); + }); + + it('should map x-traitTag to empty method list', () => { + let item = items[0]; + should.not.exist(item.items); + }); + + it('methods for tag should contain valid pointer and name', () => { + for (let item of items) { + item.should.be.an.Object(); + if (item.items) { + for (let subItem of item.items) { + subItem.should.have.properties(['metadata']); + let pointer = subItem.metadata.pointer; + let methSchema = specMgr.byPointer(pointer); + should.exist(methSchema); + if (methSchema.summary) { + methSchema.summary.should.be.equal(subItem.name); + } + } + } + } + }); + + it('should use x-displayName to set custom names', () => { + let info = items[2]; + info.id.should.be.equal('tag/tag4'); + info.name.should.be.equal('Tag Four'); + }); }); }); diff --git a/lib/services/menu.service.ts b/lib/services/menu.service.ts index f76b85fd..ab0a2602 100644 --- a/lib/services/menu.service.ts +++ b/lib/services/menu.service.ts @@ -5,97 +5,113 @@ import { BehaviorSubject } from 'rxjs/BehaviorSubject'; import { ScrollService, INVIEW_POSITION } from './scroll.service'; import { Hash } from './hash.service'; import { SpecManager } from '../utils/spec-manager'; -import { SchemaHelper, MenuCategory } from './schema-helper.service'; +import { SchemaHelper } from './schema-helper.service'; import { AppStateService } from './app-state.service'; import { LazyTasksService } from '../shared/components/LazyFor/lazy-for'; +import { JsonPointer } from '../utils/JsonPointer'; +import * as slugify from 'slugify'; + const CHANGE = { NEXT : 1, BACK : -1, - INITIAL : 0 }; +interface TagGroup { + name: string; + tags: string[]; +} + +export interface MenuItem { + id: string; + + name: string; + description?: string; + + items?: Array; + parent?: MenuItem; + + active?: boolean; + ready?: boolean; + + level?: number; + flatIdx?: number; + + metadata?: any; + isGroup?: boolean; +} + @Injectable() export class MenuService { - changed: EventEmitter = new EventEmitter(); - ready: BehaviorSubject = new BehaviorSubject(false); - categories: Array; - activeCatIdx: number = 0; - activeMethodIdx: number = -1; + items: MenuItem[]; + activeIdx: number = -1; + private _flatItems: MenuItem[]; private _hashSubscription: Subscription; + private _scrollSubscription: Subscription; + private _tagsWithMethods: any; constructor( private hash:Hash, private tasks: LazyTasksService, private scrollService: ScrollService, private appState: AppStateService, - specMgr:SpecManager + private specMgr:SpecManager ) { this.hash = hash; - this.categories = SchemaHelper.buildMenuTree(specMgr.schema); + this.buildMenu(); - scrollService.scroll.subscribe((evt) => { - this.scrollUpdate(evt.isScrolledDown); + this._scrollSubscription = scrollService.scroll.subscribe((evt) => { + this.onScroll(evt.isScrolledDown); }); - //this.changeActive(CHANGE.INITIAL); - this._hashSubscription = this.hash.value.subscribe((hash) => { - if (hash == undefined) return; - this.setActiveByHash(hash); - if (!this.tasks.empty) { - this.tasks.start(this.activeCatIdx, this.activeMethodIdx, this); - this.scrollService.setStickElement(this.getCurrentMethodEl()); - if (hash) this.scrollToActive(); - this.appState.stopLoading(); - } else { - if (hash) this.scrollToActive(); - } + this.onHashChange(hash); }); } - enableItem(catIdx, methodIdx, skipUpdate = false) { - let cat = this.categories[catIdx]; - cat.ready = true; - if (cat.methods.length) cat.methods[methodIdx].ready = true; - let prevCat = this.categories[catIdx - 1]; - if (prevCat && !prevCat.ready && (prevCat.virtual || !prevCat.methods.length)) { - this.enableItem(catIdx - 1, -1, true); + get flatItems():MenuItem[] { + if (!this._flatItems) { + this._flatItems = this.flatMenu(); + } + return this._flatItems; + } + + enableItem(idx) { + let item = this.flatItems[idx]; + item.ready = true; + if (item.parent) { + item.parent.ready = true; + idx = item.parent.flatIdx; + } + + // check if previous items can be enabled + let prevItem = this.flatItems[idx -= 1]; + while(prevItem && (!prevItem.metadata || !prevItem.items)) { + prevItem.ready = true; + prevItem = this.flatItems[idx -= 1]; } - if (skipUpdate) return; this.changed.next(); } - get activeMethodPtr() { - let cat = this.categories[this.activeCatIdx]; - let ptr = null; - if (cat && cat.methods.length) { - let mtd = cat.methods[this.activeMethodIdx]; - ptr = mtd && mtd.pointer || null; - } - return ptr; - } - - scrollUpdate(isScrolledDown) { + onScroll(isScrolledDown) { let stable = false; while(!stable) { - let $activeMethodHost = this.getCurrentMethodEl(); - if (!$activeMethodHost) return; - var elementInViewPos = this.scrollService.getElementPos($activeMethodHost); if(isScrolledDown) { - //&& elementInViewPos === INVIEW_POSITION.BELLOW - let $nextEl = this.getRelativeCatOrItem(1); + let $nextEl = this.getEl(this.activeIdx + 1); if (!$nextEl) return; let nextInViewPos = this.scrollService.getElementPos($nextEl, true); - if (elementInViewPos === INVIEW_POSITION.BELLOW && nextInViewPos === INVIEW_POSITION.ABOVE) { + if (nextInViewPos === INVIEW_POSITION.ABOVE) { stable = this.changeActive(CHANGE.NEXT); continue; } } + let $currentEl = this.getCurrentEl(); + if (!$currentEl) return; + var elementInViewPos = this.scrollService.getElementPos($currentEl); if(!isScrolledDown && elementInViewPos === INVIEW_POSITION.ABOVE ) { stable = this.changeActive(CHANGE.BACK); continue; @@ -104,136 +120,291 @@ export class MenuService { } } - getRelativeCatOrItem(offset: number = 0) { - let ptr, cat; - cat = this.categories[this.activeCatIdx]; - if (cat.methods.length === 0) { - ptr = null; - cat = this.categories[this.activeCatIdx + Math.sign(offset)] || cat; + onHashChange(hash?: string) { + if (hash == undefined) return; + let activated = this.activateByHash(hash); + if (!this.tasks.empty) { + this.tasks.start(this.activeIdx, this); + this.scrollService.setStickElement(this.getCurrentEl()); + if (activated) this.scrollToActive(); + this.appState.stopLoading(); } else { - let cat = this.categories[this.activeCatIdx]; - let idx = this.activeMethodIdx + offset; - if ((idx >= cat.methods.length - 1) || idx < 0) { - cat = this.categories[this.activeCatIdx + Math.sign(offset)] || cat; - idx = offset > 0 ? -1 : cat.methods.length - 1; + if (activated) this.scrollToActive(); + } + } + + getEl(flatIdx:number):Element { + if (flatIdx < 0) return null; + let currentItem = this.flatItems[flatIdx]; + if (!currentItem) return; + if (currentItem.isGroup) currentItem = this.flatItems[flatIdx + 1]; + + let selector = ''; + while(currentItem) { + if (currentItem.id) { + selector = `[section="${currentItem.id}"] ` + selector; + // We only need to go up the chain for methods that + // might have multiple tags. For headers/subheaders + // we need to siply early terminate. + if (!currentItem.metadata) { + break; + } } - ptr = cat.methods[idx] && cat.methods[idx].pointer; + currentItem = currentItem.parent; } - - return this.getMethodElByPtr(ptr, cat.id); + selector = selector.trim(); + return selector ? document.querySelector(selector) : null; } - getCurrentMethodEl() { - return this.getMethodElByPtr(this.activeMethodPtr, - this.categories[this.activeCatIdx].id); + getCurrentEl():Element { + return this.getEl(this.activeIdx); } - getMethodElByPtr(ptr, section) { - let selector = ptr ? `[pointer="${ptr}"][section="${section}"]` : `[section="${section}"]`; - return document.querySelector(selector); + deactivate(idx) { + if (idx < 0) return; + + let item = this.flatItems[idx]; + item.active = false; + while (item.parent) { + item.parent.active = false; + item = item.parent; + } } - getMethodElByOperId(operationId) { - let selector =`[operation-id="${operationId}"]`; - return document.querySelector(selector); + activate(idx, force = false) { + let item = this.flatItems[idx]; + if (!force && item && !item.ready) return; + + this.deactivate(this.activeIdx); + this.activeIdx = idx; + if (idx < 0) return; + + item.active = true; + + let cItem = item; + while (cItem.parent) { + cItem.parent.active = true; + cItem = cItem.parent; + } + this.changed.next(item); } - activate(catIdx, methodIdx) { - if (catIdx < 0) return; - - let menu = this.categories; - - menu[this.activeCatIdx].active = false; - if (menu[this.activeCatIdx].methods.length) { - if (this.activeMethodIdx >= 0) { - menu[this.activeCatIdx].methods[this.activeMethodIdx].active = false; - } - } - - this.activeCatIdx = catIdx; - this.activeMethodIdx = methodIdx; - menu[catIdx].active = true; - let currentItem; - if (menu[catIdx].methods.length && (methodIdx > -1)) { - currentItem = menu[catIdx].methods[methodIdx]; - currentItem.active = true; - } - - this.changed.next({cat: menu[catIdx], item: currentItem}); - } - - _calcActiveIndexes(offset) { - let menu = this.categories; - let catCount = menu.length; - if (!catCount) return [0, -1]; - let catLength = menu[this.activeCatIdx].methods.length; - - let resMethodIdx = this.activeMethodIdx + offset; - let resCatIdx = this.activeCatIdx; - - if (resMethodIdx > catLength - 1) { - resCatIdx++; - resMethodIdx = -1; - } - if (resMethodIdx < -1) { - let prevCatIdx = --resCatIdx; - catLength = menu[Math.max(prevCatIdx, 0)].methods.length; - resMethodIdx = catLength - 1; - } - if (resCatIdx > catCount - 1) { - resCatIdx = catCount - 1; - resMethodIdx = catLength - 1; - } - if (resCatIdx < 0) { - resCatIdx = 0; - resMethodIdx = 0; - } - - return [resCatIdx, resMethodIdx]; - } - - changeActive(offset = 1) { - let [catIdx, methodIdx] = this._calcActiveIndexes(offset); - this.activate(catIdx, methodIdx); - return (methodIdx === 0 && catIdx === 0); + changeActive(offset = 1):boolean { + let noChange = (this.activeIdx <= 0 && offset === -1) || + (this.activeIdx === this.flatItems.length - 1 && offset === 1); + this.activate(this.activeIdx + offset); + return noChange; } scrollToActive() { - this.scrollService.scrollTo(this.getCurrentMethodEl()); + let $el = this.getCurrentEl(); + if ($el) this.scrollService.scrollTo($el); } - setActiveByHash(hash) { - if (!hash) { - if (this.categories[0].headless) { - this.activate(0, 0); - } - return; - } - let catIdx, methodIdx; + activateByHash(hash):boolean { + if (!hash) return; + let idx = 0; hash = hash.substr(1); let namespace = hash.split('/')[0]; let ptr = decodeURIComponent(hash.substr(namespace.length + 1)); if (namespace === 'section' || namespace === 'tag') { let sectionId = ptr.split('/')[0]; - catIdx = this.categories.findIndex(cat => cat.id === namespace + '/' + sectionId); - let cat = this.categories[catIdx]; ptr = ptr.substr(sectionId.length) || null; - methodIdx = cat.methods.findIndex(method => method.pointer === ptr); - } else { - catIdx = this.categories.findIndex(cat => { - if (!cat.methods.length) return false; - methodIdx = cat.methods.findIndex(method => method.operationId === ptr || method.pointer === ptr); - if (methodIdx >= 0) { - return true; - } else { - return false; - } + + let searchId; + if (namespace === 'section') { + searchId = hash; + } else { + searchId = ptr || (namespace + '/' + sectionId);; + } + + idx = this.flatItems.findIndex(item => item.id === searchId); + if (idx < 0) this.tryScrollToId(searchId); + } else if (namespace === 'operation') { + idx = this.flatItems.findIndex(item => { + return item.metadata && item.metadata.operationId === ptr; }); } - this.activate(catIdx, methodIdx); + this.activate(idx, true); + return idx >= 0; + } + + tryScrollToId(id) { + let $el = document.querySelector(`[section="${id}"]`); + if ($el) this.scrollService.scrollTo($el); + } + + addMarkdownItems() { + let schema = this.specMgr.schema; + for (let header of (>(schema.info && schema.info['x-redoc-markdown-headers'] || []))) { + let id = 'section/' + slugify(header); + let item = { + name: header, + id: id, + items: null + }; + item.items = this.getMarkdownSubheaders(item); + + this.items.push(item); + } + } + + getMarkdownSubheaders(parent: MenuItem):MenuItem[] { + let res = []; + + let schema = this.specMgr.schema; + for (let subheader of (>(schema.info && schema.info['x-redoc-markdown-subheaders'] || []))) { + let parts = subheader.split('/'); + let header = parts[0]; + if (parent.name !== header) { + continue; + } + + let name = parts[1]; + let id = parent.id + '/' + slugify(name); + + let subItem = { + name: name, + id: id, + parent: parent + }; + res.push(subItem); + } + + return res; + } + + getMethodsItems(parent: MenuItem, tag:any):MenuItem[] { + if (!tag.methods || !tag.methods.length) return null; + + let res = []; + for (let method of tag.methods) { + let subItem = { + name: SchemaHelper.methodSummary(method), + id: method._pointer, + description: method.description, + metadata: { + type: 'method', + pointer: method._pointer, + operationId: method.operationId + }, + parent: parent + }; + res.push(subItem); + } + return res; + } + + getTagsItems(parent: MenuItem, tagGroup:TagGroup = null):MenuItem[] { + let schema = this.specMgr.schema; + + let tags; + if (!tagGroup) { + // all tags + tags = Object.keys(this._tagsWithMethods); + } else { + tags = tagGroup.tags; + } + + tags = tags.map(k => { + if (!this._tagsWithMethods[k]) { + console.warn(`Non-existing tag "${k}" is specified in tag group "${tagGroup.name}"`); + return null; + } + this._tagsWithMethods[k].used = true; + return this._tagsWithMethods[k]; + }); + + let res = []; + for (let tag of tags || []) { + if (!tag) continue; + let id = 'tag/' + slugify(tag.name); + let item: MenuItem; + + // don't put empty tag into menu, instead put their methods + if (tag.name === '') { + let items = this.getMethodsItems(null, tag); + res.push(...items); + continue; + } + + item = { + name: tag['x-displayName'] || tag.name, + id: id, + description: tag.description, + metadata: { type: 'tag' }, + parent: parent, + items: null + }; + item.items = this.getMethodsItems(item, tag); + + res.push(item); + } + return res; + } + + getTagGroupsItems(parent: MenuItem, groups: TagGroup[]):MenuItem[] { + let res = []; + for (let group of groups) { + let item; + item = { + name: group.name, + id: null, + description: '', + parent: parent, + isGroup: true, + items: null + }; + item.items = this.getTagsItems(item, group); + res.push(item); + } + this.checkAllTagsUsedInGroups(); + return res; + } + + checkAllTagsUsedInGroups() { + for (let tag of Object.keys(this._tagsWithMethods)) { + if (!this._tagsWithMethods[tag].used) { + console.warn(`Tag "${tag}" is not added to any group`); + } + } + } + + buildMenu() { + this._tagsWithMethods = SchemaHelper.getTagsWithMethods(this.specMgr.schema); + + this.items = this.items || []; + this.addMarkdownItems(); + if (this.specMgr.schema['x-tagGroups']) { + this.items.push(...this.getTagGroupsItems(null, this.specMgr.schema['x-tagGroups'])); + } else { + this.items.push(...this.getTagsItems(null)); + } + } + + flatMenu():MenuItem[] { + let menu = this.items; + let res = []; + let curDepth = 1; + + let recursive = (items) => { + for (let item of items) { + res.push(item); + item.depth = item.isGroup ? 0 : curDepth; + item.flatIdx = res.length - 1; + if (item.items) { + if (!item.isGroup) curDepth++; + recursive(item.items); + if (!item.isGroup) curDepth--; + } + } + }; + recursive(menu); + return res; } destroy() { this._hashSubscription.unsubscribe(); + this._scrollSubscription.unsubscribe(); } } diff --git a/lib/services/schema-helper.service.spec.ts b/lib/services/schema-helper.service.spec.ts index ac53571d..041bfe59 100644 --- a/lib/services/schema-helper.service.spec.ts +++ b/lib/services/schema-helper.service.spec.ts @@ -3,100 +3,6 @@ import { SchemaHelper } from './schema-helper.service'; import { SpecManager } from '../utils/spec-manager'; describe('Spec Helper', () => { - describe('buildMenuTree method', () => { - let suitSchema = { - tags: [ - {name: 'tag1', description: 'info1', 'x-traitTag': true}, - {name: 'tag2', description: 'info2'}, - {name: 'tag4', description: 'info2', 'x-displayName': 'Tag Four'} - ], - paths: { - test: { - put: { - tags: ['tag1', 'tag3'], - summary: 'test put' - }, - get: { - tags: ['tag1', 'tag2'], - summary: 'test get' - }, - delete: { - tags: ['tag4'], - summary: 'test delete' - }, - // no tags - post: { - summary: 'test post' - } - } - } - }; - - let menuTree; - let specMgr; - - beforeAll(() => { - specMgr = new SpecManager(); - specMgr._schema = suitSchema; - menuTree = SchemaHelper.buildMenuTree(suitSchema); - }); - - it('should return instance of Array', () => { - menuTree.should.be.instanceof(Array); - }); - - it('should return Array with correct number of items', () => { - //3 - defined tags, 1 - tag3 and 1 [other] tag for no-tags method - menuTree.length.should.be.equal(3 + 1 + 1); - }); - - it('should append not defined tags to the end of list', () => { - let info = menuTree[3]; - info.name.should.be.equal('tag3'); - info.methods.length.should.be.equal(1); - info.methods[0].summary.should.be.equal('test put'); - }); - - it('should append methods without tags to [other] tag', () => { - let info = menuTree[4]; - info.name.should.be.equal(''); - info.methods.length.should.be.equal(1); - info.methods[0].summary.should.be.equal('test post'); - }); - - it('should map x-traitTag to empty section', () => { - let info = menuTree[0]; - info.empty.should.be.true(); - }); - - it('should map x-traitTag to empty methods list', () => { - let info = menuTree[0]; - info.methods.should.be.empty(); - }); - - it('methods for tag should contain valid pointer and summary', () => { - for (let entr of menuTree) { - let info = entr; - info.should.be.an.Object(); - info.methods.should.be.an.Array(); - for (let methodInfo of info.methods) { - methodInfo.should.have.properties(['pointer', 'summary']); - let methSchema = specMgr.byPointer(methodInfo.pointer); - expect(methSchema).not.toBeNull(); - if (methSchema.summary) { - methSchema.summary.should.be.equal(methodInfo.summary); - } - } - } - }); - - it('should use x-displayName to set custom names', () => { - let info = menuTree[2]; - info.id.should.be.equal('tag/tag4'); - info.name.should.be.equal('Tag Four'); - }); - }); - describe('injectors', () => { it('should autodetect type if not-specified', () => { spyOn(console, 'warn').and.stub(); diff --git a/lib/services/schema-helper.service.ts b/lib/services/schema-helper.service.ts index cfdb42e2..c623fe5b 100644 --- a/lib/services/schema-helper.service.ts +++ b/lib/services/schema-helper.service.ts @@ -9,28 +9,6 @@ interface PropertyPreprocessOptions { skipReadOnly?: boolean; } -export interface MenuMethod { - active: boolean; - summary: string; - tag: string; - pointer: string; - operationId: string; - ready: boolean; -} - -export interface MenuCategory { - name: string; - id: string; - - active?: boolean; - methods?: Array; - description?: string; - empty?: string; - virtual?: boolean; - ready: boolean; - headless: boolean; -} - // global var for this module var specMgrInstance; @@ -300,30 +278,11 @@ export class SchemaHelper { } } - static buildMenuTree(schema):Array { - var catIdx = 0; - let tag2MethodMapping = {}; - - for (let header of (>(schema.info && schema.info['x-redoc-markdown-headers'] || []))) { - let id = 'section/' + slugify(header); - tag2MethodMapping[id] = { - name: header, id: id, virtual: true, methods: [], idx: catIdx - }; - catIdx++; - } - + static getTagsWithMethods(schema) { + let tags = {}; for (let tag of schema.tags || []) { - let id = 'tag/' + slugify(tag.name); - tag2MethodMapping[id] = { - name: tag['x-displayName'] || tag.name, - id: id, - description: tag.description, - headless: tag.name === '', - empty: !!tag['x-traitTag'], - methods: [], - idx: catIdx - }; - catIdx++; + tags[tag.name] = tag; + tag.methods = []; } let paths = schema.paths; @@ -331,39 +290,29 @@ export class SchemaHelper { let methods = Object.keys(paths[path]).filter((k) => swaggerMethods.has(k)); for (let method of methods) { let methodInfo = paths[path][method]; - let tags = methodInfo.tags; + let methodTags = methodInfo.tags; - if (!tags || !tags.length) { - tags = ['']; + // empty tag + if (!(methodTags && methodTags.length)) { + methodTags = ['']; } let methodPointer = JsonPointer.compile(['paths', path, method]); - let methodSummary = SchemaHelper.methodSummary(methodInfo); - for (let tag of tags) { - let id = 'tag/' + slugify(tag); - let tagDetails = tag2MethodMapping[id]; - if (!tagDetails) { - tagDetails = { - name: tag, - id: id, - headless: tag === '', - idx: catIdx + for (let tagName of methodTags) { + let tag = tags[tagName]; + if (!tag) { + tag = { + name: tagName, }; - tag2MethodMapping[id] = tagDetails; - catIdx++; + tags[tagName] = tag; } - if (tagDetails.empty) continue; - if (!tagDetails.methods) tagDetails.methods = []; - tagDetails.methods.push({ - pointer: methodPointer, - summary: methodSummary, - operationId: methodInfo.operationId, - tag: tag, - idx: tagDetails.methods.length, - catIdx: tagDetails.idx - }); + if (tag['x-traitTag']) continue; + if (!tag.methods) tag.methods = []; + tag.methods.push(methodInfo); + methodInfo._pointer = methodPointer; } } } - return Object.keys(tag2MethodMapping).map(tag => tag2MethodMapping[tag]); + + return tags; } } diff --git a/lib/shared/components/LazyFor/lazy-for.ts b/lib/shared/components/LazyFor/lazy-for.ts index 6beb8353..94019cf8 100644 --- a/lib/shared/components/LazyFor/lazy-for.ts +++ b/lib/shared/components/LazyFor/lazy-for.ts @@ -17,7 +17,7 @@ import { OptionsService } from '../../../services/options.service'; import { isSafari } from '../../../utils/helpers'; export class LazyForRow { - constructor(public $implicit: any, public index: number, public show: boolean) {} + constructor(public $implicit: any, public index: number, public ready: boolean) {} get first(): boolean { return this.index === 0; } @@ -51,8 +51,8 @@ export class LazyTasksService { } addTasks(tasks:any[], callback:Function) { - tasks.forEach((task) => { - let taskCopy = Object.assign({_callback: callback}, task); + tasks.forEach((task, idx) => { + let taskCopy = Object.assign({_callback: callback, idx: idx}, task); this._tasks.push(taskCopy); }); } @@ -62,7 +62,7 @@ export class LazyTasksService { if (!task) return; task._callback(task.idx, true); this._current++; - this.menuService.enableItem(task.catIdx, task.idx); + this.menuService.enableItem(task.flatIdx); this.loadProgress.next(this._current / this._tasks.length * 100); } @@ -72,39 +72,30 @@ export class LazyTasksService { if (!task) return; task._callback(task.idx, false).then(() => { this._current++; - this.menuService.enableItem(task.catIdx, task.idx); + this.menuService.enableItem(task.flatIdx); setTimeout(()=> this.nextTask()); this.loadProgress.next(this._current / this._tasks.length * 100); }).catch(err => console.error(err)); }); } - sortTasks(catIdx, metIdx) { + sortTasks(center) { let idxMap = {}; - this._tasks.forEach((task, idx) => { - idxMap[task.catIdx + '_' + task.idx] = idx; - }); - metIdx = metIdx < 0 ? 0 : metIdx; - let destIdx = idxMap[catIdx + '_' + metIdx] || 0; this._tasks.sort((a, b) => { - let aIdx = idxMap[a.catIdx + '_' + a.idx]; - let bIdx = idxMap[b.catIdx + '_' + b.idx]; - return Math.abs(aIdx - destIdx) - Math.abs(bIdx - destIdx); + return Math.abs(a.flatIdx - center) - Math.abs(b.flatIdx - center); }) } - start(catIdx, metIdx, menuService) { + start(idx, menuService) { this.menuService = menuService; let syncCount = 5; - // I know this is bad practice to detect browsers but there is an issue on Safari only + // I know this is a bad practice to detect browsers but there is an issue in Safari only // http://stackoverflow.com/questions/40692365/maintaining-scroll-position-while-inserting-elements-above-glitching-only-in-sa if (isSafari && this.optionsService.options.$scrollParent === window) { - syncCount = (metIdx >= 0) ? - this._tasks.findIndex(task => (task.catIdx === catIdx) && (task.idx === metIdx)) - : this._tasks.findIndex(task => task.catIdx === catIdx); + syncCount = this._tasks.findIndex(task => task.flatIdx === idx); syncCount += 1; } else { - this.sortTasks(catIdx, metIdx); + this.sortTasks(idx); } if (this.allSync) syncCount = this._tasks.length; for (var i = this._current; i < syncCount; i++) { @@ -141,8 +132,8 @@ export class LazyFor { } nextIteration(idx: number, sync: boolean):Promise { - const view = this._viewContainer.createEmbeddedView( - this._template, new LazyForRow(this.lazyForOf[idx], idx, sync), idx < this.prevIdx ? 0 : undefined); + const view = this._viewContainer.createEmbeddedView(this._template, + new LazyForRow(this.lazyForOf[idx], idx, sync), idx < this.prevIdx ? 0 : undefined); this.prevIdx = idx; view.context.index = idx; (view as ChangeDetectorRef).markForCheck(); @@ -154,7 +145,7 @@ export class LazyFor { requestAnimationFrame(() => { this.scroll.saveScroll(); - view.context.show = true; + view.context.ready = true; (view as ChangeDetectorRef).markForCheck(); (view as ChangeDetectorRef).detectChanges(); @@ -165,6 +156,7 @@ export class LazyFor { } ngOnInit() { + if (!this.lazyForOf) return; this.lazyTasks.addTasks(this.lazyForOf, this.nextIteration.bind(this)) } } diff --git a/lib/shared/components/StickySidebar/sticky-sidebar.ts b/lib/shared/components/StickySidebar/sticky-sidebar.ts index d9429e83..f4e822af 100644 --- a/lib/shared/components/StickySidebar/sticky-sidebar.ts +++ b/lib/shared/components/StickySidebar/sticky-sidebar.ts @@ -84,7 +84,6 @@ export class StickySidebar implements OnInit, OnDestroy { this.$redocEl = this.$element.offsetParent.parentNode || DOM.defaultDoc().body; this.bind(); requestAnimationFrame(() => this.updatePosition()); - //this.updatePosition() } ngOnDestroy() { diff --git a/lib/utils/md-renderer.ts b/lib/utils/md-renderer.ts index 06b5539e..b29417cb 100644 --- a/lib/utils/md-renderer.ts +++ b/lib/utils/md-renderer.ts @@ -22,6 +22,8 @@ const md = new Remarkable({ @Injectable() export class MdRenderer { public firstLevelHeadings: string[] = []; + public secondLevelHeadings: string[] = []; + public currentHeading: string = null; private _origRules:any = {}; private _preProcessors:Function[] = []; @@ -44,19 +46,27 @@ export class MdRenderer { } headingOpenRule(tokens, idx) { - if (tokens[idx].hLevel !== 1 ) { + if (tokens[idx].hLevel > 2 ) { return this._origRules.open(tokens, idx); } else { let content = tokens[idx + 1].content; - this.firstLevelHeadings.push(content); - let contentSlug = slugify(content); - return `` + - ``; + if (tokens[idx].hLevel === 1 ) { + this.firstLevelHeadings.push(content); + this.currentHeading = content; + let contentSlug = slugify(content); + return `` + + ``; + } else if (tokens[idx].hLevel === 2 ) { + this.secondLevelHeadings.push(this.currentHeading + `/` + content); + let contentSlug = slugify(this.currentHeading) + `/` + slugify(content); + return `` + + ``; + } } } headingCloseRule(tokens, idx) { - if (tokens[idx].hLevel !== 1 ) { + if (tokens[idx].hLevel > 2 ) { return this._origRules.close(tokens, idx); } else { return `\n`; diff --git a/lib/utils/spec-manager.ts b/lib/utils/spec-manager.ts index e9356895..d465b026 100644 --- a/lib/utils/spec-manager.ts +++ b/lib/utils/spec-manager.ts @@ -76,6 +76,7 @@ export class SpecManager { } this._schema.info['x-redoc-html-description'] = mdRender.renderMd(this._schema.info.description); this._schema.info['x-redoc-markdown-headers'] = mdRender.firstLevelHeadings; + this._schema.info['x-redoc-markdown-subheaders'] = mdRender.secondLevelHeadings; } get schema() { diff --git a/package.json b/package.json index 248cac67..8359238a 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "redoc", "description": "Swagger-generated API Reference Documentation", - "version": "1.6.4", + "version": "1.7.0", "repository": { "type": "git", "url": "git://github.com/Rebilly/ReDoc" diff --git a/tests/e2e/index.html b/tests/e2e/index.html index 00af0530..a0ecb02c 100644 --- a/tests/e2e/index.html +++ b/tests/e2e/index.html @@ -4,8 +4,8 @@ ReDoc - - + + Loading... diff --git a/tests/e2e/redoc.e2e.js b/tests/e2e/redoc.e2e.js index 37733c91..1e6416b6 100644 --- a/tests/e2e/redoc.e2e.js +++ b/tests/e2e/redoc.e2e.js @@ -56,16 +56,16 @@ describe('Scroll sync', () => { it('should update active menu entries on page scroll forwards', () => { scrollToEl('[section="tag/store"]').then(() => { - expect($('.menu-cat-header.active').getInnerHtml()).toContain('store'); + expect($('.menu-item.active > .menu-item-header').getInnerHtml()).toContain('store'); expect($('.selected-tag').getInnerHtml()).toContain('store'); }); }); it('should update active menu entries on page scroll backwards', () => { scrollToEl('[operation-id="getPetById"]').then(() => { - expect($('.menu-cat-header.active').getInnerHtml()).toContain('pet'); + expect($('.menu-item.menu-item-depth-1.active .menu-item-header').getInnerHtml()).toContain('pet'); expect($('.selected-tag').getInnerHtml()).toContain('pet'); - expect($('.menu-cat li.active').getInnerHtml()).toContain('Find pet by ID'); + expect($('.menu-item.menu-item-depth-2.active .menu-item-header').getInnerHtml()).toContain('Find pet by ID'); expect($('.selected-endpoint').getInnerHtml()).toContain('Find pet by ID'); }); }); @@ -85,7 +85,7 @@ describe('Language tabs sync', () => { // check if correct item expect($item.getText()).toContain('PHP'); var EC = protractor.ExpectedConditions; - browser.wait(EC.elementToBeClickable($item), 2000); + browser.wait(EC.elementToBeClickable($item), 5000); $item.click().then(() => { expect($('[operation-id="updatePet"] li.active').getText()).toContain('PHP'); }); diff --git a/tests/helpers.ts b/tests/helpers.ts index 0d3b3662..05371243 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -1,7 +1,7 @@ 'use strict'; -import {By} from '@angular/platform-browser'; -import {getDOM} from '@angular/platform-browser/src/dom/dom_adapter'; +import { By } from '@angular/platform-browser'; +import { getDOM } from '@angular/platform-browser/src/dom/dom_adapter'; /** Gets a child DebugElement by tag name. */ export function getChildDebugElement(parent, tagName) { diff --git a/tests/schemas/responses-list-component.json b/tests/schemas/responses-list-component.json new file mode 100644 index 00000000..dccaa9d8 --- /dev/null +++ b/tests/schemas/responses-list-component.json @@ -0,0 +1,41 @@ +{ + "swagger": "2.0", + "info": { + }, + "host": "petstore.swagger.io", + "basePath": "/v2/", + "tags": [{ + "name": "traitTag", + "x-traitTag": true, + "description": "description1" + },{ + "name": "tag1", + "description": "tag1", + }], + "schemes": ["http"], + "paths": { + "/test1": { + "get": { + "responses": { + "200": { + "description": "successful operation", + "schema": { + "$ref": "#/definitions/def1" + } + }, + "201": { + "description": "successful operation", + "schema": { + "$ref": "#/definitions/def1" + } + } + } + } + } + }, + "definitions": { + "def1": { + "type": "string" + } + } +}