At SketchUp we needed a CSV download that let people see what they were about to download, and change it, before they downloaded it. The solution was a set of components that do both jobs: they show the CSV in the page as a table, and they're the data the file is made from.
The download, in the template
A download is a <csv-download>. Inside it, a <csv-column> names each column, and then each row is a <csv-row> of <csv-cell>s, each with its value:
<csv-download filename="members.csv">
<csv-column name="Name"></csv-column>
<csv-column name="Email"></csv-column>
<csv-column name="Role"></csv-column>
<csv-column name="Joined"></csv-column>
<csv-row *ngFor="let member of members">
<csv-cell [value]="member.name"></csv-cell>
<csv-cell [value]="member.email"></csv-cell>
<csv-cell [value]="member.role"></csv-cell>
<csv-cell [value]="member.joined | date:'yyyy-MM-dd'"></csv-cell>
</csv-row>
</csv-download>
That's all a page needs. There's no export code in the page's component, and the values are formatted where they're written, with the same pipes as anywhere else in a template.
What it shows
In the page, the download is a preview of the file: a table with the columns along the top, a line for each row, and a Download button underneath. Each column has a checkbox, so people can leave out the columns they don't want, and see the file change before they download it:
The file that downloads is exactly what the preview shows:
Name,Role,Joined
Sam Taylor,Owner,2018-03-14
Priya Shah,"Editor, reviewer",2018-11-02
Tom Becker,Viewer,2019-02-21
Lena Novak,Editor,2019-06-30
The components
Each component draws its own part of the preview. The download lays the table out as a CSS grid, with one grid column for each csv-column, and a row uses display: contents, so its cells take part in the grid as if the row weren't there. The columns come first in the template, so they make the top line of the grid:
@Component({
selector: 'csv-download',
template: `
<div class="table" [style.grid-template-columns]="gridColumns">
<ng-content></ng-content>
</div>
<button (click)="download()">Download CSV</button>
`,
styles: [`
.table { display: grid; margin-bottom: 16px; }
`]
})
export class CsvDownloadComponent {
@Input() filename = 'download.csv';
@ContentChildren(forwardRef(() => CsvColumnComponent)) columns: QueryList<CsvColumnComponent>;
@ContentChildren(forwardRef(() => CsvRowComponent)) rows: QueryList<CsvRowComponent>;
get gridColumns(): string {
return this.columns ? this.columns.map(() => 'auto').join(' ') : '';
}
isIncluded(index: number): boolean {
const column = this.columns ? this.columns.toArray()[index] : undefined;
return !column || column.included;
}
download(): void {
saveCsv(this.filename, this.lines());
}
}
@Component({
selector: 'csv-column',
template: `
<label>
<input type="checkbox" [checked]="included" (change)="included = !included">
{{ name }}
</label>
`,
styles: [`
:host { font-weight: 600; padding: 6px 12px; border-bottom: 1px solid #c9d6da; }
:host(.excluded) { color: #9aa9ae; }
`]
})
export class CsvColumnComponent {
@Input() name = '';
included = true;
@HostBinding('class.excluded') get excluded(): boolean {
return !this.included;
}
}
@Component({
selector: 'csv-row',
template: '<ng-content></ng-content>',
styles: [':host { display: contents; }']
})
export class CsvRowComponent {
@ContentChildren(forwardRef(() => CsvCellComponent)) cells: QueryList<CsvCellComponent>;
indexOf(cell: CsvCellComponent): number {
return this.cells ? this.cells.toArray().indexOf(cell) : -1;
}
}
@Component({
selector: 'csv-cell',
template: '{{ value }}',
styles: [`
:host { padding: 6px 12px; border-bottom: 1px solid #e6eef0; }
:host(.excluded) { color: #c9d6da; text-decoration: line-through; }
`]
})
export class CsvCellComponent {
@Input() value: string | number = '';
constructor(private row: CsvRowComponent, private csv: CsvDownloadComponent) {}
@HostBinding('class.excluded') get excluded(): boolean {
return !this.csv.isIncluded(this.row.indexOf(this));
}
}
The parts find each other in two directions. Going down, the download and each row find their parts with @ContentChildren, which keeps its lists in the order of the template, so a cell's position in its row is its column. Going up, a cell finds its row and the download through dependency injection, just by asking for them in its constructor, because they're the elements around it. That's how a cell knows its column has been left out, and greys itself out in the preview.
Making the file
When the button is pressed, the download reads the file out of the same components the preview is made of. The columns give the header line, each row gives a line of its cells' values, and the columns that were left out are dropped from all of them:
lines(): string[] {
const included = this.columns.map(column => column.included);
const header = this.columns.map(column => column.name);
const rows = this.rows.map(row => row.cells.map(cell => String(cell.value)));
return [header, ...rows].map(fields =>
fields.filter((field, index) => included[index]).map(csvField).join(',')
);
}
function csvField(value: string): string {
return /[",\r\n]/.test(value) ? '"' + value.replace(/"/g, '""') + '"' : value;
}
function saveCsv(filename: string, lines: string[]): void {
const blob = new Blob([lines.join('\r\n') + '\r\n'], { type: 'text/csv' });
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = filename;
document.body.appendChild(link);
link.click();
link.remove();
setTimeout(() => URL.revokeObjectURL(link.href));
}
A field is quoted only when it needs to be, when it has a comma, a quote or a line break in it, and a quote inside it is doubled. It all happens in the browser: the server isn't asked for anything, because the page already has the data.
DOM as data
The usual way to build this would be to put the table into data first: an array of column definitions and an array of rows, built in the page's component, passed into a preview component, and passed again to an export function. The preview and the file would come from the same data, but through two different pieces of code.
Here the components in the template are the data. What Angular builds for the preview is a tree of components, and that tree already holds everything the file needs: the columns in order, the rows in order, and the value in each cell. So the download reads the file straight out of it, and the preview and the file can't disagree, because they're the same thing.
It keeps the page's component empty, too. The page doesn't build anything for the download. It writes the download in its template, from the data it already has, the same way it writes the rest of the page, and anything that works in a template works on it: *ngIf on a column to offer it only to some people, a pipe to format a value, *ngFor over whatever the page has loaded.
The same idea works for anything a page needs to send somewhere as well as show. The top-level component finds its parts, in order, with @ContentChildren, the parts find it through dependency injection, and the template is the only description of the data there is.
See also
- Declarative charts in Angular - charts written as components in the same way, with each part drawing itself
- Angular best practices for large teams: lean on the template - the principle behind this, and the user interface library it was part of