Skip to content

Commit

Permalink
feat: controls and dashboards
Browse files Browse the repository at this point in the history
  • Loading branch information
FERNman committed Apr 13, 2020
1 parent 1a9d892 commit 3c7c497
Show file tree
Hide file tree
Showing 14 changed files with 855 additions and 23 deletions.
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,23 @@ The `mouseleave` event fires when the mouse stops hovering one of the charts ele

The event is of type `ChartMouseLeaveEvent`, where `column` is the index of the no-longer hovered column and `row` is the index of the no-longer hovered row.

## Controls and Dashboards

Google Charts supports combining multiple charts into dashboards and giving users controls to manipulate what data they show, see [their documentation](https://developers.google.com/chart/interactive/docs/gallery/controls). Using this library, dashboards can be created easily.

A dashboard component can be instantiated, which can contain child controls and charts. Every control must specify one or more charts they are controlling via their `for` property. It accepts a single chart as well as an array of charts, and one chart can be controlled by multiple controls.

```html
<dashboard [columns]="dashboardColumns" [data]="dashboardData">
<control-wrapper [for]="dashboardChart" [type]="controlFilterType" [options]="controlOptions"></control-wrapper>
<google-chart #dashboardChart type="PieChart" [width]="300" [height]="300"> </google-chart>
</dashboard>
```

When creating dashboards, the charts themselves are not responsible for drawing, which means their `columns` and `data` properties are unused. Instead, the dashboard is responsible for drawing. It therefore accepts data in the same format as charts do through the `columns` and `data` properties.

Note that charts in a dashboard will not be visible if they are not referenced in at least one control.

## Advanced

### Accessing the chart wrapper directly
Expand Down
22 changes: 11 additions & 11 deletions apps/playground/src/app/test/test.component.html
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
<h1>Test Component</h1>
<button (click)="goBack()">Back</button>

<google-chart
width="600"
[title]="chart.title"
[type]="chart.type"
[data]="chart.data"
[columns]="chart.columnNames"
[options]="chart.options"
>
</google-chart>
<dashboard [columns]="['Name', 'Donuts eaten']" [data]="dashboardData">
<control-wrapper
[for]="myChart"
[type]="filterType"
[state]="{ lowValue: 3, highValue: 8 }"
[options]="{ filterColumnLabel: 'Donuts eaten', minValue: 1, maxValue: 10 }"
></control-wrapper>
<google-chart #myChart type="PieChart" [width]="300" [height]="300" [options]="{ pieSliceText: 'value' }"> </google-chart>
</dashboard>

<chart-wrapper [specs]="chartWrapperSpecs"></chart-wrapper>

<google-chart
style="width: 100%;"
Expand All @@ -21,5 +23,3 @@ <h1>Test Component</h1>
[dynamicResize]="true"
>
</google-chart>

<chart-wrapper [specs]="chartWrapperSpecs"></chart-wrapper>
13 changes: 12 additions & 1 deletion apps/playground/src/app/test/test.component.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Location } from '@angular/common';
import { Component } from '@angular/core';
import { ChartType } from 'angular-google-charts';
import { ChartType, FilterType } from 'angular-google-charts';

