diff --git a/README.md b/README.md index 5f1bcfa..371533b 100644 --- a/README.md +++ b/README.md @@ -296,20 +296,8 @@ class MyComponent { } ``` -If you don't need a specific chart package but just want to access the `google.charts` namespace, -you can use the method `ScriptLoaderService.loadGoogleCharts()`. -It will load the script into the current browser session. - -```typescript -ngOnInit() { - this.loaderService.loadGoogleCharts().subscribe(() => { - console.log(this.loaderService.isGoogleChartsAvailable()); // true - - // You can now use `google.charts` - google.charts.load(); - }); -} -``` +The `loadChartPackages` method can also be called without any parameters. This way, only the default +google charts packages will be loaded. These include the namespaces `google.charts` and `google.visualization`, but no charts. ### Preloading the Google Charts script diff --git a/libs/angular-google-charts/src/index.ts b/libs/angular-google-charts/src/index.ts index a930968..77c1c27 100644 --- a/libs/angular-google-charts/src/index.ts +++ b/libs/angular-google-charts/src/index.ts @@ -7,6 +7,6 @@ export * from './lib/components/chart-wrapper/chart-wrapper.component'; export * from './lib/helpers/chart.helper'; export * from './lib/models/events.model'; export * from './lib/models/chart-type.model'; -export { Column } from './lib/models/chart-base.model'; +export * from './lib/components/chart-base/chart-base.component'; export * from './lib/script-loader/script-loader.service'; export * from './lib/google-charts.module'; diff --git a/libs/angular-google-charts/src/lib/models/chart-base.model.ts b/libs/angular-google-charts/src/lib/components/chart-base/chart-base.component.ts similarity index 53% rename from libs/angular-google-charts/src/lib/models/chart-base.model.ts rename to libs/angular-google-charts/src/lib/components/chart-base/chart-base.component.ts index 0b5b374..6bd9afb 100644 --- a/libs/angular-google-charts/src/lib/models/chart-base.model.ts +++ b/libs/angular-google-charts/src/lib/components/chart-base/chart-base.component.ts @@ -1,22 +1,27 @@ -import { EventEmitter } from '@angular/core'; +import { Directive, EventEmitter, Output } from '@angular/core'; +import { Observable } from 'rxjs'; -import { ChartErrorEvent, ChartReadyEvent, ChartSelectionChangedEvent } from './events.model'; +import { ChartErrorEvent, ChartReadyEvent, ChartSelectionChangedEvent } from '../../models/events.model'; export type Column = string | google.visualization.ColumnSpec; export type Row = (string | number | Date)[]; -export interface ChartBase { +@Directive() +// tslint:disable-next-line: directive-class-suffix +export class ChartBase { /** * The chart is ready for external method calls. * * Emits *after* the chart was drawn for the first time every time the chart gets redrawn. */ - ready: EventEmitter; + @Output() + public ready: EventEmitter; /** * Emits when an error occurs when attempting to render the chart. */ - error: EventEmitter; + @Output() + public error: EventEmitter; /** * Emits when the user clicks a bar or legend. @@ -25,15 +30,21 @@ export interface ChartBase { * in the data table is selected; when a legend is selected, * the corresponding column in the data table is selected. */ - select: EventEmitter; + @Output() + public select: EventEmitter; /** * The drawn chart or `null`. */ - chart: google.visualization.ChartBase | null; + public chart: google.visualization.ChartBase | null; /** * The underlying chart wrapper or `null`. */ - chartWrapper: google.visualization.ChartWrapper | null; + public chartWrapper: google.visualization.ChartWrapper | null; + + /** + * Emits every time the `ChartWrapper` is recreated. + */ + public wrapperReady$: Observable; } diff --git a/libs/angular-google-charts/src/lib/components/chart-wrapper/chart-wrapper.component.spec.ts b/libs/angular-google-charts/src/lib/components/chart-wrapper/chart-wrapper.component.spec.ts index 8609ffc..797c669 100644 --- a/libs/angular-google-charts/src/lib/components/chart-wrapper/chart-wrapper.component.spec.ts +++ b/libs/angular-google-charts/src/lib/components/chart-wrapper/chart-wrapper.component.spec.ts @@ -1,6 +1,6 @@ import { SimpleChange } from '@angular/core'; import { async, ComponentFixture, TestBed } from '@angular/core/testing'; -import { EMPTY, of } from 'rxjs'; +import { of } from 'rxjs'; import { ChartType } from '../../models/chart-type.model'; import { ScriptLoaderService } from '../../script-loader/script-loader.service'; @@ -10,12 +10,21 @@ import { ChartWrapperComponent } from './chart-wrapper.component'; jest.mock('../../script-loader/script-loader.service'); const chartWrapperMock = { + setChartType: jest.fn(), + setDataTable: jest.fn(), + setOptions: jest.fn(), + setContainerId: jest.fn(), + setDataSourceUrl: jest.fn(), + setQuery: jest.fn(), + setRefreshInterval: jest.fn(), + setView: jest.fn(), draw: jest.fn(), getChart: jest.fn() }; const visualizationMock = { ChartWrapper: jest.fn(), + arrayToDataTable: jest.fn(), events: { addListener: jest.fn(), removeAllListeners: jest.fn() @@ -38,43 +47,44 @@ describe('ChartWrapperComponent', () => { }).compileComponents(); })); + beforeEach(() => { + const scriptLoaderService = TestBed.inject(ScriptLoaderService) as jest.Mocked; + scriptLoaderService.loadChartPackages.mockReturnValue(of(null)); + }); + beforeEach(() => { fixture = TestBed.createComponent(ChartWrapperComponent); component = fixture.componentInstance; - fixture.detectChanges(); + // No change detection here, we want to invoke the + // lifecycle methods in the unit tests }); it('should be created', () => { expect(component).toBeTruthy(); }); - it('should load the required package for the chart', () => { - const scriptLoaderService = TestBed.inject(ScriptLoaderService) as jest.Mocked; - scriptLoaderService.loadChartPackages.mockReturnValue(EMPTY); + it('should load google charts', () => { + component.ngOnInit(); - const specs = { chartType: ChartType.AreaChart }; - changeSpecs(specs); - - expect(scriptLoaderService.loadChartPackages).toHaveBeenCalledWith('corechart'); + const scriptLoaderService = TestBed.inject(ScriptLoaderService) as jest.Mocked; + expect(scriptLoaderService.loadChartPackages).toHaveBeenCalled(); }); it('should draw a chart using the provided specs', () => { - const scriptLoaderService = TestBed.inject(ScriptLoaderService) as jest.Mocked; - scriptLoaderService.loadChartPackages.mockReturnValue(of(void 0)); - const specs = { chartType: ChartType.AreaChart, dataTable: [] }; - changeSpecs(specs); + component.specs = specs; + component.ngOnInit(); - expect(visualizationMock.ChartWrapper).toHaveBeenCalledWith(specs); + expect(visualizationMock.ChartWrapper).toHaveBeenCalled(); + expect(chartWrapperMock.setChartType).toBeCalledWith(specs.chartType); + expect(chartWrapperMock.setDataTable).toHaveBeenCalledWith(specs.dataTable); expect(chartWrapperMock.draw).toHaveBeenCalled(); }); it('should redraw the chart if the specs change', () => { - const scriptLoaderService = TestBed.inject(ScriptLoaderService) as jest.Mocked; - scriptLoaderService.loadChartPackages.mockReturnValue(of(void 0)); - const specs = { chartType: ChartType.AreaChart, dataTable: [] } as google.visualization.ChartSpecs; - changeSpecs(specs); + component.specs = specs; + component.ngOnInit(); const newSpecs = { ...specs, chartType: ChartType.GeoChart }; changeSpecs(newSpecs); @@ -83,25 +93,21 @@ describe('ChartWrapperComponent', () => { }); it("should not redraw the chart if the specs didn't change", () => { - const scriptLoaderService = TestBed.inject(ScriptLoaderService) as jest.Mocked; - scriptLoaderService.loadChartPackages.mockReturnValue(of(void 0)); - const specs = { chartType: ChartType.AreaChart, dataTable: [] } as google.visualization.ChartSpecs; - changeSpecs(specs); + component.specs = specs; + component.ngOnInit(); component.ngOnChanges({}); expect(chartWrapperMock.draw).toHaveBeenCalledTimes(1); }); - it('should overwrite `container` and `containerId` if given', () => { - const scriptLoaderService = TestBed.inject(ScriptLoaderService) as jest.Mocked; - scriptLoaderService.loadChartPackages.mockReturnValue(of(void 0)); - + it('should ignore `container` and `containerId` if given', () => { const specs = { chartType: ChartType.AreaChart, containerId: 'test', container: {} } as google.visualization.ChartSpecs; - changeSpecs(specs); + component.specs = specs; + component.ngOnInit(); - expect(visualizationMock.ChartWrapper).toHaveBeenCalledWith({ chartType: specs.chartType }); + expect(chartWrapperMock.setContainerId).not.toBeCalled(); }); describe('chart', () => { @@ -116,11 +122,9 @@ describe('ChartWrapperComponent', () => { }); it('should return the chart wrapper', () => { - const scriptLoaderService = TestBed.inject(ScriptLoaderService) as jest.Mocked; - scriptLoaderService.loadChartPackages.mockReturnValue(of(void 0)); - const specs = { chartType: ChartType.AreaChart, dataTable: [] } as google.visualization.ChartSpecs; - changeSpecs(specs); + component.specs = specs; + component.ngOnInit(); const wrapper = component.chartWrapper; expect(wrapper).toBe(chartWrapperMock); @@ -131,18 +135,17 @@ describe('ChartWrapperComponent', () => { const specs = { chartType: ChartType.AreaChart } as google.visualization.ChartSpecs; beforeEach(() => { - const service = TestBed.inject(ScriptLoaderService) as jest.Mocked; - service.loadChartPackages.mockReturnValueOnce(of(void 0)); + component.specs = specs; }); it('should remove all event handlers before redrawing the chart', () => { - changeSpecs(specs); + component.ngOnInit(); expect(visualizationMock.events.removeAllListeners).toHaveBeenCalled(); }); it('should register chart wrapper event handlers', () => { - changeSpecs(specs); + component.ngOnInit(); expect(visualizationMock.events.addListener).toHaveBeenCalledWith(chartWrapperMock, 'ready', expect.any(Function)); expect(visualizationMock.events.addListener).toHaveBeenCalledWith(chartWrapperMock, 'error', expect.any(Function)); @@ -169,8 +172,7 @@ describe('ChartWrapperComponent', () => { const selectSpy = jest.fn(); component.select.subscribe(event => selectSpy(event)); - - changeSpecs(specs); + component.ngOnInit(); expect(selectSpy).not.toHaveBeenCalled(); diff --git a/libs/angular-google-charts/src/lib/components/chart-wrapper/chart-wrapper.component.ts b/libs/angular-google-charts/src/lib/components/chart-wrapper/chart-wrapper.component.ts index 64bb4a4..b619696 100644 --- a/libs/angular-google-charts/src/lib/components/chart-wrapper/chart-wrapper.component.ts +++ b/libs/angular-google-charts/src/lib/components/chart-wrapper/chart-wrapper.component.ts @@ -1,11 +1,19 @@ -import { ChangeDetectionStrategy, Component, ElementRef, EventEmitter, Input, OnChanges, Output, SimpleChanges } from '@angular/core'; -import { Observable } from 'rxjs'; +import { + ChangeDetectionStrategy, + Component, + ElementRef, + EventEmitter, + Input, + OnChanges, + OnInit, + Output, + SimpleChanges +} from '@angular/core'; +import { Subject } from 'rxjs'; -import { getPackageForChart } from '../../helpers/chart.helper'; -import { ChartBase } from '../../models/chart-base.model'; -import { ChartType } from '../../models/chart-type.model'; import { ChartErrorEvent, ChartReadyEvent, ChartSelectionChangedEvent } from '../../models/events.model'; import { ScriptLoaderService } from '../../script-loader/script-loader.service'; +import { ChartBase } from '../chart-base/chart-base.component'; @Component({ selector: 'chart-wrapper', @@ -15,7 +23,7 @@ import { ScriptLoaderService } from '../../script-loader/script-loader.service'; exportAs: 'chartWrapper', changeDetection: ChangeDetectionStrategy.OnPush }) -export class ChartWrapperComponent implements ChartBase, OnChanges { +export class ChartWrapperComponent implements ChartBase, OnChanges, OnInit { /** * Either a JSON object defining the chart, or a serialized string version of that object. * The format of this object is shown in the @@ -37,8 +45,10 @@ export class ChartWrapperComponent implements ChartBase, OnChanges { public select = new EventEmitter(); private wrapper: google.visualization.ChartWrapper; + private wrapperReadySubject = new Subject(); + private initialized = false; - constructor(private element: ElementRef, private loaderService: ScriptLoaderService) {} + constructor(private element: ElementRef, private scriptLoaderService: ScriptLoaderService) {} public get chart(): google.visualization.ChartBase | null { if (!this.wrapper) { @@ -48,32 +58,54 @@ export class ChartWrapperComponent implements ChartBase, OnChanges { return this.wrapper.getChart(); } + public get wrapperReady$() { + return this.wrapperReadySubject.asObservable(); + } + public get chartWrapper(): google.visualization.ChartWrapper | null { return this.wrapper; } + public ngOnInit() { + // We don't need to load any chart packages, the chart wrapper will handle this else for us + this.scriptLoaderService.loadChartPackages().subscribe(() => { + // Only ever create the wrapper once to allow animations to happen if something changes. + this.wrapper = new google.visualization.ChartWrapper(); + this.createChart(); + + this.initialized = true; + }); + } + public ngOnChanges(changes: SimpleChanges) { + if (!this.initialized) { + return; + } + if (changes.specs) { this.createChart(); } } private createChart() { - this.loadNeededPackages().subscribe(() => { - this.createWrapper(); - }); - } - - private loadNeededPackages(): Observable { - return this.loaderService.loadChartPackages(getPackageForChart(this.specs.chartType as ChartType)); - } + if (!this.specs) { + // When creating the wrapper with empty specs, the google charts library will show an error + // If we don't do this, a javascript error will be thrown, which is not as visible to the user + this.specs = {} as google.visualization.ChartSpecs; + } - private createWrapper() { - const { container, containerId, ...chartSpecs } = this.specs; - this.wrapper = new google.visualization.ChartWrapper(chartSpecs); + this.wrapper.setChartType(this.specs.chartType); + this.wrapper.setDataTable(this.specs.dataTable as any); // The typing here are not correct, this also accepts plain arrays + this.wrapper.setDataSourceUrl(this.specs.dataSourceUrl); + this.wrapper.setDataSourceUrl(this.specs.dataSourceUrl); + this.wrapper.setQuery(this.specs.query); + this.wrapper.setOptions(this.specs.options); + this.wrapper.setRefreshInterval(this.specs.refreshInterval); + this.wrapper.setView(this.specs.view); this.registerChartEvents(); + this.wrapperReadySubject.next(this.wrapper); this.wrapper.draw(this.element.nativeElement); } diff --git a/libs/angular-google-charts/src/lib/components/google-chart/google-chart.component.spec.ts b/libs/angular-google-charts/src/lib/components/google-chart/google-chart.component.spec.ts index 76a032c..02dc2ed 100644 --- a/libs/angular-google-charts/src/lib/components/google-chart/google-chart.component.spec.ts +++ b/libs/angular-google-charts/src/lib/components/google-chart/google-chart.component.spec.ts @@ -42,10 +42,16 @@ describe('GoogleChartComponent', () => { }).compileComponents(); })); + beforeEach(() => { + const scriptLoaderService = TestBed.inject(ScriptLoaderService) as jest.Mocked; + scriptLoaderService.loadChartPackages.mockReturnValue(EMPTY); + }); + beforeEach(() => { fixture = TestBed.createComponent(GoogleChartComponent); component = fixture.componentInstance; - fixture.detectChanges(); + // No change detection here, we want to invoke the + // lifecycle methods in the unit tests }); it('should be created', () => { @@ -56,20 +62,20 @@ describe('GoogleChartComponent', () => { expect(() => component.chart).not.toThrow(); }); - it('should load the packges needed for the chart type', () => { + it('should load the google chart library', () => { const service = TestBed.inject(ScriptLoaderService) as jest.Mocked; service.loadChartPackages.mockReturnValueOnce(EMPTY); - changeInput('type', ChartType.BarChart); + component.ngOnInit(); - expect(service.loadChartPackages).toHaveBeenCalledWith('corechart'); + expect(service.loadChartPackages).toHaveBeenCalled(); }); it('should not throw if only the type, but no data is provided', async(() => { const service = TestBed.inject(ScriptLoaderService) as jest.Mocked; - service.loadChartPackages.mockReturnValueOnce(of(void 0)); + service.loadChartPackages.mockReturnValueOnce(of(null)); - changeInput('type', ChartType.BarChart); + component.ngOnInit(); expect(component['wrapper']).toBeDefined(); expect(chartWrapperMock.draw).toHaveBeenCalledTimes(1); @@ -77,7 +83,7 @@ describe('GoogleChartComponent', () => { it('should create the chart with the provided data and column names', () => { const service = TestBed.inject(ScriptLoaderService) as jest.Mocked; - service.loadChartPackages.mockReturnValueOnce(of(void 0)); + service.loadChartPackages.mockReturnValueOnce(of(null)); const data = [ ['First Row', 10], @@ -92,7 +98,9 @@ describe('GoogleChartComponent', () => { visualizationMock.arrayToDataTable.mockReturnValueOnce(dataTableMock); const chartType = ChartType.BarChart; - changeInput('type', chartType); + component.type = chartType; + + component.ngOnInit(); expect(visualizationMock.arrayToDataTable).toHaveBeenCalledWith([columns, ...data], false); expect(chartWrapperMock.setDataTable).toHaveBeenCalledWith(dataTableMock); @@ -102,7 +110,9 @@ describe('GoogleChartComponent', () => { it('should draw the chart only once if `type`, `data` and `columns` changed all at once', () => { const service = TestBed.inject(ScriptLoaderService) as jest.Mocked; - service.loadChartPackages.mockReturnValueOnce(of(void 0)); + service.loadChartPackages.mockReturnValueOnce(of(null)); + + component.ngOnInit(); const data = [ ['First Row', 10], @@ -121,7 +131,7 @@ describe('GoogleChartComponent', () => { columns: new SimpleChange(null, columns, true) }); - expect(chartWrapperMock.draw).toHaveBeenCalledTimes(1); + expect(chartWrapperMock.draw).toHaveBeenCalledTimes(2); }); it('should not redraw the chart if nothing changed', () => { @@ -139,11 +149,7 @@ describe('GoogleChartComponent', () => { const chartType = ChartType.BarChart; component.type = chartType; - component.ngOnChanges({ - type: new SimpleChange(null, chartType, true), - data: new SimpleChange(null, data, true), - columns: new SimpleChange(null, columns, true) - }); + component.ngOnInit(); component.ngOnChanges({}); @@ -164,7 +170,9 @@ describe('GoogleChartComponent', () => { visualizationMock.arrayToDataTable.mockReturnValueOnce(dataTableMock); const chartType = ChartType.BarChart; - changeInput('type', chartType); + component.type = chartType; + + component.ngOnInit(); expect(visualizationMock.arrayToDataTable).toHaveBeenCalledWith(data, true); expect(chartWrapperMock.setDataTable).toHaveBeenCalledWith(dataTableMock); @@ -189,7 +197,8 @@ describe('GoogleChartComponent', () => { visualizationMock.arrayToDataTable.mockReturnValueOnce(dataTableMock); const chartType = ChartType.BarChart; - changeInput('type', chartType); + component.type = chartType; + component.ngOnInit(); const newData = [...data, ['Third Row', 12]]; changeInput('data', newData); @@ -215,7 +224,8 @@ describe('GoogleChartComponent', () => { visualizationMock.arrayToDataTable.mockReturnValueOnce(dataTableMock); const chartType = ChartType.BarChart; - changeInput('type', chartType); + component.type = chartType; + component.ngOnInit(); const newColumns = ['New label', 'Some values']; changeInput('columns', newColumns); @@ -224,23 +234,41 @@ describe('GoogleChartComponent', () => { expect(chartWrapperMock.draw).toHaveBeenCalledTimes(2); }); + it('should not throw if anything changed but the chart wrapper was not yet initialized', () => { + const data = [ + ['First Row', 10], + ['Second Row', 11] + ]; + component.data = data; + + const columns = ['Some label', 'Some values']; + component.columns = columns; + + expect(() => { + component.ngOnChanges({ + data: new SimpleChange(null, data, true), + columns: new SimpleChange(null, columns, true) + }); + }).not.toThrow(); + }); + describe('options', () => { beforeEach(() => { const service = TestBed.inject(ScriptLoaderService) as jest.Mocked; - service.loadChartPackages.mockReturnValueOnce(of(void 0)); + service.loadChartPackages.mockReturnValueOnce(of(null)); }); it('should use the provided options', () => { const options = { test: 'test' }; component.options = options; - changeInput('type', ChartType.BarChart); + component.ngOnInit(); expect(chartWrapperMock.setOptions).toHaveBeenCalledWith(options); }); it('should redraw the chart if options changed', () => { - changeInput('type', ChartType.BarChart); + component.ngOnInit(); const options = { test: 'test' }; changeInput('options', options); @@ -253,13 +281,13 @@ describe('GoogleChartComponent', () => { const title = 'some title'; component.title = title; - changeInput('type', ChartType.BarChart); + component.ngOnInit(); expect(chartWrapperMock.setOptions).toHaveBeenLastCalledWith({ title }); }); it('should redraw the chart if the title changed', () => { - changeInput('type', ChartType.BarChart); + component.ngOnInit(); const title = 'some title'; changeInput('title', title); @@ -275,13 +303,13 @@ describe('GoogleChartComponent', () => { const height = 200; component.height = height; - changeInput('type', ChartType.BarChart); + component.ngOnInit(); expect(chartWrapperMock.setOptions).toHaveBeenLastCalledWith({ width, height }); }); it('should redraw the chart if the width changed', () => { - changeInput('type', ChartType.BarChart); + component.ngOnInit(); const width = 100; changeInput('width', width); @@ -291,7 +319,7 @@ describe('GoogleChartComponent', () => { }); it('should redraw the chart if the height changed', () => { - changeInput('type', ChartType.BarChart); + component.ngOnInit(); const height = 100; changeInput('height', height); @@ -304,7 +332,7 @@ describe('GoogleChartComponent', () => { describe('formatters', () => { beforeEach(() => { const service = TestBed.inject(ScriptLoaderService) as jest.Mocked; - service.loadChartPackages.mockReturnValueOnce(of(void 0)); + service.loadChartPackages.mockReturnValueOnce(of(null)); }); it('should apply the provided formatters', () => { @@ -315,13 +343,13 @@ describe('GoogleChartComponent', () => { const dataTableMock = {}; visualizationMock.arrayToDataTable.mockReturnValueOnce(dataTableMock); - changeInput('type', ChartType.BarChart); + component.ngOnInit(); expect(formatter.formatter.format).toHaveBeenCalledWith(dataTableMock, formatter.colIndex); }); it('should redraw the chart if the formatters changed', () => { - changeInput('type', ChartType.BarChart); + component.ngOnInit(); const formatter = { formatter: { format: jest.fn() }, colIndex: 1 }; changeInput('formatters', [formatter]); @@ -358,15 +386,17 @@ describe('GoogleChartComponent', () => { })); it('should redraw the chart if the window was resized', fakeAsync(() => { - changeInput('dynamicResize', true); + const scriptLoaderService = TestBed.inject(ScriptLoaderService) as jest.Mocked; + scriptLoaderService.loadChartPackages.mockReturnValue(of(null)); - const drawSpy = jest.fn(); - component['wrapper'] = { draw: drawSpy } as any; + component.ngOnInit(); + + changeInput('dynamicResize', true); window.dispatchEvent(new Event('resize')); tick(100); - expect(drawSpy).toHaveBeenCalled(); + expect(chartWrapperMock.draw).toHaveBeenCalled(); })); describe('events', () => { @@ -376,13 +406,13 @@ describe('GoogleChartComponent', () => { }); it('should remove all event handlers before redrawing the chart', () => { - changeInput('type', ChartType.BarChart); + component.ngOnInit(); expect(visualizationMock.events.removeAllListeners).toHaveBeenCalled(); }); it('should register chart wrapper event handlers', () => { - changeInput('type', ChartType.BarChart); + component.ngOnInit(); expect(visualizationMock.events.addListener).toHaveBeenCalledWith(chartWrapperMock, 'ready', expect.any(Function)); expect(visualizationMock.events.addListener).toHaveBeenCalledWith(chartWrapperMock, 'error', expect.any(Function)); @@ -401,7 +431,7 @@ describe('GoogleChartComponent', () => { const chartMock = {}; chartWrapperMock.getChart.mockReturnValue(chartMock); - changeInput('type', ChartType.BarChart); + component.ngOnInit(); expect(visualizationMock.events.addListener).not.toHaveBeenCalledWith(chartWrapperMock, 'onmouseover', expect.any(Function)); expect(visualizationMock.events.addListener).not.toHaveBeenCalledWith(chartWrapperMock, 'onmouseout', expect.any(Function)); @@ -433,7 +463,7 @@ describe('GoogleChartComponent', () => { const selectSpy = jest.fn(); component.select.subscribe(event => selectSpy(event)); - changeInput('type', ChartType.BarChart); + component.ngOnInit(); expect(selectSpy).not.toHaveBeenCalled(); diff --git a/libs/angular-google-charts/src/lib/components/google-chart/google-chart.component.ts b/libs/angular-google-charts/src/lib/components/google-chart/google-chart.component.ts index 4136596..f0abeff 100644 --- a/libs/angular-google-charts/src/lib/components/google-chart/google-chart.component.ts +++ b/libs/angular-google-charts/src/lib/components/google-chart/google-chart.component.ts @@ -1,11 +1,19 @@ /// -import { ChangeDetectionStrategy, Component, ElementRef, EventEmitter, Input, OnChanges, Output, SimpleChanges } from '@angular/core'; -import { fromEvent, Observable, Subscription } from 'rxjs'; +import { + ChangeDetectionStrategy, + Component, + ElementRef, + EventEmitter, + Input, + OnChanges, + OnInit, + Output, + SimpleChanges +} from '@angular/core'; +import { fromEvent, Subject, Subscription } from 'rxjs'; import { debounceTime } from 'rxjs/operators'; -import { getPackageForChart } from '../../helpers/chart.helper'; -import { ChartBase, Column, Row } from '../../models/chart-base.model'; import { ChartType } from '../../models/chart-type.model'; import { ChartErrorEvent, @@ -15,6 +23,7 @@ import { ChartSelectionChangedEvent } from '../../models/events.model'; import { ScriptLoaderService } from '../../script-loader/script-loader.service'; +import { ChartBase, Column, Row } from '../chart-base/chart-base.component'; export interface Formatter { formatter: google.visualization.DefaultFormatter; @@ -29,7 +38,7 @@ export interface Formatter { exportAs: 'googleChart', changeDetection: ChangeDetectionStrategy.OnPush }) -export class GoogleChartComponent implements ChartBase, OnChanges { +export class GoogleChartComponent implements ChartBase, OnChanges, OnInit { /** * The type of the chart to create. */ @@ -118,11 +127,14 @@ export class GoogleChartComponent implements ChartBase, OnChanges { @Output() public mouseleave = new EventEmitter(); - private wrapper: google.visualization.ChartWrapper; private dataTable: google.visualization.DataTable; private resizeSubscription: Subscription; - constructor(private element: ElementRef, private loaderService: ScriptLoaderService) {} + private wrapper: google.visualization.ChartWrapper; + private wrapperReadySubject = new Subject(); + private initialized = false; + + constructor(private element: ElementRef, private scriptLoaderService: ScriptLoaderService) {} public get chart(): google.visualization.ChartBase | null { if (!this.wrapper) { @@ -132,48 +144,46 @@ export class GoogleChartComponent implements ChartBase, OnChanges { return this.wrapper.getChart(); } + public get wrapperReady$() { + return this.wrapperReadySubject.asObservable(); + } + public get chartWrapper(): google.visualization.ChartWrapper | null { return this.wrapper; } + public ngOnInit() { + // We don't need to load any chart packages, the chart wrapper will handle this else for us + this.scriptLoaderService.loadChartPackages().subscribe(() => { + // Only ever create the wrapper once to allow animations to happen when someting changes. + this.wrapper = new google.visualization.ChartWrapper(); + + // We have to create the chart from scratch here always because all other methods + // only work after the google.visulation package finished loading. + this.createDataTable(); + this.createChart(); + + this.initialized = true; + }); + } + public ngOnChanges(changes: SimpleChanges) { if (changes.dynamicResize) { this.updateResizeListener(); } - if (changes.type && this.type != null) { - this.createChart(); - // Don't update the chart when creating it from scratch. - return; - } - - if (this.wrapper != null) { + if (this.initialized) { const dataChanged = changes.data || changes.columns; if (dataChanged) { this.createDataTable(); } - if (dataChanged || changes.options || changes.width || changes.height || changes.title || changes.formatters) { - this.updateChart(); + if (dataChanged || changes.options || changes.type || changes.width || changes.height || changes.title || changes.formatters) { + this.createChart(); } } } - private createChart() { - this.loadNeededPackages().subscribe(() => { - this.wrapper = new google.visualization.ChartWrapper(); - - // We have to create the chart from scratch here always because all other methods - // Only work after the google.visulation package finished loading. - this.createDataTable(); - this.updateChart(); - }); - } - - private loadNeededPackages(): Observable { - return this.loaderService.loadChartPackages(getPackageForChart(this.type)); - } - private createDataTable() { if (this.data == null) { return; @@ -205,14 +215,14 @@ export class GoogleChartComponent implements ChartBase, OnChanges { this.resizeSubscription = fromEvent(window, 'resize') .pipe(debounceTime(100)) .subscribe(() => { - if (this.wrapper != null) { + if (this.initialized) { this.redrawChart(); } }); } } - private updateChart() { + private createChart() { this.wrapper.setChartType(this.type); this.wrapper.setDataTable(this.dataTable); this.applyFormatters(this.dataTable); @@ -222,6 +232,7 @@ export class GoogleChartComponent implements ChartBase, OnChanges { this.registerChartEvents(); + this.wrapperReadySubject.next(this.wrapper); this.redrawChart(); } diff --git a/libs/angular-google-charts/src/lib/script-loader/script-loader.service.spec.ts b/libs/angular-google-charts/src/lib/script-loader/script-loader.service.spec.ts index 76f837b..e86f35d 100644 --- a/libs/angular-google-charts/src/lib/script-loader/script-loader.service.spec.ts +++ b/libs/angular-google-charts/src/lib/script-loader/script-loader.service.spec.ts @@ -25,27 +25,6 @@ describe('ScriptLoaderService', () => { expect(service).toBeTruthy(); }); - describe('loadingComplete$', () => { - it('should emit immediately if `google.charts` is available', () => { - globalThis.google = { - charts: { load: () => {} } - } as any; - - const spy = jest.fn(); - service.loadingComplete$.subscribe(() => spy()); - expect(spy).toHaveBeenCalled(); - }); - - it('should return the `onLoadSubject` if `google.charts` is not yet available', () => { - const spy = jest.fn(); - service.loadingComplete$.subscribe(() => spy()); - expect(spy).not.toHaveBeenCalled(); - - service['onLoadSubject'].next(); - expect(spy).toHaveBeenCalled(); - }); - }); - describe('isGoogleChartsAvailable', () => { it('should be false if `google` is not available', () => { expect(service.isGoogleChartsAvailable()).toBeFalsy(); @@ -57,9 +36,10 @@ describe('ScriptLoaderService', () => { expect(service.isGoogleChartsAvailable()).toBeFalsy(); }); - it('should successfully load the google charts script', () => { + it('should be true if `google.charts` and `google.visulization` is available', () => { globalThis.google = { - charts: { load: () => {} } + charts: { load: () => {} }, + visulization: { arrayToDataTable: () => {} } } as any; expect(service.isGoogleChartsAvailable()).toBeTruthy(); @@ -67,18 +47,7 @@ describe('ScriptLoaderService', () => { }); describe('loadChartPackages', () => { - const chartsMock = { - load: jest.fn(), - setOnLoadCallback: jest.fn() - }; - - beforeEach(() => { - globalThis.google = { charts: chartsMock } as any; - }); - - it('should load `google.charts` before trying to load packages', () => { - globalThis.google = undefined; - + it('should load the google charts script before trying to load packages', () => { const createElementSpy = jest.spyOn(document, 'createElement').mockReturnValue({} as any); const headMock = { appendChild: jest.fn() }; @@ -99,150 +68,171 @@ describe('ScriptLoaderService', () => { ); }); - it('should load the chart packages if `google.charts` is available', () => { - const chart = 'corechart'; + describe('loading the charts script', () => { + let createElementSpy: jest.SpyInstance; + let getElementsByTagNameSpy: jest.SpyInstance; - service.loadChartPackages(chart).subscribe(); - - expect(chartsMock.load).toHaveBeenCalledWith('current', { - packages: [chart], - language: 'en-US', - mapsApiKey: '', - safeMode: false + beforeEach(() => { + createElementSpy = jest.spyOn(document, 'createElement').mockImplementation(); + getElementsByTagNameSpy = jest.spyOn(document, 'getElementsByTagName').mockImplementation(); }); - }); - it('should emit after loading the charts', () => { - const chart = 'corechart'; - let loadCallback: Function; + it('should not load the script if it is already loaded', () => { + globalThis.google = { + charts: { load: () => {} } + } as any; + + service.loadChartPackages().subscribe(); - chartsMock.setOnLoadCallback.mockImplementation(callback => (loadCallback = callback)); + expect(createElementSpy).not.toHaveBeenCalled(); + }); - const loadedSpy = jest.fn(); - service.loadChartPackages(chart).subscribe(() => loadedSpy()); + it('should not load the script if it is currently being loaded', () => { + getElementsByTagNameSpy.mockReturnValue([{ src: service['scriptSource'] }]); - expect(loadedSpy).not.toHaveBeenCalled(); + service.loadChartPackages().subscribe(); - loadCallback(); - expect(loadedSpy).toHaveBeenCalled(); - }); + expect(createElementSpy).not.toHaveBeenCalled(); + }); - it('should use injected config values', () => { - TestBed.resetTestingModule(); + it('should load the google charts script', () => { + createElementSpy.mockReturnValue({}); - const version = 'current'; - const mapsApiKey = 'mapsApiKey'; - const safeMode = true; - const locale = 'de-DE'; + const headMock = { appendChild: jest.fn() }; + getElementsByTagNameSpy.mockReturnValueOnce([]).mockReturnValueOnce([headMock]); - TestBed.configureTestingModule({ - providers: [ - ScriptLoaderService, - { provide: LOCALE_ID, useValue: locale }, - { provide: GOOGLE_CHARTS_CONFIG, useValue: { version, mapsApiKey, safeMode } } - ] + service.loadChartPackages().subscribe(); + + expect(createElementSpy).toHaveBeenCalledWith('script'); + expect(headMock.appendChild).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'text/javascript', + src: service['scriptSource'], + async: true + }) + ); }); - service = TestBed.inject(ScriptLoaderService); - const chart = 'corechart'; + it('should emit an error if the script fails to load', () => { + const scriptMock = { onerror: () => void 0 }; + createElementSpy.mockReturnValue(scriptMock); + + const headMock = { appendChild: jest.fn() }; + getElementsByTagNameSpy.mockReturnValueOnce([]).mockReturnValueOnce([headMock]); - service.loadChartPackages(chart).subscribe(); + const errorSpy = jest.fn(); + service + .loadChartPackages() + .pipe( + catchError(error => { + errorSpy(); + return throwError(error); + }) + ) + .subscribe(); - expect(chartsMock.load).toHaveBeenCalledWith(version, { - packages: [chart], - language: locale, - mapsApiKey, - safeMode + expect(errorSpy).not.toHaveBeenCalled(); + + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(); + scriptMock.onerror(); + consoleSpy.mockReset(); + + expect(errorSpy).toHaveBeenCalled(); }); }); - }); - describe('loadGoogleCharts', () => { - let createElementSpy: jest.SpyInstance; - let getElementsByTagNameSpy: jest.SpyInstance; + describe('loading chart packages', () => { + const chartsMock = { + load: jest.fn(), + setOnLoadCallback: jest.fn() + }; - beforeEach(() => { - createElementSpy = jest.spyOn(document, 'createElement').mockImplementation(); - getElementsByTagNameSpy = jest.spyOn(document, 'getElementsByTagName').mockImplementation(); - }); + it('should load the chart packages after loading the google charts script', () => { + const scriptMock = { onload: () => {} }; - it('should do nothing if `google.charts` is available', () => { - globalThis.google = { - charts: { load: () => {} } - } as any; + jest.spyOn(document, 'createElement').mockReturnValue(scriptMock as any); - service.loadGoogleCharts().subscribe(); + const headMock = { appendChild: jest.fn() }; + jest + .spyOn(document, 'getElementsByTagName') + .mockReturnValueOnce([] as any) + .mockReturnValueOnce([headMock] as any); - expect(createElementSpy).not.toHaveBeenCalled(); - }); + service.loadChartPackages().subscribe(); - it('should do nothing if script is already being loaded', () => { - getElementsByTagNameSpy.mockReturnValue([{ src: service['scriptSource'] }]); + globalThis.google = { charts: chartsMock } as any; + scriptMock.onload(); - service.loadGoogleCharts().subscribe(); + expect(chartsMock.load).toHaveBeenCalledWith('current', { + packages: [], + language: 'en-US', + mapsApiKey: '', + safeMode: false + }); + }); - expect(createElementSpy).not.toHaveBeenCalled(); - }); + it('should immediately load the chart packages if the google charts script is already loaded', () => { + globalThis.google = { charts: chartsMock } as any; - it('should create the google charts script', () => { - createElementSpy.mockReturnValue({}); + const chart = 'corechart'; - const headMock = { appendChild: jest.fn() }; - getElementsByTagNameSpy.mockReturnValueOnce([]).mockReturnValueOnce([headMock]); + service.loadChartPackages(chart).subscribe(); - service.loadGoogleCharts().subscribe(); + expect(chartsMock.load).toHaveBeenCalledWith('current', { + packages: [chart], + language: 'en-US', + mapsApiKey: '', + safeMode: false + }); + }); - expect(createElementSpy).toHaveBeenCalledWith('script'); - expect(headMock.appendChild).toHaveBeenCalledWith( - expect.objectContaining({ - type: 'text/javascript', - src: service['scriptSource'], - async: true - }) - ); - }); + it('should emit after loading the charts', () => { + globalThis.google = { charts: chartsMock } as any; - it('should emit as soon as the script is fully loaded', () => { - const scriptMock = { onload: () => {} }; - createElementSpy.mockReturnValue(scriptMock); + const chart = 'corechart'; + let loadCallback: Function; - const headMock = { appendChild: jest.fn() }; - getElementsByTagNameSpy.mockReturnValueOnce([]).mockReturnValueOnce([headMock]); + chartsMock.setOnLoadCallback.mockImplementation(callback => (loadCallback = callback)); - const loadedSpy = jest.fn(); - service.loadGoogleCharts().subscribe(() => loadedSpy()); + const loadedSpy = jest.fn(); + service.loadChartPackages(chart).subscribe(() => loadedSpy()); - expect(loadedSpy).not.toHaveBeenCalled(); - scriptMock.onload(); + expect(loadedSpy).not.toHaveBeenCalled(); - expect(loadedSpy).toHaveBeenCalled(); - }); + loadCallback(); + expect(loadedSpy).toHaveBeenCalled(); + }); - it('should emit error if the script fails to load', () => { - const scriptMock = { onerror: () => void 0 }; - createElementSpy.mockReturnValue(scriptMock); + it('should use injected config values', () => { + globalThis.google = { charts: chartsMock } as any; - const headMock = { appendChild: jest.fn() }; - getElementsByTagNameSpy.mockReturnValueOnce([]).mockReturnValueOnce([headMock]); - - const errorSpy = jest.fn(); - service - .loadGoogleCharts() - .pipe( - catchError(error => { - errorSpy(); - return throwError(error); - }) - ) - .subscribe(); + TestBed.resetTestingModule(); - expect(errorSpy).not.toHaveBeenCalled(); + const version = 'current'; + const mapsApiKey = 'mapsApiKey'; + const safeMode = true; + const locale = 'de-DE'; - const consoleSpy = jest.spyOn(console, 'error').mockImplementation(); - scriptMock.onerror(); - consoleSpy.mockReset(); + TestBed.configureTestingModule({ + providers: [ + ScriptLoaderService, + { provide: LOCALE_ID, useValue: locale }, + { provide: GOOGLE_CHARTS_CONFIG, useValue: { version, mapsApiKey, safeMode } } + ] + }); + service = TestBed.inject(ScriptLoaderService); - expect(errorSpy).toHaveBeenCalled(); + const chart = 'corechart'; + + service.loadChartPackages(chart).subscribe(); + + expect(chartsMock.load).toHaveBeenCalledWith(version, { + packages: [chart], + language: locale, + mapsApiKey, + safeMode + }); + }); }); }); }); diff --git a/libs/angular-google-charts/src/lib/script-loader/script-loader.service.ts b/libs/angular-google-charts/src/lib/script-loader/script-loader.service.ts index 5824ee7..5f3d85f 100644 --- a/libs/angular-google-charts/src/lib/script-loader/script-loader.service.ts +++ b/libs/angular-google-charts/src/lib/script-loader/script-loader.service.ts @@ -14,7 +14,7 @@ const DEFAULT_CONFIG: GoogleChartsConfig = { @Injectable({ providedIn: 'root' }) export class ScriptLoaderService { private readonly scriptSource = 'https://www.gstatic.com/charts/loader.js'; - private readonly onLoadSubject = new Subject(); + private readonly scriptLoadSubject = new Subject(); constructor( private zone: NgZone, @@ -24,25 +24,10 @@ export class ScriptLoaderService { this.config = { ...DEFAULT_CONFIG, ...(config || {}) }; } - /** - * A stream that emits as soon as the google charts script is loaded (i.e. `google.charts` is available). - * Emits immediately if the script is already loaded. - * - * *This does not indicate if loading a chart package is done.* - */ - public get loadingComplete$(): Observable { - if (this.isGoogleChartsAvailable()) { - return of(null); - } - - return this.onLoadSubject.asObservable(); - } - /** * Checks whether `google.charts` is available. * - * If not, it can be loaded by calling {@link ScriptLoaderService#loadChartPackages loadChartPackages()} or - * {@link ScriptLoaderService#loadGoogleCharts loadGoogleCharts()}. + * If not, it can be loaded by calling `loadChartPackages`. * * @returns `true` if `google.charts` is available, `false` otherwise. */ @@ -58,6 +43,9 @@ export class ScriptLoaderService { * Loads the Google Chart script and the provided chart packages. * Can be called multiple times to load more packages. * + * When called without any arguments, this will just load the default package + * containing the namespaces `google.charts` and `google.visualization` without any charts. + * * @param packages The packages to load. * @returns A stream emitting as soon as the chart packages are loaded. */ @@ -85,35 +73,32 @@ export class ScriptLoaderService { } /** - * Loads the Google Charts script. After the script is loaded, `google.charts` is defined - * and individual chart packages can be loaded. - * - * This should be used if you want only the Google Charts script without a chart package. - * Most of the times, you want to use {@link ScriptLoaderService#loadChartPackages loadChartPackages()} instead, - * which uses this method to load chart packages. + * Loads the Google Charts script. After the script is loaded, `google.charts` is defined. * * @returns A stream emitting as soon as loading has completed. * If the google charts script is already loaded, the stream emits immediately. */ - public loadGoogleCharts() { - if (!this.isGoogleChartsAvailable() && !this.isLoadingGoogleCharts()) { + private loadGoogleCharts() { + if (this.isGoogleChartsAvailable()) { + return of(null); + } else if (!this.isLoadingGoogleCharts()) { const script = this.createGoogleChartsScript(); script.onload = () => { this.zone.run(() => { - this.onLoadSubject.next(); - this.onLoadSubject.complete(); + this.scriptLoadSubject.next(); + this.scriptLoadSubject.complete(); }); }; script.onerror = () => { this.zone.run(() => { - console.error('Failed to load the google chart script!'); - this.onLoadSubject.error('Failed to load the google chart script!'); + console.error('Failed to load the google charts script!'); + this.scriptLoadSubject.error(new Error('Failed to load the google charts script!')); }); }; } - return this.loadingComplete$; + return this.scriptLoadSubject.asObservable(); } private isLoadingGoogleCharts() {