Skip to content

Commit

Permalink
feat: editing charts
Browse files Browse the repository at this point in the history
  • Loading branch information
FERNman committed Apr 15, 2020
1 parent 7817f6d commit c6eda2d
Show file tree
Hide file tree
Showing 15 changed files with 453 additions and 17 deletions.
41 changes: 41 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,47 @@ When creating dashboards, the charts themselves are not responsible for drawing,

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

## Editing Charts

Google Charts comes with a full-fledged [chart editor](https://developers.google.com/chart/interactive/docs/reference#google_visualization_charteditor),
allowing users to configure charts the way they want.

Angular-Google-Charts includes a component wrapping the native `ChartEditor`, the `ChartEditorComponent`.
It has to be instantiated in HTML and can be used to edit charts by calling its `editChart` method.

```html
<!--my.component.html-->
<chart-editor></chart-editor>

<google-chart #editable></google-chart>
<button (click)="editChart(editable)">Edit</button>
```

```typescript
// my.component.ts
class MyComp {
@ViewChild(ChartEditorComponent)
public readonly editor: ChartEditorComponent;

public editChart(chart: ChartBase) {
this.editor
.editChart(chart)
.afterClosed()
.subscribe(result => {
if (result) {
// Saved
} else {
// Cancelled
}
});
}
}
```

`editChart` returns a handle to the open dialog which can be used to close the edit dialog.

Note that only one chart can be edited by a chart editor at a time.

## Advanced

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

<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>
<div class="inline">
<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>
<chart-wrapper [specs]="chartWrapperSpecs"></chart-wrapper>

<div>
<google-chart #editable type="BarChart" [data]="chart.data" [columns]="chart.columnNames"></google-chart>

<chart-editor></chart-editor>
<button (click)="edit(editable)">Edit</button>
</div>
</div>

<google-chart
title="Resizing Chart"
style="width: 100%;"
title="Width: 100%"
type="BarChart"
[data]="chart.data"
[columns]="chart.columnNames"
Expand Down
23 changes: 20 additions & 3 deletions apps/playground/src/app/test/test.component.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { Location } from '@angular/common';
import { Component } from '@angular/core';
import { ChartType, FilterType } from 'angular-google-charts';
import { Component, ViewChild } from '@angular/core';
import { ChartBase, ChartEditorComponent, ChartType, FilterType } from 'angular-google-charts';

@Component({
selector: 'app-test',
templateUrl: './test.component.html'
templateUrl: './test.component.html',
styles: ['.inline > * { display: inline-block; vertical-align: top; }']
})
export class TestComponent {
public chart = {
Expand Down Expand Up @@ -52,8 +53,24 @@ export class TestComponent {
]
};

@ViewChild(ChartEditorComponent)
public readonly editor: ChartEditorComponent;

constructor(private location: Location) {}

public edit(chart: ChartBase) {
this.editor
.editChart(chart)
.afterClosed()
.subscribe(result => {
if (result) {
console.log(result);
} else {
console.log('Editing was cancelled');
}
});
}

public goBack() {
this.location.back();
}
Expand Down
2 changes: 2 additions & 0 deletions libs/angular-google-charts/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
* Public API Surface of angular-google-charts
*/

export * from './lib/components/chart-editor/chart-editor-ref';
export * from './lib/components/chart-editor/chart-editor.component';
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';
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { ChartEditorRef } from './chart-editor-ref';

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

const editorMock = {
openDialog: jest.fn(),
closeDialog: jest.fn(),
setChartWrapper: jest.fn(),
getChartWrapper: jest.fn()
} as jest.Mocked<google.visualization.ChartEditor>;

describe('ChartEditorRef', () => {
let editor: ChartEditorRef;

beforeEach(() => {
globalThis.google = { visualization: visualizationMock } as any;
editor = new ChartEditorRef(editorMock);
});

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

it('should register event listeners on create', () => {
expect(visualizationMock.events.addOneTimeListener).toHaveBeenCalledWith(editorMock, 'ok', expect.any(Function));
expect(visualizationMock.events.addOneTimeListener).toHaveBeenCalledWith(editorMock, 'cancel', expect.any(Function));
});

describe('afterClosed', () => {
it('should emit update wrapper if dialog was saved', () => {
const okCallback = visualizationMock.events.addOneTimeListener.mock.calls[0][2];

const editResult = { draw: jest.fn() };
editorMock.getChartWrapper.mockReturnValueOnce(editResult as any);

const closedSpy = jest.fn();
editor.afterClosed().subscribe(result => closedSpy(result));

okCallback();

expect(google.visualization.events.removeAllListeners).toHaveBeenCalled();
expect(closedSpy).toHaveBeenCalledWith(editResult);
});

it('should emit `null` if dialog was cancelled', () => {
const cancelCallback = visualizationMock.events.addOneTimeListener.mock.calls[1][2];

const closedSpy = jest.fn();
editor.afterClosed().subscribe(result => closedSpy(result));

cancelCallback();

expect(google.visualization.events.removeAllListeners).toHaveBeenCalled();
expect(closedSpy).toHaveBeenCalledWith(null);
});
});

describe('cancel', () => {
it('should close the dialog', () => {
editor.cancel();

expect(editorMock.closeDialog).toHaveBeenCalled();
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { Observable, Subject } from 'rxjs';

export type EditChartResult = google.visualization.ChartWrapper | null;

export class ChartEditorRef {
private readonly doneSubject = new Subject<EditChartResult>();

constructor(private readonly editor: google.visualization.ChartEditor) {
this.addEventListeners();
}

/**
* Gets an observable that is notified when the dialog is saved.
* Emits either the result if the dialog was saved or `null` if editing was cancelled.
*/
public afterClosed(): Observable<EditChartResult> {
return this.doneSubject.asObservable();
}

/**
* Stops editing the chart and closes the dialog.
*/
public cancel() {
this.editor.closeDialog();
}

private addEventListeners() {
google.visualization.events.addOneTimeListener(this.editor, 'ok', () => {
google.visualization.events.removeAllListeners(this.editor);

const updatedChartWrapper = this.editor.getChartWrapper();

this.doneSubject.next(updatedChartWrapper);
this.doneSubject.complete();
});

google.visualization.events.addOneTimeListener(this.editor, 'cancel', () => {
google.visualization.events.removeAllListeners(this.editor);

this.doneSubject.next(null);
this.doneSubject.complete();
});
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { EMPTY, of } from 'rxjs';

import { ScriptLoaderService } from '../../script-loader/script-loader.service';
import { ChartBase } from '../chart-base/chart-base.component';

import { ChartEditorRef } from './chart-editor-ref';
import { ChartEditorComponent } from './chart-editor.component';

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

const editorRefMock = {
afterClosed: jest.fn()
};

const visualizationMock = {
ChartEditor: jest.fn()
};

const editorMock = {
openDialog: jest.fn(),
closeDialog: jest.fn(),
setChartWrapper: jest.fn(),
getChartWrapper: jest.fn()
} as jest.Mocked<google.visualization.ChartEditor>;

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

beforeEach(() => {
visualizationMock.ChartEditor.mockReturnValue(editorMock);
globalThis.google = { visualization: visualizationMock } as any;
});

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

beforeEach(() => {
fixture = TestBed.createComponent(ChartEditorComponent);
component = fixture.componentInstance;
});

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

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

component.ngOnInit();

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

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

component.ngOnInit();

expect(visualizationMock.ChartEditor).toHaveBeenCalled();
});

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

const initializedSpy = jest.fn();
component.initialized$.subscribe(event => initializedSpy(event));

component.ngOnInit();

expect(initializedSpy).toHaveBeenCalledWith(editorMock);
});
});

describe('editChart', () => {
const chartWrapper = { draw: jest.fn() } as any;
const chartComponent = {} as ChartBase;

beforeEach(() => {
Object.defineProperty(chartComponent, 'chartWrapper', { get: () => chartWrapper });

component['editor'] = editorMock;
((ChartEditorRef as any) as jest.SpyInstance).mockReturnValue(editorRefMock);
editorRefMock.afterClosed.mockReturnValue(EMPTY);
});

it('should open the edit dialog', () => {
component.editChart(chartComponent);

expect(editorMock.openDialog).toHaveBeenCalledWith(chartComponent.chartWrapper, {});
});

it('should pass the provided options', () => {
const options = {
dataSourceInput: 'urlbox'
} as google.visualization.ChartEditorOptions;

component.editChart(chartComponent, options);

expect(editorMock.openDialog).toHaveBeenCalledWith(chartComponent.chartWrapper, options);
});

it('should create an editor ref and return it', () => {
const handle = component.editChart(chartComponent);

expect(ChartEditorRef).toHaveBeenCalledWith(editorMock);
expect(handle).toBe(editorRefMock);
});

it('should update the components chart wrapper with the edit result', () => {
const setSpy = jest.fn();
Object.defineProperty(chartComponent, 'chartWrapper', { get: () => chartWrapper, set: setSpy });

const updatedWrapper = { draw: jest.fn() };
editorRefMock.afterClosed.mockReturnValue(of(updatedWrapper));

component.editChart(chartComponent);

expect(setSpy).toHaveBeenCalledWith(updatedWrapper);
});

it('should not update the components wrapper if editing was cancelled', () => {
const setSpy = jest.fn();
Object.defineProperty(chartComponent, 'chartWrapper', { get: () => chartWrapper, set: setSpy });

editorRefMock.afterClosed.mockReturnValue(of(null));

component.editChart(chartComponent);

expect(setSpy).not.toHaveBeenCalled();
});
});
});
Loading

0 comments on commit c6eda2d

Please sign in to comment.