@Component({
selector: 'app-test',
Expand All @@ -26,6 +26,17 @@ export class TestComponent {
}
};

public dashboardData = [
['Michael', 5],
['Elisa', 7],
['Robert', 3],
['John', 2],
['Jessica', 6],
['Aaron', 1],
['Margareth', 8]
];
public filterType = FilterType.NumberRange;

public chartWrapperSpecs: google.visualization.ChartSpecs = {
chartType: ChartType.AreaChart,
dataTable: [
Expand Down
3 changes: 3 additions & 0 deletions libs/angular-google-charts/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,12 @@

export * from './lib/components/google-chart/google-chart.component';
export * from './lib/components/chart-wrapper/chart-wrapper.component';
export * from './lib/components/dashboard/dashboard.component';
export * from './lib/components/control-wrapper/control-wrapper.component';
export * from './lib/helpers/chart.helper';
export * from './lib/models/events.model';
export * from './lib/models/chart-type.model';
export * from './lib/models/control-type.model';
export * from './lib/components/chart-base/chart-base.component';
export * from './lib/script-loader/script-loader.service';
export * from './lib/google-charts.module';
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {
Output,
SimpleChanges
} from '@angular/core';
import { Subject } from 'rxjs';
import { ReplaySubject } from 'rxjs';

import { ChartErrorEvent, ChartReadyEvent, ChartSelectionChangedEvent } from '../../models/events.model';
import { ScriptLoaderService } from '../../script-loader/script-loader.service';
Expand Down Expand Up @@ -45,7 +45,7 @@ export class ChartWrapperComponent implements ChartBase, OnChanges, OnInit {
public select = new EventEmitter<ChartSelectionChangedEvent>();

private wrapper: google.visualization.ChartWrapper;
private wrapperReadySubject = new Subject<google.visualization.ChartWrapper>();
private wrapperReadySubject = new ReplaySubject<google.visualization.ChartWrapper>(1);
private initialized = false;

constructor(private element: ElementRef, private scriptLoaderService: ScriptLoaderService) {}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
import { SimpleChange } from '@angular/core';
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { EMPTY, of } from 'rxjs';

import { FilterType } from '../../models/control-type.model';
import { ScriptLoaderService } from '../../script-loader/script-loader.service';

import { ControlWrapperComponent } from './control-wrapper.component';

jest.mock('../../script-loader/script-loader.service');

const visualizationMock = {
ControlWrapper: jest.fn(),
events: {
addListener: jest.fn(),
removeAllListeners: jest.fn()
}
};

describe('ControlWrapperComponent', () => {
let component: ControlWrapperComponent;
let fixture: ComponentFixture<ControlWrapperComponent>;

beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [ControlWrapperComponent],
providers: [ScriptLoaderService]
}).compileComponents();
}));

beforeEach(() => {
fixture = TestBed.createComponent(ControlWrapperComponent);
component = fixture.componentInstance;
// No change detection here, we want to invoke the
// lifecycle methods in the unit tests
});

it('should create', () => {
expect(component).toBeTruthy();
});

describe('ngOnInit', () => {
it('should load the `controls` package', () => {
const scriptLoaderService = TestBed.inject(ScriptLoaderService) as jest.Mocked<ScriptLoaderService>;
scriptLoaderService.loadChartPackages.mockReturnValueOnce(EMPTY);

component.ngOnInit();

expect(scriptLoaderService.loadChartPackages).toHaveBeenCalledWith('controls');
});

it('should create the control wrapper after the packages loaded', () => {
const scriptLoaderService = TestBed.inject(ScriptLoaderService) as jest.Mocked<ScriptLoaderService>;
scriptLoaderService.loadChartPackages.mockReturnValueOnce(of(null));

globalThis.google = { visualization: visualizationMock } as any;

const options = {
containerId: 'someid',
controlType: FilterType.ChartRange,
state: { test: 1 },
options: { key: 'value' }
};

// @ts-ignore
component.id = options.containerId;
component.type = options.controlType;
component.state = options.state;
component.options = options.options;

component.ngOnInit();

expect(visualizationMock.ControlWrapper).toHaveBeenCalledWith(options);
expect(component.controlWrapper).toBeTruthy();
});

it('should add event listeners', () => {
const scriptLoaderService = TestBed.inject(ScriptLoaderService) as jest.Mocked<ScriptLoaderService>;
scriptLoaderService.loadChartPackages.mockReturnValueOnce(of(null));

const controlWrapperMock = { setControlType: jest.fn() };
visualizationMock.ControlWrapper.mockReturnValue(controlWrapperMock);

globalThis.google = { visualization: visualizationMock } as any;

component.ngOnInit();

expect(visualizationMock.events.removeAllListeners).toHaveBeenCalledWith(controlWrapperMock);
expect(visualizationMock.events.addListener).toHaveBeenCalledWith(controlWrapperMock, 'ready', expect.any(Function));
expect(visualizationMock.events.addListener).toHaveBeenCalledWith(controlWrapperMock, 'error', expect.any(Function));
expect(visualizationMock.events.addListener).toHaveBeenCalledWith(controlWrapperMock, 'statechange', expect.any(Function));
});

it('should emit wrapper ready event', () => {
const scriptLoaderService = TestBed.inject(ScriptLoaderService) as jest.Mocked<ScriptLoaderService>;
scriptLoaderService.loadChartPackages.mockReturnValueOnce(of(null));

const controlWrapperMock = { setControlType: jest.fn() };
visualizationMock.ControlWrapper.mockReturnValue(controlWrapperMock);

globalThis.google = { visualization: visualizationMock } as any;

const wrapperReadySpy = jest.fn();
component.wrapperReady$.subscribe(event => wrapperReadySpy(event));

component.ngOnInit();

expect(wrapperReadySpy).toHaveBeenCalledTimes(1);
expect(wrapperReadySpy).toHaveBeenCalledWith(controlWrapperMock);
});
});

describe('ngOnChanges', () => {
function changeInput<K extends keyof ControlWrapperComponent>(property: K, newValue: ControlWrapperComponent[K]) {
const oldValue = component[property];
component[property as any] = newValue;
component.ngOnChanges({ [property]: new SimpleChange(oldValue, newValue, oldValue == null) });
}

it('should not throw if component is not yet initialized', () => {
expect(() => component.ngOnChanges({ type: new SimpleChange(null, null, true) })).not.toThrow();
});

it('should update the control type if it changed', () => {
const controlWrapperMock = { setControlType: jest.fn() };
component['_controlWrapper'] = controlWrapperMock as any;
component['initialized'] = true;

const type = FilterType.Category;
changeInput('type', type);

expect(controlWrapperMock.setControlType).toHaveBeenCalledWith(type);
});

it('should update the options if they changed', () => {
const controlWrapperMock = { setOptions: jest.fn() };
component['_controlWrapper'] = controlWrapperMock as any;
component['initialized'] = true;

const options = { key: 'value' };
changeInput('options', options);

expect(controlWrapperMock.setOptions).toHaveBeenCalledWith(options);
});

it('should update the state if it changed', () => {
const controlWrapperMock = { setState: jest.fn() };
component['_controlWrapper'] = controlWrapperMock as any;
component['initialized'] = true;

const state = { from: 'to' };
changeInput('state', state);

expect(controlWrapperMock.setState).toHaveBeenCalledWith(state);
});
});

describe('events', () => {
beforeEach(() => {
const scriptLoaderService = TestBed.inject(ScriptLoaderService) as jest.Mocked<ScriptLoaderService>;
scriptLoaderService.loadChartPackages.mockReturnValueOnce(of(null));

const controlWrapperMock = { setControlType: jest.fn() };
visualizationMock.ControlWrapper.mockReturnValue(controlWrapperMock);

globalThis.google = { visualization: visualizationMock } as any;
});

it('should emit ready event', () => {
const readySpy = jest.fn();
component.ready.subscribe(event => readySpy(event));

// This leads to the component subscribing to all events
component.ngOnInit();

const readyCallback: Function = visualizationMock.events.addListener.mock.calls[0][2];

const eventMock = 'event';
readyCallback(eventMock);

expect(readySpy).toHaveBeenCalledWith(eventMock);
});

it('should emit error event', () => {
const errorSpy = jest.fn();
component.error.subscribe(event => errorSpy(event));

// This leads to the component subscribing to all events
component.ngOnInit();

const errorCallback: Function = visualizationMock.events.addListener.mock.calls[1][2];

const eventMock = 'event';
errorCallback(eventMock);

expect(errorSpy).toHaveBeenCalledWith(eventMock);
});

it('should emit statechange event', () => {
const stateChangeSpy = jest.fn();
component.stateChange.subscribe(event => stateChangeSpy(event));

// This leads to the component subscribing to all events
component.ngOnInit();

const stateChangeCallback: Function = visualizationMock.events.addListener.mock.calls[2][2];

const eventMock = 'event';
stateChangeCallback(eventMock);

expect(stateChangeSpy).toHaveBeenCalledWith(eventMock);
});
});
});
Loading

0 comments on commit 3c7c497

Please sign in to comment.