diff --git a/schematics/collection.json b/schematics/collection.json index e6cca6f317a..6cd12e815f2 100644 --- a/schematics/collection.json +++ b/schematics/collection.json @@ -2,6 +2,13 @@ { "$schema": "./node_modules/@angular-devkit/schematics/collection-schema.json", "schematics": { + // Creates a table component + "materialTable": { + "description": "Create a table component", + "factory": "./table/index", + "schema": "./table/schema.json", + "aliases": [ "material-table" ] + }, // Creates toolbar and navigation components "materialNav": { "description": "Create a responsive navigation component", diff --git a/schematics/table/README.md b/schematics/table/README.md new file mode 100644 index 00000000000..178750c0895 --- /dev/null +++ b/schematics/table/README.md @@ -0,0 +1,5 @@ + # Table + Creates a table component with sorting and paginator. + + Command: `ng generate material-table --collection=material-schematics` + \ No newline at end of file diff --git a/schematics/table/files/__path__/__name@dasherize@if-flat__/__name@dasherize__-datasource.ts b/schematics/table/files/__path__/__name@dasherize@if-flat__/__name@dasherize__-datasource.ts new file mode 100644 index 00000000000..44e7b4a02a3 --- /dev/null +++ b/schematics/table/files/__path__/__name@dasherize@if-flat__/__name@dasherize__-datasource.ts @@ -0,0 +1,107 @@ +import { merge } from 'rxjs/observable/merge'; +import { of as observableOf } from 'rxjs/observable/of'; +import { DataSource } from '@angular/cdk/collections'; +import { MatPaginator, MatSort } from '@angular/material'; +import { map } from 'rxjs/operators/map'; +import { Observable } from 'rxjs/Observable'; + +// TODO: Replace this with your own data model type +export interface <%= classify(name) %>Item { + name: string; + id: number; +} + +// TODO: replace this with real data from your application +const EXAMPLE_DATA = <%= classify(name) %>Item[] = [ + {id: 1, name: 'Hydrogen'}, + {id: 2, name: 'Helium'}, + {id: 3, name: 'Lithium'}, + {id: 4, name: 'Beryllium'}, + {id: 5, name: 'Boron'}, + {id: 6, name: 'Carbon'}, + {id: 7, name: 'Nitrogen'}, + {id: 8, name: 'Oxygen'}, + {id: 9, name: 'Fluorine'}, + {id: 10, name: 'Neon'}, + {id: 11, name: 'Sodium'}, + {id: 12, name: 'Magnesium'}, + {id: 13, name: 'Aluminum'}, + {id: 14, name: 'Silicon'}, + {id: 15, name: 'Phosphorus'}, + {id: 16, name: 'Sulfur'}, + {id: 17, name: 'Chlorine'}, + {id: 18, name: 'Argon'}, + {id: 19, name: 'Potassium'}, + {id: 20, name: 'Calcium'}, +]; + +/** + * Data source for the <%= classify(name) %> view. This class should + * encapsulate all logic for fetching and manipulating the displayed data + * (including sorting, pagination, and filtering). + */ +export class <%= classify(name) %>DataSource extends DataSource<<%= classify(name) %>Item> { + data: <%= classify(name) %>Item[] = EXAMPLE_DATA; + + constructor(private paginator: MatPaginator, private sort: MatSort) { + super(); + } + + /** + * Connect this data source to the table. The table will only update when + * the returned stream emits new items. + * @returns A stream of the items to be rendered. + */ + connect(): Observable<<%= classify(name) %>Item[]> { + // Combine everything that affects the rendered data into one update + // stream for the data-table to consume. + const dataMutations = [ + observableOf(this.data), + this.paginator.page, + this.sort.sortChange + ]; + + return merge(...dataMutations).pipe(map(() => { + return this.getPagedData(this.getSortedData(this.data)); + })); + } + + /** + * Called when the table is being destroyed. Use this function, to clean up + * any open connections or free any held resources that were set up during connect. + */ + disconnect() {} + + /** + * Paginate the data (client-side). If you're using server-side pagination, + * this would be replaced by requesting the appropriate data from the server. + */ + private getPagedData(data: <%= classify(name) %>Item[]) { + const startIndex = this.paginator.pageIndex * this.paginator.pageSize; + return data.splice(startIndex, this.paginator.pageSize); + } + + /** + * Sort the data (client-side). If you're using server-side sorting, + * this would be replaced by requesting the appropriate data from the server. + */ + private getSortedData(data: <%= classify(name) %>Item[]) { + if (!this.sort.active || this.sort.direction === '') { + return data; + } + + return data.sort((a, b) => { + const isAsc = this.sort.direction == 'asc'; + switch (this.sort.active) { + case 'name': return compare(a.name, b.name, isAsc); + case 'id': return compare(+a.id, +b.id, isAsc); + default: return 0; + } + }); + } +} + +/** Simple sort comparator for example ID/Name columns (for client-side sorting). */ +function compare(a, b, isAsc) { + return (a < b ? -1 : 1) * (isAsc ? 1 : -1); +} diff --git a/schematics/table/files/__path__/__name@dasherize@if-flat__/__name@dasherize__.component.__styleext__ b/schematics/table/files/__path__/__name@dasherize@if-flat__/__name@dasherize__.component.__styleext__ new file mode 100644 index 00000000000..e69de29bb2d diff --git a/schematics/table/files/__path__/__name@dasherize@if-flat__/__name@dasherize__.component.html b/schematics/table/files/__path__/__name@dasherize@if-flat__/__name@dasherize__.component.html new file mode 100644 index 00000000000..1105442ae76 --- /dev/null +++ b/schematics/table/files/__path__/__name@dasherize@if-flat__/__name@dasherize__.component.html @@ -0,0 +1,26 @@ +
+ + + + + Id + {{row.id}} + + + + + Name + {{row.name}} + + + + + + + + +
\ No newline at end of file diff --git a/schematics/table/files/__path__/__name@dasherize@if-flat__/__name@dasherize__.component.spec.ts b/schematics/table/files/__path__/__name@dasherize@if-flat__/__name@dasherize__.component.spec.ts new file mode 100644 index 00000000000..7b48791cd25 --- /dev/null +++ b/schematics/table/files/__path__/__name@dasherize@if-flat__/__name@dasherize__.component.spec.ts @@ -0,0 +1,24 @@ + +import { fakeAsync, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { <%= classify(name) %>Component } from './<%= dasherize(name) %>.component'; + +describe('<%= classify(name) %>Component', () => { + let component: <%= classify(name) %>Component; + let fixture: ComponentFixture<<%= classify(name) %>Component>; + + beforeEach(fakeAsync(() => { + TestBed.configureTestingModule({ + declarations: [ <%= classify(name) %>Component ] + }) + .compileComponents(); + + fixture = TestBed.createComponent(<%= classify(name) %>Component); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should compile', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/schematics/table/files/__path__/__name@dasherize@if-flat__/__name@dasherize__.component.ts b/schematics/table/files/__path__/__name@dasherize@if-flat__/__name@dasherize__.component.ts new file mode 100644 index 00000000000..1e940e0fd8b --- /dev/null +++ b/schematics/table/files/__path__/__name@dasherize@if-flat__/__name@dasherize__.component.ts @@ -0,0 +1,53 @@ +import { Component, OnInit, ViewChild, <% if(!!viewEncapsulation) { %>, ViewEncapsulation<% }%><% if(changeDetection !== 'Default') { %>, ChangeDetectionStrategy<% }%> } from '@angular/core'; +import { Observable } from 'rxjs/Observable'; +import { MatPaginator, MatSort } from '@angular/material'; +import { <%= classify(name) %>DataSource } from '<%= dasherize(name) %>-datasource'; + +@Component({ + selector: '<%= selector %>',<% if(inlineTemplate) { %> + template: ` +
+ + + + + Id + {{row.id}} + + + + + Name + {{row.name}} + + + + + + + + +
+ `,<% } else { %> + templateUrl: './<%= dasherize(name) %>.component.html',<% } if(inlineStyle) { %> + styles: []<% } else { %> + styleUrls: ['./<%= dasherize(name) %>.component.<%= styleext %>']<% } %><% if(!!viewEncapsulation) { %>, + encapsulation: ViewEncapsulation.<%= viewEncapsulation %><% } if (changeDetection !== 'Default') { %>, + changeDetection: ChangeDetectionStrategy.<%= changeDetection %><% } %> +}) +export class <%= classify(name) %>Component implements OnInit { + @ViewChild(MatPaginator) paginator: MatPaginator; + @ViewChild(MatSort) sort: MatSort; + dataSource: <%= classify(name) %>DataSource; + + /** Columns displayed in the table. Columns IDs can be added, removed, or reordered. */ + displayedColumns = ['id', 'name']; + + ngOnInit() { + this.dataSource = new <%= classify(name) %>DataSource(this.paginator, this.sort); + } +} diff --git a/schematics/table/index.ts b/schematics/table/index.ts new file mode 100644 index 00000000000..621f31567d4 --- /dev/null +++ b/schematics/table/index.ts @@ -0,0 +1,29 @@ +import {chain, Rule, noop, Tree, SchematicContext} from '@angular-devkit/schematics'; +import {Schema} from './schema'; +import {addModuleImportToModule} from '../utils/ast'; +import {findModuleFromOptions} from '../utils/devkit-utils/find-module'; +import {buildComponent} from '../utils/devkit-utils/component'; + +/** + * Scaffolds a new table component. + * Internally it bootstraps the base component schematic + */ +export default function(options: Schema): Rule { + return chain([ + buildComponent({...options}), + options.skipImport ? noop() : addTableModulesToModule(options) + ]); +} + +/** + * Adds the required modules to the relative module. + */ +function addTableModulesToModule(options: Schema) { + return (host: Tree) => { + const modulePath = findModuleFromOptions(host, options); + addModuleImportToModule(host, modulePath, 'MatTableModule', '@angular/material'); + addModuleImportToModule(host, modulePath, 'MatPaginatorModule', '@angular/material'); + addModuleImportToModule(host, modulePath, 'MatSortModule', '@angular/material'); + return host; + }; +} diff --git a/schematics/table/index_spec.ts b/schematics/table/index_spec.ts new file mode 100644 index 00000000000..b498fa1a0f1 --- /dev/null +++ b/schematics/table/index_spec.ts @@ -0,0 +1,64 @@ +import {SchematicTestRunner} from '@angular-devkit/schematics/testing'; +import {join} from 'path'; +import {Tree} from '@angular-devkit/schematics'; +import {createTestApp} from '../utils/testing'; +import {getFileContent} from '@schematics/angular/utility/test'; + +const collectionPath = join(__dirname, '../collection.json'); + +describe('material-table-schematic', () => { + let runner: SchematicTestRunner; + const options = { + name: 'foo', + path: 'app', + sourceDir: 'src', + inlineStyle: false, + inlineTemplate: false, + changeDetection: 'Default', + styleext: 'css', + spec: true, + module: undefined, + export: false, + prefix: undefined, + viewEncapsulation: undefined, + }; + + beforeEach(() => { + runner = new SchematicTestRunner('schematics', collectionPath); + }); + + it('should create table files and add them to module', () => { + const tree = runner.runSchematic('materialTable', { ...options }, createTestApp()); + const files = tree.files; + + expect(files).toContain('/src/app/foo/foo.component.css'); + expect(files).toContain('/src/app/foo/foo.component.html'); + expect(files).toContain('/src/app/foo/foo.component.spec.ts'); + expect(files).toContain('/src/app/foo/foo.component.ts'); + expect(files).toContain('/src/app/foo/foo-datasource.ts'); + + const moduleContent = getFileContent(tree, '/src/app/app.module.ts'); + expect(moduleContent).toMatch(/import.*Foo.*from '.\/foo\/foo.component'/); + expect(moduleContent).toMatch(/declarations:\s*\[[^\]]+?,\r?\n\s+FooComponent\r?\n/m); + + const datasourceContent = getFileContent(tree, '/src/app/foo/foo-datasource.ts'); + expect(datasourceContent).toContain('FooItem'); + expect(datasourceContent).toContain('FooDataSource'); + + const componentContent = getFileContent(tree, '/src/app/foo/foo.component.ts'); + expect(componentContent).toContain('FooDataSource'); + }); + + it('should add table imports to module', () => { + const tree = runner.runSchematic('materialTable', { ...options }, createTestApp()); + const moduleContent = getFileContent(tree, '/src/app/app.module.ts'); + + expect(moduleContent).toContain('MatTableModule'); + expect(moduleContent).toContain('MatPaginatorModule'); + expect(moduleContent).toContain('MatSortModule'); + + expect(moduleContent).toContain( + `import { MatTableModule, MatPaginatorModule, MatSortModule } from '@angular/material';`); + }); + +}); diff --git a/schematics/table/schema.d.ts b/schematics/table/schema.d.ts new file mode 100644 index 00000000000..49a799dd153 --- /dev/null +++ b/schematics/table/schema.d.ts @@ -0,0 +1,3 @@ +import {Schema as ComponentSchema} from '@schematics/angular/component/schema'; + +export interface Schema extends ComponentSchema {} diff --git a/schematics/table/schema.json b/schematics/table/schema.json new file mode 100644 index 00000000000..16e4a8ed90e --- /dev/null +++ b/schematics/table/schema.json @@ -0,0 +1,98 @@ +{ + "$schema": "http://json-schema.org/schema", + "id": "SchematicsMaterialTable", + "title": "Material Table Options Schema", + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "The path to create the component.", + "default": "app", + "visible": false + }, + "sourceDir": { + "type": "string", + "description": "The path of the source directory.", + "default": "src", + "alias": "sd", + "visible": false + }, + "appRoot": { + "type": "string", + "description": "The root of the application.", + "visible": false + }, + "name": { + "type": "string", + "description": "The name of the component." + }, + "inlineStyle": { + "description": "Specifies if the style will be in the ts file.", + "type": "boolean", + "default": false, + "alias": "is" + }, + "inlineTemplate": { + "description": "Specifies if the template will be in the ts file.", + "type": "boolean", + "default": false, + "alias": "it" + }, + "viewEncapsulation": { + "description": "Specifies the view encapsulation strategy.", + "enum": ["Emulated", "Native", "None"], + "type": "string", + "alias": "ve" + }, + "changeDetection": { + "description": "Specifies the change detection strategy.", + "enum": ["Default", "OnPush"], + "type": "string", + "default": "Default", + "alias": "cd" + }, + "prefix": { + "type": "string", + "description": "The prefix to apply to generated selectors.", + "default": "app", + "alias": "p" + }, + "styleext": { + "description": "The file extension to be used for style files.", + "type": "string", + "default": "css" + }, + "spec": { + "type": "boolean", + "description": "Specifies if a spec file is generated.", + "default": true + }, + "flat": { + "type": "boolean", + "description": "Flag to indicate if a dir is created.", + "default": false + }, + "skipImport": { + "type": "boolean", + "description": "Flag to skip the module import.", + "default": false + }, + "selector": { + "type": "string", + "description": "The selector to use for the component." + }, + "module": { + "type": "string", + "description": "Allows specification of the declaring module.", + "alias": "m" + }, + "export": { + "type": "boolean", + "default": false, + "description": "Specifies if declaring module exports the component." + } + }, + "required": [ + "name" + ] +}