diff --git a/demo/src/app/app.component.html b/demo/src/app/app.component.html index 9e402a9..fa1c99a 100644 --- a/demo/src/app/app.component.html +++ b/demo/src/app/app.component.html @@ -1,5 +1,5 @@ diff --git a/demo/src/app/app.component.ts b/demo/src/app/app.component.ts index d938432..2267106 100644 --- a/demo/src/app/app.component.ts +++ b/demo/src/app/app.component.ts @@ -3,7 +3,7 @@ import { Component } from '@angular/core'; @Component({ selector: 'app-root', template: require('./app.component.html'), - styles: [require('./app.component.less')] + styles: [require('../styles.less'), require('./app.component.less')] }) export class AppComponent { title = 'demo'; diff --git a/demo/src/app/components/components-page/components-page.component.html b/demo/src/app/components/components-page/components-page.component.html index a18e80c..715c169 100644 --- a/demo/src/app/components/components-page/components-page.component.html +++ b/demo/src/app/components/components-page/components-page.component.html @@ -3,6 +3,7 @@ diff --git a/demo/src/app/components/components-page/components-page.component.less b/demo/src/app/components/components-page/components-page.component.less index f71d695..3b04cdd 100644 --- a/demo/src/app/components/components-page/components-page.component.less +++ b/demo/src/app/components/components-page/components-page.component.less @@ -19,7 +19,7 @@ background-color: #E3E8E8 } .nav-link.active { - background-color: lightgray !important; - font-weight: 500 !important; + background-color: lightgray; + font-weight: 500; } } diff --git a/demo/src/app/components/components-routing.module.ts b/demo/src/app/components/components-routing.module.ts index 49e541a..7227dba 100644 --- a/demo/src/app/components/components-routing.module.ts +++ b/demo/src/app/components/components-routing.module.ts @@ -2,6 +2,7 @@ import { NgModule } from '@angular/core'; import { RouterModule } from '@angular/router'; import { ComponentsPageComponent } from './components-page/components-page.component'; import { VmPageComponent } from './vm-page-component/vm-page.component'; +import { VappPageComponent } from './vapp-page-component/vapp-page.component'; import { MiscPageComponent } from './misc-page-component/misc-page.component'; const routes = [ @@ -17,6 +18,9 @@ const routes = [ { path: 'vm', component: VmPageComponent }, + { + path: 'vapp', component: VappPageComponent + }, { path: 'misc', component: MiscPageComponent } diff --git a/demo/src/app/components/components.module.ts b/demo/src/app/components/components.module.ts index 1d79e24..a4421bf 100644 --- a/demo/src/app/components/components.module.ts +++ b/demo/src/app/components/components.module.ts @@ -9,6 +9,8 @@ import { DemoComponent } from './demo-component/demo.component'; import { VmPageComponent } from './vm-page-component/vm-page.component'; import { VmCreateDemoComponent } from './vm-create-demo-component/vm-create-demo.component'; import { VmDeleteDemoComponent } from './vm-delete-demo-component/vm-delete-demo.component'; +import { VappPageComponent } from './vapp-page-component/vapp-page.component'; +import { VappStaticDemoComponent } from './vapp-static-demo-component/vapp-static-demo.component'; import { MiscPageComponent } from './misc-page-component/misc-page.component'; import { MiscScrollbarHorizontalDemoComponent } from './misc-scrollbar-demo-component/misc-scrollbar-horizontal-demo.component'; @@ -23,6 +25,8 @@ import { MiscScrollbarVerticalDemoComponent } from VmBasicDemoComponent, ComponentsPageComponent, DemoComponent, + VappPageComponent, + VappStaticDemoComponent, MiscPageComponent, MiscScrollbarHorizontalDemoComponent, MiscScrollbarVerticalDemoComponent diff --git a/demo/src/app/components/demo-component/demo.component.ts b/demo/src/app/components/demo-component/demo.component.ts index 20ce784..69364ac 100644 --- a/demo/src/app/components/demo-component/demo.component.ts +++ b/demo/src/app/components/demo-component/demo.component.ts @@ -42,6 +42,9 @@ export class DemoComponent implements OnInit { private activeButton: 'RESET' | 'RUN' = 'RUN'; ngOnInit(): void { + // apply this setting globally in the demo, so child components behave relatively to their parent component. + // disable on an individual basis by setting the item's applyMatrix property to `true` + paper.settings.applyMatrix = false; this.project = new paper.Project(this.canvas.nativeElement); this.backgroundColor = DEFAULT_BACKGROUND_COLOR; } diff --git a/demo/src/app/components/misc-page-component/misc-page.component.ts b/demo/src/app/components/misc-page-component/misc-page.component.ts index 5a9481e..814efd4 100644 --- a/demo/src/app/components/misc-page-component/misc-page.component.ts +++ b/demo/src/app/components/misc-page-component/misc-page.component.ts @@ -1,9 +1,9 @@ import { Component } from '@angular/core'; @Component({ - selector: 'other-page', + selector: 'misc-page', template: ` -
+
diff --git a/demo/src/app/components/misc-scrollbar-demo-component/misc-scrollbar-horizontal-demo.component.ts b/demo/src/app/components/misc-scrollbar-demo-component/misc-scrollbar-horizontal-demo.component.ts index 5ef0c59..af39197 100644 --- a/demo/src/app/components/misc-scrollbar-demo-component/misc-scrollbar-horizontal-demo.component.ts +++ b/demo/src/app/components/misc-scrollbar-demo-component/misc-scrollbar-horizontal-demo.component.ts @@ -8,7 +8,7 @@ import { DEFAULT_SCROLLBAR_THICKNESS } from '../../../../../src/constants/dimens @Component({ selector: 'misc-scrollbar-horizontal-demo', template: ` - ` }) @@ -23,10 +23,8 @@ export class MiscScrollbarHorizontalDemoComponent implements AfterViewInit { // sets up Paper Project const proj = this.demo.getProject(); proj.activate(); - proj.activeLayer.applyMatrix = false; this.demo.backgroundColor = CANVAS_BACKGROUND_COLOR; const view = paper.view; - const canvas = this.demo.canvas.nativeElement; const VIEW_PADDING = 30; // create content @@ -37,13 +35,13 @@ export class MiscScrollbarHorizontalDemoComponent implements AfterViewInit { : i % 15 === 0 && 'fizzbuzz' || i % 3 === 0 && 'fizz' || i % 5 === 0 && 'buzz' || i; content.addChildren([ new paper.Path.Circle({ - position: new paper.Point((100 + 15) * i + 50, view.center.y - 15), + position: new paper.Point((100 + 15) * i + 50, view.center.y), radius: 50, strokeWidth: 1, strokeColor: LIGHT_GREY }), new paper.PointText({ - point: new paper.Point((100 + 15) * i + 50, view.center.y + 10 - 15), + point: new paper.Point((100 + 15) * i + 50, view.center.y + 10), content: textContent, fillColor: LIGHT_GREY, fontSize: 25, @@ -54,21 +52,16 @@ export class MiscScrollbarHorizontalDemoComponent implements AfterViewInit { content.translate(new paper.Point(VIEW_PADDING, 0)); // create scrollbar - const scrollbar = new ScrollbarComponent( - { content: content, containerBounds: view.bounds, contentOffsetEnd: VIEW_PADDING }, - new paper.Point(VIEW_PADDING, view.bounds.bottom - VIEW_PADDING - DEFAULT_SCROLLBAR_THICKNESS), - view.bounds.width - VIEW_PADDING * 2, - 'horizontal' + const scrollbar = new ScrollbarComponent({ + content: content, + container: view.element, + containerBounds: view.bounds, + contentOffsetEnd: VIEW_PADDING + }, + new paper.Point(VIEW_PADDING, view.size.height - DEFAULT_SCROLLBAR_THICKNESS - 10), + view.bounds.width - VIEW_PADDING * 2 ); - // add scroll listening. paper doesn't have a wheel event handler - canvas.onwheel = (event: WheelEvent) => { - scrollbar.onScroll(event); - }; - // paper tools are global, so specific tools need to be activated when a different view is active - view.onMouseEnter = () => { - scrollbar.activateDefaultTool(); - }; } run() { diff --git a/demo/src/app/components/misc-scrollbar-demo-component/misc-scrollbar-vertical-demo.component.ts b/demo/src/app/components/misc-scrollbar-demo-component/misc-scrollbar-vertical-demo.component.ts index bd75978..9b681e1 100644 --- a/demo/src/app/components/misc-scrollbar-demo-component/misc-scrollbar-vertical-demo.component.ts +++ b/demo/src/app/components/misc-scrollbar-demo-component/misc-scrollbar-vertical-demo.component.ts @@ -1,15 +1,14 @@ import { AfterViewInit, Component, ViewChild } from '@angular/core'; import * as paper from 'paper'; import { DemoComponent } from '../demo-component/demo.component'; -import { LIGHT_GREY, CANVAS_BACKGROUND_COLOR, VAPP_BACKGROUND_COLOR } from '../../../../../src/constants/colors'; +import { LIGHT_GREY, CANVAS_BACKGROUND_COLOR } from '../../../../../src/constants/colors'; import { ScrollbarComponent } from '../../../../../src/components/scrollbar'; -import { DEFAULT_SCROLLBAR_THICKNESS } from '../../../../../src/constants/dimensions'; @Component({ selector: 'misc-scrollbar-vertical-demo', template: ` ` }) export class MiscScrollbarVerticalDemoComponent implements AfterViewInit { @@ -23,10 +22,8 @@ export class MiscScrollbarVerticalDemoComponent implements AfterViewInit { // sets up Paper Project const proj = this.demo.getProject(); proj.activate(); - proj.activeLayer.applyMatrix = false; this.demo.backgroundColor = CANVAS_BACKGROUND_COLOR; const view = paper.view; - const canvas = this.demo.canvas.nativeElement; const VIEW_PADDING = 30; // create content @@ -54,57 +51,17 @@ export class MiscScrollbarVerticalDemoComponent implements AfterViewInit { content.translate(new paper.Point(0, VIEW_PADDING)); // create scrollbar - const scrollbar = new ScrollbarComponent( - { content: content, containerBounds: view.bounds, contentOffsetEnd: VIEW_PADDING }, - new paper.Point(view.bounds.right - VIEW_PADDING - DEFAULT_SCROLLBAR_THICKNESS, VIEW_PADDING), + const scrollbar = new ScrollbarComponent({ + content: content, + container: view.element, + containerBounds: view.bounds, + contentOffsetEnd: VIEW_PADDING + }, + new paper.Point(view.bounds.right - VIEW_PADDING, VIEW_PADDING), view.bounds.height - VIEW_PADDING * 2, - 'vertical'); + 'vertical' + ); - // add scroll listening. paper doesn't have a wheel event handler - canvas.onwheel = (event: WheelEvent) => { - scrollbar.onScroll(event); - }; - // paper tools are global, so specific tools need to be activated when a different view is active - view.onMouseEnter = () => { - scrollbar.activateDefaultTool(); - }; - - scrollbar.getScrollbar().fillColor = 'red'; - scrollbar.getTrack().fillColor = 'blue'; - - // set up custom scrollbar - const customScrollbar = new paper.Path.Rectangle({ - point: new paper.Point(-6.5, 0), - size: new paper.Size(15, 15), - pivot: new paper.Point(0, 0), - radius: 15 / 2, - fillColor: LIGHT_GREY - }); - customScrollbar.remove(); - scrollbar.setScrollbar(customScrollbar); - - // set up custom track - const customTrack = new paper.Path.Rectangle({ - point: new paper.Point(0, 0), - size: new paper.Size(2, view.bounds.height - VIEW_PADDING * 2), - fillColor: VAPP_BACKGROUND_COLOR - }); - customTrack.remove(); - scrollbar.setTrack(customTrack); - - // set custom Effects - (scrollbar.getScrollbar() as paper.Path).opacity = 1; - scrollbar.disableDefaultEffects(); - scrollbar.setCustomEffects({ - setActive: () => { - (scrollbar.getScrollbar() as paper.Path).fillColor = 'DeepSkyBlue'; - }, - setNormal: () => { - (scrollbar.getScrollbar() as any).tweenTo({ - fillColor: LIGHT_GREY - }, 250); - } - }); } run() { diff --git a/demo/src/app/components/vapp-page-component/vapp-page.component.ts b/demo/src/app/components/vapp-page-component/vapp-page.component.ts new file mode 100644 index 0000000..5e42f3b --- /dev/null +++ b/demo/src/app/components/vapp-page-component/vapp-page.component.ts @@ -0,0 +1,12 @@ +import { Component } from '@angular/core'; + +@Component({ + selector: 'vapp-demo', + template: ` +
+ +
+ ` +}) +export class VappPageComponent { +} diff --git a/demo/src/app/components/vapp-static-demo-component/vapp-static-demo.component.ts b/demo/src/app/components/vapp-static-demo-component/vapp-static-demo.component.ts new file mode 100644 index 0000000..583bdd6 --- /dev/null +++ b/demo/src/app/components/vapp-static-demo-component/vapp-static-demo.component.ts @@ -0,0 +1,57 @@ +import { AfterViewInit, Component, ViewChild } from '@angular/core'; +import * as paper from 'paper'; +import { DemoComponent } from '../demo-component/demo.component'; +import { VappData, VappComponent } from '../../../../../src/components/vapp'; +import { placeholderArrayOfVappData } from '../../constants/vapp-static-placeholder-data'; +import { ScrollbarComponent } from '../../../../../src/components/scrollbar'; +import { CANVAS_BACKGROUND_COLOR } from '../../../../../src/constants/colors'; +import { CONNECTOR_RADIUS, DEFAULT_SCROLLBAR_THICKNESS } from '../../../../../src/constants/dimensions'; + +@Component({ + selector: 'vapp-static-demo', + template: ` + + ` }) +export class VappStaticDemoComponent implements AfterViewInit { + + @ViewChild(DemoComponent) + demo: DemoComponent; + + ngAfterViewInit() { + // sets up Paper Project + const proj = this.demo.getProject(); + proj.activate(); + const view = proj.view; + this.demo.backgroundColor = CANVAS_BACKGROUND_COLOR; + + const VIEW_PADDING = 30; + const DEMO_VAPP_TOP_ALIGNMENT = 59; + const VERTICAL_POSITION = VIEW_PADDING + DEMO_VAPP_TOP_ALIGNMENT + CONNECTOR_RADIUS; + const vapps: Array = placeholderArrayOfVappData; + + const content = new paper.Group(); + + // create vapps + vapps.forEach(vappData => { + const position = new paper.Point( + content.lastChild ? content.lastChild.bounds.right : VIEW_PADDING, VERTICAL_POSITION); + content.addChild(new VappComponent(vappData, position)); + }); + (content.lastChild as VappComponent).margin.right = 0; + + // create view horizontal scrollbar + const horizontalScrollbar = new ScrollbarComponent({ + content: content, + container: view.element, + containerBounds: view.bounds, + contentOffsetEnd: VIEW_PADDING + }, + new paper.Point(VIEW_PADDING, view.size.height - DEFAULT_SCROLLBAR_THICKNESS - 10), + view.bounds.width - VIEW_PADDING * 2, + 'horizontal' + ); + ScrollbarComponent.defaultScrollbar = horizontalScrollbar; + + } +} diff --git a/demo/src/app/components/vm-basic-demo-component/vm-basic-demo.component.ts b/demo/src/app/components/vm-basic-demo-component/vm-basic-demo.component.ts index 17d7669..c909c9b 100644 --- a/demo/src/app/components/vm-basic-demo-component/vm-basic-demo.component.ts +++ b/demo/src/app/components/vm-basic-demo-component/vm-basic-demo.component.ts @@ -21,69 +21,91 @@ export class VmBasicDemoComponent implements AfterViewInit { proj1.activate(); // tslint:disable-next-line new VmComponent({ - name: 'ubuntu', uuid: '', - operatingSystem: 'ubuntu64Guest' + name: 'ubuntu', + vapp_uuid: '', + operatingSystem: 'ubuntu64Guest', + vnics: [] }, new paper.Point(15, 15), true); // tslint:disable-next-line new VmComponent({ - name: 'fedora', uuid: '', - operatingSystem: 'fedora64Guest' + name: 'fedora', + vapp_uuid: '', + operatingSystem: 'fedora64Guest', + vnics: [] }, new paper.Point(15, 55), true); // tslint:disable-next-line new VmComponent({ - name: 'windows', uuid: '', - operatingSystem: 'windows7Guest' + name: 'windows', + vapp_uuid: '', + operatingSystem: 'windows7Guest', + vnics: [] }, new paper.Point(15, 95), true); // tslint:disable-next-line new VmComponent({ - name: 'windows xp', uuid: '', - operatingSystem: 'winXPHomeGuest' + name: 'windows xp', + vapp_uuid: '', + operatingSystem: 'winXPHomeGuest', + vnics: [] }, new paper.Point(15, 135), true); // tslint:disable-next-line new VmComponent({ - name: 'debian', uuid: '', - operatingSystem: 'debian8Guest' + name: 'debian', + vapp_uuid: '', + operatingSystem: 'debian8Guest', + vnics: [] }, new paper.Point(15, 175), true); // tslint:disable-next-line new VmComponent({ - name: 'redhat', uuid: '', - operatingSystem: 'redhatGuest' + name: 'redhat', + vapp_uuid: '', + operatingSystem: 'redhatGuest', + vnics: [] }, new paper.Point(15, 215), true); // tslint:disable-next-line new VmComponent({ - name: 'generic linux', uuid: '', - operatingSystem: 'other24xLinux64Guest' + name: 'generic linux', + vapp_uuid: '', + operatingSystem: 'other24xLinux64Guest', + vnics: [] }, new paper.Point(15, 255), true); // tslint:disable-next-line new VmComponent({ - name: 'centos', uuid: '', - operatingSystem: 'centos64Guest' + name: 'centos', + vapp_uuid: '', + operatingSystem: 'centos64Guest', + vnics: [] }, new paper.Point(15, 295), true); // tslint:disable-next-line new VmComponent({ - name: 'free bsd', uuid: '', - operatingSystem: 'freebsd64Guest' + name: 'free bsd', + vapp_uuid: '', + operatingSystem: 'freebsd64Guest', + vnics: [] }, new paper.Point(15, 335), true); // tslint:disable-next-line new VmComponent({ - name: 'core os', uuid: '', - operatingSystem: 'coreos64Guest' + name: 'core os', + vapp_uuid: '', + operatingSystem: 'coreos64Guest', + vnics: [] }, new paper.Point(15, 375), true); // tslint:disable-next-line new VmComponent({ - name: 'generic operating system with long name', uuid: '', - operatingSystem: 'other' as OperatingSystem + name: 'generic operating system with long name', + vapp_uuid: '', + operatingSystem: 'other' as OperatingSystem, + vnics: [] }, new paper.Point(15, 415), true); } diff --git a/demo/src/app/components/vm-create-demo-component/vm-create-demo.component.ts b/demo/src/app/components/vm-create-demo-component/vm-create-demo.component.ts index 6eb9a1a..2aaf684 100644 --- a/demo/src/app/components/vm-create-demo-component/vm-create-demo.component.ts +++ b/demo/src/app/components/vm-create-demo-component/vm-create-demo.component.ts @@ -23,19 +23,25 @@ export class VmCreateDemoComponent implements AfterViewInit { const proj = this.demo.getProject(); proj.activate(); this.vmOne = new VmComponent({ - name: 'fedora', uuid: '', - operatingSystem: 'fedora64Guest' + name: 'fedora', + vapp_uuid: '', + operatingSystem: 'redhatGuest', + vnics: [] }, new paper.Point(15, 15)); this.vmTwo = new VmComponent({ - name: 'redhat linux vm', uuid: '', - operatingSystem: 'redhatGuest' + name: 'redhat linux vm', + vapp_uuid: '', + operatingSystem: 'windows7Guest', + vnics: [] }, new paper.Point(15, 55)); this.vmThree = new VmComponent({ - name: 'centos vm with a really long name', uuid: '', - operatingSystem: 'centos64Guest' + name: 'centos vm with a really long name', + vapp_uuid: '', + operatingSystem: 'centos64Guest', + vnics: [] }, new paper.Point(15, 95)); } diff --git a/demo/src/app/components/vm-delete-demo-component/vm-delete-demo.component.ts b/demo/src/app/components/vm-delete-demo-component/vm-delete-demo.component.ts index 589904e..4ccf66c 100644 --- a/demo/src/app/components/vm-delete-demo-component/vm-delete-demo.component.ts +++ b/demo/src/app/components/vm-delete-demo-component/vm-delete-demo.component.ts @@ -23,19 +23,25 @@ export class VmDeleteDemoComponent implements AfterViewInit { const proj = this.demo.getProject(); proj.activate(); this.vmOne = new VmComponent({ - name: 'fedora', uuid: '', - operatingSystem: 'fedora64Guest' + name: 'fedora', + vapp_uuid: '', + operatingSystem: 'fedora64Guest', + vnics: [] }, new paper.Point(15, 15), true); this.vmTwo = new VmComponent({ - name: 'redhat linux vm', uuid: '', - operatingSystem: 'redhatGuest' + name: 'redhat linux vm', + vapp_uuid: '', + operatingSystem: 'redhatGuest', + vnics: [] }, new paper.Point(15, 55), true); this.vmThree = new VmComponent({ - name: 'centos vm with a really long name', uuid: '', - operatingSystem: 'centos64Guest' + name: 'centos vm with a really long name', + vapp_uuid: '', + operatingSystem: 'centos64Guest', + vnics: [] }, new paper.Point(15, 95), true); } diff --git a/demo/src/app/constants/vapp-static-placeholder-data.ts b/demo/src/app/constants/vapp-static-placeholder-data.ts new file mode 100644 index 0000000..72f8375 --- /dev/null +++ b/demo/src/app/constants/vapp-static-placeholder-data.ts @@ -0,0 +1,2145 @@ +import { VappData } from '../../../../src/components/vapp'; +import { OperatingSystem } from 'iland-sdk'; + +/** + * Placeholder vApp data for the Vapp Static Demo + */ + + // 0. nat-routed vapp network + // 1. isolated vapp network + // 2. multiple isolated vapp networks + // 3. long label name + // 4. no vapp network + // 5. vapp network with no attached vms or vnics + // 6. vm with multiple unattached vnics + // 7. max amount of vnics + // 8. no vapp network or vnics + // 9. attached vnic that is disconnected + // 10. multiple nat-routed vapp networks + // 11. long vms list with scrollbar + // 12. long vms list and nat-routed vapp network with scrollbar + // 13. multiple vms with max amount of vnics - width edge case + // 14. many vms attached to their own network - width edge case + // 15. variations for vnics on multiple vapp networks + // 16. variations for unattached vnics + // 17. nat-routed vApp network with no attached vms and vnics + // 18. vapp with no vapp networks or vms + // 19. network-less vm in a list that has other vapp networks +export const placeholderArrayOfVappData: Array = [ + // 0. nat-routed vapp network + { + uuid: '', + name: 'Coke RES & BURST', + vapp_networks: [ + { + uuid: '0', + name: '172.16.55.0 Failover Network', + vapp_uuid: '', + fence_mode: 'NAT_ROUTED' + } + ], + vms: [ + { + uuid: '', + name: 'lin-hytrust-01', + vapp_uuid: '', + operatingSystem: 'windows7Guest', + vnics: [ + { + vnic_id: 0, + network_name: '172.16.55.0 Failover Network', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'Alert Resource Non Regression VM', + vapp_uuid: '', + operatingSystem: 'windows7Guest', + vnics: [ + { + vnic_id: 1, + network_name: '172.16.55.0 Failover Network', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'AutomatedSecurityTest1', + vapp_uuid: '', + operatingSystem: 'windows7Guest', + vnics: [ + { + vnic_id: 2, + network_name: '172.16.55.0 Failover Network', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'CatalogResourceNonRegression1', + vapp_uuid: '', + operatingSystem: 'ubuntu64Guest', + vnics: [ + { + vnic_id: 3, + network_name: '172.16.55.0 Failover Network', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'lin-hytrust-01', + vapp_uuid: '', + operatingSystem: 'windows7Guest', + vnics: [ + { + vnic_id: 4, + network_name: '172.16.55.0 Failover Network', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'windows-as-01', + vapp_uuid: '', + operatingSystem: 'ubuntu64Guest', + vnics: [ + { + vnic_id: 5, + network_name: '172.16.55.0 Failover Network', + is_connected: true + } + ] + } + ] + }, + // 1. isolated vapp network + { + uuid: '', + name: 'BC Test vApp', + vapp_networks: [ + { + uuid: '0', + name: 'JTRAN 172.16.100.0/24', + vapp_uuid: '', + fence_mode: 'ISOLATED' + } + ], + vms: [ + { + uuid: '', + name: 'AutomatedSecurityTest1', + vapp_uuid: '', + operatingSystem: 'windows7Guest', + vnics: [ + { + vnic_id: 0, + network_name: 'JTRAN 172.16.100.0/24', + is_connected: true + } + ] + } + ] + }, + // 2. multiple isolated vapp networks + { + uuid: '', + name: 'BC Test vApp', + vapp_networks: [ + { + uuid: '0', + name: 'JTRAN 172.16.100.0/24', + vapp_uuid: '', + fence_mode: 'ISOLATED' + }, + { + uuid: '1', + name: 'JTRAN 172.16.100.0/24 2', + vapp_uuid: '', + fence_mode: 'ISOLATED' + } + ], + vms: [ + { + uuid: '', + name: 'AutomatedSecurityTest1', + vapp_uuid: '', + operatingSystem: 'windows7Guest', + vnics: [ + { + vnic_id: 0, + network_name: 'JTRAN 172.16.100.0/24', + is_connected: true + }, + { + vnic_id: 0, + network_name: 'JTRAN 172.16.100.0/24 2', + is_connected: true + } + ] + } + ] + }, + // 3. long label name + { + uuid: '', + name: 'BillingResourceNonRegressionoooo', + vapp_networks: [ + { + uuid: '0', + name: 'A', + vapp_uuid: '', + fence_mode: 'BRIDGED' + }, + { + uuid: '1', + name: 'B', + vapp_uuid: '', + fence_mode: 'BRIDGED' + } + ], + vms: [ + { + uuid: '', + name: 'windows-as-01', + vapp_uuid: '', + operatingSystem: 'ubuntu64Guest', + vnics: [ + { + vnic_id: 0, + network_name: 'A', + is_connected: true + }, + { + vnic_id: 1, + network_name: 'B', + is_connected: true + } + ] + } + ] + }, + // 4. no vapp network + { + uuid: '', + name: 'Delete me build', + vapp_networks: [], + vms: [ + { + uuid: '', + name: 'Delete me VMs Lin', + vapp_uuid: '', + operatingSystem: 'windows7Guest', + vnics: [ + { + vnic_id: 0, + network_name: '', + is_connected: false + } + ] + } + ] + }, + // 5. vapp network with no attached vms or vnics + { + uuid: '', + name: 'Coke RES & BURST', + vapp_networks: [ + { + uuid: '0', + name: 'A', + vapp_uuid: '', + fence_mode: 'BRIDGED' + } + ], + vms: [] + }, + // 6. vm with multiple unattached vnics + { + uuid: '', + name: 'BC Test vApp', + vapp_networks: [ + { + uuid: '0', + name: 'A', + vapp_uuid: '', + fence_mode: 'BRIDGED' + } + ], + vms: [ + { + uuid: '', + name: 'AutomatedSecurityTest1', + vapp_uuid: '', + operatingSystem: 'windows7Guest', + vnics: [ + { + vnic_id: 0, + network_name: '', + is_connected: false + }, + { + vnic_id: 1, + network_name: 'A', + is_connected: true + }, + { + vnic_id: 2, + network_name: '', + is_connected: false + } + ] + } + ] + }, + // 7. max amount of vnics + { + uuid: '', + name: 'BC Test vApp', + vapp_networks: [ + { + uuid: '0', + name: 'A', + vapp_uuid: '', + fence_mode: 'BRIDGED' + } + ], + vms: [ + { + uuid: '', + name: 'AutomatedSecurityTest1', + vapp_uuid: '', + operatingSystem: 'windows7Guest', + vnics: [ + { + vnic_id: 0, + network_name: 'A', + is_connected: true + }, + { + vnic_id: 1, + network_name: '', + is_connected: false + }, + { + vnic_id: 2, + network_name: '', + is_connected: false + }, + { + vnic_id: 3, + network_name: '', + is_connected: false + }, + { + vnic_id: 4, + network_name: '', + is_connected: false + }, + { + vnic_id: 5, + network_name: '', + is_connected: false + }, + { + vnic_id: 6, + network_name: '', + is_connected: false + }, + { + vnic_id: 7, + network_name: '', + is_connected: false + }, + { + vnic_id: 8, + network_name: '', + is_connected: false + }, + { + vnic_id: 9, + network_name: '', + is_connected: false + } + ] + } + ] + }, + // 8. no vapp network or vnics + { + uuid: '', + name: 'BC Test vApp', + vapp_networks: [], + vms: [ + { + uuid: '0', + name: 'AutomatedSecurityTest1', + vapp_uuid: '', + operatingSystem: 'windows7Guest', + vnics: [] + } + ] + }, + // 9. attached vnic that is disconnected + { + uuid: '', + name: 'BC Test vApp', + vapp_networks: [ + { + uuid: '0', + name: 'A', + vapp_uuid: '', + fence_mode: 'BRIDGED' + } + ], + vms: [ + { + uuid: '', + name: 'AutomatedSecurityTest1', + vapp_uuid: '', + operatingSystem: 'windows7Guest', + vnics: [ + { + vnic_id: 0, + network_name: 'A', + is_connected: false + } + ] + } + ] + }, + // 10. multiple nat-routed vapp networks + { + uuid: '', + name: 'Coke RES & BURST', + vapp_networks: [ + { + uuid: '0', + name: '172.16.55.0 Failover Network 1', + vapp_uuid: '', + fence_mode: 'NAT_ROUTED' + }, + { + uuid: '1', + name: '172.16.55.0 Failover Network 2', + vapp_uuid: '', + fence_mode: 'NAT_ROUTED' + }, + { + uuid: '2', + name: '172.16.55.0 Failover Network 3', + vapp_uuid: '', + fence_mode: 'NAT_ROUTED' + } + ], + vms: [ + { + uuid: '', + name: 'lin-hytrust-01', + vapp_uuid: '', + operatingSystem: 'windows7Guest', + vnics: [ + { + vnic_id: 0, + network_name: '172.16.55.0 Failover Network 1', + is_connected: true + }, + { + vnic_id: 1, + network_name: '172.16.55.0 Failover Network 2', + is_connected: true + }, + { + vnic_id: 2, + network_name: '172.16.55.0 Failover Network 3', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'lin-hytrust-01', + vapp_uuid: '', + operatingSystem: 'windows7Guest', + vnics: [ + { + vnic_id: 0, + network_name: '172.16.55.0 Failover Network 1', + is_connected: true + }, + { + vnic_id: 1, + network_name: '172.16.55.0 Failover Network 2', + is_connected: true + }, + { + vnic_id: 2, + network_name: '172.16.55.0 Failover Network 3', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'lin-hytrust-01', + vapp_uuid: '', + operatingSystem: 'windows7Guest', + vnics: [ + { + vnic_id: 0, + network_name: '172.16.55.0 Failover Network 1', + is_connected: true + }, + { + vnic_id: 1, + network_name: '172.16.55.0 Failover Network 2', + is_connected: true + }, + { + vnic_id: 2, + network_name: '172.16.55.0 Failover Network 3', + is_connected: true + } + ] + } + ] + }, + // 11. long vms list with scrollbar + { + uuid: '', + name: 'Coke RES & BURST', + vapp_networks: [ + { + uuid: '0', + name: 'A', + vapp_uuid: '', + fence_mode: 'BRIDGED' + } + ], + vms: [ + { + uuid: '', + name: 'Alert Resource Non Regression VM', + vapp_uuid: '', + operatingSystem: 'windows7Guest', + vnics: [ + { + vnic_id: 0, + network_name: 'A', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'AutomatedSecurityTest1', + vapp_uuid: '', + operatingSystem: 'windows7Guest', + vnics: [ + { + vnic_id: 0, + network_name: 'A', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'CatalogResourceNonRegression1', + vapp_uuid: '', + operatingSystem: 'ubuntu64Guest', + vnics: [ + { + vnic_id: 0, + network_name: 'A', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'lin-hytrust-01', + vapp_uuid: '', + operatingSystem: 'windows7Guest', + vnics: [ + { + vnic_id: 0, + network_name: 'A', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'Alert Resource Non Regression VM', + vapp_uuid: '', + operatingSystem: 'windows7Guest', + vnics: [ + { + vnic_id: 0, + network_name: 'A', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'AutomatedSecurityTest1', + vapp_uuid: '', + operatingSystem: 'windows7Guest', + vnics: [ + { + vnic_id: 0, + network_name: 'A', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'CatalogResourceNonRegression1', + vapp_uuid: '', + operatingSystem: 'ubuntu64Guest', + vnics: [ + { + vnic_id: 0, + network_name: 'A', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'lin-hytrust-01', + vapp_uuid: '', + operatingSystem: 'windows7Guest', + vnics: [ + { + vnic_id: 0, + network_name: 'A', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'windows-as-01', + vapp_uuid: '', + operatingSystem: 'ubuntu64Guest', + vnics: [ + { + vnic_id: 0, + network_name: 'A', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'Alert Resource Non Regression VM', + vapp_uuid: '', + operatingSystem: 'windows7Guest', + vnics: [ + { + vnic_id: 0, + network_name: 'A', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'AutomatedSecurityTest1', + vapp_uuid: '', + operatingSystem: 'windows7Guest', + vnics: [ + { + vnic_id: 0, + network_name: 'A', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'CatalogResourceNonRegression1', + vapp_uuid: '', + operatingSystem: 'ubuntu64Guest', + vnics: [ + { + vnic_id: 0, + network_name: 'A', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'lin-hytrust-01', + vapp_uuid: '', + operatingSystem: 'windows7Guest', + vnics: [ + { + vnic_id: 0, + network_name: 'A', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'Alert Resource Non Regression VM', + vapp_uuid: '', + operatingSystem: 'windows7Guest', + vnics: [ + { + vnic_id: 0, + network_name: 'A', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'AutomatedSecurityTest1', + vapp_uuid: '', + operatingSystem: 'windows7Guest', + vnics: [ + { + vnic_id: 0, + network_name: 'A', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'CatalogResourceNonRegression1', + vapp_uuid: '', + operatingSystem: 'windows7Guest', + vnics: [ + { + vnic_id: 0, + network_name: 'A', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'CatalogResourceNonRegression1', + vapp_uuid: '', + operatingSystem: 'ubuntu64Guest', + vnics: [ + { + vnic_id: 0, + network_name: 'A', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'lin-hytrust-01', + vapp_uuid: '', + operatingSystem: 'windows7Guest', + vnics: [ + { + vnic_id: 0, + network_name: 'A', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'windows-as-01', + vapp_uuid: '', + operatingSystem: 'ubuntu64Guest', + vnics: [ + { + vnic_id: 0, + network_name: 'A', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'Alert Resource Non Regression VM', + vapp_uuid: '', + operatingSystem: 'windows7Guest', + vnics: [ + { + vnic_id: 0, + network_name: 'A', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'AutomatedSecurityTest1', + vapp_uuid: '', + operatingSystem: 'windows7Guest', + vnics: [ + { + vnic_id: 0, + network_name: 'A', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'CatalogResourceNonRegression1', + vapp_uuid: '', + operatingSystem: 'ubuntu64Guest', + vnics: [ + { + vnic_id: 0, + network_name: 'A', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'lin-hytrust-01', + vapp_uuid: '', + operatingSystem: 'windows7Guest', + vnics: [ + { + vnic_id: 0, + network_name: 'A', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'Alert Resource Non Regression VM', + vapp_uuid: '', + operatingSystem: 'windows7Guest', + vnics: [ + { + vnic_id: 0, + network_name: 'A', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'AutomatedSecurityTest1', + vapp_uuid: '', + operatingSystem: 'windows7Guest', + vnics: [ + { + vnic_id: 0, + network_name: 'A', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'CatalogResourceNonRegression1', + vapp_uuid: '', + operatingSystem: 'ubuntu64Guest', + vnics: [ + { + vnic_id: 0, + network_name: 'A', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'lin-hytrust-01', + vapp_uuid: '', + operatingSystem: 'windows7Guest', + vnics: [ + { + vnic_id: 0, + network_name: 'A', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'windows-as-01', + vapp_uuid: '', + operatingSystem: 'ubuntu64Guest', + vnics: [ + { + vnic_id: 0, + network_name: 'A', + is_connected: true + } + ] + } + ] + }, + // 12. long vms list and nat-routed vapp network with scrollbar + { + uuid: '', + name: 'Coke RES & BURST', + vapp_networks: [ + { + uuid: '0', + name: '172.16.55.0 Failover Network', + vapp_uuid: '', + fence_mode: 'NAT_ROUTED' + } + ], + vms: [ + { + uuid: '', + name: 'Alert Resource Non Regression VM', + vapp_uuid: '', + operatingSystem: 'windows7Guest', + vnics: [ + { + vnic_id: 0, + network_name: '172.16.55.0 Failover Network', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'AutomatedSecurityTest1', + vapp_uuid: '', + operatingSystem: 'windows7Guest', + vnics: [ + { + vnic_id: 0, + network_name: '172.16.55.0 Failover Network', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'CatalogResourceNonRegression1', + vapp_uuid: '', + operatingSystem: 'ubuntu64Guest', + vnics: [ + { + vnic_id: 0, + network_name: '172.16.55.0 Failover Network', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'lin-hytrust-01', + vapp_uuid: '', + operatingSystem: 'windows7Guest', + vnics: [ + { + vnic_id: 0, + network_name: '172.16.55.0 Failover Network', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'Alert Resource Non Regression VM', + vapp_uuid: '', + operatingSystem: 'windows7Guest', + vnics: [ + { + vnic_id: 0, + network_name: '172.16.55.0 Failover Network', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'AutomatedSecurityTest1', + vapp_uuid: '', + operatingSystem: 'windows7Guest', + vnics: [ + { + vnic_id: 0, + network_name: '172.16.55.0 Failover Network', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'CatalogResourceNonRegression1', + vapp_uuid: '', + operatingSystem: 'ubuntu64Guest', + vnics: [ + { + vnic_id: 0, + network_name: '172.16.55.0 Failover Network', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'lin-hytrust-01', + vapp_uuid: '', + operatingSystem: 'windows7Guest', + vnics: [ + { + vnic_id: 0, + network_name: '172.16.55.0 Failover Network', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'windows-as-01', + vapp_uuid: '', + operatingSystem: 'ubuntu64Guest', + vnics: [ + { + vnic_id: 0, + network_name: '172.16.55.0 Failover Network', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'Alert Resource Non Regression VM', + vapp_uuid: '', + operatingSystem: 'windows7Guest', + vnics: [ + { + vnic_id: 0, + network_name: '172.16.55.0 Failover Network', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'AutomatedSecurityTest1', + vapp_uuid: '', + operatingSystem: 'windows7Guest', + vnics: [ + { + vnic_id: 0, + network_name: '172.16.55.0 Failover Network', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'CatalogResourceNonRegression1', + vapp_uuid: '', + operatingSystem: 'ubuntu64Guest', + vnics: [ + { + vnic_id: 0, + network_name: '172.16.55.0 Failover Network', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'lin-hytrust-01', + vapp_uuid: '', + operatingSystem: 'windows7Guest', + vnics: [ + { + vnic_id: 0, + network_name: '172.16.55.0 Failover Network', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'Alert Resource Non Regression VM', + vapp_uuid: '', + operatingSystem: 'windows7Guest', + vnics: [ + { + vnic_id: 0, + network_name: '172.16.55.0 Failover Network', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'AutomatedSecurityTest1', + vapp_uuid: '', + operatingSystem: 'windows7Guest', + vnics: [ + { + vnic_id: 0, + network_name: '172.16.55.0 Failover Network', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'CatalogResourceNonRegression1', + vapp_uuid: '', + operatingSystem: 'ubuntu64Guest', + vnics: [ + { + vnic_id: 0, + network_name: '172.16.55.0 Failover Network', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'lin-hytrust-01', + vapp_uuid: '', + operatingSystem: 'windows7Guest', + vnics: [ + { + vnic_id: 0, + network_name: '172.16.55.0 Failover Network', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'windows-as-01', + vapp_uuid: '', + operatingSystem: 'ubuntu64Guest', + vnics: [ + { + vnic_id: 0, + network_name: '172.16.55.0 Failover Network', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'Alert Resource Non Regression VM', + vapp_uuid: '', + operatingSystem: 'windows7Guest', + vnics: [ + { + vnic_id: 0, + network_name: '172.16.55.0 Failover Network', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'AutomatedSecurityTest1', + vapp_uuid: '', + operatingSystem: 'windows7Guest', + vnics: [ + { + vnic_id: 0, + network_name: '172.16.55.0 Failover Network', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'CatalogResourceNonRegression1', + vapp_uuid: '', + operatingSystem: 'ubuntu64Guest', + vnics: [ + { + vnic_id: 0, + network_name: '172.16.55.0 Failover Network', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'lin-hytrust-01', + vapp_uuid: '', + operatingSystem: 'windows7Guest', + vnics: [ + { + vnic_id: 0, + network_name: '172.16.55.0 Failover Network', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'Alert Resource Non Regression VM', + vapp_uuid: '', + operatingSystem: 'windows7Guest', + vnics: [ + { + vnic_id: 0, + network_name: '172.16.55.0 Failover Network', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'AutomatedSecurityTest1', + vapp_uuid: '', + operatingSystem: 'windows7Guest', + vnics: [ + { + vnic_id: 0, + network_name: '172.16.55.0 Failover Network', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'CatalogResourceNonRegression1', + vapp_uuid: '', + operatingSystem: 'ubuntu64Guest', + vnics: [ + { + vnic_id: 0, + network_name: '172.16.55.0 Failover Network', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'lin-hytrust-01', + vapp_uuid: '', + operatingSystem: 'windows7Guest', + vnics: [ + { + vnic_id: 0, + network_name: '172.16.55.0 Failover Network', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'windows-as-01', + vapp_uuid: '', + operatingSystem: 'ubuntu64Guest', + vnics: [ + { + vnic_id: 0, + network_name: '172.16.55.0 Failover Network', + is_connected: true + } + ] + } + ] + }, + // 13. multiple vms with max amount of vnics - width edge case + { + uuid: '', + name: 'BillingResourceNonRegressionoooo', + vapp_networks: [ + { + uuid: '0', + name: '0', + vapp_uuid: '', + fence_mode: 'BRIDGED' + }, + { + uuid: '1', + name: '1', + vapp_uuid: '', + fence_mode: 'BRIDGED' + }, + { + uuid: '2', + name: '2', + vapp_uuid: '', + fence_mode: 'BRIDGED' + }, + { + uuid: '3', + name: '3', + vapp_uuid: '', + fence_mode: 'BRIDGED' + }, + { + uuid: '4', + name: '4', + vapp_uuid: '', + fence_mode: 'BRIDGED' + }, + { + uuid: '5', + name: '5', + vapp_uuid: '', + fence_mode: 'BRIDGED' + }, + { + uuid: '6', + name: '6', + vapp_uuid: '', + fence_mode: 'BRIDGED' + }, + { + uuid: '7', + name: '7', + vapp_uuid: '', + fence_mode: 'BRIDGED' + }, + { + uuid: '8', + name: '8', + vapp_uuid: '', + fence_mode: 'BRIDGED' + }, + { + uuid: '9', + name: '9', + vapp_uuid: '', + fence_mode: 'BRIDGED' + }, + { + uuid: '10', + name: '10', + vapp_uuid: '', + fence_mode: 'BRIDGED' + }, + { + uuid: '11', + name: '11', + vapp_uuid: '', + fence_mode: 'BRIDGED' + }, + { + uuid: '12', + name: '12', + vapp_uuid: '', + fence_mode: 'BRIDGED' + }, + { + uuid: '13', + name: '13', + vapp_uuid: '', + fence_mode: 'BRIDGED' + }, + { + uuid: '14', + name: '14', + vapp_uuid: '', + fence_mode: 'BRIDGED' + }, + { + uuid: '15', + name: '15', + vapp_uuid: '', + fence_mode: 'BRIDGED' + }, + { + uuid: '16', + name: '16', + vapp_uuid: '', + fence_mode: 'BRIDGED' + }, + { + uuid: '17', + name: '17', + vapp_uuid: '', + fence_mode: 'BRIDGED' + }, + { + uuid: '18', + name: '18', + vapp_uuid: '', + fence_mode: 'BRIDGED' + }, + { + uuid: '19', + name: '19', + vapp_uuid: '', + fence_mode: 'BRIDGED' + }, + { + uuid: '20', + name: '20', + vapp_uuid: '', + fence_mode: 'BRIDGED' + }, + { + uuid: '21', + name: '21', + vapp_uuid: '', + fence_mode: 'BRIDGED' + }, + { + uuid: '22', + name: '22', + vapp_uuid: '', + fence_mode: 'BRIDGED' + }, + { + uuid: '23', + name: '23', + vapp_uuid: '', + fence_mode: 'BRIDGED' + }, + { + uuid: '24', + name: '24', + vapp_uuid: '', + fence_mode: 'BRIDGED' + }, + { + uuid: '25', + name: '25', + vapp_uuid: '', + fence_mode: 'BRIDGED' + }, + { + uuid: '26', + name: '26', + vapp_uuid: '', + fence_mode: 'BRIDGED' + }, + { + uuid: '27', + name: '27', + vapp_uuid: '', + fence_mode: 'BRIDGED' + }, + { + uuid: '28', + name: '28', + vapp_uuid: '', + fence_mode: 'BRIDGED' + }, + { + uuid: '29', + name: '29', + vapp_uuid: '', + fence_mode: 'BRIDGED' + } + ], + vms: [ + { + uuid: '', + name: 'windows-as-01', + vapp_uuid: '', + operatingSystem: 'ubuntu64Guest', + vnics: [ + { + vnic_id: 0, + network_name: '0', + is_connected: true + }, + { + vnic_id: 1, + network_name: '1', + is_connected: true + }, + { + vnic_id: 2, + network_name: '2', + is_connected: true + }, + { + vnic_id: 3, + network_name: '3', + is_connected: true + }, + { + vnic_id: 4, + network_name: '4', + is_connected: true + }, + { + vnic_id: 5, + network_name: '5', + is_connected: true + }, + { + vnic_id: 6, + network_name: '6', + is_connected: true + }, + { + vnic_id: 7, + network_name: '7', + is_connected: true + }, + { + vnic_id: 8, + network_name: '8', + is_connected: true + }, + { + vnic_id: 9, + network_name: '9', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'AutomatedSecurityTest1', + vapp_uuid: '', + operatingSystem: 'windows7Guest', + vnics: [ + { + vnic_id: 0, + network_name: '10', + is_connected: true + }, + { + vnic_id: 1, + network_name: '11', + is_connected: true + }, + { + vnic_id: 2, + network_name: '12', + is_connected: true + }, + { + vnic_id: 3, + network_name: '13', + is_connected: true + }, + { + vnic_id: 4, + network_name: '14', + is_connected: true + }, + { + vnic_id: 5, + network_name: '15', + is_connected: true + }, + { + vnic_id: 6, + network_name: '16', + is_connected: true + }, + { + vnic_id: 7, + network_name: '17', + is_connected: true + }, + { + vnic_id: 8, + network_name: '18', + is_connected: true + }, + { + vnic_id: 9, + network_name: '19', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'AutomatedSecurityTest2', + vapp_uuid: '', + operatingSystem: 'windows7Guest', + vnics: [ + { + vnic_id: 0, + network_name: '20', + is_connected: true + }, + { + vnic_id: 1, + network_name: '21', + is_connected: true + }, + { + vnic_id: 2, + network_name: '22', + is_connected: true + }, + { + vnic_id: 3, + network_name: '23', + is_connected: true + }, + { + vnic_id: 4, + network_name: '24', + is_connected: true + }, + { + vnic_id: 5, + network_name: '25', + is_connected: true + }, + { + vnic_id: 6, + network_name: '26', + is_connected: true + }, + { + vnic_id: 7, + network_name: '27', + is_connected: true + }, + { + vnic_id: 8, + network_name: '28', + is_connected: true + }, + { + vnic_id: 9, + network_name: '29', + is_connected: true + } + ] + } + ] + }, + // 14. many vms attached to their own network - width edge case + { + uuid: '', + name: 'BillingResourceNonRegressionoooo', + vapp_networks: [ + { + uuid: '0', + name: '0', + vapp_uuid: '', + fence_mode: 'BRIDGED' + }, + { + uuid: '1', + name: '1', + vapp_uuid: '', + fence_mode: 'BRIDGED' + }, + { + uuid: '2', + name: '2', + vapp_uuid: '', + fence_mode: 'BRIDGED' + }, + { + uuid: '3', + name: '3', + vapp_uuid: '', + fence_mode: 'BRIDGED' + }, + { + uuid: '4', + name: '4', + vapp_uuid: '', + fence_mode: 'BRIDGED' + }, + { + uuid: '5', + name: '5', + vapp_uuid: '', + fence_mode: 'BRIDGED' + }, + { + uuid: '6', + name: '6', + vapp_uuid: '', + fence_mode: 'BRIDGED' + }, + { + uuid: '7', + name: '7', + vapp_uuid: '', + fence_mode: 'BRIDGED' + }, + { + uuid: '8', + name: '8', + vapp_uuid: '', + fence_mode: 'BRIDGED' + }, + { + uuid: '9', + name: '9', + vapp_uuid: '', + fence_mode: 'BRIDGED' + }, + { + uuid: '10', + name: '10', + vapp_uuid: '', + fence_mode: 'BRIDGED' + }, + { + uuid: '11', + name: '11', + vapp_uuid: '', + fence_mode: 'BRIDGED' + }, + { + uuid: '12', + name: '12', + vapp_uuid: '', + fence_mode: 'BRIDGED' + } + ], + vms: [ + { + uuid: '', + name: 'Alert Resource Non Regression VM', + vapp_uuid: '', + operatingSystem: 'windows7Guest', + vnics: [ + { + vnic_id: 0, + network_name: '0', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'AutomatedSecurityTest1', + vapp_uuid: '', + operatingSystem: 'windows7Guest', + vnics: [ + { + vnic_id: 0, + network_name: '1', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'CatalogResourceNonRegression1', + vapp_uuid: '', + operatingSystem: 'ubuntu64Guest', + vnics: [ + { + vnic_id: 0, + network_name: '2', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'lin-hytrust-01', + vapp_uuid: '', + operatingSystem: 'windows7Guest', + vnics: [ + { + vnic_id: 0, + network_name: '3', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'Alert Resource Non Regression VM', + vapp_uuid: '', + operatingSystem: 'windows7Guest', + vnics: [ + { + vnic_id: 0, + network_name: '4', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'AutomatedSecurityTest1', + vapp_uuid: '', + operatingSystem: 'windows7Guest', + vnics: [ + { + vnic_id: 0, + network_name: '5', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'CatalogResourceNonRegression1', + vapp_uuid: '', + operatingSystem: 'ubuntu64Guest', + vnics: [ + { + vnic_id: 0, + network_name: '6', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'lin-hytrust-01', + vapp_uuid: '', + operatingSystem: 'windows7Guest', + vnics: [ + { + vnic_id: 0, + network_name: '7', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'windows-as-01', + vapp_uuid: '', + operatingSystem: 'ubuntu64Guest', + vnics: [ + { + vnic_id: 0, + network_name: '8', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'Alert Resource Non Regression VM', + vapp_uuid: '', + operatingSystem: 'windows7Guest', + vnics: [ + { + vnic_id: 0, + network_name: '9', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'AutomatedSecurityTest1', + vapp_uuid: '', + operatingSystem: 'windows7Guest', + vnics: [ + { + vnic_id: 0, + network_name: '10', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'CatalogResourceNonRegression1', + vapp_uuid: '', + operatingSystem: 'ubuntu64Guest', + vnics: [ + { + vnic_id: 0, + network_name: '11', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'lin-hytrust-01', + vapp_uuid: '', + operatingSystem: 'windows7Guest', + vnics: [ + { + vnic_id: 0, + network_name: '12', + is_connected: true + } + ] + } + ] + }, + // 15. variations for vnics on multiple vapp networks + { + uuid: '', + name: 'BC Test vApp', + vapp_networks: [ + { + uuid: '0', + name: 'A', + vapp_uuid: '', + fence_mode: 'BRIDGED' + }, + { + uuid: '1', + name: 'B', + vapp_uuid: '', + fence_mode: 'BRIDGED' + }, + { + uuid: '2', + name: 'C', + vapp_uuid: '', + fence_mode: 'BRIDGED' + }, + { + uuid: '3', + name: 'D', + vapp_uuid: '', + fence_mode: 'BRIDGED' + } + ], + vms: [ + { + uuid: '', + name: 'windows-as-01', + vapp_uuid: '', + operatingSystem: 'ubuntu64Guest', + vnics: [ + { + vnic_id: 0, + network_name: 'A', + is_connected: true + }, + { + vnic_id: 1, + network_name: 'B', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'windows-as-02', + vapp_uuid: '', + operatingSystem: 'ubuntu64Guest', + vnics: [ + { + vnic_id: 0, + network_name: 'C', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'windows-as-03', + vapp_uuid: '', + operatingSystem: 'ubuntu64Guest', + vnics: [ + { + vnic_id: 0, + network_name: 'B', + is_connected: true + }, + { + vnic_id: 1, + network_name: 'D', + is_connected: true + } + ] + } + ] + }, + // 16. variations for unattached vnics + { + uuid: '', + name: 'BC Test vApp', + vapp_networks: [ + { + uuid: '1', + name: 'A', + vapp_uuid: '', + fence_mode: 'BRIDGED' + }, + { + uuid: '2', + name: 'B', + vapp_uuid: '', + fence_mode: 'BRIDGED' + }, + { + uuid: '3', + name: 'C', + vapp_uuid: '', + fence_mode: 'BRIDGED' + } + ], + vms: [ + { + uuid: '', + name: 'windows-as-01', + vapp_uuid: '', + operatingSystem: 'ubuntu64Guest', + vnics: [ + { + vnic_id: 0, + network_name: 'A', + is_connected: true + }, + { + vnic_id: 1, + network_name: '', + is_connected: false + }, + { + vnic_id: 2, + network_name: '', + is_connected: false + }, + { + vnic_id: 3, + network_name: '', + is_connected: false + } + ] + }, + { + uuid: '', + name: 'windows-as-02', + vapp_uuid: '', + operatingSystem: 'ubuntu64Guest', + vnics: [ + { + vnic_id: 0, + network_name: 'B', + is_connected: true + }, + { + vnic_id: 1, + network_name: '', + is_connected: false + } + ] + }, + { + uuid: '', + name: 'windows-as-03', + vapp_uuid: '', + operatingSystem: 'ubuntu64Guest', + vnics: [ + { + vnic_id: 0, + network_name: 'C', + is_connected: true + } + ] + } + ] + }, + // 17. nat-routed vApp network with no attached vms and vnics + { + uuid: '', + name: 'Coke RES & BURST', + vapp_networks: [ + { + uuid: '0', + name: '172.16.55.0 Failover Network 1', + vapp_uuid: '', + fence_mode: 'NAT_ROUTED' + } + ], + vms: [] + }, + // 18. vapp with no vapp networks or vms + { + uuid: '', + name: 'Coke RES & BURST', + vapp_networks: [], + vms: [] + }, + // 19. network-less vm in a list that has other vapp networks + { + uuid: '', + name: 'BC Test vApp', + vapp_networks: [ + { + uuid: '0', + name: 'A', + vapp_uuid: '', + fence_mode: 'BRIDGED' + }, + { + uuid: '1', + name: 'B', + vapp_uuid: '', + fence_mode: 'BRIDGED' + }, + { + uuid: '2', + name: 'C', + vapp_uuid: '', + fence_mode: 'BRIDGED' + } + ], + vms: [ + { + uuid: '', + name: 'redhat-as-01', + vapp_uuid: '', + operatingSystem: 'redhatGuest', + vnics: [ + { + vnic_id: 0, + network_name: 'A', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'debian-as-02', + vapp_uuid: '', + operatingSystem: 'debian8Guest', + vnics: [ + { + vnic_id: 0, + network_name: 'B', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'linux-as-03', + vapp_uuid: '', + operatingSystem: 'other24xLinux64Guest', + vnics: [ + { + vnic_id: 1, + network_name: 'C', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'arch-as-04', + vapp_uuid: '', + operatingSystem: 'btwIUseArch' as OperatingSystem, + vnics: [ + { + vnic_id: 0, + network_name: '', + is_connected: false + } + ] + } + ] + } +]; diff --git a/demo/src/styles.less b/demo/src/styles.less index 73ea989..5be7bb0 100644 --- a/demo/src/styles.less +++ b/demo/src/styles.less @@ -1,4 +1,7 @@ /* You can add global styles to this file, and also import other style files */ + +@import (css) url('https://fonts.googleapis.com/css?family=Roboto:400,500,700&display=swap'); + body { font-family: 'Roboto', sans-serif; } diff --git a/src/.DS_Store b/src/.DS_Store new file mode 100644 index 0000000..1ff6fd9 Binary files /dev/null and b/src/.DS_Store differ diff --git a/src/components/bullet-point-connection-icon.test.ts b/src/components/bullet-point-connection-icon.test.ts new file mode 100644 index 0000000..84861bf --- /dev/null +++ b/src/components/bullet-point-connection-icon.test.ts @@ -0,0 +1,26 @@ +import { BulletPointConnectionIconComponent } from './bullet-point-connection-icon'; +import * as paper from 'paper'; + +describe('bullet point connection icon component', () => { + + beforeAll(() => { + const canvasEl = document.createElement('canvas'); + paper.setup(canvasEl); + paper.settings.applyMatrix = false; + }); + + test('basic properties', () => { + const position = new paper.Point(0, 0); + const connector = new BulletPointConnectionIconComponent(); + expect(connector.position.x).toBe(position.x); + expect(connector.position.y).toBe(position.y); + }); + + test('custom position', () => { + const position = new paper.Point(42, -10); + const connector = new BulletPointConnectionIconComponent(position); + expect(connector.position.x).toBe(position.x); + expect(connector.position.y).toBe(position.y); + }); + +}); diff --git a/src/components/bullet-point-connection-icon.ts b/src/components/bullet-point-connection-icon.ts new file mode 100644 index 0000000..1df6616 --- /dev/null +++ b/src/components/bullet-point-connection-icon.ts @@ -0,0 +1,36 @@ +import * as paper from 'paper'; +import { LIGHT_GREY } from '../constants/colors'; +import { SMALL_CONNECTOR_SIZE } from '../constants/dimensions'; + +/** + * Bullet Point Connection Icon Visual Component. + * The small (7px) grey filled circle icon that represents special types of connections or bullet points for labels. + * Used for isolated vApp Network labels or at the end of vApp networks that have no attached VNICs. + */ +export class BulletPointConnectionIconComponent extends paper.Group { + + // the small filled circle icon + readonly _icon: paper.Path.Circle; + + /** + * Creates a new BulletPointConnectionIconComponent instance. + * + * @param _point the location that the bullet connection icon should be rendered at + */ + constructor(private _point: paper.Point = new paper.Point(0, 0)) { + super(); + this.position = _point; + this.pivot = new paper.Point(0, 0); + + this._icon = new paper.Path.Circle({ + position: new paper.Point(0, 0), + radius: SMALL_CONNECTOR_SIZE / 2, + fillColor: LIGHT_GREY, + parent: this + }); + } + + get icon(): paper.Path.Circle { + return this._icon; + } +} diff --git a/src/components/connection-icon.test.ts b/src/components/connection-icon.test.ts new file mode 100644 index 0000000..e73ddb5 --- /dev/null +++ b/src/components/connection-icon.test.ts @@ -0,0 +1,31 @@ +import { ConnectionIconComponent } from './connection-icon'; +import { VAPP_BACKGROUND_COLOR, CANVAS_BACKGROUND_COLOR } from '../constants/colors'; +import * as paper from 'paper'; + +describe('connection icon component', () => { + + beforeAll(() => { + const canvasEl = document.createElement('canvas'); + paper.setup(canvasEl); + paper.settings.applyMatrix = false; + }); + + test('basic properties', () => { + const position = new paper.Point(0, 0); + const defaultColor = new paper.Color(VAPP_BACKGROUND_COLOR); + const connector = new ConnectionIconComponent(); + expect(connector.position.x).toBe(position.x); + expect(connector.position.y).toBe(position.y); + expect((connector.icon.fillColor as paper.Color).equals(defaultColor)).toBe(true); + }); + + test('custom position and fill color', () => { + const position = new paper.Point(-20, 30); + const color = new paper.Color(CANVAS_BACKGROUND_COLOR); + const connector = new ConnectionIconComponent(position, color); + expect(connector.position.x).toBe(position.x); + expect(connector.position.y).toBe(position.y); + expect((connector.icon.fillColor as paper.Color).equals(color)).toBe(true); + }); + +}); diff --git a/src/components/connection-icon.ts b/src/components/connection-icon.ts new file mode 100644 index 0000000..ba8eb68 --- /dev/null +++ b/src/components/connection-icon.ts @@ -0,0 +1,42 @@ +import * as paper from 'paper'; +import { VAPP_BACKGROUND_COLOR } from '../constants/colors'; +import { CONNECTOR_RADIUS } from '../constants/dimensions'; +import { DEFAULT_STROKE_STYLE } from '../constants/styles'; + +/** + * Connection Icon Visual Component. + * The large (11px) open circle icon with a grey stroke that represents connections. Used for VNICs that are + * attached and connected to a vApp Network or for the vApp Network to Org-Vdc network connections. + */ +export class ConnectionIconComponent extends paper.Group { + + // the stroked and 'unfilled' circle icon + readonly _icon: paper.Path.Circle; + + /** + * Creates a new ConnectionIconComponent instance. + * + * @param _point the location that the icon should be rendered at + * @param _fillColor the inner fill color that usually matches the background color of the element it is on top of + * to make it seem 'unfilled' + */ + constructor(private _point: paper.Point = new paper.Point(0, 0), + private _fillColor: paper.Color | string = VAPP_BACKGROUND_COLOR) { + super(); + this.position = _point; + this.pivot = new paper.Point(0, 0); + + this._icon = new paper.Path.Circle( + { + position: new paper.Point(0, 0), + radius: CONNECTOR_RADIUS, + style: DEFAULT_STROKE_STYLE, + fillColor: this._fillColor, + parent: this + }); + } + + get icon(): paper.Path.Circle { + return this._icon; + } +} diff --git a/src/components/entity-label.test.ts b/src/components/entity-label.test.ts new file mode 100644 index 0000000..eca0487 --- /dev/null +++ b/src/components/entity-label.test.ts @@ -0,0 +1,37 @@ +import { EntityLabelComponent } from './entity-label'; +import * as paper from 'paper'; +import { LABEL_HORIZONTAL_PADDING } from '../constants/dimensions'; + +describe('entity label component', () => { + + beforeAll(() => { + const canvasEl = document.createElement('canvas'); + paper.setup(canvasEl); + paper.settings.applyMatrix = false; + }); + + test('basic properties', () => { + const text = 'foobar'; + const position = new paper.Point(0, 0); + const color = new paper.Color('red'); + const label = new EntityLabelComponent(text, color); + expect(label.position.x).toBe(position.x); + expect(label.position.y).toBe(position.y); + expect(label.icon.fillColor).toBe(color); + }); + + test('textOptions', () => { + const text = 'foobar'; + const position = new paper.Point(10, 90); + const color = new paper.Color('blue'); + const textOptions = { fontWeight: 'bold' }; + const label = new EntityLabelComponent(text, color, position, textOptions); + expect(label.position.x).toBe(position.x); + expect(label.position.y).toBe(position.y); + expect(label.icon.fillColor).toBe(color); + expect(label.text.fontWeight).toBe(textOptions.fontWeight); + expect(label.bounds.right - LABEL_HORIZONTAL_PADDING) + .toBe(label.localToGlobal(label.text.bounds.bottomRight).x); + }); + +}); diff --git a/src/components/entity-label.ts b/src/components/entity-label.ts new file mode 100644 index 0000000..4be438c --- /dev/null +++ b/src/components/entity-label.ts @@ -0,0 +1,52 @@ +import * as paper from 'paper'; +import { LABEL_HORIZONTAL_PADDING } from '../constants/dimensions'; +import { LabelComponent } from './label'; +import { TextOptions } from './label-text'; + +const ICON_SIZE = 10; +const ICON_MARGIN = 10; + +/** + * Entity Label Visual Component. + */ +export class EntityLabelComponent extends LabelComponent { + + // the entity icon + readonly _icon: paper.Path.Rectangle; + + /** + * Creates a new EntityLabelComponent instance. + * + * @param _text the text to be displayed on the label + * @param iconColor the icon color specific to the entity type + * @param _point the location that the entity label should be rendered at + * @param textOptions the paper.PointText options object to customize the text + */ + constructor(protected _text: string, + protected iconColor: paper.Color | string, + protected _point: paper.Point = new paper.Point(0, 0), + protected textOptions: TextOptions = {}) { + super(_text, _point, textOptions); + this.pivot = new paper.Point(0, 0); + + this._icon = new paper.Path.Rectangle({ + rectangle: new paper.Rectangle(0, 0, ICON_SIZE, ICON_SIZE), + radius: 2, + pivot: new paper.Point(0, 0), + position: new paper.Point(ICON_MARGIN, ICON_MARGIN), + fillColor: this.iconColor, + parent: this + }); + + // reposition and resize other elements to fit the icon + this._label.position.x = this._icon.bounds.right + LABEL_HORIZONTAL_PADDING; + this._background.bounds.width += ICON_SIZE + ICON_MARGIN; + } + + /** + * Gets the icon path item. + */ + get icon(): paper.Path.Rectangle { + return this._icon; + } +} diff --git a/src/components/icon-label-loader.ts b/src/components/icon-label-loader.ts index 88d78ba..40cbf6d 100644 --- a/src/components/icon-label-loader.ts +++ b/src/components/icon-label-loader.ts @@ -1,9 +1,10 @@ import * as paper from 'paper'; import { VM_ICON_SIZE } from '../constants/dimensions'; +import { CANVAS_BACKGROUND_COLOR } from '../constants/colors'; const SLIDEOVER_ANIMATIONS_PER_SECOND = .125; const SIZE = 30; -const BACKGROUND_COLOR = '#191C28'; +const BACKGROUND_COLOR = CANVAS_BACKGROUND_COLOR; const SPINNER_COLOR = '#81A2B6'; const SPINNER_ARC_WIDTH = 3; const SPINNER_ARC_MARGIN = 9; @@ -28,7 +29,6 @@ export class IconLabelLoaderComponent extends paper.Group { symbolPromise: Promise) { super(); const self = this; - this.applyMatrix = false; this.position = _point; this._background = new paper.Path.Rectangle({ rectangle: new paper.Rectangle(0, 0, diff --git a/src/components/icon-label.ts b/src/components/icon-label.ts index 6edf400..87e5348 100644 --- a/src/components/icon-label.ts +++ b/src/components/icon-label.ts @@ -24,7 +24,6 @@ export class IconLabelComponent extends LabelComponent { constructor(protected _text: string, symbolPromise: Promise, protected _point: paper.Point = new paper.Point(0, 0)) { super(_text, _point); - this.applyMatrix = false; this.pivot = new paper.Point(0, 0); const self = this; // tslint:disable-next-line:no-floating-promises diff --git a/src/components/isolated-network-label.test.ts b/src/components/isolated-network-label.test.ts new file mode 100644 index 0000000..aefc9c2 --- /dev/null +++ b/src/components/isolated-network-label.test.ts @@ -0,0 +1,31 @@ +import { IsolatedNetworkLabelComponent } from './isolated-network-label'; +import { DEFAULT_MAX_LABEL_WIDTH } from '../constants/dimensions'; +import * as paper from 'paper'; + +describe('isolated network label component', () => { + + beforeAll(() => { + const canvasEl = document.createElement('canvas'); + paper.setup(canvasEl); + paper.settings.applyMatrix = false; + }); + + test('basic properties', () => { + const text = 'foobar'; + const position = new paper.Point(10, 90); + const label = new IsolatedNetworkLabelComponent(text, position); + expect(label.position.x).toBe(position.x); + expect(label.position.y).toBe(position.y); + expect(label.label.text.content).toBe(text.toUpperCase()); + }); + + test('maxWidth', () => { + const text = 'really really really really really really really really really really long name'; + const position = new paper.Point(10, 90); + const label = new IsolatedNetworkLabelComponent(text, position); + expect(label.position.x).toBe(position.x); + expect(label.position.y).toBe(position.y); + expect(label.label.bounds.width).toBeLessThan(DEFAULT_MAX_LABEL_WIDTH); + }); + +}); diff --git a/src/components/isolated-network-label.ts b/src/components/isolated-network-label.ts new file mode 100644 index 0000000..a80ef66 --- /dev/null +++ b/src/components/isolated-network-label.ts @@ -0,0 +1,52 @@ +import * as paper from 'paper'; +import { LIGHT_GREY } from '../constants/colors'; +import { BulletPointConnectionIconComponent } from './bullet-point-connection-icon'; +import { LabelTextComponent } from './label-text'; + +const LABEL_PADDING_LEFT = 10; + +/** + * Isolated Network Label Visual Component. + */ +export class IsolatedNetworkLabelComponent extends paper.Group { + + // the label text + private _label: LabelTextComponent; + // small circle icon at the top of the isolated vApp network path + private icon: BulletPointConnectionIconComponent; + + /** + * Creates a new IsolatedNetworkLabelComponent instance. + * + * @param network the network name + * @param _point the location that the isolated network label should be rendered at + */ + constructor(private network: string, + private _point: paper.Point = new paper.Point(0, 0)) { + super(); + this.pivot = new paper.Point(0, 0); + this.position = _point; + + this.icon = new BulletPointConnectionIconComponent(); + + this._label = new LabelTextComponent( + this.network.toUpperCase(), + new paper.Point(0, 0),{ + fillColor: LIGHT_GREY, + fontWeight: 'bold', + fontSize: 10 + }); + + // position the label to the right of the icon + this._label.bounds.leftCenter = this.icon.bounds.rightCenter.add(new paper.Point(LABEL_PADDING_LEFT, 0)); + + this.addChildren([this.icon, this._label]); + } + + /** + * Gets the label. + */ + get label(): LabelTextComponent { + return this._label; + } +} diff --git a/src/components/label-text.test.ts b/src/components/label-text.test.ts new file mode 100644 index 0000000..6abd62d --- /dev/null +++ b/src/components/label-text.test.ts @@ -0,0 +1,65 @@ +import { LabelTextComponent } from './label-text'; +import * as paper from 'paper'; +import { DEFAULT_MAX_LABEL_WIDTH } from '../constants/dimensions'; + +describe('label text component', () => { + + beforeAll(() => { + const canvasEl = document.createElement('canvas'); + paper.setup(canvasEl); + paper.settings.applyMatrix = false; + }); + + test('basic properties', () => { + const text = 'foobar'; + const position = new paper.Point(0, 0); + const label = new LabelTextComponent(text); + expect(label.position.x).toBe(position.x); + expect(label.position.y).toBe(position.y); + expect(label.text.content).toBe(text); + }); + + test('textOptions configuration', () => { + const text = 'old'; + const position = new paper.Point(20, 50); + const textOptions = { + fontWeight: 'bold', + justification: 'right', + fillColor: new paper.Color('blue'), + fontSize: 30, + leading: 35, + content: 'new' + }; + const label = new LabelTextComponent(text, position, textOptions); + expect(label.position.x).toBe(position.x); + expect(label.position.y).toBe(position.y); + expect(label.text.content).toBe(textOptions.content); + expect(label.text.fontWeight).toBe(textOptions.fontWeight); + expect(label.text.justification).toBe(textOptions.justification); + expect(label.text.fillColor).toBe(textOptions.fillColor); + expect(label.text.fontSize).toBe(textOptions.fontSize); + expect(label.text.leading).toBe(textOptions.leading); + }); + + test('default maxWidth', () => { + const text = 'suuuuuuuuuuper duuuuuuuuuuper loooooooooooooooooong name'; + const position = new paper.Point(56, 3); + const label = new LabelTextComponent(text, position, undefined); + expect(label.position.x).toBe(position.x); + expect(label.position.y).toBe(position.y); + expect(label.bounds.width).toBeLessThan(DEFAULT_MAX_LABEL_WIDTH); + }); + + test('custom maxWidth', () => { + const text = 'suuuuuuuuuuper duuuuuuuuuuper loooooooooooooooooong name'; + const position = new paper.Point(10, 90); + const textOptions = { fontFamily: 'serif' }; + const maxWidth = 100; + const label = new LabelTextComponent(text, position, textOptions, maxWidth); + expect(label.position.x).toBe(position.x); + expect(label.position.y).toBe(position.y); + expect(label.text.fontFamily).toBe(textOptions.fontFamily); + expect(label.bounds.width).toBeLessThan(maxWidth); + }); + +}); diff --git a/src/components/label-text.ts b/src/components/label-text.ts new file mode 100644 index 0000000..7df8129 --- /dev/null +++ b/src/components/label-text.ts @@ -0,0 +1,89 @@ +import * as paper from 'paper'; +import { DEFAULT_MAX_LABEL_WIDTH } from '../constants/dimensions'; +import { WHITE } from '../constants/colors'; + +const TEXT_COLOR = WHITE; +export const FONT_SIZE = 13; +const LINE_HEIGHT = 15; +// TODO: custom font loads asynchronously after the canvas renders. this causes any reliant background items to +// prematurely render at the wrong size. re-enable 'Roboto' custom font when a solution for canvas font loading is +// implemented. can try redraw on frame method or redraw once font is loaded to the HTML canvas. +const FONT_FAMILY = 'Roboto'; + +export type TextOptions = Record; + +/** + * Label Text Visual Component. + */ +export class LabelTextComponent extends paper.Group { + + // the label text + protected _label: paper.PointText; + + /** + * Creates a new LabelTextComponent instance. + * + * @param _text the text to be displayed + * @param _point the location that the label text should be rendered at + * @param textOptions the paper.PointText options object to customize the text properties + * @param maxWidth the maximum width of the label (text will be truncated with an ellipsis if the max width is + * exceeded) + * + * @example + * // basic configuration + * const text = new LabelTextComponent('Hello', new paper.Point(10, 30)); + * + * @example + * // with text options + * const textOptions = { + * fontWeight: 'bold', + * justification: 'right', + * fillColor: new paper.Color('blue'), + * fontSize: 30, + * leading: 35 + * } + * const fancyText = new LabelTextComponent('Salutations', new paper.Point(10, 5), textOptions); + */ + constructor(protected _text: string, + protected _point: paper.Point = new paper.Point(0, 0), + protected textOptions: TextOptions = {}, + protected maxWidth = DEFAULT_MAX_LABEL_WIDTH) { + super(); + this.pivot = new paper.Point(0, 0); + this.position = _point; + + this._label = new paper.PointText({ + pivot: new paper.Point(0, 0), + justification: 'left', + fillColor: TEXT_COLOR, + fontSize: FONT_SIZE, + leading: LINE_HEIGHT, + content: _text, + ...textOptions, + parent: this + }); + + this.clip(); + } + + /** + * Gets the text component. + */ + get text(): paper.PointText { + return this._label; + } + + /** + * Clips the text and inserts an ellipsis to ensure that the max width is not exceeded. + */ + private clip() { + let clipped = false; + while (this._label.bounds.width > this.maxWidth) { + clipped = true; + this._label.content = this._label.content.substring(0, this._label.content.length - 1); + } + if (clipped) { + this._label.content = this._label.content.substring(0, this._label.content.length - 3) + '...'; + } + } +} diff --git a/src/components/label.test.ts b/src/components/label.test.ts new file mode 100644 index 0000000..34f59c0 --- /dev/null +++ b/src/components/label.test.ts @@ -0,0 +1,39 @@ +import { LabelComponent } from './label'; +import * as paper from 'paper'; + +describe('entity label component', () => { + + beforeAll(() => { + const canvasEl = document.createElement('canvas'); + paper.setup(canvasEl); + paper.settings.applyMatrix = false; + }); + + test('basic properties', () => { + const text = 'foobarbaz'; + const position = new paper.Point(0, 0); + const label = new LabelComponent(text); + expect(label.position.x).toBe(position.x); + expect(label.position.y).toBe(position.y); + expect(label.text.content).toBe(text); + }); + + test('background component', () => { + const text = 'hello world'; + const position = new paper.Point(2, 448); + const label = new LabelComponent(text, position); + const styleOptions = { + fillColor: new paper.Color('green'), + strokeWidth: 2, + strokeColor: new paper.Color('pink') + }; + label.background.style = styleOptions; + expect(label.position.x).toBe(position.x); + expect(label.position.y).toBe(position.y); + expect(label.text.content).toBe(text); + expect(label.background.fillColor).toBe(styleOptions.fillColor); + expect(label.background.strokeWidth).toBe(styleOptions.strokeWidth); + expect(label.background.strokeColor).toBe(styleOptions.strokeColor); + }); + +}); diff --git a/src/components/label.ts b/src/components/label.ts index b42a3a5..91a1e81 100644 --- a/src/components/label.ts +++ b/src/components/label.ts @@ -1,25 +1,21 @@ import * as paper from 'paper'; -import { LABEL_HORIZONTAL_PADDING } from '../constants/dimensions'; +import { LABEL_HORIZONTAL_PADDING, LABEL_HEIGHT, DEFAULT_MAX_LABEL_WIDTH } from '../constants/dimensions'; +import { LabelTextComponent, TextOptions, FONT_SIZE } from './label-text'; +import { WHITE, CANVAS_BACKGROUND_COLOR } from '../constants/colors'; -const HEIGHT = 30; -const TEXT_COLOR = '#FFFFFF'; -const TEXT_HOVER_COLOR = '#FFFFFF'; +const TEXT_COLOR = WHITE; +const TEXT_HOVER_COLOR = WHITE; const ACTIVE_TEXT_COLOR = '#252A3A'; -const ACTIVE_BACKGROUND_COLOR = '#FFFFFF'; -const BACKGROUND_COLOR = '#191C28'; +const ACTIVE_BACKGROUND_COLOR = WHITE; +const BACKGROUND_COLOR = CANVAS_BACKGROUND_COLOR; const HOVER_BACKGROUND_COLOR = '#242A3B'; export const VERTICAL_PADDING_TOP = 6; -export const FONT_SIZE = 13; -const LINE_HEIGHT = 15; -const DEFAULT_MAX_LABEL_WIDTH = 200; /** - * LabelComponent Visual Component. + * Label Visual Component. */ -export class LabelComponent extends paper.Group { +export class LabelComponent extends LabelTextComponent { - // the text label - protected _label: paper.PointText; // the background item protected _background: paper.Path.Rectangle; @@ -28,45 +24,35 @@ export class LabelComponent extends paper.Group { * * @param _text the text to be displayed on the label * @param _point the location to render the label at + * @param textOptions the paper.PointText options object to customize the text properties * @param maxWidth the maximum width of the label (text will be truncated with an ellipsis if the max width is * exceeded) */ - constructor(protected _text: string, protected _point: paper.Point = new paper.Point(0, 0), + constructor(protected _text: string, + protected _point: paper.Point = new paper.Point(0, 0), + protected textOptions: TextOptions = {}, protected maxWidth = DEFAULT_MAX_LABEL_WIDTH) { - super(); - this.applyMatrix = false; + super(_text, _point, textOptions, maxWidth); + this.pivot = new paper.Point(0, 0); this.position = _point; - this._label = new paper.PointText(new paper.Point(LABEL_HORIZONTAL_PADDING, VERTICAL_PADDING_TOP + - FONT_SIZE)); - this._label.justification = 'left'; - this._label.fillColor = TEXT_COLOR; - this._label.content = _text; - this._label.fontSize = FONT_SIZE; - this._label.leading = LINE_HEIGHT; - this._label.pivot = new paper.Point(0, 0); - this.clip(); + + this._label.position = new paper.Point(LABEL_HORIZONTAL_PADDING, VERTICAL_PADDING_TOP + FONT_SIZE); + this._background = new paper.Path.Rectangle({ rectangle: new paper.Rectangle(0, 0, - this._label.bounds.width + (LABEL_HORIZONTAL_PADDING * 2), HEIGHT), - radius: 3 + this._label.bounds.width + (LABEL_HORIZONTAL_PADDING * 2), LABEL_HEIGHT), + radius: 3, + fillColor: BACKGROUND_COLOR, + pivot: new paper.Point(0, 0) }); - this._background.fillColor = BACKGROUND_COLOR; - this._background.pivot = new paper.Point(0, 0); - this.addChild(this._background); - this.addChild(this._label); - } - /** - * Gets the label text component. - */ - getTextComponent(): paper.PointText { - return this._label; + this.addChildren([this._background, this._label]); } /** * Gets the label background component. */ - getBackgroundComponent(): paper.Path.Rectangle { + get background(): paper.Path.Rectangle { return this._background; } @@ -87,25 +73,11 @@ export class LabelComponent extends paper.Group { } /** - * Sets the label to its visual active state; + * Sets the label to its visual active state. */ setActive() { this._label.fillColor = ACTIVE_TEXT_COLOR; this._background.fillColor = ACTIVE_BACKGROUND_COLOR; } - /** - * Clips the text and inserts an ellipsis to ensure that the max width is not exceeded. - */ - private clip() { - let clipped = false; - while (this._label.bounds.width > this.maxWidth) { - clipped = true; - this._label.content = this._label.content.substring(0, this._label.content.length - 1); - } - if (clipped) { - this._label.content = this._label.content.substring(0, this._label.content.length - 3) + '...'; - } - } - } diff --git a/src/components/margin.test.ts b/src/components/margin.test.ts new file mode 100644 index 0000000..c8407eb --- /dev/null +++ b/src/components/margin.test.ts @@ -0,0 +1,379 @@ +import { MarginComponent, MarginValues } from './margin'; +import * as paper from 'paper'; + +describe('margin component', () => { + + beforeAll(() => { + const canvasEl = document.createElement('canvas'); + paper.setup(canvasEl); + paper.settings.applyMatrix = false; + }); + + function createContent() { + return new paper.Path.Rectangle({ + pivot: new paper.Point(0, 0), + position: new paper.Point(0, 0), + size: new paper.Size(100, 100) + }); + } + + test('basic default properties', () => { + const content = createContent(); + const margin = new MarginComponent(content); + expect(margin.bounds.size.height).toBe(content.bounds.height); + expect(margin.bounds.size.width).toBe(content.bounds.width); + expect(margin.bounds.top).toBe(content.bounds.top); + expect(margin.bounds.right).toBe(content.bounds.right); + expect(margin.bounds.bottom).toBe(content.bounds.bottom); + expect(margin.bounds.left).toBe(content.bounds.left); + expect(margin.position.x).toBe(content.position.x); + expect(margin.position.y).toBe(content.position.y); + }); + + test('initialized with a MarginValues instance', () => { + const content = createContent(); + const marginValue: MarginValues = { + top: 5, + right: 21, + bottom: 74, + left: 12 + }; + const margin = new MarginComponent(content, marginValue); + expect(margin.bounds.size.height).toBe(content.bounds.height + marginValue.top + marginValue.bottom); + expect(margin.bounds.size.width).toBe(content.bounds.width + marginValue.right + marginValue.left); + expect(margin.bounds.top).toBe(content.bounds.top - marginValue.top); + expect(margin.bounds.right).toBe(content.bounds.right + marginValue.right); + expect(margin.bounds.bottom).toBe(content.bounds.bottom + marginValue.bottom); + expect(margin.bounds.left).toBe(content.bounds.left - marginValue.left); + expect(margin.position.x).toBe(content.position.x - marginValue.left); + expect(margin.position.y).toBe(content.position.y - marginValue.top); + }); + + test('initialized with a top and left partial MarginValues instance', () => { + const content = createContent(); + const marginValue: Partial = { + top: 25, + left: 95 + }; + const testValue = marginValue as any; // to avoid object is possibly undefined error + const margin = new MarginComponent(content, marginValue); + expect(margin.bounds.size.height).toBe(content.bounds.height + testValue.top); + expect(margin.bounds.size.width).toBe(content.bounds.width + testValue.left); + expect(margin.bounds.top).toBe(content.bounds.top - testValue.top); + expect(margin.bounds.right).toBe(content.bounds.right); + expect(margin.bounds.bottom).toBe(content.bounds.bottom); + expect(margin.bounds.left).toBe(content.bounds.left - testValue.left); + expect(margin.position.x).toBe(content.position.x - testValue.left); + expect(margin.position.y).toBe(content.position.y - testValue.top); + }); + + test('initialized with a right and bottom partial MarginValues instance', () => { + const content = createContent(); + const marginValue: Partial = { + right: 51, + bottom: 32 + }; + const testValue = marginValue as any; + const margin = new MarginComponent(content, marginValue); + expect(margin.bounds.size.height).toBe(content.bounds.height + testValue.bottom); + expect(margin.bounds.size.width).toBe(content.bounds.width + testValue.right); + expect(margin.bounds.top).toBe(content.bounds.top); + expect(margin.bounds.right).toBe(content.bounds.right + testValue.right); + expect(margin.bounds.bottom).toBe(content.bounds.bottom + testValue.bottom); + expect(margin.bounds.left).toBe(content.bounds.left); + expect(margin.position.x).toBe(content.position.x); + expect(margin.position.y).toBe(content.position.y); + }); + + test('initialized with four values in css shorthand notation', () => { + const content = createContent(); + const marginValue = { + top: 10, + right: 20, + bottom: 15, + left: 30 + }; + const margin = new MarginComponent( + content, marginValue.top, marginValue.right, marginValue.bottom, marginValue.left); + expect(margin.bounds.size.height).toBe(content.bounds.height + marginValue.top + marginValue.bottom); + expect(margin.bounds.size.width).toBe(content.bounds.width + marginValue.right + marginValue.left); + expect(margin.bounds.top).toBe(content.bounds.top - marginValue.top); + expect(margin.bounds.right).toBe(content.bounds.right + marginValue.right); + expect(margin.bounds.bottom).toBe(content.bounds.bottom + marginValue.bottom); + expect(margin.bounds.left).toBe(content.bounds.left - marginValue.left); + expect(margin.position.x).toBe(content.position.x - marginValue.left); + expect(margin.position.y).toBe(content.position.y - marginValue.top); + }); + + test('initialized with one value in css shorthand notation', () => { + const content = createContent(); + const marginValue = 20; + const margin = new MarginComponent(content, marginValue); + expect(margin.bounds.size.height).toBe(content.bounds.height + marginValue * 2); + expect(margin.bounds.size.width).toBe(content.bounds.width + marginValue * 2); + expect(margin.bounds.top).toBe(content.bounds.top - marginValue); + expect(margin.bounds.right).toBe(content.bounds.right + marginValue); + expect(margin.bounds.bottom).toBe(content.bounds.bottom + marginValue); + expect(margin.bounds.left).toBe(content.bounds.left - marginValue); + expect(margin.position.x).toBe(content.position.x - marginValue); + expect(margin.position.y).toBe(content.position.y - marginValue); + }); + + test('initialized with two values in css shorthand notation', () => { + const content = createContent(); + const marginValue = { + vertical: 10, + horizontal: 20 + }; + const margin = new MarginComponent(content, marginValue.vertical, marginValue.horizontal); + expect(margin.bounds.size.height).toBe(content.bounds.height + marginValue.vertical * 2); + expect(margin.bounds.size.width).toBe(content.bounds.width + marginValue.horizontal * 2); + expect(margin.bounds.top).toBe(content.bounds.top - marginValue.vertical); + expect(margin.bounds.right).toBe(content.bounds.right + marginValue.horizontal); + expect(margin.bounds.bottom).toBe(content.bounds.bottom + marginValue.vertical); + expect(margin.bounds.left).toBe(content.bounds.left - marginValue.horizontal); + expect(margin.position.x).toBe(content.position.x - marginValue.horizontal); + expect(margin.position.y).toBe(content.position.y - marginValue.vertical); + }); + + test('initialized with three values in css shorthand notation', () => { + const content = createContent(); + const marginValue = { + top: 10, + horizontal: 20, + bottom: 15 + }; + const margin = new MarginComponent(content, marginValue.top, marginValue.horizontal, marginValue.bottom); + expect(margin.bounds.size.height).toBe(content.bounds.height + marginValue.top + marginValue.bottom); + expect(margin.bounds.size.width).toBe(content.bounds.width + marginValue.horizontal * 2); + expect(margin.bounds.top).toBe(content.bounds.top - marginValue.top); + expect(margin.bounds.right).toBe(content.bounds.right + marginValue.horizontal); + expect(margin.bounds.bottom).toBe(content.bounds.bottom + marginValue.bottom); + expect(margin.bounds.left).toBe(content.bounds.left - marginValue.horizontal); + expect(margin.position.x).toBe(content.position.x - marginValue.horizontal); + expect(margin.position.y).toBe(content.position.y - marginValue.top); + }); + + test('initialized with four values in css shorthand notation', () => { + const content = createContent(); + const marginValue = { + top: 10, + right: 20, + bottom: 15, + left: 30 + }; + const margin = new MarginComponent( + content, marginValue.top, marginValue.right, marginValue.bottom, marginValue.left); + expect(margin.bounds.size.height).toBe(content.bounds.height + marginValue.top + marginValue.bottom); + expect(margin.bounds.size.width).toBe(content.bounds.width + marginValue.right + marginValue.left); + expect(margin.bounds.top).toBe(content.bounds.top - marginValue.top); + expect(margin.bounds.right).toBe(content.bounds.right + marginValue.right); + expect(margin.bounds.bottom).toBe(content.bounds.bottom + marginValue.bottom); + expect(margin.bounds.left).toBe(content.bounds.left - marginValue.left); + expect(margin.position.x).toBe(content.position.x - marginValue.left); + expect(margin.position.y).toBe(content.position.y - marginValue.top); + }); + + test('setValues with a MarginValues instance', () => { + const content = createContent(); + const originalMarginValue = { + top: 62, + right: 5, + bottom: 44, + left: 67 + }; + const marginValue: MarginValues = { + top: 7, + right: 37, + bottom: 1, + left: 64 + }; + const deltaTop = marginValue.top - originalMarginValue.top; + const deltaLeft = marginValue.left - originalMarginValue.left; + const margin = new MarginComponent(content, + originalMarginValue.top, originalMarginValue.right, originalMarginValue.bottom, originalMarginValue.left); + margin.setValues(marginValue); + expect(margin.bounds.size.height).toBe(content.bounds.height + marginValue.top + marginValue.bottom); + expect(margin.bounds.size.width).toBe(content.bounds.width + marginValue.right + marginValue.left); + expect(margin.bounds.top).toBe(content.bounds.top - marginValue.top - deltaTop); + expect(margin.bounds.right).toBe(content.bounds.right + marginValue.right - deltaLeft); + expect(margin.bounds.bottom).toBe(content.bounds.bottom + marginValue.bottom - deltaTop); + expect(margin.bounds.left).toBe(content.bounds.left - marginValue.left - deltaLeft); + expect(margin.position.x).toBe(content.position.x - marginValue.left - deltaLeft); + expect(margin.position.y).toBe(content.position.y - marginValue.top - deltaTop); + }); + + test('setValues with a top and left partial MarginValues instance', () => { + const content = createContent(); + const originalMarginValue = { + top: 73, + right: 8, + bottom: 90, + left: 42 + }; + const marginValue: Partial = { + top: 22, + left: 51 + }; + const testValue = marginValue as any; + const deltaTop = testValue.top - originalMarginValue.top; + const deltaLeft = testValue.left - originalMarginValue.left; + const margin = new MarginComponent(content, + originalMarginValue.top, originalMarginValue.right, originalMarginValue.bottom, originalMarginValue.left); + margin.setValues(marginValue); + expect(margin.bounds.size.height).toBe(content.bounds.height + testValue.top + originalMarginValue.bottom); + expect(margin.bounds.size.width).toBe(content.bounds.width + originalMarginValue.right + testValue.left); + expect(margin.bounds.top).toBe(content.bounds.top - testValue.top - deltaTop); + expect(margin.bounds.right).toBe(content.bounds.right + originalMarginValue.right - deltaLeft); + expect(margin.bounds.bottom).toBe(content.bounds.bottom + originalMarginValue.bottom - deltaTop); + expect(margin.bounds.left).toBe(content.bounds.left - testValue.left - deltaLeft); + expect(margin.position.x).toBe(content.position.x - testValue.left - deltaLeft); + expect(margin.position.y).toBe(content.position.y - testValue.top - deltaTop); + }); + + test('setValues with a right and bottom partial MarginValues instance', () => { + const content = createContent(); + const originalMarginValue = { + top: 26, + right: 25, + bottom: 6, + left: 2 + }; + const marginValue: Partial = { + right: 10, + bottom: 10 + }; + const testValue = marginValue as any; + const deltaTop = originalMarginValue.top - originalMarginValue.top; + const deltaLeft = originalMarginValue.left - originalMarginValue.left; + const margin = new MarginComponent(content, + originalMarginValue.top, originalMarginValue.right, originalMarginValue.bottom, originalMarginValue.left); + margin.setValues(marginValue); + expect(margin.bounds.size.height).toBe(content.bounds.height + originalMarginValue.top + testValue.bottom); + expect(margin.bounds.size.width).toBe(content.bounds.width + testValue.right + originalMarginValue.left); + expect(margin.bounds.top).toBe(content.bounds.top - originalMarginValue.top - deltaTop); + expect(margin.bounds.right).toBe(content.bounds.right + testValue.right - deltaLeft); + expect(margin.bounds.bottom).toBe(content.bounds.bottom + testValue.bottom - deltaTop); + expect(margin.bounds.left).toBe(content.bounds.left - originalMarginValue.left - deltaLeft); + expect(margin.position.x).toBe(content.position.x - originalMarginValue.left - deltaLeft); + expect(margin.position.y).toBe(content.position.y - originalMarginValue.top - deltaTop); + }); + + test('setValues with css shorthand notation', () => { + const content = createContent(); + const originalMarginValue = { + top: 10, + right: 20, + bottom: 15, + left: 30 + }; + const marginValue = { + top: 22, + right: 63, + bottom: 35, + left: 3 + }; + const deltaTop = marginValue.top - originalMarginValue.top; + const deltaLeft = marginValue.left - originalMarginValue.left; + const margin = new MarginComponent(content, + originalMarginValue.top, originalMarginValue.right, originalMarginValue.bottom, originalMarginValue.left); + margin.setValues(marginValue.top, marginValue.right, marginValue.bottom, marginValue.left); + expect(margin.bounds.size.height).toBe(content.bounds.height + marginValue.top + marginValue.bottom); + expect(margin.bounds.size.width).toBe(content.bounds.width + marginValue.right + marginValue.left); + expect(margin.bounds.top).toBe(content.bounds.top - marginValue.top - deltaTop); + expect(margin.bounds.right).toBe(content.bounds.right + marginValue.right - deltaLeft); + expect(margin.bounds.bottom).toBe(content.bounds.bottom + marginValue.bottom - deltaTop); + expect(margin.bounds.left).toBe(content.bounds.left - marginValue.left - deltaLeft); + expect(margin.position.x).toBe(content.position.x - marginValue.left - deltaLeft); + expect(margin.position.y).toBe(content.position.y - marginValue.top - deltaTop); + }); + + test('set top', () => { + const marginValue = { + top: 10, + right: 20, + bottom: 15, + left: 30 + }; + const newValue = 3; + const margin = new MarginComponent( + createContent(), marginValue.top, marginValue.right, marginValue.bottom, marginValue.left); + margin.top = newValue; + const content = createContent(); + expect(margin.bounds.size.height).toBe(content.bounds.height + newValue + marginValue.bottom); + expect(margin.bounds.size.width).toBe(content.bounds.width + marginValue.right + marginValue.left); + expect(margin.bounds.top).toBe(content.bounds.top - newValue); + expect(margin.bounds.right).toBe(content.bounds.right + marginValue.right); + expect(margin.bounds.bottom).toBe(content.bounds.bottom + marginValue.bottom); + expect(margin.bounds.left).toBe(content.bounds.left - marginValue.left); + expect(margin.position.x).toBe(content.position.x - marginValue.left); + expect(margin.position.y).toBe(content.position.y - newValue); + }); + + test('set right', () => { + const marginValue = { + top: 10, + right: 20, + bottom: 15, + left: 30 + }; + const newValue = 8; + const margin = new MarginComponent( + createContent(), marginValue.top, marginValue.right, marginValue.bottom, marginValue.left); + margin.right = newValue; + const content = createContent(); + expect(margin.bounds.size.height).toBe(content.bounds.height + marginValue.top + marginValue.bottom); + expect(margin.bounds.size.width).toBe(content.bounds.width + newValue + marginValue.left); + expect(margin.bounds.top).toBe(content.bounds.top - marginValue.top); + expect(margin.bounds.right).toBe(content.bounds.right + newValue); + expect(margin.bounds.bottom).toBe(content.bounds.bottom + marginValue.bottom); + expect(margin.bounds.left).toBe(content.bounds.left - marginValue.left); + expect(margin.position.x).toBe(content.position.x - marginValue.left); + expect(margin.position.y).toBe(content.position.y - marginValue.top); + }); + + test('set bottom', () => { + const marginValue = { + top: 10, + right: 20, + bottom: 15, + left: 30 + }; + const newValue = 8; + const margin = new MarginComponent( + createContent(), marginValue.top, marginValue.right, marginValue.bottom, marginValue.left); + margin.bottom = newValue; + const content = createContent(); + expect(margin.bounds.size.height).toBe(content.bounds.height + marginValue.top + newValue); + expect(margin.bounds.size.width).toBe(content.bounds.width + marginValue.right + marginValue.left); + expect(margin.bounds.top).toBe(content.bounds.top - marginValue.top); + expect(margin.bounds.right).toBe(content.bounds.right + marginValue.right); + expect(margin.bounds.bottom).toBe(content.bounds.bottom + newValue); + expect(margin.bounds.left).toBe(content.bounds.left - marginValue.left); + expect(margin.position.x).toBe(content.position.x - marginValue.left); + expect(margin.position.y).toBe(content.position.y - marginValue.top); + }); + + test('set left', () => { + const marginValue = { + top: 10, + right: 20, + bottom: 15, + left: 30 + }; + const newValue = 8; + const margin = new MarginComponent( + createContent(), marginValue.top, marginValue.right, marginValue.bottom, marginValue.left); + margin.left = newValue; + const content = createContent(); + expect(margin.bounds.size.height).toBe(content.bounds.height + marginValue.top + marginValue.bottom); + expect(margin.bounds.size.width).toBe(content.bounds.width + marginValue.right + newValue); + expect(margin.bounds.top).toBe(content.bounds.top - marginValue.top); + expect(margin.bounds.right).toBe(content.bounds.right + marginValue.right); + expect(margin.bounds.bottom).toBe(content.bounds.bottom + marginValue.bottom); + expect(margin.bounds.left).toBe(content.bounds.left - newValue); + expect(margin.position.x).toBe(content.position.x - newValue); + expect(margin.position.y).toBe(content.position.y - marginValue.top); + }); + +}); diff --git a/src/components/margin.ts b/src/components/margin.ts new file mode 100644 index 0000000..3019c48 --- /dev/null +++ b/src/components/margin.ts @@ -0,0 +1,252 @@ +import * as paper from 'paper'; + +const DEFAULT_MARGIN = 0; + +/** + * Interface for margin values. + */ +export class MarginValues { + top: number; + right: number; + bottom: number; + left: number; +} + +/** + * Margin Component. + */ +export class MarginComponent extends paper.Group { + + private marginValues: MarginValues = { + top: DEFAULT_MARGIN, + right: DEFAULT_MARGIN, + bottom: DEFAULT_MARGIN, + left: DEFAULT_MARGIN + }; + // the surrounding margin rectangle + private margin: paper.Path.Rectangle; + + /** + * Creates a new margin instance with a partial MarginValues instance. + * + * @param content The content that the margin surrounds. + * @param marginValues Values for the margin sides. Can set `top`, `right`, `bottom`, or `left` partially with + * an instance of MarginValues. Default is 0 for all margins. + * + * @example + * // all values assigned + * const marginValues: MarginValues = { + * top: 5, + * right: 21, + * bottom: 74, + * left: 12 + * }; + * const margin = new MarginComponent((your content), marginValues); + * + * @example + * // values partially assigned + * const marginValues: Partial = { + * top: 5, + * right: 21 + * }; + * const margin = new MarginComponent((your content), marginValues); + */ + constructor(content: paper.Item | paper.Group, marginValues: Partial); + /** + * Creates a new margin instance with margin values written in CSS shorthand notation. + * + * @param content The content that the margin surrounds. + * @param marginValuesCssShorthand Values for the margin sides. Can have a series of 0 to 4 that are comma separated + * in CSS shorthand notation. Default is 0 for all margins. + * + * @example + * // initialized with all four values in css shorthand notation - top, right, bottom, left + * const margin = new MarginComponent((your content), 5, 21, 74, 12); + * + * @example + * // initialized with two values in css shorthand notation - top/bottom, sides + * const margin = new MarginComponent((your content), 10, 15); + */ + constructor(content: paper.Item | paper.Group, ...marginValuesCssShorthand: number[]); + /** + * Creates a new margin instance. + * + * @param content The content that the margin surrounds. + * @param values Values for the margin side. Implementation of overloaded marginValues: Partial and + * marginValuesCssShorthand: number[]. + */ + constructor(private content: paper.Item | paper.Group, ...values: Partial[] | number[]) { + super(); + this.pivot = new paper.Point(0, 0); + + this.marginValues = this.assignMarginValues(values); + this.createAndPositionMargin(); + } + + /** + * Sets new margin values with a partial MarginValues instance. + * + * @param marginValues Values for the margin sides. Can override current `top`, `right`, `bottom`, or `left` + * values partially with an instance of MarginValues. Default is the original assignment from instantiation for + * all margins. + * + * @example + * // all values set + * const marginValues: MarginValues = { + * top: 5, + * right: 21, + * bottom: 74, + * left: 12 + * }; + * marginInstance.setValues(marginValues); + * + * @example + * // values partially set + * const marginValues: Partial = { + * top: 5, + * right: 21 + * }; + * marginInstance.setValues(marginValues); + */ + setValues(marginValues: Partial): void; + /** + * Sets new margin values written in CSS shorthand notation. + * + * @param marginValuesCssShorthand Values for the margin sides. Can have a series of 0 to 4 that are comma separated + * in CSS shorthand notation. Default is 0 for all margins. + * + * @example + * // set all four values in css shorthand notation - top, right, bottom, left + * marginInstance.setValues(5, 21, 74, 12); + * + * @example + * // set with two values in css shorthand notation - top/bottom, sides + * marginInstance.setValues(10, 15); + */ + setValues(...marginValuesCssShorthand: number[]): void; + /** + * Sets new margin values. + * + * @param values Values for the margin side. Implementation of overloaded marginValues: Partial and + * marginValuesCssShorthand: number[]. + */ + setValues(...values: Partial[] | number[]): void { + const previousTop = this.marginValues.top; + const previousLeft = this.marginValues.left; + this.marginValues = this.assignMarginValues(values); + this.rebuildMargin(); + this.content.position.y += this.marginValues.top - previousTop; + this.content.position.x += this.marginValues.left - previousLeft; + } + + /** + * Sets top margin value. + * @param newValue New value for top margin. + */ + set top(newValue: number) { + const delta = newValue - this.marginValues.top; + this.marginValues.top = newValue; + this.rebuildMargin(); + this.content.position.y += delta; + } + + /** + * Sets right margin value. + * @param newValue New value for right margin. + */ + set right(newValue: number) { + this.marginValues.right = newValue; + this.rebuildMargin(); + } + + /** + * Sets bottom margin value. + * @param newValue New value for bottom margin. + */ + set bottom(newValue: number) { + this.marginValues.bottom = newValue; + this.rebuildMargin(); + } + + /** + * Sets left margin value. + * @param newValue New value for left margin. + */ + set left(newValue: number) { + const delta = newValue - this.marginValues.left; + this.marginValues.left = newValue; + this.rebuildMargin(); + this.content.position.x += delta; + } + + /** + * Assigns margin values according to the type of the first value. + * @param values Margin values. + */ + private assignMarginValues(values: Partial[] | number[]): MarginValues { + const firstValue = values[0]; + return typeof firstValue === 'number' + ? this.assignCssStyleMarginValues(values as number[]) + : { ...this.marginValues, ...firstValue }; + } + + /** + * Assigns margin values based on CSS shorthand notation. + * @param values Margin values. + */ + private assignCssStyleMarginValues(values: number[]): MarginValues { + let top; + let right; + let bottom; + let left; + switch (values.length) { + case 4: + [top, right, bottom, left] = values; + break; + case 3: + [top, right, bottom] = values; + left = right; + break; + case 2: + [top, right] = values; + bottom = top; + left = right; + break; + case 1: + top = right = bottom = left = values[0]; + break; + default: + top = right = bottom = left = DEFAULT_MARGIN; + } + + return { + top: top, + right: right, + bottom: bottom, + left: left + }; + } + + /** + * Creates and positions margin. + */ + private createAndPositionMargin(): void { + this.margin = new paper.Path.Rectangle({ + pivot: new paper.Point(0, 0), + position: this.content.bounds.topLeft, + size: this.content.bounds.size.add(new paper.Size( + this.marginValues.left + this.marginValues.right, + this.marginValues.top + this.marginValues.bottom)), + parent: this + }); + this.position = new paper.Point(-this.marginValues.left, -this.marginValues.top); + } + + /** + * Rebuilds margin. + */ + private rebuildMargin(): void { + this.margin.remove(); + this.createAndPositionMargin(); + } +} diff --git a/src/components/scrollbar.test.ts b/src/components/scrollbar.test.ts index 81dc5c9..b0dd023 100644 --- a/src/components/scrollbar.test.ts +++ b/src/components/scrollbar.test.ts @@ -3,17 +3,16 @@ import { ScrollbarComponent } from './scrollbar'; describe('scrollbar component', () => { - beforeEach(() => { + beforeAll(() => { const canvasEl = document.createElement('canvas'); paper.setup(canvasEl); + paper.settings.applyMatrix = false; }); test('basic properties and defaults', () => { const scrollable = { - content: new paper.Path.Rectangle( - new paper.Point(10, 0), - new paper.Size(1000, 500) - ), + content: new paper.Path.Rectangle(new paper.Rectangle(10, 0, 1000, 500)), + container: new paper.Path.Rectangle(new paper.Rectangle(0, 0, 500, 500)), containerBounds: new paper.Rectangle(0, 0, 500, 500) }; const defaultPosition = new paper.Point(0, 0); @@ -31,10 +30,8 @@ describe('scrollbar component', () => { test('basic properties and custom position', () => { const scrollable = { - content: new paper.Path.Rectangle( - new paper.Point(10, 0), - new paper.Size(1000, 500) - ), + content: new paper.Path.Rectangle(new paper.Rectangle(10, 0, 1000, 500)), + container: new paper.Path.Rectangle(new paper.Rectangle(0, 0, 500, 500)), containerBounds: new paper.Rectangle(0, 0, 500, 500) }; const customPosition = new paper.Point(30, 30); @@ -53,10 +50,8 @@ describe('scrollbar component', () => { test('basic properties and custom scrollTrackLength', () => { const scrollable = { - content: new paper.Path.Rectangle( - new paper.Point(10, 0), - new paper.Size(1000, 500) - ), + content: new paper.Path.Rectangle(new paper.Rectangle(10, 0, 1000, 500)), + container: new paper.Path.Rectangle(new paper.Rectangle(0, 0, 500, 500)), containerBounds: new paper.Rectangle(0, 0, 500, 500) }; const defaultPosition = new paper.Point(0, 0); @@ -75,10 +70,8 @@ describe('scrollbar component', () => { test('does not build when content fits inside container' , () => { const scrollable = { - content: new paper.Path.Rectangle( - new paper.Point(0, 0), - new paper.Size(500, 500) - ), + content: new paper.Path.Rectangle(new paper.Rectangle(0, 0, 500, 500)), + container: new paper.Path.Rectangle(new paper.Rectangle(0, 0, 500, 500)), containerBounds: new paper.Rectangle(0, 0, 500, 500) }; const scrollbar = new ScrollbarComponent(scrollable, new paper.Point(0, 0)); @@ -87,10 +80,8 @@ describe('scrollbar component', () => { test('builds when content does not fit inside container' , () => { const scrollable = { - content: new paper.Path.Rectangle( - new paper.Point(0, 0), - new paper.Size(501, 500) - ), + content: new paper.Path.Rectangle(new paper.Rectangle(0, 0, 501, 500)), + container: new paper.Path.Rectangle(new paper.Rectangle(0, 0, 500, 500)), containerBounds: new paper.Rectangle(0, 0, 500, 500) }; const scrollbar = new ScrollbarComponent(scrollable, new paper.Point(0, 0)); @@ -99,10 +90,8 @@ describe('scrollbar component', () => { test('does not build when content including offsets do not fit but checkFitWithOffsets is off' , () => { const scrollable = { - content: new paper.Path.Rectangle( - new paper.Point(0, 0), - new paper.Size(500, 500) - ), + content: new paper.Path.Rectangle(new paper.Rectangle(0, 0, 500, 500)), + container: new paper.Path.Rectangle(new paper.Rectangle(0, 0, 500, 500)), containerBounds: new paper.Rectangle(0, 0, 500, 500), contentOffsetStart: 10, contentOffsetEnd: 10, @@ -114,10 +103,8 @@ describe('scrollbar component', () => { test('builds when content including offsets do not fit and checkFitWithOffsets is on' , () => { const scrollable = { - content: new paper.Path.Rectangle( - new paper.Point(0, 0), - new paper.Size(500, 500) - ), + content: new paper.Path.Rectangle(new paper.Rectangle(0, 0, 500, 500)), + container: new paper.Path.Rectangle(new paper.Rectangle(0, 0, 500, 500)), containerBounds: new paper.Rectangle(0, 0, 500, 500), contentOffsetStart: 10, contentOffsetEnd: 10, @@ -129,10 +116,8 @@ describe('scrollbar component', () => { test('getScrollbar returns child scrollbar when component is enabled' , () => { const scrollable = { - content: new paper.Path.Rectangle( - new paper.Point(0, 0), - new paper.Size(1000, 500) - ), + content: new paper.Path.Rectangle(new paper.Rectangle(0, 0, 1000, 500)), + container: new paper.Path.Rectangle(new paper.Rectangle(0, 0, 500, 500)), containerBounds: new paper.Rectangle(0, 0, 500, 500) }; const scrollbar = new ScrollbarComponent(scrollable, new paper.Point(0, 0)); @@ -143,10 +128,8 @@ describe('scrollbar component', () => { test('getScrollbar returns scrollbar that\'s not a child when component is disabled' , () => { const scrollable = { - content: new paper.Path.Rectangle( - new paper.Point(0, 0), - new paper.Size(500, 500) - ), + content: new paper.Path.Rectangle(new paper.Rectangle(0, 0, 500, 500)), + container: new paper.Path.Rectangle(new paper.Rectangle(0, 0, 500, 500)), containerBounds: new paper.Rectangle(0, 0, 500, 500) }; const scrollbar = new ScrollbarComponent(scrollable, new paper.Point(0, 0)); @@ -157,10 +140,8 @@ describe('scrollbar component', () => { test('getScrollbar allows changes to scrollbar attributes' , () => { const scrollable = { - content: new paper.Path.Rectangle( - new paper.Point(0, 0), - new paper.Size(500, 500) - ), + content: new paper.Path.Rectangle(new paper.Rectangle(0, 0, 500, 500)), + container: new paper.Path.Rectangle(new paper.Rectangle(0, 0, 500, 500)), containerBounds: new paper.Rectangle(0, 0, 500, 500) }; const scrollbar = new ScrollbarComponent(scrollable, new paper.Point(0, 0)); @@ -177,10 +158,8 @@ describe('scrollbar component', () => { test('setScrollbar allows fuller changes to scrollbar' , () => { const scrollable = { - content: new paper.Path.Rectangle( - new paper.Point(0, 0), - new paper.Size(500, 500) - ), + content: new paper.Path.Rectangle(new paper.Rectangle(0, 0, 500, 500)), + container: new paper.Path.Rectangle(new paper.Rectangle(0, 0, 500, 500)), containerBounds: new paper.Rectangle(0, 0, 500, 500) }; const scrollbar = new ScrollbarComponent(scrollable, new paper.Point(0, 0)); @@ -203,10 +182,8 @@ describe('scrollbar component', () => { test('getTrack returns child track when component is enabled' , () => { const scrollable = { - content: new paper.Path.Rectangle( - new paper.Point(0, 0), - new paper.Size(501, 500) - ), + content: new paper.Path.Rectangle(new paper.Rectangle(0, 0, 501, 500)), + container: new paper.Path.Rectangle(new paper.Rectangle(0, 0, 500, 500)), containerBounds: new paper.Rectangle(0, 0, 500, 500) }; const scrollbar = new ScrollbarComponent(scrollable, new paper.Point(0, 0)); @@ -217,10 +194,8 @@ describe('scrollbar component', () => { test('getTrack returns track that\'s not a child when component is disabled' , () => { const scrollable = { - content: new paper.Path.Rectangle( - new paper.Point(0, 0), - new paper.Size(500, 500) - ), + content: new paper.Path.Rectangle(new paper.Rectangle(0, 0, 500, 500)), + container: new paper.Path.Rectangle(new paper.Rectangle(0, 0, 500, 500)), containerBounds: new paper.Rectangle(0, 0, 500, 500) }; const scrollbar = new ScrollbarComponent(scrollable, new paper.Point(0, 0)); @@ -231,10 +206,8 @@ describe('scrollbar component', () => { test('getTrack allows changes to track attributes' , () => { const scrollable = { - content: new paper.Path.Rectangle( - new paper.Point(0, 0), - new paper.Size(500, 500) - ), + content: new paper.Path.Rectangle(new paper.Rectangle(0, 0, 500, 500)), + container: new paper.Path.Rectangle(new paper.Rectangle(0, 0, 500, 500)), containerBounds: new paper.Rectangle(0, 0, 500, 500) }; const scrollbar = new ScrollbarComponent(scrollable, new paper.Point(0, 0)); @@ -251,10 +224,8 @@ describe('scrollbar component', () => { test('setTrack allows fuller changes to track' , () => { const scrollable = { - content: new paper.Path.Rectangle( - new paper.Point(0, 0), - new paper.Size(500, 500) - ), + content: new paper.Path.Rectangle(new paper.Rectangle(0, 0, 500, 500)), + container: new paper.Path.Rectangle(new paper.Rectangle(0, 0, 500, 500)), containerBounds: new paper.Rectangle(0, 0, 500, 500) }; const scrollbar = new ScrollbarComponent(scrollable, new paper.Point(0, 0)); diff --git a/src/components/scrollbar.ts b/src/components/scrollbar.ts index bbc3c0b..4d53447 100644 --- a/src/components/scrollbar.ts +++ b/src/components/scrollbar.ts @@ -10,6 +10,9 @@ const ACTIVE_SCROLLBAR_OPACITY = 1; const HIT_TEST_TOLERANCE = 11; const DEFAULT_SCROLLABLE_OFFSET = 0; +/** + * Enumeration of scrollbar directions. + */ type ScrollbarDirection = 'horizontal' | 'vertical'; /** @@ -21,8 +24,12 @@ interface Scrollable { */ content: paper.Group | paper.Item; /** - * @property containerBounds Bounds of the container that displays the viewable content. Could be something like - * the view or a clip mask. + * @property container The container that displays the viewable content. Could be something like the view or a + * clip mask. Used for hover event listening. HTMLCanvasElement used to solve issues with multiple paper views. + */ + container: paper.Group | paper.Item | paper.View | HTMLCanvasElement; + /** + * @property containerBounds Bounds of the container that displays the viewable content. Used for measurements. */ containerBounds: paper.Rectangle; /** @@ -55,6 +62,14 @@ class CustomEffects { * Scrollbar Visual Component. */ export class ScrollbarComponent extends paper.Group { + + // checks if any scrollbars are currently receiving mouse drag input to override other default events + static anyIsDragging: boolean = false; + // the default scrollbar that can still receive input events while a scrollbar of another direction is active. For + // example, the main view's horizontal scrollbar can scroll from horizontal input events even if a vapp vertical + // scrollbar is active. + static _defaultScrollbar: ScrollbarComponent | undefined; + static defaultScrollbarDirection: ScrollbarDirection | undefined; protected scrollbar: paper.Path; protected track: paper.Path; protected scrollAmount: number; @@ -67,15 +82,18 @@ export class ScrollbarComponent extends paper.Group { private dimension: (keyof paper.Rectangle); // content's original position. used to constrain content position while scrolling private contentInitialPosition: number; - // scrollbar is rendered and enabled when it's needed - private isEnabled: boolean = true; + // scrollbar is visible and interactive when container is hovered + private enabled: boolean = true; private containerSize: number; + // make a bigger area to make the track easier to interact with private extendedTrackArea: paper.Path.Rectangle; + // make a bigger area to make the scrollbar easier to interact with private extendedScrollbarArea: paper.Path.Rectangle; private arrowKeyDown: paper.Tool; private scrollbarDrag: paper.Tool; private dragging: boolean = false; - private hovering: boolean = false; + private scrollbarHovering: boolean = false; + private containerHovering: boolean = false; private activeScrollTimeout: ReturnType; /** @@ -91,6 +109,7 @@ export class ScrollbarComponent extends paper.Group { * // Create a horizontal scrollbar with mostly default configuration. * const scrollable = { * content: (your content), + * container: (content's container), * containerBounds: (content's container.bounds) * } * const scrollbar = new ScrollbarComponent(scrollable, new paper.Point(30, 30)); @@ -100,6 +119,7 @@ export class ScrollbarComponent extends paper.Group { * const containerPadding = 20; * const scrollable = { * content: (your content), + * container: (content's container), * containerBounds: (content's container.bounds), * contentOffsetEnd: containerPadding * }; @@ -115,7 +135,6 @@ export class ScrollbarComponent extends paper.Group { readonly scrollTrackLength: number = 0, private direction: ScrollbarDirection = 'horizontal') { super(); - this.applyMatrix = false; this.position = this._point as paper.Point; this.pivot = new paper.Point(0, 0); @@ -132,11 +151,10 @@ export class ScrollbarComponent extends paper.Group { this.track = this.createTrack(this.scrollTrackLength, DEFAULT_SCROLLBAR_THICKNESS); this.scrollbar = this.createScrollbar(this.getProportionalLength(), DEFAULT_SCROLLBAR_THICKNESS); - // extend track and scrollbar hit areas based on hit tolerance to make interaction easier. used for - // onClick event and hover tests + // extend track and scrollbar hit areas based on hit tolerance to make interaction easier. used for onClick + // event and hover tests this.extendedTrackArea = this.extendHitArea(this.track); this.extendedScrollbarArea = this.extendHitArea(this.scrollbar); - // check if scrollbar is necessary if (this.scrollableContentFitsContainer()) { this.disable(); @@ -151,41 +169,87 @@ export class ScrollbarComponent extends paper.Group { this.onClick = this.trackClick; this.scrollbarDrag = this.scrollbarDragTool(); this.arrowKeyDown = this.arrowKeyDownTool(); + + // not visible until container is hovered and no other scrollbars are currently in dragging state + this.visible = false; + // handles hover events for html canvas and paper items + if (this.scrollable.container instanceof HTMLCanvasElement) { + this.scrollable.container.onmouseenter = () => this.containerMouseEnter(); + this.scrollable.container.onmouseleave = () => this.containerMouseLeave(); + } else { + this.scrollable.container.onMouseEnter = this.containerMouseEnter; + this.scrollable.container.onMouseLeave = this.containerMouseLeave; + } + } + + /** + * Sets the default scrollbar. + * @param value The scrollbar that will be set as default. + */ + static set defaultScrollbar(value: ScrollbarComponent) { + this._defaultScrollbar = value; + this.defaultScrollbarDirection = this._defaultScrollbar.isHorizontal ? 'horizontal' : 'vertical'; + } + + /** + * Handler for container mouse enter event. + */ + containerMouseEnter = (): void => { + this.containerHovering = true; + if (!ScrollbarComponent.anyIsDragging) { + this.enabled = true; + this.visible = true; + this.activateDefaultTool(); + // handle scroll listening from the HTML canvas element. paper doesn't have a scroll event handler + this.project.view.element.onwheel = (event: WheelEvent) => { + this.onScroll(event); + }; + } + } + + /** + * Handler for container mouse leave event. + */ + containerMouseLeave = (): void => { + this.containerHovering = false; + if (!ScrollbarComponent.anyIsDragging) { + this.enabled = false; + this.visible = false; + if (ScrollbarComponent._defaultScrollbar) { + this.resetDefaultTool(); + // reset default scroll listening from the HTML canvas element. paper doesn't have a scroll event handler + this.project.view.element.onwheel = (event: WheelEvent) => { + ScrollbarComponent._defaultScrollbar!.onScroll(event); + }; + } + } } /** * Activate the default tool. Used when the scrollable container is hovered or active. */ activateDefaultTool(): void { - if (this.isEnabled) { + if (this.enabled) { this.arrowKeyDown.activate(); } } /** * Handler for the wheel event. - * PaperJS does not have a scroll event handler, so this is set up externally where the HTML canvas element can be - * accessed. - * @param event WheelEvent passed from the HTML canvas. - * - * @example - * const scrollbar = new ScrollbarComponent(...); - * const canvas = this.demo.canvas.nativeElement; - * canvas.onwheel = (event: WheelEvent) => { - * scrollbar.onScroll(event); - * }; + * @param event PaperJS does not have a scroll event handler, so this is the WheelEvent passed from the HTML canvas. */ onScroll(event: WheelEvent): void { - if (this.isEnabled) { - const validScrollDirection = this.isHorizontal ? event.deltaX !== 0 : event.deltaY !== 0; - if (!validScrollDirection) { - return; + if (this.enabled) { + const scrollDirection = Math.abs(event.deltaX) > Math.abs(event.deltaY) ? 'horizontal' : 'vertical'; + if (scrollDirection === this.direction) { + event.preventDefault(); + this.setActiveContinuously(); + this.isHorizontal + ? this.changeScrollAndContentPosition(this.scrollbar.position.x + event.deltaX) + : this.changeScrollAndContentPosition(this.scrollbar.position.y + event.deltaY); + } else if (scrollDirection === ScrollbarComponent.defaultScrollbarDirection) { + ScrollbarComponent._defaultScrollbar!.onScroll(event); } - event.preventDefault(); - this.setActiveContinuously(); - this.isHorizontal - ? this.changeScrollAndContentPosition(this.scrollbar.position.x + event.deltaX) - : this.changeScrollAndContentPosition(this.scrollbar.position.y + event.deltaY); } } @@ -287,30 +351,41 @@ export class ScrollbarComponent extends paper.Group { } /** - * Enable scrollbar visibility and interactivity. + * Reset current paper tool to the default scrollbar's default paper tool when another scrollbar is no longer + * active and hovered. + */ + private resetDefaultTool() { + if (ScrollbarComponent._defaultScrollbar) { + ScrollbarComponent._defaultScrollbar!.activateDefaultTool(); + } + } + + /** + * Enable scrollbar component elements and interactivity. */ private enable(): void { - this.isEnabled = true; - this.addChildren([this.track, this.scrollbar, this.extendedTrackArea]); + this.enabled = true; + this.addChildren([this.track, this.extendedScrollbarArea, this.extendedTrackArea]); } /** - * Disable scrollbar visibility and interactivity. + * Disable scrollbar component elements and interactivity. */ private disable(): void { - this.isEnabled = false; + this.enabled = false; this.removeChildren(); } /** - * Assign scrollable defaults for the optional properties. + * Assign scrollable defaults for the optional properties, so that none will be undefined. */ private assignScrollableDefaults(): Scrollable { - return Object.assign({ + return { contentOffsetStart: DEFAULT_SCROLLABLE_OFFSET, contentOffsetEnd: DEFAULT_SCROLLABLE_OFFSET, - checkFitWithOffsets: true - }, this.scrollable); + checkFitWithOffsets: true, + ...this.scrollable + }; } /** @@ -324,10 +399,10 @@ export class ScrollbarComponent extends paper.Group { const contentWithOffset = new paper.Path.Rectangle({ rectangle: content.bounds }); if (this.isHorizontal) { contentWithOffset.bounds.width = this.contentSizeWithOffsets(); - contentWithOffset.position.x -= (contentOffsetStart || DEFAULT_SCROLLABLE_OFFSET); + contentWithOffset.position.x -= contentOffsetStart!; } else { contentWithOffset.bounds.height = this.contentSizeWithOffsets(); - contentWithOffset.position.y -= (contentOffsetStart || DEFAULT_SCROLLABLE_OFFSET); + contentWithOffset.position.y -= contentOffsetStart!; } return contentWithOffset.isInside(containerBounds); } @@ -337,8 +412,7 @@ export class ScrollbarComponent extends paper.Group { */ private contentSizeWithOffsets(): number { const { content, contentOffsetStart, contentOffsetEnd } = this.scrollable; - return (this.isHorizontal ? content.bounds.width : content.bounds.height) - + (contentOffsetStart || DEFAULT_SCROLLABLE_OFFSET) + (contentOffsetEnd || DEFAULT_SCROLLABLE_OFFSET); + return (this.isHorizontal ? content.bounds.width : content.bounds.height) + contentOffsetStart! + contentOffsetEnd!; } /** @@ -385,7 +459,9 @@ export class ScrollbarComponent extends paper.Group { * The proportionate length for the scrollbar. Based on viewable content size divided by the full content size. */ private getProportionalLength(): number { - return (this.containerSize / this.contentSizeWithOffsets()) * this.scrollTrackLength; + const fullSize = this.contentSizeWithOffsets() + this.scrollable.contentOffsetEnd! + - this.scrollable.contentOffsetStart!; + return this.containerSize / fullSize * this.scrollTrackLength; } /** @@ -418,7 +494,7 @@ export class ScrollbarComponent extends paper.Group { * @param event {paper.MouseEvent} */ private mouseLeave(event: paper.MouseEvent): void { - this.hovering = false; + this.scrollbarHovering = false; if (!this.dragging) { this.project.view.element.style.cursor = 'default'; this.setNormal(); @@ -429,7 +505,7 @@ export class ScrollbarComponent extends paper.Group { * Handler for mouse enter event. */ private mouseEnter(): void { - this.hovering = true; + this.scrollbarHovering = true; if (!this.dragging) { this.project.view.element.style.cursor = 'pointer'; } @@ -509,10 +585,9 @@ export class ScrollbarComponent extends paper.Group { private changeContentPosition(): void { const { content, contentOffsetStart, contentOffsetEnd } = this.scrollable; const contentMaxPosition = this.containerSize - (content.bounds[this.dimension] as number); - const contentDistance = contentMaxPosition - (contentOffsetStart || DEFAULT_SCROLLABLE_OFFSET) - - (contentOffsetEnd || DEFAULT_SCROLLABLE_OFFSET) * 2; + const contentDistance = contentMaxPosition - contentOffsetStart! - contentOffsetEnd! * 2; (content.position[this.axis] as number) = (this.scrollAmount * contentDistance) + this.contentInitialPosition - + (contentOffsetStart || DEFAULT_SCROLLABLE_OFFSET); + + contentOffsetStart!; } /** @@ -523,21 +598,31 @@ export class ScrollbarComponent extends paper.Group { let offsetPoint: paper.Point; tool.onMouseDown = (event) => { this.dragging = true; + ScrollbarComponent.anyIsDragging = true; this.project.view.element.style.cursor = 'grabbing'; offsetPoint = new paper.Point(event.downPoint.subtract(this.scrollbar.position)); }; - tool.onMouseUp = () => { + tool.onMouseUp = (event: paper.ToolEvent) => { this.dragging = false; - this.project.view.element.style.cursor = this.hovering ? 'pointer' : 'default'; + ScrollbarComponent.anyIsDragging = false; + this.project.view.element.style.cursor = this.scrollbarHovering ? 'pointer' : 'default'; + if (!this.containerHovering) { + this.visible = false; + this.resetDefaultTool(); + this.setNormal(); + } else if (!this.scrollbarHovering) { + this.setNormal(); + } }; tool.onMouseDrag = (event: paper.ToolEvent) => { this.setActive(); + this.visible = true; return this.isHorizontal ? this.changeScrollAndContentPosition(event.point.x - offsetPoint.x) : this.changeScrollAndContentPosition(event.point.y - offsetPoint.y); }; - // arrowKeyDown tool is inactive while hovering on the scrollbar and scrollbarDrag is active. this handles keyDown - // events + // arrowKeyDown tool is inactive while hovering on the scrollbar and scrollbarDrag tool is active. this handles + // keyDown events tool.onKeyDown = (event) => { this.moveByKeyDown(event); this.activateDefaultTool(); @@ -561,11 +646,12 @@ export class ScrollbarComponent extends paper.Group { * @param event {paper.KeyEvent} */ private moveByKeyDown(event: paper.KeyEvent) { - const validKeyPress = this.isHorizontal - ? event.key === 'left' || event.key === 'right' - : event.key === 'up' || event.key === 'down'; - if (validKeyPress) { - const movementAmount = Math.floor(this.getProportionalLength()); + const horizontalKeys = event.key === 'left' || event.key === 'right'; + const verticalKeys = event.key === 'up' || event.key === 'down'; + const keyPressDirection = (horizontalKeys && 'horizontal') || (verticalKeys && 'vertical'); + + if (keyPressDirection === this.direction) { + const movementAmount = Math.floor(this.getProportionalLength()) / 3; event.preventDefault(); this.setActiveContinuously(); switch (event.key) { @@ -582,6 +668,8 @@ export class ScrollbarComponent extends paper.Group { this.changeScrollAndContentPosition(this.scrollbar.position.x + movementAmount); break; } + } else if (keyPressDirection === ScrollbarComponent.defaultScrollbarDirection) { + ScrollbarComponent._defaultScrollbar!.moveByKeyDown(event); } } diff --git a/src/components/vapp-edge-label.test.ts b/src/components/vapp-edge-label.test.ts new file mode 100644 index 0000000..446227c --- /dev/null +++ b/src/components/vapp-edge-label.test.ts @@ -0,0 +1,21 @@ +import { VappEdgeLabelComponent } from './vapp-edge-label'; +import * as paper from 'paper'; + +describe('vapp edge label component', () => { + + beforeAll(() => { + const canvasEl = document.createElement('canvas'); + paper.setup(canvasEl); + paper.settings.applyMatrix = false; + }); + + test('basic properties', () => { + const text = 'test name'; + const position = new paper.Point(35, 80); + const label = new VappEdgeLabelComponent(text, position); + expect(label.text).toBe(text); + expect(label.position.x).toBe(position.x); + expect(label.position.y).toBe(position.y); + }); + +}); diff --git a/src/components/vapp-edge-label.ts b/src/components/vapp-edge-label.ts new file mode 100644 index 0000000..369394e --- /dev/null +++ b/src/components/vapp-edge-label.ts @@ -0,0 +1,56 @@ +import * as paper from 'paper'; +import { EntityLabelComponent } from './entity-label'; +import { VAPP_BACKGROUND_COLOR, LIGHT_GREY } from '../constants/colors'; +import { CONNECTOR_RADIUS, DEFAULT_STROKE_WIDTH, LABEL_HEIGHT, CONNECTOR_MARGIN } from '../constants/dimensions'; +import { DEFAULT_STROKE_STYLE } from '../constants/styles'; + +const ICON_COLOR = '#EBB86C'; + +/** + * Vapp Nat-Routed Edge Label Visual Component. + */ +export class VappEdgeLabelComponent extends paper.Group { + + readonly _label: EntityLabelComponent; + + /** + * Creates a new VappEdgeLabelComponent instance. + * + * @param _text the name of the vApp edge's parent network + * @param _point the location that the component will be rendered at + */ + constructor(private _text: string, + private _point: paper.Point = new paper.Point(0, 0)) { + super(); + this.pivot = new paper.Point(0, 0); + this.position = _point; + + this._label = new EntityLabelComponent(this._text, ICON_COLOR); + this._label.background.style = { + ...DEFAULT_STROKE_STYLE, + fillColor: VAPP_BACKGROUND_COLOR + }; + this.addChild(this._label); + + // create the top and bottom arcs that connects the label to the network path + const connectorTopArc = new paper.Path.Arc({ + from: new paper.Point(-CONNECTOR_RADIUS, 0), + through: new paper.Point(0, -CONNECTOR_RADIUS), + to: new paper.Point(CONNECTOR_RADIUS, 0), + pivot: new paper.Point(0, 0), + position: new paper.Point(CONNECTOR_MARGIN + CONNECTOR_RADIUS - DEFAULT_STROKE_WIDTH / 2, 0), + fillColor: LIGHT_GREY, + parent: this + }); + const connectorBottomArc = connectorTopArc.clone(); + connectorBottomArc.rotate(180); + connectorBottomArc.position.y = LABEL_HEIGHT; + } + + /** + * Gets the label text. + */ + get text(): string { + return this._text; + } +} diff --git a/src/components/vapp-network-list.test.ts b/src/components/vapp-network-list.test.ts new file mode 100644 index 0000000..c3f14de --- /dev/null +++ b/src/components/vapp-network-list.test.ts @@ -0,0 +1,62 @@ +import { VappNetworkListComponent } from './vapp-network-list'; +import { VappNetworkData } from './vapp-network'; +import * as paper from 'paper'; + +describe('vapp component', () => { + + beforeAll(() => { + const canvasEl = document.createElement('canvas'); + paper.setup(canvasEl); + paper.settings.applyMatrix = false; + }); + + test('basic properties', () => { + const vappNetworksData: VappNetworkData[] = [ + { + uuid: '0', + name: '172.16.55.0 Failover Network 1', + vapp_uuid: '', + fence_mode: 'BRIDGED' + }, + { + uuid: '1', + name: '172.16.55.0 Failover Network 2', + vapp_uuid: '', + fence_mode: 'NAT_ROUTED' + }, + { + uuid: '2', + name: '172.16.55.0 Failover Network 3', + vapp_uuid: '', + fence_mode: 'ISOLATED' + } + ]; + const position = new paper.Point(0, 0); + const networks = new VappNetworkListComponent(vappNetworksData); + vappNetworksData.forEach((data, i) => { + expect(networks.data[i].uuid).toBe(data.uuid); + expect(networks.data[i].name).toBe(data.name); + expect(networks.data[i].vapp_uuid).toBe(data.vapp_uuid); + expect(networks.data[i].fence_mode).toBe(data.fence_mode); + }); + expect(networks.position.x).toBe(position.x); + expect(networks.position.y).toBe(position.y); + expect(networks.children.length).toBe(3); // 3 paths created from data + }); + + test('with no networks', () => { + const vappNetworksData: VappNetworkData[] = []; + const position = new paper.Point(30, 40); + const networks = new VappNetworkListComponent(vappNetworksData, position); + vappNetworksData.forEach((data, i) => { + expect(networks.data[i].uuid).toBe(data.uuid); + expect(networks.data[i].name).toBe(data.name); + expect(networks.data[i].vapp_uuid).toBe(data.vapp_uuid); + expect(networks.data[i].fence_mode).toBe(data.fence_mode); + }); + expect(networks.position.x).toBe(position.x); + expect(networks.position.y).toBe(position.y); + expect(networks.children.length).toBe(0); // no paths + }); + +}); diff --git a/src/components/vapp-network-list.ts b/src/components/vapp-network-list.ts new file mode 100644 index 0000000..c762e88 --- /dev/null +++ b/src/components/vapp-network-list.ts @@ -0,0 +1,113 @@ +import * as paper from 'paper'; +import { VappNetworkData, VappNetworkComponent } from './vapp-network'; +import { LowestVnicPointByNetworkName } from './vm-and-vnic-list'; +import { VAPP_NETWORK_RIGHT_MARGIN } from '../constants/dimensions'; + +/** + * Interface for network positions by network name. + */ +export interface VappNetworkPositionsByName { + [name: string]: paper.Point; +} + +/** + * Virtual Application Network Visual Component. + */ +export class VappNetworkListComponent extends paper.Group { + // store network position by network name for matching vnic horizontal positioning + private _networkPositionsByName: VappNetworkPositionsByName = {}; + // list of network paths for easier iteration + private networkPathList: VappNetworkComponent[] = []; + // number of isolated networks used to stagger the height of isolated network labels + private isolatedNetworkCount = 0; + + /** + * Creates a new VappNetworkListComponent instance. + * + * @param vappNetworks the vapp networks data + * @param _point the location that the vapp network list should be rendered at + */ + constructor(private _vappNetworks: VappNetworkData[], + private _point: paper.Point = new paper.Point(0, 0)) { + super(); + this.pivot = new paper.Point(0, 0); + this.position = this._point; + + // create and start the network path at the vapp's top boundary + // number of edge networks used to stagger the height of edge network labels + let edgeNetworkCount = 0; + // TODO: pass in the y coordinate of matching org-vdc network when they are eventually included + this._vappNetworks.forEach((networkData, index) => { + const network = new VappNetworkComponent( + networkData, + new paper.Point(VAPP_NETWORK_RIGHT_MARGIN * index, 0), + edgeNetworkCount + ); + this._networkPositionsByName[networkData.name] = network.position; + this.networkPathList.push(network); + // insert at the bottom below any existing vapp network edge labels that were added in the VappNetworkComponent + this.insertChild(0, network); + if (networkData.fence_mode === 'NAT_ROUTED') { + edgeNetworkCount++; + } else if (networkData.fence_mode === 'ISOLATED') { + this.isolatedNetworkCount++; + } + }); + } + + /** + * Gets the position of the last vApp network (furthest right). + */ + get lastNetworkPosition(): paper.Point { + const listCount = this.networkPathList.length; + return listCount ? this.networkPathList[listCount - 1].position : new paper.Point(0, 0); + } + + /** + * Gets the vApp Network data. + */ + get data(): VappNetworkData[] { + return this._vappNetworks; + } + + /** + * Gets vApp network positions by name data for horizontally position matching VNICs. + */ + get networkPositionsByName(): VappNetworkPositionsByName { + return this._networkPositionsByName; + } + + /** + * Sets the vapp network top segment (above the vapp background) and the bottom segment (inside the vapp). + * @param lowestVnicPointByNetworkName lowest vnic position by network name from VmAndVnicListComponent + */ + setTopAndBottomSegments(lowestVnicPointByNetworkName: LowestVnicPointByNetworkName) { + this.networkPathList.forEach(network => { + // top segment above vApp background + network.setTopmostSegmentAndConnection(this.isolatedNetworkCount); + // bottom segment within vApp drawn to the lowest attached VNIC or disconnected if it has no VNICs + if (lowestVnicPointByNetworkName[network.data.name]) { + network.setBottommostSegment(lowestVnicPointByNetworkName[network.data.name].y); + } else { + network.setDisconnected(); + } + if (network.data.fence_mode === 'ISOLATED') { + this.isolatedNetworkCount--; + } + }); + } + + /** + * Clones the bottom segment of vapp networks to separate for scrolling and removes the original segment. + * @param splitPositionY vertical point where the network path should be split for cloning and separation + */ + cloneVmListSegments(splitPositionY: number): paper.Group { + const clones = new paper.Group(); + clones.pivot = new paper.Point(0, 0); + clones.position = this._point; + this.networkPathList.forEach(network => { + clones.addChild(network.cloneAndSplit(splitPositionY)); + }); + return clones; + } +} diff --git a/src/components/vapp-network.test.ts b/src/components/vapp-network.test.ts new file mode 100644 index 0000000..1c5c104 --- /dev/null +++ b/src/components/vapp-network.test.ts @@ -0,0 +1,111 @@ +import { VappNetworkComponent, VappNetworkData } from './vapp-network'; +import * as paper from 'paper'; +import { CONNECTOR_RADIUS, VAPP_PADDING, LABEL_HEIGHT } from '../constants/dimensions'; + +describe('vapp component', () => { + + beforeAll(() => { + const canvasEl = document.createElement('canvas'); + paper.setup(canvasEl); + paper.settings.applyMatrix = false; + }); + + test('basic properties', () => { + const vappNetworkData: VappNetworkData = { + uuid: '0', + name: 'A', + vapp_uuid: '', + fence_mode: 'BRIDGED' + }; + const position = new paper.Point(0, 0); + const network = new VappNetworkComponent(vappNetworkData); + expect(network.data.uuid).toBe(vappNetworkData.uuid); + expect(network.data.name).toBe(vappNetworkData.name); + expect(network.data.vapp_uuid).toBe(vappNetworkData.vapp_uuid); + expect(network.data.fence_mode).toBe(vappNetworkData.fence_mode); + expect(network.position.x).toBe(position.x); + expect(network.position.y).toBe(position.y); + expect(network.children.length).toBe(1); // path only + }); + + test('nat-routed topmost segment', () => { + const vappNetworkData: VappNetworkData = { + uuid: '0', + name: 'A', + vapp_uuid: '', + fence_mode: 'NAT_ROUTED' + }; + const edgeCount = 0; + const topmostPointY = 20; + const position = new paper.Point(40, 50); + const isolatedCount = 0; + const network = new VappNetworkComponent(vappNetworkData, position, edgeCount, topmostPointY); + network.setTopmostSegmentAndConnection(isolatedCount); + expect(network.children.length).toBe(3); // path, edge label, and connection + expect(network.path.bounds.top).toBe(-(topmostPointY + CONNECTOR_RADIUS)); + }); + + test('isolated topmost segment', () => { + const vappNetworkData: VappNetworkData = { + uuid: '0', + name: 'A', + vapp_uuid: '', + fence_mode: 'ISOLATED' + }; + const position = new paper.Point(0, 0); + const isolatedCount = 1; + const network = new VappNetworkComponent(vappNetworkData, position); + network.setTopmostSegmentAndConnection(isolatedCount); + expect(network.children.length).toBe(2); // path and connection + // (CONNECTOR_RADIUS + MULTIPLE_ISOLATED_NETWORK_PADDING) * isolatedCount + ISOLATED_NETWORK_PADDING ~ 11/2 + 13 + 5 + expect(network.path.bounds.top).toBe(-23.5); + }); + + test('setBottommostSegment method', () => { + const vappNetworkData: VappNetworkData = { + uuid: '0', + name: 'A', + vapp_uuid: '', + fence_mode: 'ISOLATED' + }; + const pointY = 20; + const network = new VappNetworkComponent(vappNetworkData); + network.setBottommostSegment(pointY); + expect(network.children.length).toBe(1); // path only + expect(network.path.bounds.bottom).toBe(pointY); + }); + + test('setDisconnected method', () => { + const vappNetworkData: VappNetworkData = { + uuid: '0', + name: 'A', + vapp_uuid: '', + fence_mode: 'ISOLATED' + }; + const network = new VappNetworkComponent(vappNetworkData); + network.setDisconnected(); + expect(network.children.length).toBe(2); // path and bullet point connection icon component + expect(network.path.bounds.bottom).toBe(VAPP_PADDING + LABEL_HEIGHT / 2); + }); + + test('cloneAndSplit method', () => { + const vappNetworkData: VappNetworkData = { + uuid: '0', + name: 'A', + vapp_uuid: '', + fence_mode: 'BRIDGED' + }; + const position = new paper.Point(85, 10); + const network = new VappNetworkComponent(vappNetworkData, position); + const length = 100; + const split = 50; + network.setTopmostSegmentAndConnection(0); + network.setBottommostSegment(length); + const initialSegmentCount = network.path.segments.length; + const clone = network.cloneAndSplit(split); + expect(network.path.segments.length).toBe(initialSegmentCount); // a segment was added and a segment was removed + expect(clone.length).toBe(length - split); + expect(clone.position.x).toBe(network.path.position.x + position.x); + }); + +}); diff --git a/src/components/vapp-network.ts b/src/components/vapp-network.ts new file mode 100644 index 0000000..9d058ef --- /dev/null +++ b/src/components/vapp-network.ts @@ -0,0 +1,160 @@ +import * as paper from 'paper'; +import { CANVAS_BACKGROUND_COLOR } from '../constants/colors'; +import { IsolatedNetworkLabelComponent } from './isolated-network-label'; +import { VappEdgeLabelComponent } from './vapp-edge-label'; +import { ConnectionIconComponent } from './connection-icon'; +import { BulletPointConnectionIconComponent } from './bullet-point-connection-icon'; +import { CONNECTOR_RADIUS, CONNECTOR_MARGIN, VAPP_PADDING, LABEL_HEIGHT, DEFAULT_STROKE_WIDTH, LABEL_BOTTOM_PADDING } + from '../constants/dimensions'; +import { DEFAULT_STROKE_STYLE } from '../constants/styles'; + +const MULTIPLE_ISOLATED_NETWORK_PADDING = 13; +const ISOLATED_NETWORK_PADDING = 5; + +/** + * Enumeration of vApp fence modes. + */ +export type FenceMode = 'BRIDGED' | 'NAT_ROUTED' | 'ISOLATED'; + +/** + * Interface for vApp network data. + */ +export interface VappNetworkData { + uuid: string; + name: string; + vapp_uuid: string; + fence_mode: FenceMode; +} + +/** + * Virtual Application Network Visual Component. + */ +export class VappNetworkComponent extends paper.Group { + + readonly _path: paper.Path.Line; + // connection icon or isolated network label at the top of the network path + private connectionComponent: ConnectionIconComponent | IsolatedNetworkLabelComponent; + readonly edgeLabel: VappEdgeLabelComponent; + readonly isNatRouted: boolean = false; + readonly isIsolated: boolean = false; + // network has no attached vnics + private isDisconnected: boolean = false; + + /** + * Creates a new VappNetworkComponent instance. + * + * @param _vappNetwork the vapp network data + * @param _point the location that the vapp network should be rendered at + * @param edgeNetworkCount the number of nat-routed networks + * @param topmostPointY the y coordinate of the matching org-vdc network it will be connected to + */ + // TODO: 59 is hardcoded to topmostPointY in for the vapp demo. replace it with the matching org-vdc vertical position + // when it's eventually added + constructor(private _vappNetwork: VappNetworkData, + private _point: paper.Point = new paper.Point(0, 0), + private edgeNetworkCount: number = 0, + private topmostPointY: number = 59) { + super(); + this.pivot = new paper.Point(0, 0); + this.position = this._point; + + this.isNatRouted = this._vappNetwork.fence_mode === 'NAT_ROUTED'; + this.isIsolated = this._vappNetwork.fence_mode === 'ISOLATED'; + + // create and start the network path at the vApp top boundary + this._path = new paper.Path.Line({ + segment: new paper.Point(0, 0), + style: DEFAULT_STROKE_STYLE, + parent: this + }); + + // add vapp edge label if needed + if (this.isNatRouted) { + const pointX = -CONNECTOR_MARGIN - CONNECTOR_RADIUS; + const pointY = (LABEL_HEIGHT + LABEL_BOTTOM_PADDING + DEFAULT_STROKE_WIDTH) * edgeNetworkCount + + VAPP_PADDING + LABEL_HEIGHT + LABEL_BOTTOM_PADDING; + this.edgeLabel = new VappEdgeLabelComponent(this._vappNetwork.name, new paper.Point(pointX, pointY)); + this.addChild(this.edgeLabel); + } + } + + /** + * Gets the network path. + */ + get path(): paper.Path.Line { + return this._path; + } + + /** + * Gets the VAppNetworkData. + */ + get data(): VappNetworkData { + return this._vappNetwork; + } + + /** + * Sets the topmost segment outside of the vApp and connection icon. + * @param isolatedCount the number of isolated networks used to determine the topmost point. + */ + setTopmostSegmentAndConnection(isolatedCount: number): void { + // create the topmost point + const topmostPositionY = this.isIsolated + ? (CONNECTOR_RADIUS + MULTIPLE_ISOLATED_NETWORK_PADDING) * isolatedCount + ISOLATED_NETWORK_PADDING + : this.topmostPointY + CONNECTOR_RADIUS; + const topmostPoint = new paper.Point(0, -topmostPositionY); + // add the topmost point to the path + this.path.add(topmostPoint); + // add the connection component based on network's fence mode + this.connectionComponent = this.isIsolated + ? new IsolatedNetworkLabelComponent(this._vappNetwork.name, topmostPoint) + : new ConnectionIconComponent(topmostPoint, CANVAS_BACKGROUND_COLOR); + this.addChild(this.connectionComponent); + } + + /** + * Sets the bottommost segment if the vApp has at least one attached VNIC. + * @param pointY the y coordinate of the vApp network's lowest attached VNIC. + */ + setBottommostSegment(pointY: number): void { + this._path.add(new paper.Point(0, pointY)); + } + + /** + * Sets the bottommost segment and connection icon if the vApp has no attached VNICs. + */ + setDisconnected(): void { + this.isDisconnected = true; + if (!this.isNatRouted) { + // add the bottommost point and disconnected icon next to the vapp label + this._path.add(new paper.Point(0, VAPP_PADDING + LABEL_HEIGHT / 2)); + this.addChild(new BulletPointConnectionIconComponent(this._path.bounds.bottomCenter)); + } else { + // add the bottommost point at the edge label's position + this.path.add(new paper.Point(0, this.edgeLabel.position.y)); + } + } + + /** + * Clones and splits the bottom segment in the VmAndVnicListComponent if scrolling is required. + * @param splitPositionY vertical position where the network path should be split for cloning and separation + */ + cloneAndSplit(splitPositionY: number): paper.Path { + let clone: paper.Path = new paper.Path(); + // disconnected networks (without any attached vnics) would not have a segment in the vm and vnic list component + if (!this.isDisconnected) { + // add new point to split the path at the top of the vm list + this._path.add(new paper.Point(0, splitPositionY)); + const segments = this._path.segments; + // clone last segment + clone = new paper.Path.Line({ + from: segments[3].point, + to: segments[4].point, + style: DEFAULT_STROKE_STYLE + }); + clone.position.x = this.position.x; + // remove the original reference segment + segments[3].remove(); + } + return clone; + } +} diff --git a/src/components/vapp.test.ts b/src/components/vapp.test.ts new file mode 100644 index 0000000..f8dd87a --- /dev/null +++ b/src/components/vapp.test.ts @@ -0,0 +1,81 @@ +import { VappComponent, VappData } from './vapp'; +import * as paper from 'paper'; + +describe('vapp component', () => { + + beforeAll(() => { + const xhrMockClass = () => ({ + open : jest.fn(), + send : jest.fn(), + setRequestHeader: jest.fn() + }); + (window as any).XMLHttpRequest = jest.fn().mockImplementation(xhrMockClass); + const canvasEl = document.createElement('canvas'); + paper.setup(canvasEl); + }); + + test('basic properties', () => { + const vappData: VappData = { + uuid: '', + name: 'Coke RES & BURST', + vapp_networks: [ + { + uuid: '0', + name: 'A', + vapp_uuid: '', + fence_mode: 'BRIDGED' + } + ], + vms: [ + { + uuid: '', + name: 'Alert Resource Non Regression VM', + vapp_uuid: '', + operatingSystem: 'windows7Guest', + vnics: [ + { + vnic_id: 0, + network_name: 'A', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'AutomatedSecurityTest1', + vapp_uuid: '', + operatingSystem: 'windows7Guest', + vnics: [ + { + vnic_id: 0, + network_name: 'A', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'CatalogResourceNonRegression1', + vapp_uuid: '', + operatingSystem: 'ubuntu64Guest', + vnics: [ + { + vnic_id: 0, + network_name: 'A', + is_connected: true + } + ] + } + ] + }; + const position = new paper.Point(20, 15); + const vapp = new VappComponent(vappData, position); + expect(vapp.data.uuid).toBe(vappData.uuid); + expect(vapp.data.name).toBe(vappData.name); + expect(vapp.data.vapp_networks).toBe(vappData.vapp_networks); + expect(vapp.data.vms).toBe(vappData.vms); + expect(vapp.position.x).toBe(position.x); + expect(vapp.position.y).toBe(position.y); + }); + +}); diff --git a/src/components/vapp.ts b/src/components/vapp.ts new file mode 100644 index 0000000..b05ea02 --- /dev/null +++ b/src/components/vapp.ts @@ -0,0 +1,221 @@ +import * as paper from 'paper'; +import { EntityLabelComponent } from './entity-label'; +import { VmData } from './vm'; +import { VmAndVnicListComponent } from './vm-and-vnic-list'; +import { VAPP_BACKGROUND_COLOR } from '../constants/colors'; +import { CONNECTOR_RADIUS, VIEW_BOTTOM_PADDING, DEFAULT_SCROLLBAR_THICKNESS, VAPP_NETWORK_RIGHT_MARGIN, VAPP_PADDING, + DEFAULT_STROKE_WIDTH } from '../constants/dimensions'; +import { MarginComponent } from './margin'; +import { VappNetworkData } from './vapp-network'; +import { VappNetworkListComponent } from './vapp-network-list'; +import { ScrollbarComponent } from './scrollbar'; + +const MARGIN_RIGHT = 30; +const BACKGROUND_RADIUS = 5; +const LABEL_ICON_COLOR = '#CA67B8'; +const VAPP_LABEL_BOTTOM_MARGIN = 20; +const EDGE_LABEL_BOTTOM_MARGIN = 15; + +/** + * Interface for vApp data. + */ +export interface VappData { + uuid: string; + name: string; + vapp_networks: VappNetworkData[]; + vms: VmData[]; +} + +/** + * Virtual Application Visual Component. + */ +export class VappComponent extends paper.Group { + + private label: EntityLabelComponent; + private background: paper.Path.Rectangle; + private _margin: MarginComponent; + private vms: VmAndVnicListComponent; + private vappNetworks: VappNetworkListComponent; + // position for the division between any labels and vm/vnic list + private divisionPositionY: number; + private scrollbar: ScrollbarComponent; + // content needs scrollbar + private _isScrollable: boolean = false; + + /** + * Creates a new VappComponent instance. + * + * @param _vapp the vapp data + * @param _point the location that the vm should be rendered at + */ + constructor(private _vapp: VappData, + private _point: paper.Point = new paper.Point(0, 0)) { + super(); + this.pivot = new paper.Point(0, 0); + this.position = _point; + + let backgroundOffsetX = 0; + let backgroundOffsetY = 0; + const vappNetworkCount = this._vapp.vapp_networks.length; + const vmCount = this._vapp.vms.length; + + // vapp label + // x position based on if there are any vApp networks (and how many) or vms + const labelPositionX = vappNetworkCount || vmCount + ? Math.max(1, vappNetworkCount) * VAPP_NETWORK_RIGHT_MARGIN + VAPP_PADDING + : VAPP_PADDING; + this.label = new EntityLabelComponent( + this._vapp.name, + LABEL_ICON_COLOR, + new paper.Point(labelPositionX, VAPP_PADDING), + { fontWeight: 'bold' } + ); + this.addChild(this.label); + + // start vapp network paths - add a point to each network to have x position data for matching vnics + // and edge labels to have y position data for vapp header (which includes the vapp label and edge labels) + if (vappNetworkCount) { + this.vappNetworks = new VappNetworkListComponent( + this._vapp.vapp_networks, + new paper.Point(VAPP_PADDING + CONNECTOR_RADIUS, 0)); + this.addChild(this.vappNetworks); + } + + // calculate division position between the vapp header (which includes the vapp label and edge labels) and vm + // and vnic list + const headerBase = this.globalToLocal(this.bounds.bottomLeft).y; + // headerBase will equal vapp label bottom if there are no edge labels. margin based on which label is positionally + // the lowest. + const vappLabelIsBottomMost = headerBase === this.label.bounds.bottom; + const headerBaseMargin = vappLabelIsBottomMost ? VAPP_LABEL_BOTTOM_MARGIN : EDGE_LABEL_BOTTOM_MARGIN; + this.divisionPositionY = headerBase + headerBaseMargin; + + // maximum bottom position for vApp background based on the canvas view size + const maxBottomPosition = this.globalToLocal(paper.view.bounds.bottomLeft).y - VIEW_BOTTOM_PADDING; + + // vms and vnics list + this.vms = new VmAndVnicListComponent( + this._vapp.vms, + this.vappNetworks && this.vappNetworks.networkPositionsByName, + this.vappNetworks && this.vappNetworks.lastNetworkPosition, + new paper.Point(VAPP_PADDING + DEFAULT_STROKE_WIDTH, this.divisionPositionY)); + this.addChild(this.vms); + + this._isScrollable = this.vms.bounds.bottom > maxBottomPosition; + + // finish VappNetworks - draw the top external segment and internal segment based on matching vnic positions + if (vappNetworkCount) { + this.vappNetworks.setTopAndBottomSegments(this.vms.lowestVnicPointByNetworkName); + // offset based on any edge labels that extend too far left (if it's on the first left-most vapp network) + backgroundOffsetX = VAPP_PADDING - this.vappNetworks.bounds.left; + // offset based on the vapp network paths' top external segment + backgroundOffsetY = -this.vappNetworks.bounds.top + VAPP_PADDING; + } + + // handles case where the vapp edge label is the bottom most child (when a nat-routed network has no attached + // vnics and there are no vms) and updates background offset to accommodate the label's bottom arc icon + if (!vmCount && !vappLabelIsBottomMost) { + backgroundOffsetY += CONNECTOR_RADIUS; + } + + // background - based on the bounds of all current items and placed beneath everything + const content = this.bounds; + this.background = new paper.Path.Rectangle({ + size: new paper.Size( + content.width + VAPP_PADDING * 2 - backgroundOffsetX, + this._isScrollable ? maxBottomPosition : content.height + VAPP_PADDING * 2 - backgroundOffsetY), + point: new paper.Point(0, 0), + radius: BACKGROUND_RADIUS, + fillColor: VAPP_BACKGROUND_COLOR + }); + this.insertChild(0, this.background); + + // set up scrolling if necessary + if (this._isScrollable) { + this.clipAndScrollVmList(); + } + + // margin - used by other vapps for static or dynamic positioning + this._margin = new MarginComponent(this.background, 0, MARGIN_RIGHT, 0, 0); + this.insertChild(0, this._margin); + } + + /** + * Gets the vApp data. + */ + get data(): VappData { + return this._vapp; + } + + /** + * Gets the margin. + */ + get margin(): MarginComponent { + return this._margin; + } + + /** + * Clips and adds scrolling to the VmAndVnicList component when it's too large for the view. + */ + private clipAndScrollVmList(): void { + // create drop shadow at the top of the vm list that fades in or out onScroll + const dropShadow = new paper.Path.Rectangle({ + point: new paper.Point(0, 0), + size: new paper.Size(this.bounds.width, this.divisionPositionY), + opacity: 0, + style: { + fillColor: VAPP_BACKGROUND_COLOR, + shadowColor: new paper.Color(0, 0, 0, 0.25), + shadowBlur: 5, + shadowOffset: new paper.Point(0, 2) + } + }); + + // clip mask container that will clip any vm vnic list component items outside of the vapp background + const vmListClipMask = new paper.Path.Rectangle( + new paper.Point(0, this.divisionPositionY), + new paper.Point(this.background.bounds.bottomRight)); + + // clones segment of vapp network paths inside the vm vnic list component to separate for scrolling + const vappNetworkClone = this.vappNetworks.cloneVmListSegments(this.divisionPositionY); + + // items that will be scrollable + const scrollableContent = new paper.Group({ + children: [vappNetworkClone, this.vms] + }); + + // apply clip mask + // tslint:disable-next-line + new paper.Group({ + children: [vmListClipMask, scrollableContent, dropShadow], + clipped: true, + parent: this + }); + + // scrollbar set up + const scrollbarPadding = 5; + this.scrollbar = new ScrollbarComponent({ + content: scrollableContent, + container: this, + containerBounds: vmListClipMask.bounds, + contentOffsetEnd: VAPP_PADDING / 2 + }, + new paper.Point(vmListClipMask.bounds.right - DEFAULT_SCROLLBAR_THICKNESS - scrollbarPadding, + this.divisionPositionY), + vmListClipMask.bounds.height - VAPP_PADDING, + 'vertical' + ); + this.addChild(this.scrollbar); + // drop shadow fades in or out onScroll + this.scrollbar.setCustomEffects({ + setActive: function() { + dropShadow.opacity = 1; + }, + setNormal: function() { + (dropShadow as any).tweenTo({ + opacity: 0 + }, 150); + } + }); + } +} diff --git a/src/components/vm-and-vnic-list.test.ts b/src/components/vm-and-vnic-list.test.ts new file mode 100644 index 0000000..6d2dd38 --- /dev/null +++ b/src/components/vm-and-vnic-list.test.ts @@ -0,0 +1,207 @@ +import { VmAndVnicListComponent, LowestVnicPointByNetworkName } from './vm-and-vnic-list'; +import { VappNetworkPositionsByName } from './vapp-network-list'; +import { VmData } from './vm'; +import * as paper from 'paper'; +import { CONNECTOR_RADIUS, DEFAULT_STROKE_WIDTH, LABEL_HEIGHT, VM_MARGIN_VERTICAL } from '../constants/dimensions'; + +describe('vapp component', () => { + + beforeAll(() => { + const xhrMockClass = () => ({ + open : jest.fn(), + send : jest.fn(), + setRequestHeader: jest.fn() + }); + (window as any).XMLHttpRequest = jest.fn().mockImplementation(xhrMockClass); + const canvasEl = document.createElement('canvas'); + paper.setup(canvasEl); + paper.settings.applyMatrix = false; + }); + + function getExpectedVnicPoints(vmData: VmData[], + networkPositions: VappNetworkPositionsByName, + position: paper.Point): LowestVnicPointByNetworkName { + const expectedVnicPoints: LowestVnicPointByNetworkName = {}; + let networkCount = 0; + vmData.forEach(vm => { + vm.vnics.forEach(vnic => { + if (expectedVnicPoints[vnic.network_name]) { + expectedVnicPoints[vnic.network_name].y += LABEL_HEIGHT + VM_MARGIN_VERTICAL; + } else { + expectedVnicPoints[vnic.network_name] = + new paper.Point( + networkPositions[vnic.network_name].x + position.x + CONNECTOR_RADIUS - DEFAULT_STROKE_WIDTH, + networkCount * (LABEL_HEIGHT + VM_MARGIN_VERTICAL) + LABEL_HEIGHT / 2 + position.y); + networkCount++; + } + }); + }); + return expectedVnicPoints; + } + + test('basic properties and vnics on different networks', () => { + const vmsData: VmData[] = [ + { + uuid: '', + name: 'Alert Resource Non Regression VM', + vapp_uuid: '', + operatingSystem: 'windows7Guest', + vnics: [ + { + vnic_id: 0, + network_name: 'A', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'AutomatedSecurityTest1', + vapp_uuid: '', + operatingSystem: 'windows7Guest', + vnics: [ + { + vnic_id: 0, + network_name: 'B', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'CatalogResourceNonRegression1', + vapp_uuid: '', + operatingSystem: 'ubuntu64Guest', + vnics: [ + { + vnic_id: 0, + network_name: 'C', + is_connected: true + } + ] + } + ]; + const networkPositions: VappNetworkPositionsByName = { + A: new paper.Point(0, 30), + B: new paper.Point(20, 30), + C: new paper.Point(40, 30) + }; + const position = new paper.Point(0, 0); + const vms = new VmAndVnicListComponent(vmsData, networkPositions); + vmsData.forEach((data, i) => { + expect(vms.data[i].uuid).toBe(data.uuid); + expect(vms.data[i].name).toBe(data.name); + expect(vms.data[i].operatingSystem).toBe(data.operatingSystem); + expect(vms.data[i].vnics).toBe(data.vnics); + }); + expect(vms.lowestVnicPointByNetworkName).toEqual(getExpectedVnicPoints(vmsData, networkPositions, position)); + expect(vms.position.x).toBe(position.x); + expect(vms.position.y).toBe(position.y); + }); + + test('multiple vnics on same network', () => { + const vmsData: VmData[] = [ + { + uuid: '', + name: 'Alert Resource Non Regression VM', + vapp_uuid: '', + operatingSystem: 'windows7Guest', + vnics: [ + { + vnic_id: 0, + network_name: 'A', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'AutomatedSecurityTest1', + vapp_uuid: '', + operatingSystem: 'windows7Guest', + vnics: [ + { + vnic_id: 0, + network_name: 'A', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'CatalogResourceNonRegression1', + vapp_uuid: '', + operatingSystem: 'ubuntu64Guest', + vnics: [ + { + vnic_id: 0, + network_name: 'A', + is_connected: true + } + ] + } + ]; + const networkPositions: VappNetworkPositionsByName = { + A: new paper.Point(0, 30) + }; + const position = new paper.Point(20, 50); + const vms = new VmAndVnicListComponent(vmsData, networkPositions, networkPositions['A'], position); + expect(vms.lowestVnicPointByNetworkName).toEqual(getExpectedVnicPoints(vmsData, networkPositions, position)); + expect(vms.position.x).toBe(position.x); + expect(vms.position.y).toBe(position.y); + }); + + test('mix vnics on same network or different network', () => { + const vmsData: VmData[] = [ + { + uuid: '', + name: 'Alert Resource Non Regression VM', + vapp_uuid: '', + operatingSystem: 'windows7Guest', + vnics: [ + { + vnic_id: 0, + network_name: 'A', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'AutomatedSecurityTest1', + vapp_uuid: '', + operatingSystem: 'windows7Guest', + vnics: [ + { + vnic_id: 0, + network_name: 'B', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'CatalogResourceNonRegression1', + vapp_uuid: '', + operatingSystem: 'ubuntu64Guest', + vnics: [ + { + vnic_id: 0, + network_name: 'B', + is_connected: true + } + ] + } + ]; + const networkPositions: VappNetworkPositionsByName = { + A: new paper.Point(0, 30), + B: new paper.Point(20, 30) + }; + const position = new paper.Point(60, 10); + const vms = new VmAndVnicListComponent(vmsData, networkPositions, networkPositions['B'], position); + expect(vms.lowestVnicPointByNetworkName).toEqual(getExpectedVnicPoints(vmsData, networkPositions, position)); + expect(vms.position.x).toBe(position.x); + expect(vms.position.y).toBe(position.y); + }); + +}); diff --git a/src/components/vm-and-vnic-list.ts b/src/components/vm-and-vnic-list.ts new file mode 100644 index 0000000..0fd8f52 --- /dev/null +++ b/src/components/vm-and-vnic-list.ts @@ -0,0 +1,97 @@ +import * as paper from 'paper'; +import { VmData, VmComponent } from './vm'; +import { VnicComponent } from './vnic'; +import { CONNECTOR_RADIUS, CONNECTOR_SIZE, CONNECTOR_RIGHT_MARGIN, DEFAULT_STROKE_WIDTH, LABEL_HEIGHT, + VM_MARGIN_VERTICAL } from '../constants/dimensions'; +import { VappNetworkPositionsByName } from './vapp-network-list'; + +/** + * Interface for the lowest vnic point by network name. + */ +export interface LowestVnicPointByNetworkName { + [name: string]: paper.Point; +} + +/** + * Vm List Visual Component. + */ +export class VmAndVnicListComponent extends paper.Group { + + // store lowest vnic point by network name for setting the lowest point of the vapp network path + private _lowestVnicPointByNetworkName: LowestVnicPointByNetworkName = {}; + + /** + * Creates a new VmAndVnicListComponent instance. + * + * @param _vms the vms data + * @param vappNetworkPositionsByName the vapp network positions by name from the VappNetworkListComponent used for + * positioning x value of matching vnics + * @param lastNetworkPosition the position of the last (furthest right) vapp network used for positioning vms and + * unattached vnics + * @param _point the location that the vm and vnic list should be rendered at + */ + constructor(private _vms: Array, + private vappNetworkPositionsByName: VappNetworkPositionsByName = {}, + private lastNetworkPosition: paper.Point = new paper.Point(0, 0), + private _point: paper.Point = new paper.Point(0, 0)) { + super(); + this.pivot = new paper.Point(0, 0); + this.position = _point; + + // reusable values + const hasVappNetworks = Object.keys(this.vappNetworkPositionsByName).length !== 0; + // middle of vm label + const vnicOffsetY = LABEL_HEIGHT / 2; + // offset from vnic pivot in the center to the left stroke bounds + const vnicOffsetX = CONNECTOR_RADIUS - DEFAULT_STROKE_WIDTH; + + // draw vms and their vnics + this._vms.forEach((vmData, index) => { + const sharedPointY = (LABEL_HEIGHT + VM_MARGIN_VERTICAL) * index; + let xPositionMultiplier = hasVappNetworks ? 1 : 0; + + vmData.vnics.forEach(vnicData => { + const matchingNetwork = this.vappNetworkPositionsByName[vnicData.network_name]; + const vnicPointX = matchingNetwork ? matchingNetwork.x : this.getPointX(xPositionMultiplier); + const vnic = new VnicComponent( + vnicData, + new paper.Point(vnicPointX + vnicOffsetX, sharedPointY + vnicOffsetY)); + this.addChild(vnic); + this._lowestVnicPointByNetworkName[vnicData.network_name] = this.localToGlobal(vnic.position); + if (!matchingNetwork) { + xPositionMultiplier++; + } + }); + + const vm = new VmComponent( + vmData, + new paper.Point(this.getPointX(xPositionMultiplier), sharedPointY), + true); + this.addChild(vm); + }); + } + + /** + * Gets array of VM data. + */ + get data(): VmData[] { + return this._vms; + } + + /** + * Gets the lowestVnicPointByNetworkName data. + */ + get lowestVnicPointByNetworkName(): LowestVnicPointByNetworkName { + return this._lowestVnicPointByNetworkName; + } + + /** + * Calculates x position for VMs and unattached VNICs. + * @param multiplier amount to stagger horizontal position by. + */ + private getPointX(multiplier: number): number { + // multiply by the size of the vnic icon including margin and stroke + return multiplier * (CONNECTOR_SIZE + CONNECTOR_RIGHT_MARGIN + DEFAULT_STROKE_WIDTH * 2) + + this.lastNetworkPosition.x; + } +} diff --git a/src/components/vm.test.ts b/src/components/vm.test.ts index 7e15ce2..da38dfc 100644 --- a/src/components/vm.test.ts +++ b/src/components/vm.test.ts @@ -1,7 +1,8 @@ import { VmComponent, VmData } from './vm'; import * as paper from 'paper'; import { LABEL_HORIZONTAL_PADDING, VM_ICON_SIZE } from '../constants/dimensions'; -import { FONT_SIZE, VERTICAL_PADDING_TOP } from './label'; +import { VERTICAL_PADDING_TOP } from './label'; +import { FONT_SIZE } from './label-text'; describe('vm component', () => { @@ -12,26 +13,37 @@ describe('vm component', () => { setRequestHeader: jest.fn() }); (window as any).XMLHttpRequest = jest.fn().mockImplementation(xhrMockClass); + paper.settings.applyMatrix = false; }); test('basic properties', () => { const canvasEl = document.createElement('canvas'); paper.setup(canvasEl); const vmData: VmData = { - name: 'test', uuid: 'uuid', - operatingSystem: 'asianux3_64Guest' + name: 'sandbox.ts', + vapp_uuid: '', + operatingSystem: 'asianux3_64Guest', + vnics: [ + { + vnic_id: 0, + network_name: 'A', + is_connected: true + } + ] }; const position = new paper.Point(60, 25); const vm = new VmComponent(vmData, position); - expect(vm.getVmData().name).toBe(vmData.name); expect(vm.getVmData().uuid).toBe(vmData.uuid); + expect(vm.getVmData().name).toBe(vmData.name); + expect(vm.getVmData().vapp_uuid).toBe(vmData.vapp_uuid); expect(vm.getVmData().operatingSystem).toBe(vmData.operatingSystem); + expect(vm.getVmData().vnics).toBe(vmData.vnics); expect(vm.position.x).toBe(position.x); expect(vm.position.y).toBe(position.y); - expect(vm.getLabelComponent().getTextComponent().position.x).toBe(LABEL_HORIZONTAL_PADDING + VM_ICON_SIZE); - expect(vm.getLabelComponent().getTextComponent().position.y).toBe(VERTICAL_PADDING_TOP + FONT_SIZE); - expect(vm.getLabelComponent().getTextComponent().content).toBe(vmData.name); + expect(vm.getLabelComponent().text.position.x).toBe(LABEL_HORIZONTAL_PADDING + VM_ICON_SIZE); + expect(vm.getLabelComponent().text.position.y).toBe(VERTICAL_PADDING_TOP + FONT_SIZE); + expect(vm.getLabelComponent().text.content).toBe(vmData.name); }); }); diff --git a/src/components/vm.ts b/src/components/vm.ts index 0ef3e12..6ca357c 100644 --- a/src/components/vm.ts +++ b/src/components/vm.ts @@ -4,13 +4,16 @@ import { OperatingSystem } from 'iland-sdk'; import { IconLabelComponent } from './icon-label'; import { CanvasEventService } from '../services/canvas-event-service'; import { Subscription } from 'rxjs'; +import { VnicData } from './vnic'; const SIZE_DELTA_ON_HOVER = 2; export interface VmData { - operatingSystem: OperatingSystem; - name: string; uuid: string; + name: string; + vapp_uuid: string; + operatingSystem: OperatingSystem; + vnics: VnicData[]; } /** @@ -38,11 +41,11 @@ export class VmComponent extends paper.Group { * @param visible whether the component is immediate visible (default is false because typically the component is * rendered with a creation animation */ - constructor(private _vm: VmData, private _point: paper.Point = new paper.Point(0, 0), + constructor(private _vm: VmData, + private _point: paper.Point = new paper.Point(0, 0), visible: boolean = false) { super(); const self = this; - this.applyMatrix = false; this.position = _point; this.pivot = new paper.Point(0, 0); self._label = new IconLabelComponent(self._vm.name, @@ -151,7 +154,7 @@ export class VmComponent extends paper.Group { */ private mouseLeave(event: paper.MouseEvent): void { if (this.hovering) { - const result = this.hitTest(event.point); + const result = this._label.hitTest(this.globalToLocal(event.point)); if (!result) { this.hovering = false; (this as any).tween({ @@ -181,7 +184,7 @@ export class VmComponent extends paper.Group { } /** - * Handler for the containting canvas mouse down event. + * Handler for the containing canvas mouse down event. * @param event {paper.MouseEvent} */ private canvasMouseDown(event: paper.MouseEvent): void { diff --git a/src/components/vnic.test.ts b/src/components/vnic.test.ts new file mode 100644 index 0000000..655bd7e --- /dev/null +++ b/src/components/vnic.test.ts @@ -0,0 +1,44 @@ +import { VnicComponent, VnicData } from './vnic'; +import * as paper from 'paper'; + +describe('vnic component', () => { + + beforeAll(() => { + const canvasEl = document.createElement('canvas'); + paper.setup(canvasEl); + paper.settings.applyMatrix = false; + }); + + test('basic properties', () => { + const vnicData: VnicData = { + vnic_id: 0, + network_name: 'A', + is_connected: true + }; + const position = new paper.Point(60, 25); + const vnic = new VnicComponent(vnicData, position); + expect(vnic.data.vnic_id).toBe(vnicData.vnic_id); + expect(vnic.data.network_name).toBe(vnicData.network_name); + expect(vnic.data.is_connected).toBe(vnicData.is_connected); + expect(vnic.position.x).toBe(position.x); + expect(vnic.position.y).toBe(position.y); + expect(vnic.children.length).toBe(1); + }); + + test('disconnected vnic', () => { + const vnicData: VnicData = { + vnic_id: 0, + network_name: 'A', + is_connected: false + }; + const position = new paper.Point(-60, 25); + const vnic = new VnicComponent(vnicData, position); + expect(vnic.data.vnic_id).toBe(vnicData.vnic_id); + expect(vnic.data.network_name).toBe(vnicData.network_name); + expect(vnic.data.is_connected).toBe(vnicData.is_connected); + expect(vnic.position.x).toBe(position.x); + expect(vnic.position.y).toBe(position.y); + expect(vnic.children.length).toBe(3); + }); + +}); diff --git a/src/components/vnic.ts b/src/components/vnic.ts new file mode 100644 index 0000000..c31ccf9 --- /dev/null +++ b/src/components/vnic.ts @@ -0,0 +1,55 @@ +import * as paper from 'paper'; +import { ConnectionIconComponent } from './connection-icon'; +import { VAPP_BACKGROUND_COLOR } from '../constants/colors'; +import { DEFAULT_STROKE_STYLE } from '../constants/styles'; + +/** + * Interface for vnic data. + */ +export interface VnicData { + vnic_id: number; + network_name: string; + is_connected: boolean; +} + +/** + * Virtual Network Identifier Card Visual Component. + */ +export class VnicComponent extends paper.Group { + + readonly icon: ConnectionIconComponent; + + /** + * Creates a new VnicComponent instance. + * @param vnic The vnic data. + * @param _point The position where the vnic will be rendered at. Default is (0, 0). + */ + constructor(private _vnic: VnicData, + private _point: paper.Point = new paper.Point(0, 0)) { + super(); + this.position = _point; + + this.icon = new ConnectionIconComponent(); + this.addChild(this.icon); + + // draw additional icon visual elements (slash and circle cut) if vnic is disconnected + if (!this._vnic.is_connected) { + let disconnectSlash = new paper.Path.Line(new paper.Point(-17 / 2, 0), new paper.Point(17 / 2, 0)); + disconnectSlash.rotate(-45); + disconnectSlash.style = DEFAULT_STROKE_STYLE; + let disconnectCut = disconnectSlash.clone(); + disconnectCut.style = { + strokeWidth: 6, + strokeColor: VAPP_BACKGROUND_COLOR + }; + this.addChildren([disconnectCut, disconnectSlash]); + } + } + + /** + * Gets the VnicData. + */ + get data(): VnicData { + return this._vnic; + } +} diff --git a/src/constants/colors.ts b/src/constants/colors.ts index 292a27e..1c23bcc 100644 --- a/src/constants/colors.ts +++ b/src/constants/colors.ts @@ -5,3 +5,4 @@ export const LIGHT_GREY = '#87A1B5'; export const VAPP_BACKGROUND_COLOR = '#343B4E'; export const CANVAS_BACKGROUND_COLOR = '#191C28'; +export const WHITE = '#FFFFFF'; diff --git a/src/constants/dimensions.ts b/src/constants/dimensions.ts index 56355bd..9e0f925 100644 --- a/src/constants/dimensions.ts +++ b/src/constants/dimensions.ts @@ -2,6 +2,19 @@ * Dimensional constants that are shared among multiple components should be defined here. */ +export const DEFAULT_STROKE_WIDTH = 1; +export const VIEW_BOTTOM_PADDING = 35; +export const LABEL_HEIGHT = 30; export const LABEL_HORIZONTAL_PADDING = 9; +export const LABEL_BOTTOM_PADDING = 20; +export const DEFAULT_MAX_LABEL_WIDTH = 200; export const VM_ICON_SIZE = 30; +export const VM_MARGIN_VERTICAL = 10; +export const CONNECTOR_SIZE = 11; +export const CONNECTOR_RADIUS = CONNECTOR_SIZE / 2; +export const CONNECTOR_MARGIN = 10; +export const CONNECTOR_RIGHT_MARGIN = 8; +export const SMALL_CONNECTOR_SIZE = 7; export const DEFAULT_SCROLLBAR_THICKNESS = 5; +export const VAPP_PADDING = 20; +export const VAPP_NETWORK_RIGHT_MARGIN = 20; diff --git a/src/constants/styles.ts b/src/constants/styles.ts new file mode 100644 index 0000000..e8ef6a2 --- /dev/null +++ b/src/constants/styles.ts @@ -0,0 +1,10 @@ +/** + * Style constants that are shared among multiple components should be defined here. + */ +import { DEFAULT_STROKE_WIDTH } from './dimensions'; +import { LIGHT_GREY } from './colors'; + +export const DEFAULT_STROKE_STYLE = { + strokeWidth: DEFAULT_STROKE_WIDTH, + strokeColor: LIGHT_GREY +}; diff --git a/src/gibraltar.ts b/src/gibraltar.ts index 013cc82..3d279f6 100644 --- a/src/gibraltar.ts +++ b/src/gibraltar.ts @@ -19,6 +19,9 @@ export class Gibraltar { this.canvas = el as HTMLCanvasElement; } paper.setup(this.canvas); + // apply this setting globally to applicable PaperJS types, so child components behave relatively to their parent + // component. disable on an individual basis by setting the item's applyMatrix property to `true` + paper.settings.applyMatrix = false; } }