Merge pull request #2677 from coreos-inc/refactor-cor-tabs-pr
Improve cor-tabs Architecture and API
This commit is contained in:
commit
ed48dfd599
32 changed files with 629 additions and 326 deletions
|
@ -6,7 +6,7 @@
|
|||
<div class="cor-loader"></div>
|
||||
</div>
|
||||
<div class="co-tab-modal-body" ng-show="!credentials.loading">
|
||||
<cor-tab-panel remember-cookie="quay.credentialsTab" vertical-tabs="true">
|
||||
<cor-tab-panel orientation="vertical" cor-cookie-tabs="quay.credentialsTab">
|
||||
<!-- Tabs -->
|
||||
<cor-tabs>
|
||||
<cor-tab tab-active="true" tab-id="cred-secret-{{ ::dialogID }}">
|
||||
|
|
|
@ -10,7 +10,7 @@
|
|||
</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<cor-tab-panel>
|
||||
<cor-tab-panel cor-nav-tabs>
|
||||
<cor-tabs>
|
||||
<cor-tab tab-title="Description" tab-id="description">
|
||||
<i class="fa fa-info-circle"></i>
|
||||
|
|
|
@ -0,0 +1,61 @@
|
|||
import { CorCookieTabsDirective } from './cor-cookie-tabs.directive';
|
||||
import { CorTabPanelComponent } from '../cor-tab-panel/cor-tab-panel.component';
|
||||
import { Mock } from 'ts-mocks';
|
||||
import { BehaviorSubject } from 'rxjs/BehaviorSubject';
|
||||
import Spy = jasmine.Spy;
|
||||
|
||||
|
||||
describe("CorCookieTabsDirective", () => {
|
||||
var directive: CorCookieTabsDirective;
|
||||
var panelMock: Mock<CorTabPanelComponent>;
|
||||
var cookieServiceMock: Mock<any>;
|
||||
var activeTab: BehaviorSubject<string>;
|
||||
|
||||
beforeEach(() => {
|
||||
activeTab = new BehaviorSubject<string>(null);
|
||||
spyOn(activeTab, "subscribe").and.returnValue(null);
|
||||
panelMock = new Mock<CorTabPanelComponent>();
|
||||
panelMock.setup(mock => mock.activeTab).is(activeTab);
|
||||
cookieServiceMock = new Mock<any>();
|
||||
cookieServiceMock.setup(mock => mock.putPermanent).is((cookieName, value) => null);
|
||||
|
||||
directive = new CorCookieTabsDirective(panelMock.Object, cookieServiceMock.Object);
|
||||
directive.cookieName = "quay.credentialsTab";
|
||||
});
|
||||
|
||||
describe("constructor", () => {
|
||||
|
||||
it("subscribes to active tab changes", () => {
|
||||
expect((<Spy>panelMock.Object.activeTab.subscribe)).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls cookie service to put new permanent cookie on active tab changes", () => {
|
||||
const tabId: string = "description";
|
||||
(<Spy>panelMock.Object.activeTab.subscribe).calls.argsFor(0)[0](tabId);
|
||||
|
||||
expect((<Spy>cookieServiceMock.Object.putPermanent).calls.argsFor(0)[0]).toEqual(directive.cookieName);
|
||||
expect((<Spy>cookieServiceMock.Object.putPermanent).calls.argsFor(0)[1]).toEqual(tabId);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ngAfterContentInit", () => {
|
||||
const tabId: string = "description";
|
||||
|
||||
beforeEach(() => {
|
||||
cookieServiceMock.setup(mock => mock.get).is((name) => tabId);
|
||||
spyOn(activeTab, "next").and.returnValue(null);
|
||||
});
|
||||
|
||||
it("calls cookie service to retrieve initial tab id", () => {
|
||||
directive.ngAfterContentInit();
|
||||
|
||||
expect((<Spy>cookieServiceMock.Object.get).calls.argsFor(0)[0]).toEqual(directive.cookieName);
|
||||
});
|
||||
|
||||
it("emits retrieved tab id as next active tab", () => {
|
||||
directive.ngAfterContentInit();
|
||||
|
||||
expect((<Spy>panelMock.Object.activeTab.next).calls.argsFor(0)[0]).toEqual(tabId);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,27 @@
|
|||
import { Directive, Inject, Host, AfterContentInit, Input } from 'ng-metadata/core';
|
||||
import { CorTabPanelComponent } from '../cor-tab-panel/cor-tab-panel.component';
|
||||
|
||||
|
||||
/**
|
||||
* Adds routing capabilities to cor-tab-panel using a browser cookie.
|
||||
*/
|
||||
@Directive({
|
||||
selector: '[corCookieTabs]'
|
||||
})
|
||||
export class CorCookieTabsDirective implements AfterContentInit {
|
||||
|
||||
@Input('@corCookieTabs') public cookieName: string;
|
||||
|
||||
constructor(@Host() @Inject(CorTabPanelComponent) private panel: CorTabPanelComponent,
|
||||
@Inject('CookieService') private cookieService: any) {
|
||||
this.panel.activeTab.subscribe((tab: string) => {
|
||||
this.cookieService.putPermanent(this.cookieName, tab);
|
||||
});
|
||||
}
|
||||
|
||||
public ngAfterContentInit(): void {
|
||||
// Set initial tab
|
||||
const tabId: string = this.cookieService.get(this.cookieName);
|
||||
this.panel.activeTab.next(tabId);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,73 @@
|
|||
import { CorNavTabsDirective } from './cor-nav-tabs.directive';
|
||||
import { CorTabPanelComponent } from '../cor-tab-panel/cor-tab-panel.component';
|
||||
import { Mock } from 'ts-mocks';
|
||||
import { BehaviorSubject } from 'rxjs/BehaviorSubject';
|
||||
import Spy = jasmine.Spy;
|
||||
|
||||
|
||||
describe("CorNavTabsDirective", () => {
|
||||
var directive: CorNavTabsDirective;
|
||||
var panelMock: Mock<CorTabPanelComponent>;
|
||||
var $locationMock: Mock<ng.ILocationService>;
|
||||
var $rootScopeMock: Mock<ng.IRootScopeService>;
|
||||
var activeTab: BehaviorSubject<string>;
|
||||
const tabId: string = "description";
|
||||
|
||||
beforeEach(() => {
|
||||
activeTab = new BehaviorSubject<string>(null);
|
||||
spyOn(activeTab, "next").and.returnValue(null);
|
||||
panelMock = new Mock<CorTabPanelComponent>();
|
||||
panelMock.setup(mock => mock.activeTab).is(activeTab);
|
||||
$locationMock = new Mock<ng.ILocationService>();
|
||||
$locationMock.setup(mock => mock.search).is(() => <any>{tab: tabId});
|
||||
$rootScopeMock = new Mock<ng.IRootScopeService>();
|
||||
$rootScopeMock.setup(mock => mock.$on);
|
||||
|
||||
directive = new CorNavTabsDirective(panelMock.Object, $locationMock.Object, $rootScopeMock.Object);
|
||||
});
|
||||
|
||||
describe("constructor", () => {
|
||||
|
||||
it("subscribes to $routeUpdate event on the root scope", () => {
|
||||
expect((<Spy>$rootScopeMock.Object.$on).calls.argsFor(0)[0]).toEqual("$routeUpdate");
|
||||
});
|
||||
|
||||
it("calls location service to retrieve tab id from URL query parameters on route update", () => {
|
||||
(<Spy>$rootScopeMock.Object.$on).calls.argsFor(0)[1]();
|
||||
|
||||
expect(<Spy>$locationMock.Object.search).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("emits retrieved tab id as next active tab on route update", () => {
|
||||
(<Spy>$rootScopeMock.Object.$on).calls.argsFor(0)[1]();
|
||||
|
||||
expect((<Spy>activeTab.next).calls.argsFor(0)[0]).toEqual(tabId);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ngAfterContentInit", () => {
|
||||
const path: string = "quay.io/repository/devtable/simple";
|
||||
|
||||
beforeEach(() => {
|
||||
$locationMock.setup(mock => mock.path).is(() => <any>path);
|
||||
});
|
||||
|
||||
it("calls location service to retrieve the current URL path and sets panel's base path", () => {
|
||||
directive.ngAfterContentInit();
|
||||
|
||||
expect(panelMock.Object.basePath).toEqual(path);
|
||||
});
|
||||
|
||||
it("calls location service to retrieve tab id from URL query parameters", () => {
|
||||
directive.ngAfterContentInit();
|
||||
|
||||
expect(<Spy>$locationMock.Object.search).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("emits retrieved tab id as next active tab", () => {
|
||||
directive.ngAfterContentInit();
|
||||
|
||||
expect((<Spy>activeTab.next).calls.argsFor(0)[0]).toEqual(tabId);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,29 @@
|
|||
import { Directive, Inject, Host, AfterContentInit, Input } from 'ng-metadata/core';
|
||||
import { CorTabPanelComponent } from '../cor-tab-panel/cor-tab-panel.component';
|
||||
|
||||
|
||||
/**
|
||||
* Adds routing capabilities to cor-tab-panel, either using URL query parameters, or browser cookie.
|
||||
*/
|
||||
@Directive({
|
||||
selector: '[corNavTabs]'
|
||||
})
|
||||
export class CorNavTabsDirective implements AfterContentInit {
|
||||
|
||||
constructor(@Host() @Inject(CorTabPanelComponent) private panel: CorTabPanelComponent,
|
||||
@Inject('$location') private $location: ng.ILocationService,
|
||||
@Inject('$rootScope') private $rootScope: ng.IRootScopeService) {
|
||||
this.$rootScope.$on('$routeUpdate', () => {
|
||||
const tabId: string = this.$location.search()['tab'];
|
||||
this.panel.activeTab.next(tabId);
|
||||
});
|
||||
}
|
||||
|
||||
public ngAfterContentInit(): void {
|
||||
this.panel.basePath = this.$location.path();
|
||||
|
||||
// Set initial tab
|
||||
const tabId: string = this.$location.search()['tab'];
|
||||
this.panel.activeTab.next(tabId);
|
||||
}
|
||||
}
|
|
@ -6,10 +6,12 @@ import { Component } from 'ng-metadata/core';
|
|||
*/
|
||||
@Component({
|
||||
selector: 'cor-tab-content',
|
||||
templateUrl: '/static/js/directives/ui/cor-tabs/cor-tab-content.component.html',
|
||||
templateUrl: '/static/js/directives/ui/cor-tabs/cor-tab-content/cor-tab-content.component.html',
|
||||
legacy: {
|
||||
transclude: true,
|
||||
replace: true,
|
||||
}
|
||||
})
|
||||
export class CorTabContentComponent {}
|
||||
export class CorTabContentComponent {
|
||||
|
||||
}
|
|
@ -1,83 +0,0 @@
|
|||
import { CorTabComponent } from './cor-tab.component';
|
||||
import { CorTabPanelComponent } from './cor-tab-panel.component';
|
||||
|
||||
/**
|
||||
* Defines an interface for reading and writing the current tab state.
|
||||
*/
|
||||
export interface CorTabCurrentHandler {
|
||||
getInitialTabId(): string
|
||||
|
||||
notifyTabChanged(tab: CorTabComponent, isDefaultTab: boolean)
|
||||
|
||||
dispose(): void
|
||||
}
|
||||
|
||||
export function CorTabCurrentHandlerFactory(options?: any): CorTabCurrentHandler {
|
||||
switch (options.type) {
|
||||
case "cookie":
|
||||
return new CookieCurrentTabHandler(options.cookieService, options.cookieName);
|
||||
default:
|
||||
return new LocationCurrentTabHandler(options.panel, options.$location, options.$rootScope);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads and writes the tab from the `tab` query parameter in the location.
|
||||
*/
|
||||
export class LocationCurrentTabHandler implements CorTabCurrentHandler {
|
||||
private cancelWatchHandle: Function;
|
||||
|
||||
constructor (private panel: CorTabPanelComponent,
|
||||
private $location: ng.ILocationService,
|
||||
private $rootScope: ng.IRootScopeService) {
|
||||
}
|
||||
|
||||
private checkLocation(): void {
|
||||
var specifiedTabId = this.$location.search()['tab'];
|
||||
var specifiedTab = this.panel.findTab(specifiedTabId);
|
||||
this.panel.setActiveTab(specifiedTab);
|
||||
}
|
||||
|
||||
public getInitialTabId(): string {
|
||||
if (!this.cancelWatchHandle) {
|
||||
this.cancelWatchHandle = this.$rootScope.$on('$routeUpdate', () => this.checkLocation());
|
||||
}
|
||||
return this.$location.search()['tab'];
|
||||
}
|
||||
|
||||
public notifyTabChanged(tab: CorTabComponent, isDefaultTab: boolean) {
|
||||
var newSearch = $.extend(this.$location.search(), {});
|
||||
if (isDefaultTab) {
|
||||
delete newSearch['tab'];
|
||||
} else {
|
||||
newSearch['tab'] = tab.tabId;
|
||||
}
|
||||
|
||||
this.$location.search(newSearch);
|
||||
}
|
||||
|
||||
public dispose(): void {
|
||||
this.cancelWatchHandle();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads and writes the tab from a cookie,.
|
||||
*/
|
||||
export class CookieCurrentTabHandler implements CorTabCurrentHandler {
|
||||
constructor (private CookieService: any, private cookieName: string) {}
|
||||
|
||||
public getInitialTabId(): string {
|
||||
return this.CookieService.get(this.cookieName);
|
||||
}
|
||||
|
||||
public notifyTabChanged(tab: CorTabComponent, isDefaultTab: boolean) {
|
||||
if (isDefaultTab) {
|
||||
this.CookieService.clear(this.cookieName);
|
||||
} else {
|
||||
this.CookieService.putPermanent(this.cookieName, tab.tabId);
|
||||
}
|
||||
}
|
||||
|
||||
public dispose(): void {}
|
||||
}
|
|
@ -1,31 +0,0 @@
|
|||
import { Component, Input, Inject, Host, OnInit } from 'ng-metadata/core';
|
||||
import { CorTabPanelComponent } from './cor-tab-panel.component';
|
||||
|
||||
|
||||
/**
|
||||
* A component that creates a single tab pane under a cor-tabs component.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'cor-tab-pane',
|
||||
templateUrl: '/static/js/directives/ui/cor-tabs/cor-tab-pane.component.html',
|
||||
legacy: {
|
||||
transclude: true,
|
||||
}
|
||||
})
|
||||
export class CorTabPaneComponent implements OnInit {
|
||||
@Input('@') public id: string;
|
||||
|
||||
// Whether this is the active tab.
|
||||
private isActiveTab: boolean = false;
|
||||
|
||||
constructor(@Host() @Inject(CorTabPanelComponent) private parent: CorTabPanelComponent) {
|
||||
}
|
||||
|
||||
public ngOnInit(): void {
|
||||
this.parent.addTabPane(this);
|
||||
}
|
||||
|
||||
public changeState(isActive: boolean): void {
|
||||
this.isActiveTab = isActive;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,40 @@
|
|||
import { CorTabPaneComponent } from './cor-tab-pane.component';
|
||||
import { CorTabPanelComponent } from '../cor-tab-panel/cor-tab-panel.component';
|
||||
import { Mock } from 'ts-mocks';
|
||||
import { BehaviorSubject } from 'rxjs/BehaviorSubject';
|
||||
import Spy = jasmine.Spy;
|
||||
|
||||
|
||||
describe("CorTabPaneComponent", () => {
|
||||
var component: CorTabPaneComponent;
|
||||
var panelMock: Mock<CorTabPanelComponent>;
|
||||
var activeTab: BehaviorSubject<string>;
|
||||
|
||||
beforeEach(() => {
|
||||
activeTab = new BehaviorSubject<string>(null);
|
||||
spyOn(activeTab, "subscribe").and.returnValue(null);
|
||||
panelMock = new Mock<CorTabPanelComponent>();
|
||||
panelMock.setup(mock => mock.activeTab).is(activeTab);
|
||||
|
||||
component = new CorTabPaneComponent(panelMock.Object);
|
||||
});
|
||||
|
||||
describe("ngOnInit", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
panelMock.setup(mock => mock.addTabPane);
|
||||
});
|
||||
|
||||
it("adds self as tab pane to panel", () => {
|
||||
component.ngOnInit();
|
||||
|
||||
expect((<Spy>panelMock.Object.addTabPane).calls.argsFor(0)[0]).toBe(component);
|
||||
});
|
||||
|
||||
it("subscribes to active tab changes", () => {
|
||||
component.ngOnInit();
|
||||
|
||||
expect((<Spy>panelMock.Object.activeTab.subscribe)).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,32 @@
|
|||
import { Component, Input, Inject, Host, OnInit } from 'ng-metadata/core';
|
||||
import { CorTabPanelComponent } from '../cor-tab-panel/cor-tab-panel.component';
|
||||
|
||||
|
||||
/**
|
||||
* A component that creates a single tab pane under a cor-tabs component.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'cor-tab-pane',
|
||||
templateUrl: '/static/js/directives/ui/cor-tabs/cor-tab-pane/cor-tab-pane.component.html',
|
||||
legacy: {
|
||||
transclude: true,
|
||||
}
|
||||
})
|
||||
export class CorTabPaneComponent implements OnInit {
|
||||
|
||||
@Input('@') public id: string;
|
||||
|
||||
private isActiveTab: boolean = false;
|
||||
|
||||
constructor(@Host() @Inject(CorTabPanelComponent) private panel: CorTabPanelComponent) {
|
||||
|
||||
}
|
||||
|
||||
public ngOnInit(): void {
|
||||
this.panel.addTabPane(this);
|
||||
|
||||
this.panel.activeTab.subscribe((tabId: string) => {
|
||||
this.isActiveTab = (this.id === tabId);
|
||||
});
|
||||
}
|
||||
}
|
|
@ -1,128 +0,0 @@
|
|||
import { Component, Input, Inject, OnDestroy } from 'ng-metadata/core';
|
||||
import { CorTabComponent } from './cor-tab.component';
|
||||
import { CorTabPaneComponent } from './cor-tab-pane.component';
|
||||
import { CorTabCurrentHandler, LocationCurrentTabHandler, CookieCurrentTabHandler, CorTabCurrentHandlerFactory } from './cor-tab-handlers'
|
||||
|
||||
|
||||
/**
|
||||
* A component that contains a cor-tabs and handles all of its logic.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'cor-tab-panel',
|
||||
templateUrl: '/static/js/directives/ui/cor-tabs/cor-tab-panel.component.html',
|
||||
legacy: {
|
||||
transclude: true,
|
||||
}
|
||||
})
|
||||
export class CorTabPanelComponent implements OnDestroy {
|
||||
// If supplied, the currently selected tab will be remembered via the named cookie and not
|
||||
// the page URL.
|
||||
@Input('@') public rememberCookie: string;
|
||||
|
||||
// If 'true', the tabs will be displayed vertically, as opposed to horizontally.
|
||||
@Input('<') public verticalTabs: boolean;
|
||||
|
||||
// The tabs under this tabs component.
|
||||
private tabs: CorTabComponent[] = [];
|
||||
|
||||
// The tab panes under the tabs component, indexed by the tab id.
|
||||
private tabPanes: {[id: string]: CorTabPaneComponent} = {};
|
||||
|
||||
// The currently active tab, if any.
|
||||
private activeTab: CorTabComponent = null;
|
||||
|
||||
// Whether the initial tab was set.
|
||||
private initialTabSet: boolean = false;
|
||||
|
||||
// The handler to use to read/write the current tab.
|
||||
private currentTabHandler: CorTabCurrentHandler = null;
|
||||
|
||||
constructor(@Inject('$location') private $location: ng.ILocationService,
|
||||
@Inject('$rootScope') private $rootScope: ng.IRootScopeService,
|
||||
@Inject('CookieService') private CookieService: any,
|
||||
@Inject('CorTabCurrentHandlerFactory') private CorTabCurrentHandlerFactory: (Object) => CorTabCurrentHandler) {
|
||||
}
|
||||
|
||||
public ngOnDestroy(): void {
|
||||
if (this.currentTabHandler) {
|
||||
this.currentTabHandler.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* isVertical returns true if the tabs in this panel are displayed vertically.
|
||||
*/
|
||||
public isVertical(): boolean {
|
||||
return this.verticalTabs;
|
||||
}
|
||||
|
||||
public tabClicked(tab: CorTabComponent): void {
|
||||
this.setActiveTab(tab);
|
||||
}
|
||||
|
||||
public findTab(tabId: string): CorTabComponent {
|
||||
if (!this.tabs.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
var tab = this.tabs.find(function(current) {
|
||||
return current.tabId == tabId;
|
||||
}) || this.tabs[0];
|
||||
|
||||
if (!this.tabPanes[tab.tabId]) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return tab;
|
||||
}
|
||||
|
||||
public setActiveTab(tab: CorTabComponent): void {
|
||||
if (this.activeTab == tab) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.activeTab != null) {
|
||||
this.activeTab.changeState(false);
|
||||
this.tabPanes[this.activeTab.tabId].changeState(false);
|
||||
}
|
||||
|
||||
this.activeTab = tab;
|
||||
this.activeTab.changeState(true);
|
||||
this.tabPanes[this.activeTab.tabId].changeState(true);
|
||||
this.currentTabHandler.notifyTabChanged(tab, this.tabs[0] == tab);
|
||||
}
|
||||
|
||||
public addTab(tab: CorTabComponent): void {
|
||||
this.tabs.push(tab);
|
||||
this.checkInitialTab();
|
||||
}
|
||||
|
||||
public addTabPane(tabPane: CorTabPaneComponent): void {
|
||||
this.tabPanes[tabPane.id] = tabPane;
|
||||
this.checkInitialTab();
|
||||
}
|
||||
|
||||
private checkInitialTab(): void {
|
||||
if (this.tabs.length < 1 || this.initialTabSet) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.currentTabHandler = this.CorTabCurrentHandlerFactory({
|
||||
type: this.rememberCookie ? 'cookie' : 'location',
|
||||
cookieService: this.CookieService,
|
||||
cookeName: this.rememberCookie,
|
||||
panel: this,
|
||||
$location: this.$location,
|
||||
$rootScope: this.$rootScope,
|
||||
});
|
||||
|
||||
var tabId = this.currentTabHandler.getInitialTabId();
|
||||
var tab = this.findTab(tabId);
|
||||
if (!tab) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.initialTabSet = true;
|
||||
this.setActiveTab(tab);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,111 @@
|
|||
import { CorTabPanelComponent } from './cor-tab-panel.component';
|
||||
import { CorTabComponent } from '../cor-tab/cor-tab.component';
|
||||
import { SimpleChanges } from 'ng-metadata/core';
|
||||
import Spy = jasmine.Spy;
|
||||
|
||||
|
||||
describe("CorTabPanelComponent", () => {
|
||||
var component: CorTabPanelComponent;
|
||||
|
||||
beforeEach(() => {
|
||||
component = new CorTabPanelComponent();
|
||||
});
|
||||
|
||||
describe("ngOnInit", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
spyOn(component.activeTab, "subscribe").and.returnValue(null);
|
||||
spyOn(component.tabChange, "emit").and.returnValue(null);
|
||||
});
|
||||
|
||||
it("subscribes to active tab changes", () => {
|
||||
component.ngOnInit();
|
||||
|
||||
expect(<Spy>component.activeTab.subscribe).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("emits output event for tab change when ", () => {
|
||||
component.ngOnInit();
|
||||
const tabId: string = "description";
|
||||
(<Spy>component.activeTab.subscribe).calls.argsFor(0)[0](tabId);
|
||||
|
||||
expect((<Spy>component.tabChange.emit).calls.argsFor(0)[0]).toEqual(tabId);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ngOnChanges", () => {
|
||||
var changes: SimpleChanges;
|
||||
var tabs: CorTabComponent[] = [];
|
||||
|
||||
beforeEach(() => {
|
||||
// Add tabs to panel
|
||||
tabs.push(new CorTabComponent(component));
|
||||
tabs.forEach((tab) => component.addTab(tab));
|
||||
|
||||
changes = {
|
||||
'selectedIndex': {
|
||||
currentValue: 0,
|
||||
previousValue: null,
|
||||
isFirstChange: () => false
|
||||
},
|
||||
};
|
||||
|
||||
spyOn(component.activeTab, "next").and.returnValue(null);
|
||||
});
|
||||
|
||||
it("emits next active tab if 'selectedIndex' input changes and is valid", () => {
|
||||
component.ngOnChanges(changes);
|
||||
|
||||
expect((<Spy>component.activeTab.next).calls.argsFor(0)[0]).toEqual(tabs[changes['selectedIndex'].currentValue].tabId);
|
||||
});
|
||||
|
||||
it("does nothing if 'selectedIndex' input changed to invalid value", () => {
|
||||
changes['selectedIndex'].currentValue = 100;
|
||||
component.ngOnChanges(changes);
|
||||
|
||||
expect(<Spy>component.activeTab.next).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("addTab", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
spyOn(component.activeTab, "next").and.returnValue(null);
|
||||
});
|
||||
|
||||
it("emits next active tab if it is not set", () => {
|
||||
const tab: CorTabComponent = new CorTabComponent(component);
|
||||
component.addTab(tab);
|
||||
|
||||
expect((<Spy>component.activeTab.next).calls.argsFor(0)[0]).toEqual(tab.tabId);
|
||||
});
|
||||
|
||||
it("does not emit next active tab if it is already set", () => {
|
||||
spyOn(component.activeTab, "getValue").and.returnValue("description");
|
||||
const tab: CorTabComponent = new CorTabComponent(component);
|
||||
component.addTab(tab);
|
||||
|
||||
expect(<Spy>component.activeTab.next).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("addTabPane", () => {
|
||||
|
||||
});
|
||||
|
||||
describe("isVertical", () => {
|
||||
|
||||
it("returns true if orientation is 'vertical'", () => {
|
||||
component.orientation = 'vertical';
|
||||
const isVertical: boolean = component.isVertical();
|
||||
|
||||
expect(isVertical).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false if orientation is not 'vertical'", () => {
|
||||
const isVertical: boolean = component.isVertical();
|
||||
|
||||
expect(isVertical).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,60 @@
|
|||
import { Component, Input, Output, EventEmitter, OnChanges, SimpleChanges, OnInit } from 'ng-metadata/core';
|
||||
import { CorTabComponent } from '../cor-tab/cor-tab.component';
|
||||
import { CorTabPaneComponent } from '../cor-tab-pane/cor-tab-pane.component';
|
||||
import { BehaviorSubject } from 'rxjs/BehaviorSubject';
|
||||
|
||||
|
||||
/**
|
||||
* A component that contains a cor-tabs and handles all of its logic.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'cor-tab-panel',
|
||||
templateUrl: '/static/js/directives/ui/cor-tabs/cor-tab-panel/cor-tab-panel.component.html',
|
||||
legacy: {
|
||||
transclude: true
|
||||
}
|
||||
})
|
||||
export class CorTabPanelComponent implements OnInit, OnChanges {
|
||||
|
||||
@Input('@') public orientation: 'horizontal' | 'vertical' = 'horizontal';
|
||||
|
||||
@Output() public tabChange: EventEmitter<string> = new EventEmitter();
|
||||
|
||||
public basePath: string;
|
||||
public activeTab: BehaviorSubject<string> = new BehaviorSubject(null);
|
||||
|
||||
private tabs: CorTabComponent[] = [];
|
||||
private tabPanes: {[id: string]: CorTabPaneComponent} = {};
|
||||
|
||||
public ngOnInit(): void {
|
||||
this.activeTab.subscribe((tabId: string) => {
|
||||
this.tabChange.emit(tabId);
|
||||
});
|
||||
}
|
||||
|
||||
public ngOnChanges(changes: SimpleChanges): void {
|
||||
switch (Object.keys(changes)[0]) {
|
||||
case 'selectedIndex':
|
||||
if (this.tabs.length > changes['selectedIndex'].currentValue) {
|
||||
this.activeTab.next(this.tabs[changes['selectedIndex'].currentValue].tabId);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
public addTab(tab: CorTabComponent): void {
|
||||
this.tabs.push(tab);
|
||||
|
||||
if (!this.activeTab.getValue()) {
|
||||
this.activeTab.next(this.tabs[0].tabId);
|
||||
}
|
||||
}
|
||||
|
||||
public addTabPane(tabPane: CorTabPaneComponent): void {
|
||||
this.tabPanes[tabPane.id] = tabPane;
|
||||
}
|
||||
|
||||
public isVertical(): boolean {
|
||||
return this.orientation == 'vertical';
|
||||
}
|
||||
}
|
|
@ -1,11 +0,0 @@
|
|||
<li class="cor-tab-itself" ng-class="{'active': $ctrl.isActive, 'co-top-tab': !$ctrl.parent.isVertical()}">
|
||||
<a ng-click="$ctrl.tabClicked()">
|
||||
<span class="cor-tab-icon"
|
||||
data-title="{{ ::($ctrl.parent.isVertical() ? $ctrl.tabTitle : '') }}"
|
||||
data-placement="right"
|
||||
data-container="body"
|
||||
style="display: inline-block"
|
||||
bs-tooltip><span ng-transclude/></span><span class="horizontal-label">{{ ::$ctrl.tabTitle }}</span>
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
|
@ -1,46 +0,0 @@
|
|||
import { Component, Input, Output, Inject, EventEmitter, Host, OnInit } from 'ng-metadata/core';
|
||||
import { CorTabPanelComponent } from './cor-tab-panel.component';
|
||||
|
||||
|
||||
/**
|
||||
* A component that creates a single tab under a cor-tabs component.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'cor-tab',
|
||||
templateUrl: '/static/js/directives/ui/cor-tabs/cor-tab.component.html',
|
||||
legacy: {
|
||||
transclude: true,
|
||||
}
|
||||
})
|
||||
export class CorTabComponent implements OnInit {
|
||||
@Input('@') public tabId: string;
|
||||
@Input('@') public tabTitle: string;
|
||||
|
||||
@Output() public tabInit: EventEmitter<any> = new EventEmitter();
|
||||
@Output() public tabShown: EventEmitter<any> = new EventEmitter();
|
||||
@Output() public tabHidden: EventEmitter<any> = new EventEmitter();
|
||||
|
||||
// Whether this is the active tab.
|
||||
private isActive: boolean = false;
|
||||
|
||||
constructor(@Host() @Inject(CorTabPanelComponent) private parent: CorTabPanelComponent) {
|
||||
}
|
||||
|
||||
public ngOnInit(): void {
|
||||
this.parent.addTab(this);
|
||||
}
|
||||
|
||||
public changeState(isActive: boolean): void {
|
||||
this.isActive = isActive;
|
||||
if (isActive) {
|
||||
this.tabInit.emit({});
|
||||
this.tabShown.emit({});
|
||||
} else {
|
||||
this.tabHidden.emit({});
|
||||
}
|
||||
}
|
||||
|
||||
private tabClicked(): void {
|
||||
this.parent.tabClicked(this);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,13 @@
|
|||
<li class="cor-tab-itself" ng-class="{'active': $ctrl.isActive, 'co-top-tab': !$ctrl.parent.isVertical()}">
|
||||
<a href="{{ $ctrl.panel.basePath ? $ctrl.panel.basePath + '?tab=' + $ctrl.tabId : '' }}"
|
||||
ng-click="$ctrl.tabClicked($event)">
|
||||
<span class="cor-tab-icon"
|
||||
data-title="{{ ::($ctrl.panel.isVertical() ? $ctrl.tabTitle : '') }}"
|
||||
data-placement="right"
|
||||
data-container="body"
|
||||
style="display: inline-block"
|
||||
bs-tooltip>
|
||||
<span ng-transclude /><span class="horizontal-label">{{ ::$ctrl.tabTitle }}</span>
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
|
@ -0,0 +1,76 @@
|
|||
import { CorTabComponent } from './cor-tab.component';
|
||||
import { CorTabPanelComponent } from '../cor-tab-panel/cor-tab-panel.component';
|
||||
import { Mock } from 'ts-mocks';
|
||||
import { BehaviorSubject } from 'rxjs/BehaviorSubject';
|
||||
import Spy = jasmine.Spy;
|
||||
|
||||
|
||||
describe("CorTabComponent", () => {
|
||||
var component: CorTabComponent;
|
||||
var panelMock: Mock<CorTabPanelComponent>;
|
||||
var activeTab: BehaviorSubject<string>;
|
||||
|
||||
beforeEach(() => {
|
||||
activeTab = new BehaviorSubject<string>(null);
|
||||
spyOn(activeTab, "subscribe").and.returnValue(null);
|
||||
panelMock = new Mock<CorTabPanelComponent>();
|
||||
panelMock.setup(mock => mock.activeTab).is(activeTab);
|
||||
|
||||
component = new CorTabComponent(panelMock.Object);
|
||||
});
|
||||
|
||||
describe("ngOnInit", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
panelMock.setup(mock => mock.addTab);
|
||||
spyOn(component.tabInit, "emit").and.returnValue(null);
|
||||
spyOn(component.tabShow, "emit").and.returnValue(null);
|
||||
spyOn(component.tabHide, "emit").and.returnValue(null);
|
||||
component.tabId = "description";
|
||||
});
|
||||
|
||||
it("subscribes to active tab changes", () => {
|
||||
component.ngOnInit();
|
||||
|
||||
expect((<Spy>panelMock.Object.activeTab.subscribe)).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("emits output event for tab init if it is new active tab", () => {
|
||||
component.ngOnInit();
|
||||
(<Spy>panelMock.Object.activeTab.subscribe).calls.argsFor(0)[0](component.tabId);
|
||||
|
||||
expect(<Spy>component.tabInit.emit).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("emits output event for tab show if it is new active tab", () => {
|
||||
component.ngOnInit();
|
||||
(<Spy>panelMock.Object.activeTab.subscribe).calls.argsFor(0)[0](component.tabId);
|
||||
|
||||
expect(<Spy>component.tabShow.emit).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("emits output event for tab hide if active tab changes to different tab", () => {
|
||||
const newTabId: string = component.tabId.split('').reverse().join('');
|
||||
component.ngOnInit();
|
||||
// Call twice, first time to set 'isActive' to true
|
||||
(<Spy>panelMock.Object.activeTab.subscribe).calls.argsFor(0)[0](component.tabId);
|
||||
(<Spy>panelMock.Object.activeTab.subscribe).calls.argsFor(0)[0](newTabId);
|
||||
|
||||
expect(<Spy>component.tabHide.emit).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not emit output event for tab hide if was not previously active tab", () => {
|
||||
const newTabId: string = component.tabId.split('').reverse().join('');
|
||||
component.ngOnInit();
|
||||
(<Spy>panelMock.Object.activeTab.subscribe).calls.argsFor(0)[0](newTabId);
|
||||
|
||||
expect(<Spy>component.tabHide.emit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("adds self as tab to panel", () => {
|
||||
component.ngOnInit();
|
||||
|
||||
expect((<Spy>panelMock.Object.addTab).calls.argsFor(0)[0]).toBe(component);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,51 @@
|
|||
import { Component, Input, Output, Inject, EventEmitter, Host, OnInit } from 'ng-metadata/core';
|
||||
import { CorTabPanelComponent } from '../cor-tab-panel/cor-tab-panel.component';
|
||||
|
||||
|
||||
/**
|
||||
* A component that creates a single tab under a cor-tabs component.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'cor-tab',
|
||||
templateUrl: '/static/js/directives/ui/cor-tabs/cor-tab/cor-tab.component.html',
|
||||
legacy: {
|
||||
transclude: true,
|
||||
}
|
||||
})
|
||||
export class CorTabComponent implements OnInit {
|
||||
|
||||
@Input('@') public tabId: string;
|
||||
@Input('@') public tabTitle: string;
|
||||
|
||||
@Output() public tabInit: EventEmitter<any> = new EventEmitter();
|
||||
@Output() public tabShow: EventEmitter<any> = new EventEmitter();
|
||||
@Output() public tabHide: EventEmitter<any> = new EventEmitter();
|
||||
|
||||
private isActive: boolean = false;
|
||||
|
||||
constructor(@Host() @Inject(CorTabPanelComponent) private panel: CorTabPanelComponent) {
|
||||
|
||||
}
|
||||
|
||||
public ngOnInit(): void {
|
||||
this.panel.activeTab.subscribe((tabId: string) => {
|
||||
if (!this.isActive && this.tabId === tabId) {
|
||||
this.isActive = true;
|
||||
this.tabInit.emit({});
|
||||
this.tabShow.emit({});
|
||||
} else if (this.isActive && this.tabId !== tabId) {
|
||||
this.isActive = false;
|
||||
this.tabHide.emit({});
|
||||
}
|
||||
});
|
||||
|
||||
this.panel.addTab(this);
|
||||
}
|
||||
|
||||
private tabClicked(event: MouseEvent): void {
|
||||
if (!this.panel.basePath) {
|
||||
event.preventDefault();
|
||||
this.panel.activeTab.next(this.tabId);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,6 +1,5 @@
|
|||
import { Component, Input, Output, Inject, EventEmitter, Host } from 'ng-metadata/core';
|
||||
import { CorTabComponent } from './cor-tab.component';
|
||||
import { CorTabPanelComponent } from './cor-tab-panel.component';
|
||||
import { CorTabPanelComponent } from './cor-tab-panel/cor-tab-panel.component';
|
||||
|
||||
|
||||
/**
|
||||
|
@ -14,10 +13,11 @@ import { CorTabPanelComponent } from './cor-tab-panel.component';
|
|||
}
|
||||
})
|
||||
export class CorTabsComponent {
|
||||
// If true, the tabs are in a closed state. Only applies in the mobile view.
|
||||
|
||||
private isClosed: boolean = true;
|
||||
|
||||
constructor(@Host() @Inject(CorTabPanelComponent) private parent: CorTabPanelComponent) {
|
||||
|
||||
}
|
||||
|
||||
private toggleClosed(e): void {
|
||||
|
|
33
static/js/directives/ui/cor-tabs/cor-tabs.module.ts
Normal file
33
static/js/directives/ui/cor-tabs/cor-tabs.module.ts
Normal file
|
@ -0,0 +1,33 @@
|
|||
import { NgModule } from 'ng-metadata/core'
|
||||
import { CorTabsComponent } from './cor-tabs.component';
|
||||
import { CorTabComponent } from './cor-tab/cor-tab.component';
|
||||
import { CorNavTabsDirective } from './cor-nav-tabs/cor-nav-tabs.directive';
|
||||
import { CorTabContentComponent } from './cor-tab-content/cor-tab-content.component';
|
||||
import { CorTabPaneComponent } from './cor-tab-pane/cor-tab-pane.component';
|
||||
import { CorTabPanelComponent } from './cor-tab-panel/cor-tab-panel.component';
|
||||
import { CorCookieTabsDirective } from './cor-cookie-tabs/cor-cookie-tabs.directive';
|
||||
|
||||
|
||||
/**
|
||||
* Module containing everything needed for cor-tabs.
|
||||
*/
|
||||
@NgModule({
|
||||
imports: [
|
||||
|
||||
],
|
||||
declarations: [
|
||||
CorNavTabsDirective,
|
||||
CorTabComponent,
|
||||
CorTabContentComponent,
|
||||
CorTabPaneComponent,
|
||||
CorTabPanelComponent,
|
||||
CorTabsComponent,
|
||||
CorCookieTabsDirective,
|
||||
],
|
||||
providers: [
|
||||
|
||||
]
|
||||
})
|
||||
export class CorTabsModule {
|
||||
|
||||
}
|
|
@ -11,12 +11,6 @@ import { AppPublicViewComponent } from './directives/ui/app-public-view/app-publ
|
|||
import { VisibilityIndicatorComponent } from './directives/ui/visibility-indicator/visibility-indicator.component';
|
||||
import { CorTableComponent } from './directives/ui/cor-table/cor-table.component';
|
||||
import { CorTableColumn } from './directives/ui/cor-table/cor-table-col.component';
|
||||
import { CorTabPanelComponent } from './directives/ui/cor-tabs/cor-tab-panel.component';
|
||||
import { CorTabContentComponent } from './directives/ui/cor-tabs/cor-tab-content.component';
|
||||
import { CorTabsComponent } from './directives/ui/cor-tabs/cor-tabs.component';
|
||||
import { CorTabComponent } from './directives/ui/cor-tabs/cor-tab.component';
|
||||
import { CorTabPaneComponent } from './directives/ui/cor-tabs/cor-tab-pane.component';
|
||||
import { CorTabCurrentHandlerFactory } from './directives/ui/cor-tabs/cor-tab-handlers';
|
||||
import { ChannelIconComponent } from './directives/ui/channel-icon/channel-icon.component';
|
||||
import { TagSigningDisplayComponent } from './directives/ui/tag-signing-display/tag-signing-display.component';
|
||||
import { RepositorySigningConfigComponent } from './directives/ui/repository-signing-config/repository-signing-config.component';
|
||||
|
@ -37,6 +31,7 @@ import { MarkdownEditorComponent } from './directives/ui/markdown/markdown-edito
|
|||
import { BrowserPlatform, browserPlatform } from './constants/platform.constant';
|
||||
import { ManageTriggerComponent } from './directives/ui/manage-trigger/manage-trigger.component';
|
||||
import { ClipboardCopyDirective } from './directives/ui/clipboard-copy/clipboard-copy.directive';
|
||||
import { CorTabsModule } from './directives/ui/cor-tabs/cor-tabs.module';
|
||||
import { Converter, ConverterOptions } from 'showdown';
|
||||
import * as Clipboard from 'clipboard';
|
||||
|
||||
|
@ -48,6 +43,7 @@ import * as Clipboard from 'clipboard';
|
|||
imports: [
|
||||
QuayRoutesModule,
|
||||
QuayConfigModule,
|
||||
CorTabsModule,
|
||||
],
|
||||
declarations: [
|
||||
RegexMatchViewComponent,
|
||||
|
@ -71,11 +67,6 @@ import * as Clipboard from 'clipboard';
|
|||
MarkdownEditorComponent,
|
||||
SearchBoxComponent,
|
||||
TypeaheadDirective,
|
||||
CorTabPanelComponent,
|
||||
CorTabContentComponent,
|
||||
CorTabsComponent,
|
||||
CorTabComponent,
|
||||
CorTabPaneComponent,
|
||||
ManageTriggerComponent,
|
||||
ClipboardCopyDirective,
|
||||
],
|
||||
|
@ -88,7 +79,6 @@ import * as Clipboard from 'clipboard';
|
|||
{provide: 'fileReaderFactory', useValue: () => new FileReader()},
|
||||
{provide: 'markdownConverterFactory', useValue: (options?: ConverterOptions) => new Converter(options)},
|
||||
{provide: 'BrowserPlatform', useValue: browserPlatform},
|
||||
{provide: 'CorTabCurrentHandlerFactory', useValue: CorTabCurrentHandlerFactory},
|
||||
{provide: 'clipboardFactory', useValue: (trigger, options) => new Clipboard(trigger, options)},
|
||||
],
|
||||
})
|
||||
|
|
|
@ -15,7 +15,7 @@
|
|||
</span>
|
||||
</div>
|
||||
|
||||
<cor-tab-panel vertical-tabs="true">
|
||||
<cor-tab-panel orientation="vertical" cor-nav-tabs>
|
||||
<cor-tabs>
|
||||
<cor-tab tab-title="Layers" tab-id="layers">
|
||||
<i class="fa ci-layers"></i>
|
||||
|
|
|
@ -20,7 +20,7 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<cor-tab-panel vertical-tabs="true">
|
||||
<cor-tab-panel orientation="vertical" cor-nav-tabs>
|
||||
<cor-tabs>
|
||||
<cor-tab tab-title="Settings" tab-id="settings">
|
||||
<i class="fa fa-gear"></i>
|
||||
|
|
|
@ -20,7 +20,9 @@
|
|||
<div class="repo-list-view padded" namespaces="[organization]"> </div>
|
||||
</div>
|
||||
|
||||
<cor-tab-panel ng-if="!user.anonymous && isMember" vertical-tabs="true">
|
||||
<cor-tab-panel ng-if="!user.anonymous && isMember"
|
||||
orientation="vertical"
|
||||
cor-nav-tabs>
|
||||
<cor-tabs quay-show="isMember">
|
||||
<cor-tab tab-active="true" tab-title="Repositories" tab-id="repos">
|
||||
<i class="fa fa-hdd-o"></i>
|
||||
|
|
|
@ -27,7 +27,7 @@
|
|||
</span>
|
||||
</div>
|
||||
|
||||
<cor-tab-panel vertical-tabs="true">
|
||||
<cor-tab-panel orientation="vertical" cor-nav-tabs>
|
||||
<cor-tabs>
|
||||
<cor-tab tab-title="Information" tab-id="info"
|
||||
tab-init="showInfo()">
|
||||
|
@ -111,7 +111,7 @@
|
|||
is-enabled="settingsShown"></div>
|
||||
</cor-tab-pane>
|
||||
</cor-tab-content>
|
||||
</cor-tabs>
|
||||
</cor-tab-panel>
|
||||
</cor-tab-panel>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -22,7 +22,7 @@
|
|||
<span class="cor-title-content">Quay Enterprise Management</span>
|
||||
</div>
|
||||
|
||||
<cor-tab-panel vertical-tabs="true">
|
||||
<cor-tab-panel orientation="vertical" cor-nav-tabs>
|
||||
<cor-tabs>
|
||||
<cor-tab tab-title="Manage Users"
|
||||
tab-id="users" tab-init="loadUsers()">
|
||||
|
|
|
@ -20,7 +20,9 @@
|
|||
<div class="repo-list-view padded" namespaces="[context.viewuser]"> </div>
|
||||
</div>
|
||||
|
||||
<cor-tab-panel ng-if="context.viewuser.is_me" vertical-tabs="true">
|
||||
<cor-tab-panel ng-if="context.viewuser.is_me"
|
||||
orientation="vertical"
|
||||
cor-nav-tabs>
|
||||
<cor-tabs quay-show="context.viewuser.is_me">
|
||||
<cor-tab tab-active="true" tab-title="Repositories" tab-id="repos">
|
||||
<i class="fa fa-hdd-o"></i>
|
||||
|
|
Binary file not shown.
Reference in a new issue