From 9da1487bbce27eb76e18d1d1cd3fb7d9471034cf Mon Sep 17 00:00:00 2001 From: alecmerdler Date: Mon, 29 May 2017 15:39:14 -0700 Subject: [PATCH] improve cor-tabs architecture --- static/directives/credentials-dialog.html | 2 +- .../app-public-view.component.html | 2 +- .../cor-cookie-tabs.directive.spec.ts | 61 +++++++++ .../cor-cookie-tabs.directive.ts | 27 ++++ .../cor-nav-tabs.directive.spec.ts | 73 ++++++++++ .../cor-nav-tabs/cor-nav-tabs.directive.ts | 29 ++++ .../cor-tab-content.component.html | 0 .../cor-tab-content.component.ts | 6 +- .../ui/cor-tabs/cor-tab-handlers.ts | 83 ------------ .../ui/cor-tabs/cor-tab-pane.component.ts | 31 ----- .../cor-tab-pane.component.html | 0 .../cor-tab-pane.component.spec.ts | 40 ++++++ .../cor-tab-pane/cor-tab-pane.component.ts | 32 +++++ .../ui/cor-tabs/cor-tab-panel.component.ts | 128 ------------------ .../cor-tab-panel.component.html | 0 .../cor-tab-panel.component.spec.ts | 111 +++++++++++++++ .../cor-tab-panel/cor-tab-panel.component.ts | 60 ++++++++ .../ui/cor-tabs/cor-tab.component.html | 11 -- .../ui/cor-tabs/cor-tab.component.ts | 46 ------- .../cor-tabs/cor-tab/cor-tab.component.html | 13 ++ .../cor-tab/cor-tab.component.spec.ts | 76 +++++++++++ .../ui/cor-tabs/cor-tab/cor-tab.component.ts | 51 +++++++ .../ui/cor-tabs/cor-tabs.component.ts | 6 +- .../directives/ui/cor-tabs/cor-tabs.module.ts | 33 +++++ static/js/quay.module.ts | 14 +- static/partials/image-view.html | 2 +- static/partials/manage-application.html | 2 +- static/partials/org-view.html | 4 +- static/partials/repo-view.html | 6 +- static/partials/super-user.html | 2 +- static/partials/user-view.html | 4 +- test/data/test.db | Bin 1679360 -> 1687552 bytes 32 files changed, 629 insertions(+), 326 deletions(-) create mode 100644 static/js/directives/ui/cor-tabs/cor-cookie-tabs/cor-cookie-tabs.directive.spec.ts create mode 100644 static/js/directives/ui/cor-tabs/cor-cookie-tabs/cor-cookie-tabs.directive.ts create mode 100644 static/js/directives/ui/cor-tabs/cor-nav-tabs/cor-nav-tabs.directive.spec.ts create mode 100644 static/js/directives/ui/cor-tabs/cor-nav-tabs/cor-nav-tabs.directive.ts rename static/js/directives/ui/cor-tabs/{ => cor-tab-content}/cor-tab-content.component.html (100%) rename static/js/directives/ui/cor-tabs/{ => cor-tab-content}/cor-tab-content.component.ts (80%) delete mode 100644 static/js/directives/ui/cor-tabs/cor-tab-handlers.ts delete mode 100644 static/js/directives/ui/cor-tabs/cor-tab-pane.component.ts rename static/js/directives/ui/cor-tabs/{ => cor-tab-pane}/cor-tab-pane.component.html (100%) create mode 100644 static/js/directives/ui/cor-tabs/cor-tab-pane/cor-tab-pane.component.spec.ts create mode 100644 static/js/directives/ui/cor-tabs/cor-tab-pane/cor-tab-pane.component.ts delete mode 100644 static/js/directives/ui/cor-tabs/cor-tab-panel.component.ts rename static/js/directives/ui/cor-tabs/{ => cor-tab-panel}/cor-tab-panel.component.html (100%) create mode 100644 static/js/directives/ui/cor-tabs/cor-tab-panel/cor-tab-panel.component.spec.ts create mode 100644 static/js/directives/ui/cor-tabs/cor-tab-panel/cor-tab-panel.component.ts delete mode 100644 static/js/directives/ui/cor-tabs/cor-tab.component.html delete mode 100644 static/js/directives/ui/cor-tabs/cor-tab.component.ts create mode 100644 static/js/directives/ui/cor-tabs/cor-tab/cor-tab.component.html create mode 100644 static/js/directives/ui/cor-tabs/cor-tab/cor-tab.component.spec.ts create mode 100644 static/js/directives/ui/cor-tabs/cor-tab/cor-tab.component.ts create mode 100644 static/js/directives/ui/cor-tabs/cor-tabs.module.ts diff --git a/static/directives/credentials-dialog.html b/static/directives/credentials-dialog.html index c491741bb..2707c0971 100644 --- a/static/directives/credentials-dialog.html +++ b/static/directives/credentials-dialog.html @@ -6,7 +6,7 @@
- + diff --git a/static/js/directives/ui/app-public-view/app-public-view.component.html b/static/js/directives/ui/app-public-view/app-public-view.component.html index 753ac6293..dbb881347 100644 --- a/static/js/directives/ui/app-public-view/app-public-view.component.html +++ b/static/js/directives/ui/app-public-view/app-public-view.component.html @@ -10,7 +10,7 @@
- + diff --git a/static/js/directives/ui/cor-tabs/cor-cookie-tabs/cor-cookie-tabs.directive.spec.ts b/static/js/directives/ui/cor-tabs/cor-cookie-tabs/cor-cookie-tabs.directive.spec.ts new file mode 100644 index 000000000..b7e237998 --- /dev/null +++ b/static/js/directives/ui/cor-tabs/cor-cookie-tabs/cor-cookie-tabs.directive.spec.ts @@ -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; + var cookieServiceMock: Mock; + var activeTab: BehaviorSubject; + + beforeEach(() => { + activeTab = new BehaviorSubject(null); + spyOn(activeTab, "subscribe").and.returnValue(null); + panelMock = new Mock(); + panelMock.setup(mock => mock.activeTab).is(activeTab); + cookieServiceMock = new Mock(); + 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((panelMock.Object.activeTab.subscribe)).toHaveBeenCalled(); + }); + + it("calls cookie service to put new permanent cookie on active tab changes", () => { + const tabId: string = "description"; + (panelMock.Object.activeTab.subscribe).calls.argsFor(0)[0](tabId); + + expect((cookieServiceMock.Object.putPermanent).calls.argsFor(0)[0]).toEqual(directive.cookieName); + expect((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((cookieServiceMock.Object.get).calls.argsFor(0)[0]).toEqual(directive.cookieName); + }); + + it("emits retrieved tab id as next active tab", () => { + directive.ngAfterContentInit(); + + expect((panelMock.Object.activeTab.next).calls.argsFor(0)[0]).toEqual(tabId); + }); + }); +}); diff --git a/static/js/directives/ui/cor-tabs/cor-cookie-tabs/cor-cookie-tabs.directive.ts b/static/js/directives/ui/cor-tabs/cor-cookie-tabs/cor-cookie-tabs.directive.ts new file mode 100644 index 000000000..2cd429d48 --- /dev/null +++ b/static/js/directives/ui/cor-tabs/cor-cookie-tabs/cor-cookie-tabs.directive.ts @@ -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); + } +} diff --git a/static/js/directives/ui/cor-tabs/cor-nav-tabs/cor-nav-tabs.directive.spec.ts b/static/js/directives/ui/cor-tabs/cor-nav-tabs/cor-nav-tabs.directive.spec.ts new file mode 100644 index 000000000..5676facdd --- /dev/null +++ b/static/js/directives/ui/cor-tabs/cor-nav-tabs/cor-nav-tabs.directive.spec.ts @@ -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; + var $locationMock: Mock; + var $rootScopeMock: Mock; + var activeTab: BehaviorSubject; + const tabId: string = "description"; + + beforeEach(() => { + activeTab = new BehaviorSubject(null); + spyOn(activeTab, "next").and.returnValue(null); + panelMock = new Mock(); + panelMock.setup(mock => mock.activeTab).is(activeTab); + $locationMock = new Mock(); + $locationMock.setup(mock => mock.search).is(() => {tab: tabId}); + $rootScopeMock = new Mock(); + $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(($rootScopeMock.Object.$on).calls.argsFor(0)[0]).toEqual("$routeUpdate"); + }); + + it("calls location service to retrieve tab id from URL query parameters on route update", () => { + ($rootScopeMock.Object.$on).calls.argsFor(0)[1](); + + expect($locationMock.Object.search).toHaveBeenCalled(); + }); + + it("emits retrieved tab id as next active tab on route update", () => { + ($rootScopeMock.Object.$on).calls.argsFor(0)[1](); + + expect((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(() => 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($locationMock.Object.search).toHaveBeenCalled(); + }); + + it("emits retrieved tab id as next active tab", () => { + directive.ngAfterContentInit(); + + expect((activeTab.next).calls.argsFor(0)[0]).toEqual(tabId); + }); + }); +}); diff --git a/static/js/directives/ui/cor-tabs/cor-nav-tabs/cor-nav-tabs.directive.ts b/static/js/directives/ui/cor-tabs/cor-nav-tabs/cor-nav-tabs.directive.ts new file mode 100644 index 000000000..2701f7058 --- /dev/null +++ b/static/js/directives/ui/cor-tabs/cor-nav-tabs/cor-nav-tabs.directive.ts @@ -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); + } +} diff --git a/static/js/directives/ui/cor-tabs/cor-tab-content.component.html b/static/js/directives/ui/cor-tabs/cor-tab-content/cor-tab-content.component.html similarity index 100% rename from static/js/directives/ui/cor-tabs/cor-tab-content.component.html rename to static/js/directives/ui/cor-tabs/cor-tab-content/cor-tab-content.component.html diff --git a/static/js/directives/ui/cor-tabs/cor-tab-content.component.ts b/static/js/directives/ui/cor-tabs/cor-tab-content/cor-tab-content.component.ts similarity index 80% rename from static/js/directives/ui/cor-tabs/cor-tab-content.component.ts rename to static/js/directives/ui/cor-tabs/cor-tab-content/cor-tab-content.component.ts index a79d7db5d..5877a441a 100644 --- a/static/js/directives/ui/cor-tabs/cor-tab-content.component.ts +++ b/static/js/directives/ui/cor-tabs/cor-tab-content/cor-tab-content.component.ts @@ -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 { + +} diff --git a/static/js/directives/ui/cor-tabs/cor-tab-handlers.ts b/static/js/directives/ui/cor-tabs/cor-tab-handlers.ts deleted file mode 100644 index a9eba4945..000000000 --- a/static/js/directives/ui/cor-tabs/cor-tab-handlers.ts +++ /dev/null @@ -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 {} -} \ No newline at end of file diff --git a/static/js/directives/ui/cor-tabs/cor-tab-pane.component.ts b/static/js/directives/ui/cor-tabs/cor-tab-pane.component.ts deleted file mode 100644 index 03fcc04c9..000000000 --- a/static/js/directives/ui/cor-tabs/cor-tab-pane.component.ts +++ /dev/null @@ -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; - } -} diff --git a/static/js/directives/ui/cor-tabs/cor-tab-pane.component.html b/static/js/directives/ui/cor-tabs/cor-tab-pane/cor-tab-pane.component.html similarity index 100% rename from static/js/directives/ui/cor-tabs/cor-tab-pane.component.html rename to static/js/directives/ui/cor-tabs/cor-tab-pane/cor-tab-pane.component.html diff --git a/static/js/directives/ui/cor-tabs/cor-tab-pane/cor-tab-pane.component.spec.ts b/static/js/directives/ui/cor-tabs/cor-tab-pane/cor-tab-pane.component.spec.ts new file mode 100644 index 000000000..46441848a --- /dev/null +++ b/static/js/directives/ui/cor-tabs/cor-tab-pane/cor-tab-pane.component.spec.ts @@ -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; + var activeTab: BehaviorSubject; + + beforeEach(() => { + activeTab = new BehaviorSubject(null); + spyOn(activeTab, "subscribe").and.returnValue(null); + panelMock = new Mock(); + 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((panelMock.Object.addTabPane).calls.argsFor(0)[0]).toBe(component); + }); + + it("subscribes to active tab changes", () => { + component.ngOnInit(); + + expect((panelMock.Object.activeTab.subscribe)).toHaveBeenCalled(); + }); + }); +}); diff --git a/static/js/directives/ui/cor-tabs/cor-tab-pane/cor-tab-pane.component.ts b/static/js/directives/ui/cor-tabs/cor-tab-pane/cor-tab-pane.component.ts new file mode 100644 index 000000000..95867aa3b --- /dev/null +++ b/static/js/directives/ui/cor-tabs/cor-tab-pane/cor-tab-pane.component.ts @@ -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); + }); + } +} diff --git a/static/js/directives/ui/cor-tabs/cor-tab-panel.component.ts b/static/js/directives/ui/cor-tabs/cor-tab-panel.component.ts deleted file mode 100644 index dc24c39b0..000000000 --- a/static/js/directives/ui/cor-tabs/cor-tab-panel.component.ts +++ /dev/null @@ -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); - } -} diff --git a/static/js/directives/ui/cor-tabs/cor-tab-panel.component.html b/static/js/directives/ui/cor-tabs/cor-tab-panel/cor-tab-panel.component.html similarity index 100% rename from static/js/directives/ui/cor-tabs/cor-tab-panel.component.html rename to static/js/directives/ui/cor-tabs/cor-tab-panel/cor-tab-panel.component.html diff --git a/static/js/directives/ui/cor-tabs/cor-tab-panel/cor-tab-panel.component.spec.ts b/static/js/directives/ui/cor-tabs/cor-tab-panel/cor-tab-panel.component.spec.ts new file mode 100644 index 000000000..c26634472 --- /dev/null +++ b/static/js/directives/ui/cor-tabs/cor-tab-panel/cor-tab-panel.component.spec.ts @@ -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(component.activeTab.subscribe).toHaveBeenCalled(); + }); + + it("emits output event for tab change when ", () => { + component.ngOnInit(); + const tabId: string = "description"; + (component.activeTab.subscribe).calls.argsFor(0)[0](tabId); + + expect((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((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(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((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(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); + }); + }); +}); diff --git a/static/js/directives/ui/cor-tabs/cor-tab-panel/cor-tab-panel.component.ts b/static/js/directives/ui/cor-tabs/cor-tab-panel/cor-tab-panel.component.ts new file mode 100644 index 000000000..3d3321054 --- /dev/null +++ b/static/js/directives/ui/cor-tabs/cor-tab-panel/cor-tab-panel.component.ts @@ -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 = new EventEmitter(); + + public basePath: string; + public activeTab: BehaviorSubject = 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'; + } +} diff --git a/static/js/directives/ui/cor-tabs/cor-tab.component.html b/static/js/directives/ui/cor-tabs/cor-tab.component.html deleted file mode 100644 index 56b97fccf..000000000 --- a/static/js/directives/ui/cor-tabs/cor-tab.component.html +++ /dev/null @@ -1,11 +0,0 @@ -
  • - - {{ ::$ctrl.tabTitle }} - - -
  • diff --git a/static/js/directives/ui/cor-tabs/cor-tab.component.ts b/static/js/directives/ui/cor-tabs/cor-tab.component.ts deleted file mode 100644 index 7697d757b..000000000 --- a/static/js/directives/ui/cor-tabs/cor-tab.component.ts +++ /dev/null @@ -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 = new EventEmitter(); - @Output() public tabShown: EventEmitter = new EventEmitter(); - @Output() public tabHidden: EventEmitter = 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); - } -} diff --git a/static/js/directives/ui/cor-tabs/cor-tab/cor-tab.component.html b/static/js/directives/ui/cor-tabs/cor-tab/cor-tab.component.html new file mode 100644 index 000000000..c10fbf0ef --- /dev/null +++ b/static/js/directives/ui/cor-tabs/cor-tab/cor-tab.component.html @@ -0,0 +1,13 @@ +
  • + + + {{ ::$ctrl.tabTitle }} + + +
  • diff --git a/static/js/directives/ui/cor-tabs/cor-tab/cor-tab.component.spec.ts b/static/js/directives/ui/cor-tabs/cor-tab/cor-tab.component.spec.ts new file mode 100644 index 000000000..ead20c980 --- /dev/null +++ b/static/js/directives/ui/cor-tabs/cor-tab/cor-tab.component.spec.ts @@ -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; + var activeTab: BehaviorSubject; + + beforeEach(() => { + activeTab = new BehaviorSubject(null); + spyOn(activeTab, "subscribe").and.returnValue(null); + panelMock = new Mock(); + 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((panelMock.Object.activeTab.subscribe)).toHaveBeenCalled(); + }); + + it("emits output event for tab init if it is new active tab", () => { + component.ngOnInit(); + (panelMock.Object.activeTab.subscribe).calls.argsFor(0)[0](component.tabId); + + expect(component.tabInit.emit).toHaveBeenCalled(); + }); + + it("emits output event for tab show if it is new active tab", () => { + component.ngOnInit(); + (panelMock.Object.activeTab.subscribe).calls.argsFor(0)[0](component.tabId); + + expect(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 + (panelMock.Object.activeTab.subscribe).calls.argsFor(0)[0](component.tabId); + (panelMock.Object.activeTab.subscribe).calls.argsFor(0)[0](newTabId); + + expect(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(); + (panelMock.Object.activeTab.subscribe).calls.argsFor(0)[0](newTabId); + + expect(component.tabHide.emit).not.toHaveBeenCalled(); + }); + + it("adds self as tab to panel", () => { + component.ngOnInit(); + + expect((panelMock.Object.addTab).calls.argsFor(0)[0]).toBe(component); + }); + }); +}); diff --git a/static/js/directives/ui/cor-tabs/cor-tab/cor-tab.component.ts b/static/js/directives/ui/cor-tabs/cor-tab/cor-tab.component.ts new file mode 100644 index 000000000..2bb832194 --- /dev/null +++ b/static/js/directives/ui/cor-tabs/cor-tab/cor-tab.component.ts @@ -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 = new EventEmitter(); + @Output() public tabShow: EventEmitter = new EventEmitter(); + @Output() public tabHide: EventEmitter = 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); + } + } +} diff --git a/static/js/directives/ui/cor-tabs/cor-tabs.component.ts b/static/js/directives/ui/cor-tabs/cor-tabs.component.ts index 7c7c60a61..85b709d09 100644 --- a/static/js/directives/ui/cor-tabs/cor-tabs.component.ts +++ b/static/js/directives/ui/cor-tabs/cor-tabs.component.ts @@ -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 { diff --git a/static/js/directives/ui/cor-tabs/cor-tabs.module.ts b/static/js/directives/ui/cor-tabs/cor-tabs.module.ts new file mode 100644 index 000000000..02570a1b0 --- /dev/null +++ b/static/js/directives/ui/cor-tabs/cor-tabs.module.ts @@ -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 { + +} diff --git a/static/js/quay.module.ts b/static/js/quay.module.ts index cce2b51da..c274af627 100644 --- a/static/js/quay.module.ts +++ b/static/js/quay.module.ts @@ -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)}, ], }) diff --git a/static/partials/image-view.html b/static/partials/image-view.html index 231a8ce0e..770965e9e 100644 --- a/static/partials/image-view.html +++ b/static/partials/image-view.html @@ -15,7 +15,7 @@ - + diff --git a/static/partials/manage-application.html b/static/partials/manage-application.html index 85c986c4a..52264bd45 100644 --- a/static/partials/manage-application.html +++ b/static/partials/manage-application.html @@ -20,7 +20,7 @@ - + diff --git a/static/partials/org-view.html b/static/partials/org-view.html index 8cfef7e10..8dad36c5e 100644 --- a/static/partials/org-view.html +++ b/static/partials/org-view.html @@ -20,7 +20,9 @@
     
    - + diff --git a/static/partials/repo-view.html b/static/partials/repo-view.html index af949ed0d..dbbb6e28a 100644 --- a/static/partials/repo-view.html +++ b/static/partials/repo-view.html @@ -27,7 +27,7 @@ - + @@ -111,7 +111,7 @@ is-enabled="settingsShown"> - - + + diff --git a/static/partials/super-user.html b/static/partials/super-user.html index 7ade80b23..d8f7d2154 100644 --- a/static/partials/super-user.html +++ b/static/partials/super-user.html @@ -22,7 +22,7 @@ Quay Enterprise Management - + diff --git a/static/partials/user-view.html b/static/partials/user-view.html index 4fc2e620c..e9ea15f99 100644 --- a/static/partials/user-view.html +++ b/static/partials/user-view.html @@ -20,7 +20,9 @@
     
    - + diff --git a/test/data/test.db b/test/data/test.db index 6514b43796d82fb48afc7e894b02477d22f96453..2dde19399bb4c06e10d48ae38768671c4dfcd86e 100644 GIT binary patch delta 7371 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 jQ*(nZt%$ugj&_-56M?l%vm5i zC#xe(IMmS#hf0a2@aZAhP(CV6#R;=y=Ve@X=Hx+E|@LAw1Fku#jm)H1>D4S zT*W-5b-d(`Vk~3R^d)zX0zM#nE|4A%Y`5s+Z`{XCpd2hS&NU05@~2{#1GE}Q<8yq3 ztCN1k>S!hh)O$_P{IX@j#8XWRZ zai!h-_5E#PAJ5SO+Wo43p6BQ`(1YK3@!#WXR+jKKj^iM@b+X{cS-t8+_qfkk%~-=| PW~^nbW3*8Bc=Y1G)qYh>