Compare commits
64 Commits
Author | SHA1 | Date | |
---|---|---|---|
06e479ff66 | |||
0065868f37 | |||
77fa3c3e48 | |||
f4cb45345d | |||
9329bfb86a | |||
3efc88fb81 | |||
954b09022a | |||
71f5e78bcb | |||
f0c3ed0f14 | |||
c8fd3f5237 | |||
e0660b1b72 | |||
5a165ebcef | |||
3212f8c826 | |||
c421ccaae9 | |||
bbec7db7ba | |||
00134ae4e0 | |||
07bd459baa | |||
302adf1081 | |||
1a6a13425b | |||
072a772ca6 | |||
5f0e0a46fd | |||
c7b72aa575 | |||
732eb61957 | |||
e7e7622971 | |||
4176832266 | |||
71de92a189 | |||
e0021d4cf5 | |||
4e44102e31 | |||
111b70d108 | |||
5e4054b8f3 | |||
5afc7abcb0 | |||
65d0888708 | |||
adfd2373b8 | |||
3a82af3bde | |||
3af62306b4 | |||
afe339396f | |||
c4b51bf689 | |||
b65fe3e44e | |||
116ee334fb | |||
dbc5c5817a | |||
baf4ce0dd0 | |||
24db1ed938 | |||
82798e9d04 | |||
da8bb1b45b | |||
f5cbc2ee25 | |||
cbc1986c6f | |||
0982f993cb | |||
a5a29b0591 | |||
a8f3197f24 | |||
e6f37120fe | |||
6840b7bda9 | |||
68f458909a | |||
12acecf756 | |||
cfbed40ab6 | |||
fe1a6b8e42 | |||
13e29c4e89 | |||
fd52b178ed | |||
ca1f071b2e | |||
296adbbb72 | |||
c795ee1176 | |||
b550618afd | |||
d08d6eebff | |||
e9789abd05 | |||
f2ec2cbb99 |
@ -53,9 +53,10 @@ env:
|
|||||||
- CI_MODE=browserstack_required
|
- CI_MODE=browserstack_required
|
||||||
- CI_MODE=saucelabs_optional
|
- CI_MODE=saucelabs_optional
|
||||||
- CI_MODE=browserstack_optional
|
- CI_MODE=browserstack_optional
|
||||||
- CI_MODE=docs_test
|
- CI_MODE=aio_tools_test
|
||||||
- CI_MODE=aio
|
- CI_MODE=aio
|
||||||
- CI_MODE=aio_e2e
|
- CI_MODE=aio_e2e AIO_SHARD=0
|
||||||
|
- CI_MODE=aio_e2e AIO_SHARD=1
|
||||||
- CI_MODE=bazel
|
- CI_MODE=bazel
|
||||||
|
|
||||||
matrix:
|
matrix:
|
||||||
@ -63,7 +64,6 @@ matrix:
|
|||||||
allow_failures:
|
allow_failures:
|
||||||
- env: "CI_MODE=saucelabs_optional"
|
- env: "CI_MODE=saucelabs_optional"
|
||||||
- env: "CI_MODE=browserstack_optional"
|
- env: "CI_MODE=browserstack_optional"
|
||||||
- env: "CI_MODE=aio_e2e"
|
|
||||||
|
|
||||||
before_install:
|
before_install:
|
||||||
# source the env.sh script so that the exported variables are available to other scripts later on
|
# source the env.sh script so that the exported variables are available to other scripts later on
|
||||||
|
18
CHANGELOG.md
@ -1,3 +1,21 @@
|
|||||||
|
<a name="4.3.4"></a>
|
||||||
|
## [4.3.4](https://github.com/angular/angular/compare/4.3.3...4.3.4) (2017-08-10)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **animations:** revert container/queried animations accordingly during cancel ([#18516](https://github.com/angular/angular/issues/18516)) ([5a165eb](https://github.com/angular/angular/commit/5a165eb))
|
||||||
|
* **animations:** support persisting dynamic styles within animation states ([#18468](https://github.com/angular/angular/issues/18468)) ([e0660b1](https://github.com/angular/angular/commit/e0660b1)), closes [#18423](https://github.com/angular/angular/issues/18423) [#17505](https://github.com/angular/angular/issues/17505)
|
||||||
|
* **benchpress:** compile cleanly with TS 2.4 ([#18455](https://github.com/angular/angular/issues/18455)) ([5afc7ab](https://github.com/angular/angular/commit/5afc7ab))
|
||||||
|
* **compiler:** cleanly compile with TypeScript 2.4 ([#18456](https://github.com/angular/angular/issues/18456)) ([5e4054b](https://github.com/angular/angular/commit/5e4054b))
|
||||||
|
* **compiler:** ignore [@import](https://github.com/import) in multi-line css ([#18452](https://github.com/angular/angular/issues/18452)) ([e7e7622](https://github.com/angular/angular/commit/e7e7622)), closes [#18038](https://github.com/angular/angular/issues/18038)
|
||||||
|
|
||||||
|
<a name="4.3.3"></a>
|
||||||
|
## [4.3.3](https://github.com/angular/angular/compare/4.3.2...4.3.3) (2017-08-02)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **compiler:** fix for element needing implicit parent placed in top-level ng-container ([f5cbc2e](https://github.com/angular/angular/commit/f5cbc2e)), closes [#18314](https://github.com/angular/angular/issues/18314)
|
||||||
|
|
||||||
<a name="4.3.2"></a>
|
<a name="4.3.2"></a>
|
||||||
## [4.3.2](https://github.com/angular/angular/compare/4.3.1...4.3.2) (2017-07-26)
|
## [4.3.2](https://github.com/angular/angular/compare/4.3.1...4.3.2) (2017-07-26)
|
||||||
|
|
||||||
|
@ -31,8 +31,9 @@
|
|||||||
"environmentSource": "environments/environment.ts",
|
"environmentSource": "environments/environment.ts",
|
||||||
"environments": {
|
"environments": {
|
||||||
"dev": "environments/environment.ts",
|
"dev": "environments/environment.ts",
|
||||||
"stage": "environments/environment.stage.ts",
|
"next": "environments/environment.next.ts",
|
||||||
"prod": "environments/environment.prod.ts"
|
"stable": "environments/environment.stable.ts",
|
||||||
|
"archive": "environments/environment.archive.ts"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
10
aio/content/examples/.gitignore
vendored
@ -43,13 +43,9 @@ dist/
|
|||||||
**/app/**/*.ajs.js
|
**/app/**/*.ajs.js
|
||||||
|
|
||||||
# aot
|
# aot
|
||||||
**/*.ngfactory.ts
|
*/aot/**/*
|
||||||
**/*.ngsummary.json
|
!*/aot/bs-config.json
|
||||||
**/*.ngsummary.ts
|
!*/aot/index.html
|
||||||
**/*.shim.ngstyle.ts
|
|
||||||
**/*.metadata.json
|
|
||||||
!aot/bs-config.json
|
|
||||||
!aot/index.html
|
|
||||||
!rollup-config.js
|
!rollup-config.js
|
||||||
|
|
||||||
# i18n
|
# i18n
|
||||||
|
@ -9,30 +9,20 @@ describe('Form Validation Tests', function () {
|
|||||||
browser.get('');
|
browser.get('');
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Hero Form 1', () => {
|
describe('Template-driven form', () => {
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
getPage('hero-form-template1');
|
getPage('hero-form-template');
|
||||||
});
|
});
|
||||||
|
|
||||||
tests();
|
tests('Template-Driven Form');
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Hero Form 2', () => {
|
describe('Reactive form', () => {
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
getPage('hero-form-template2');
|
getPage('hero-form-reactive');
|
||||||
});
|
});
|
||||||
|
|
||||||
tests();
|
tests('Reactive Form');
|
||||||
bobTests();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Hero Form 3 (Reactive)', () => {
|
|
||||||
beforeAll(() => {
|
|
||||||
getPage('hero-form-reactive3');
|
|
||||||
makeNameTooLong();
|
|
||||||
});
|
|
||||||
|
|
||||||
tests();
|
|
||||||
bobTests();
|
bobTests();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@ -48,6 +38,7 @@ let page: {
|
|||||||
nameInput: ElementFinder,
|
nameInput: ElementFinder,
|
||||||
alterEgoInput: ElementFinder,
|
alterEgoInput: ElementFinder,
|
||||||
powerSelect: ElementFinder,
|
powerSelect: ElementFinder,
|
||||||
|
powerOption: ElementFinder,
|
||||||
errorMessages: ElementArrayFinder,
|
errorMessages: ElementArrayFinder,
|
||||||
heroFormButtons: ElementArrayFinder,
|
heroFormButtons: ElementArrayFinder,
|
||||||
heroSubmitted: ElementFinder
|
heroSubmitted: ElementFinder
|
||||||
@ -64,19 +55,21 @@ function getPage(sectionTag: string) {
|
|||||||
nameInput: section.element(by.css('#name')),
|
nameInput: section.element(by.css('#name')),
|
||||||
alterEgoInput: section.element(by.css('#alterEgo')),
|
alterEgoInput: section.element(by.css('#alterEgo')),
|
||||||
powerSelect: section.element(by.css('#power')),
|
powerSelect: section.element(by.css('#power')),
|
||||||
|
powerOption: section.element(by.css('#power option')),
|
||||||
errorMessages: section.all(by.css('div.alert')),
|
errorMessages: section.all(by.css('div.alert')),
|
||||||
heroFormButtons: buttons,
|
heroFormButtons: buttons,
|
||||||
heroSubmitted: section.element(by.css('hero-submitted > div'))
|
heroSubmitted: section.element(by.css('.submitted-message'))
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function tests() {
|
function tests(title: string) {
|
||||||
|
|
||||||
it('should display correct title', function () {
|
it('should display correct title', function () {
|
||||||
expect(page.title.getText()).toContain('Hero Form');
|
expect(page.title.getText()).toContain(title);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not display submitted message before submit', function () {
|
it('should not display submitted message before submit', function () {
|
||||||
expect(page.heroSubmitted.isElementPresent(by.css('h2'))).toBe(false);
|
expect(page.heroSubmitted.isElementPresent(by.css('p'))).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should have form buttons', function () {
|
it('should have form buttons', function () {
|
||||||
@ -130,11 +123,11 @@ function tests() {
|
|||||||
|
|
||||||
it('should hide form after submit', function () {
|
it('should hide form after submit', function () {
|
||||||
page.heroFormButtons.get(0).click();
|
page.heroFormButtons.get(0).click();
|
||||||
expect(page.title.isDisplayed()).toBe(false);
|
expect(page.heroFormButtons.get(0).isDisplayed()).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('submitted form should be displayed', function () {
|
it('submitted form should be displayed', function () {
|
||||||
expect(page.heroSubmitted.isElementPresent(by.css('h2'))).toBe(true);
|
expect(page.heroSubmitted.isElementPresent(by.css('p'))).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('submitted form should have new hero name', function () {
|
it('submitted form should have new hero name', function () {
|
||||||
@ -142,9 +135,9 @@ function tests() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('clicking edit button should reveal form again', function () {
|
it('clicking edit button should reveal form again', function () {
|
||||||
const editBtn = page.heroSubmitted.element(by.css('button'));
|
const newFormBtn = page.heroSubmitted.element(by.css('button'));
|
||||||
editBtn.click();
|
newFormBtn.click();
|
||||||
expect(page.heroSubmitted.isElementPresent(by.css('h2')))
|
expect(page.heroSubmitted.isElementPresent(by.css('p')))
|
||||||
.toBe(false, 'submitted hidden again');
|
.toBe(false, 'submitted hidden again');
|
||||||
expect(page.title.isDisplayed()).toBe(true, 'can see form title');
|
expect(page.title.isDisplayed()).toBe(true, 'can see form title');
|
||||||
});
|
});
|
||||||
@ -159,9 +152,13 @@ function expectFormIsInvalid() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function bobTests() {
|
function bobTests() {
|
||||||
const emsg = 'Someone named "Bob" cannot be a hero.';
|
const emsg = 'Name cannot be Bob.';
|
||||||
|
|
||||||
it('should produce "no bob" error after setting name to "Bobby"', function () {
|
it('should produce "no bob" error after setting name to "Bobby"', function () {
|
||||||
|
// Re-populate select element
|
||||||
|
page.powerSelect.click();
|
||||||
|
page.powerOption.click();
|
||||||
|
|
||||||
page.nameInput.clear();
|
page.nameInput.clear();
|
||||||
page.nameInput.sendKeys('Bobby');
|
page.nameInput.sendKeys('Bobby');
|
||||||
expectFormIsInvalid();
|
expectFormIsInvalid();
|
||||||
@ -174,8 +171,3 @@ function bobTests() {
|
|||||||
expectFormIsValid();
|
expectFormIsValid();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeNameTooLong() {
|
|
||||||
// make the first name invalid
|
|
||||||
page.nameInput.sendKeys('ThisHeroNameHasWayWayTooManyLetters');
|
|
||||||
}
|
|
||||||
|
@ -3,10 +3,8 @@ import { Component } from '@angular/core';
|
|||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'my-app',
|
selector: 'my-app',
|
||||||
template: `<hero-form-template1></hero-form-template1>
|
template: `<hero-form-template></hero-form-template>
|
||||||
<hr>
|
<hr>
|
||||||
<hero-form-template2></hero-form-template2>
|
<hero-form-reactive></hero-form-reactive>`
|
||||||
<hr>
|
|
||||||
<hero-form-reactive3></hero-form-reactive3>`
|
|
||||||
})
|
})
|
||||||
export class AppComponent { }
|
export class AppComponent { }
|
||||||
|
@ -1,18 +1,26 @@
|
|||||||
// #docregion
|
// #docregion
|
||||||
import { NgModule } from '@angular/core';
|
import { NgModule } from '@angular/core';
|
||||||
|
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
|
||||||
import { BrowserModule } from '@angular/platform-browser';
|
import { BrowserModule } from '@angular/platform-browser';
|
||||||
|
|
||||||
import { AppComponent } from './app.component';
|
import { AppComponent } from './app.component';
|
||||||
import { HeroFormTemplateModule } from './template/hero-form-template.module';
|
import { HeroFormTemplateComponent } from './template/hero-form-template.component';
|
||||||
import { HeroFormReactiveModule } from './reactive/hero-form-reactive.module';
|
import { HeroFormReactiveComponent } from './reactive/hero-form-reactive.component';
|
||||||
|
import { ForbiddenValidatorDirective } from './shared/forbidden-name.directive';
|
||||||
|
|
||||||
|
|
||||||
@NgModule({
|
@NgModule({
|
||||||
imports: [
|
imports: [
|
||||||
BrowserModule,
|
BrowserModule,
|
||||||
HeroFormTemplateModule,
|
FormsModule,
|
||||||
HeroFormReactiveModule
|
ReactiveFormsModule
|
||||||
|
],
|
||||||
|
declarations: [
|
||||||
|
AppComponent,
|
||||||
|
HeroFormTemplateComponent,
|
||||||
|
HeroFormReactiveComponent,
|
||||||
|
ForbiddenValidatorDirective
|
||||||
],
|
],
|
||||||
declarations: [ AppComponent ],
|
|
||||||
bootstrap: [ AppComponent ]
|
bootstrap: [ AppComponent ]
|
||||||
})
|
})
|
||||||
export class AppModule { }
|
export class AppModule { }
|
||||||
|
@ -1,26 +1,38 @@
|
|||||||
<!-- #docregion -->
|
<!-- #docregion -->
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div [hidden]="submitted">
|
|
||||||
<h1>Hero Form 3 (Reactive)</h1>
|
|
||||||
<!-- #docregion form-tag-->
|
|
||||||
<form [formGroup]="heroForm" *ngIf="active" (ngSubmit)="onSubmit()">
|
|
||||||
<!-- #enddocregion form-tag-->
|
|
||||||
<div class="form-group">
|
|
||||||
<!-- #docregion name-with-error-msg -->
|
|
||||||
<label for="name">Name</label>
|
|
||||||
|
|
||||||
<input type="text" id="name" class="form-control"
|
<h1>Reactive Form</h1>
|
||||||
|
|
||||||
|
<form [formGroup]="heroForm" #formDir="ngForm">
|
||||||
|
|
||||||
|
<div [hidden]="formDir.submitted">
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
|
||||||
|
<label for="name">Name</label>
|
||||||
|
<!-- #docregion name-with-error-msg -->
|
||||||
|
<input id="name" class="form-control"
|
||||||
formControlName="name" required >
|
formControlName="name" required >
|
||||||
|
|
||||||
<div *ngIf="formErrors.name" class="alert alert-danger">
|
<div *ngIf="name.invalid && (name.dirty || name.touched)"
|
||||||
{{ formErrors.name }}
|
class="alert alert-danger">
|
||||||
|
|
||||||
|
<div *ngIf="name.errors.required">
|
||||||
|
Name is required.
|
||||||
|
</div>
|
||||||
|
<div *ngIf="name.errors.minlength">
|
||||||
|
Name must be at least 4 characters long.
|
||||||
|
</div>
|
||||||
|
<div *ngIf="name.errors.forbiddenName">
|
||||||
|
Name cannot be Bob.
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- #enddocregion name-with-error-msg -->
|
<!-- #enddocregion name-with-error-msg -->
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="alterEgo">Alter Ego</label>
|
<label for="alterEgo">Alter Ego</label>
|
||||||
<input type="text" id="alterEgo" class="form-control"
|
<input id="alterEgo" class="form-control"
|
||||||
formControlName="alterEgo" >
|
formControlName="alterEgo" >
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -31,17 +43,20 @@
|
|||||||
<option *ngFor="let p of powers" [value]="p">{{p}}</option>
|
<option *ngFor="let p of powers" [value]="p">{{p}}</option>
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<div *ngIf="formErrors.power" class="alert alert-danger">
|
<div *ngIf="power.invalid && power.touched" class="alert alert-danger">
|
||||||
{{ formErrors.power }}
|
<div *ngIf="power.errors.required">Power is required.</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button type="submit" class="btn btn-default"
|
<button type="submit" class="btn btn-default"
|
||||||
[disabled]="!heroForm.valid">Submit</button>
|
[disabled]="heroForm.invalid">Submit</button>
|
||||||
<button type="button" class="btn btn-default"
|
<button type="button" class="btn btn-default"
|
||||||
(click)="addHero()">New Hero</button>
|
(click)="formDir.resetForm({})">Reset</button>
|
||||||
</form>
|
</div>
|
||||||
</div>
|
</form>
|
||||||
|
|
||||||
<hero-submitted [hero]="hero" [(submitted)]="submitted"></hero-submitted>
|
<div class="submitted-message" *ngIf="formDir.submitted">
|
||||||
|
<p>You've submitted your hero, {{ heroForm.value.name }}!</p>
|
||||||
|
<button (click)="formDir.resetForm({})">Add new hero</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -2,115 +2,39 @@
|
|||||||
// #docplaster
|
// #docplaster
|
||||||
// #docregion
|
// #docregion
|
||||||
import { Component, OnInit } from '@angular/core';
|
import { Component, OnInit } from '@angular/core';
|
||||||
import { FormGroup, FormBuilder, Validators } from '@angular/forms';
|
import { FormControl, FormGroup, Validators } from '@angular/forms';
|
||||||
|
|
||||||
import { Hero } from '../shared/hero';
|
|
||||||
import { forbiddenNameValidator } from '../shared/forbidden-name.directive';
|
import { forbiddenNameValidator } from '../shared/forbidden-name.directive';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'hero-form-reactive3',
|
selector: 'hero-form-reactive',
|
||||||
templateUrl: './hero-form-reactive.component.html'
|
templateUrl: './hero-form-reactive.component.html'
|
||||||
})
|
})
|
||||||
export class HeroFormReactiveComponent implements OnInit {
|
export class HeroFormReactiveComponent implements OnInit {
|
||||||
|
|
||||||
powers = ['Really Smart', 'Super Flexible', 'Weather Changer'];
|
powers = ['Really Smart', 'Super Flexible', 'Weather Changer'];
|
||||||
|
|
||||||
hero = new Hero(18, 'Dr. WhatIsHisName', this.powers[0], 'Dr. What');
|
hero = {name: 'Dr.', alterEgo: 'Dr. What', power: this.powers[0]};
|
||||||
|
|
||||||
submitted = false;
|
|
||||||
|
|
||||||
// #docregion on-submit
|
|
||||||
onSubmit() {
|
|
||||||
this.submitted = true;
|
|
||||||
this.hero = this.heroForm.value;
|
|
||||||
}
|
|
||||||
// #enddocregion on-submit
|
|
||||||
// #enddocregion
|
|
||||||
|
|
||||||
// Reset the form with a new hero AND restore 'pristine' class state
|
|
||||||
// by toggling 'active' flag which causes the form
|
|
||||||
// to be removed/re-added in a tick via NgIf
|
|
||||||
// TODO: Workaround until NgForm has a reset method (#6822)
|
|
||||||
active = true;
|
|
||||||
// #docregion class
|
|
||||||
// #docregion add-hero
|
|
||||||
addHero() {
|
|
||||||
this.hero = new Hero(42, '', '');
|
|
||||||
this.buildForm();
|
|
||||||
// #enddocregion add-hero
|
|
||||||
// #enddocregion class
|
|
||||||
|
|
||||||
this.active = false;
|
|
||||||
setTimeout(() => this.active = true, 0);
|
|
||||||
// #docregion
|
|
||||||
// #docregion add-hero
|
|
||||||
}
|
|
||||||
// #enddocregion add-hero
|
|
||||||
|
|
||||||
// #docregion form-builder
|
|
||||||
heroForm: FormGroup;
|
heroForm: FormGroup;
|
||||||
constructor(private fb: FormBuilder) { }
|
|
||||||
|
|
||||||
|
// #docregion form-group
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
this.buildForm();
|
// #docregion custom-validator
|
||||||
}
|
this.heroForm = new FormGroup({
|
||||||
|
'name': new FormControl(this.hero.name, [
|
||||||
buildForm(): void {
|
Validators.required,
|
||||||
this.heroForm = this.fb.group({
|
Validators.minLength(4),
|
||||||
// #docregion name-validators
|
forbiddenNameValidator(/bob/i) // <-- Here's how you pass in the custom validator.
|
||||||
'name': [this.hero.name, [
|
]),
|
||||||
Validators.required,
|
'alterEgo': new FormControl(this.hero.alterEgo),
|
||||||
Validators.minLength(4),
|
'power': new FormControl(this.hero.power, Validators.required)
|
||||||
Validators.maxLength(24),
|
|
||||||
forbiddenNameValidator(/bob/i)
|
|
||||||
]
|
|
||||||
],
|
|
||||||
// #enddocregion name-validators
|
|
||||||
'alterEgo': [this.hero.alterEgo],
|
|
||||||
'power': [this.hero.power, Validators.required]
|
|
||||||
});
|
});
|
||||||
|
// #enddocregion custom-validator
|
||||||
this.heroForm.valueChanges
|
|
||||||
.subscribe(data => this.onValueChanged(data));
|
|
||||||
|
|
||||||
this.onValueChanged(); // (re)set validation messages now
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// #enddocregion form-builder
|
get name() { return this.heroForm.get('name'); }
|
||||||
|
|
||||||
onValueChanged(data?: any) {
|
get power() { return this.heroForm.get('power'); }
|
||||||
if (!this.heroForm) { return; }
|
// #enddocregion form-group
|
||||||
const form = this.heroForm;
|
|
||||||
|
|
||||||
for (const field in this.formErrors) {
|
|
||||||
// clear previous error message (if any)
|
|
||||||
this.formErrors[field] = '';
|
|
||||||
const control = form.get(field);
|
|
||||||
|
|
||||||
if (control && control.dirty && !control.valid) {
|
|
||||||
const messages = this.validationMessages[field];
|
|
||||||
for (const key in control.errors) {
|
|
||||||
this.formErrors[field] += messages[key] + ' ';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
formErrors = {
|
|
||||||
'name': '',
|
|
||||||
'power': ''
|
|
||||||
};
|
|
||||||
|
|
||||||
validationMessages = {
|
|
||||||
'name': {
|
|
||||||
'required': 'Name is required.',
|
|
||||||
'minlength': 'Name must be at least 4 characters long.',
|
|
||||||
'maxlength': 'Name cannot be more than 24 characters long.',
|
|
||||||
'forbiddenName': 'Someone named "Bob" cannot be a hero.'
|
|
||||||
},
|
|
||||||
'power': {
|
|
||||||
'required': 'Power is required.'
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
// #enddocregion
|
// #enddocregion
|
||||||
|
@ -1,13 +0,0 @@
|
|||||||
// #docregion
|
|
||||||
import { NgModule } from '@angular/core';
|
|
||||||
import { ReactiveFormsModule } from '@angular/forms';
|
|
||||||
|
|
||||||
import { SharedModule } from '../shared/shared.module';
|
|
||||||
import { HeroFormReactiveComponent } from './hero-form-reactive.component';
|
|
||||||
|
|
||||||
@NgModule({
|
|
||||||
imports: [ SharedModule, ReactiveFormsModule ],
|
|
||||||
declarations: [ HeroFormReactiveComponent ],
|
|
||||||
exports: [ HeroFormReactiveComponent ]
|
|
||||||
})
|
|
||||||
export class HeroFormReactiveModule { }
|
|
@ -6,9 +6,8 @@ import { AbstractControl, NG_VALIDATORS, Validator, ValidatorFn, Validators } fr
|
|||||||
/** A hero's name can't match the given regular expression */
|
/** A hero's name can't match the given regular expression */
|
||||||
export function forbiddenNameValidator(nameRe: RegExp): ValidatorFn {
|
export function forbiddenNameValidator(nameRe: RegExp): ValidatorFn {
|
||||||
return (control: AbstractControl): {[key: string]: any} => {
|
return (control: AbstractControl): {[key: string]: any} => {
|
||||||
const name = control.value;
|
const forbidden = nameRe.test(control.value);
|
||||||
const no = nameRe.test(name);
|
return forbidden ? {'forbiddenName': {value: control.value}} : null;
|
||||||
return no ? {'forbiddenName': {name}} : null;
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
// #enddocregion custom-validator
|
// #enddocregion custom-validator
|
||||||
@ -20,23 +19,12 @@ export function forbiddenNameValidator(nameRe: RegExp): ValidatorFn {
|
|||||||
providers: [{provide: NG_VALIDATORS, useExisting: ForbiddenValidatorDirective, multi: true}]
|
providers: [{provide: NG_VALIDATORS, useExisting: ForbiddenValidatorDirective, multi: true}]
|
||||||
// #enddocregion directive-providers
|
// #enddocregion directive-providers
|
||||||
})
|
})
|
||||||
export class ForbiddenValidatorDirective implements Validator, OnChanges {
|
export class ForbiddenValidatorDirective implements Validator {
|
||||||
@Input() forbiddenName: string;
|
@Input() forbiddenName: string;
|
||||||
private valFn = Validators.nullValidator;
|
|
||||||
|
|
||||||
ngOnChanges(changes: SimpleChanges): void {
|
|
||||||
const change = changes['forbiddenName'];
|
|
||||||
if (change) {
|
|
||||||
const val: string | RegExp = change.currentValue;
|
|
||||||
const re = val instanceof RegExp ? val : new RegExp(val, 'i');
|
|
||||||
this.valFn = forbiddenNameValidator(re);
|
|
||||||
} else {
|
|
||||||
this.valFn = Validators.nullValidator;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
validate(control: AbstractControl): {[key: string]: any} {
|
validate(control: AbstractControl): {[key: string]: any} {
|
||||||
return this.valFn(control);
|
return this.forbiddenName ? forbiddenNameValidator(new RegExp(this.forbiddenName, 'i'))(control)
|
||||||
|
: null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// #enddocregion directive
|
// #enddocregion directive
|
||||||
|
@ -1,9 +0,0 @@
|
|||||||
// #docregion
|
|
||||||
export class Hero {
|
|
||||||
constructor(
|
|
||||||
public id: number,
|
|
||||||
public name: string,
|
|
||||||
public power: string,
|
|
||||||
public alterEgo?: string
|
|
||||||
) { }
|
|
||||||
}
|
|
@ -1,14 +0,0 @@
|
|||||||
// #docregion
|
|
||||||
import { NgModule } from '@angular/core';
|
|
||||||
import { CommonModule } from '@angular/common';
|
|
||||||
|
|
||||||
import { ForbiddenValidatorDirective } from './forbidden-name.directive';
|
|
||||||
import { SubmittedComponent } from './submitted.component';
|
|
||||||
|
|
||||||
@NgModule({
|
|
||||||
imports: [ CommonModule],
|
|
||||||
declarations: [ ForbiddenValidatorDirective, SubmittedComponent ],
|
|
||||||
exports: [ ForbiddenValidatorDirective, SubmittedComponent,
|
|
||||||
CommonModule ]
|
|
||||||
})
|
|
||||||
export class SharedModule { }
|
|
@ -1,32 +0,0 @@
|
|||||||
// #docregion
|
|
||||||
import { Component, EventEmitter, Input, Output } from '@angular/core';
|
|
||||||
|
|
||||||
import { Hero } from './hero';
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'hero-submitted',
|
|
||||||
template: `
|
|
||||||
<div *ngIf="submitted">
|
|
||||||
<h2>You submitted the following:</h2>
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-xs-3">Name</div>
|
|
||||||
<div class="col-xs-9 pull-left">{{ hero.name }}</div>
|
|
||||||
</div>
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-xs-3">Alter Ego</div>
|
|
||||||
<div class="col-xs-9 pull-left">{{ hero.alterEgo }}</div>
|
|
||||||
</div>
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-xs-3">Power</div>
|
|
||||||
<div class="col-xs-9 pull-left">{{ hero.power }}</div>
|
|
||||||
</div>
|
|
||||||
<br>
|
|
||||||
<button class="btn btn-default" (click)="onClick()">Edit</button>
|
|
||||||
</div>`
|
|
||||||
})
|
|
||||||
export class SubmittedComponent {
|
|
||||||
@Input() hero: Hero;
|
|
||||||
@Input() submitted = false;
|
|
||||||
@Output() submittedChange = new EventEmitter<boolean>();
|
|
||||||
onClick() { this.submittedChange.emit(false); }
|
|
||||||
}
|
|
@ -0,0 +1,66 @@
|
|||||||
|
<!-- #docregion -->
|
||||||
|
<div class="container">
|
||||||
|
|
||||||
|
<h1>Template-Driven Form</h1>
|
||||||
|
<!-- #docregion form-tag-->
|
||||||
|
<form #heroForm="ngForm">
|
||||||
|
<!-- #enddocregion form-tag-->
|
||||||
|
<div [hidden]="heroForm.submitted">
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="name">Name</label>
|
||||||
|
<!-- #docregion name-with-error-msg -->
|
||||||
|
<!-- #docregion name-input -->
|
||||||
|
<input id="name" name="name" class="form-control"
|
||||||
|
required minlength="4" forbiddenName="bob"
|
||||||
|
[(ngModel)]="hero.name" #name="ngModel" >
|
||||||
|
<!-- #enddocregion name-input -->
|
||||||
|
|
||||||
|
<div *ngIf="name.invalid && (name.dirty || name.touched)"
|
||||||
|
class="alert alert-danger">
|
||||||
|
|
||||||
|
<div *ngIf="name.errors.required">
|
||||||
|
Name is required.
|
||||||
|
</div>
|
||||||
|
<div *ngIf="name.errors.minlength">
|
||||||
|
Name must be at least 4 characters long.
|
||||||
|
</div>
|
||||||
|
<div *ngIf="name.errors.forbiddenName">
|
||||||
|
Name cannot be Bob.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<!-- #enddocregion name-with-error-msg -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="alterEgo">Alter Ego</label>
|
||||||
|
<input id="alterEgo" class="form-control"
|
||||||
|
name="alterEgo" [(ngModel)]="hero.alterEgo" >
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="power">Hero Power</label>
|
||||||
|
<select id="power" name="power" class="form-control"
|
||||||
|
required [(ngModel)]="hero.power" #power="ngModel" >
|
||||||
|
<option *ngFor="let p of powers" [value]="p">{{p}}</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<div *ngIf="power.errors && power.touched" class="alert alert-danger">
|
||||||
|
<div *ngIf="power.errors.required">Power is required.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="btn btn-default"
|
||||||
|
[disabled]="heroForm.invalid">Submit</button>
|
||||||
|
<button type="button" class="btn btn-default"
|
||||||
|
(click)="heroForm.resetForm({})">Reset</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="submitted-message" *ngIf="heroForm.submitted">
|
||||||
|
<p>You've submitted your hero, {{ heroForm.value.name }}!</p>
|
||||||
|
<button (click)="heroForm.resetForm({})">Add new hero</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
</div>
|
@ -0,0 +1,16 @@
|
|||||||
|
/* tslint:disable: member-ordering */
|
||||||
|
// #docplaster
|
||||||
|
// #docregion
|
||||||
|
import { Component } from '@angular/core';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'hero-form-template',
|
||||||
|
templateUrl: './hero-form-template.component.html'
|
||||||
|
})
|
||||||
|
export class HeroFormTemplateComponent {
|
||||||
|
|
||||||
|
powers = ['Really Smart', 'Super Flexible', 'Weather Changer'];
|
||||||
|
|
||||||
|
hero = {name: 'Dr.', alterEgo: 'Dr. What', power: this.powers[0]};
|
||||||
|
|
||||||
|
}
|
@ -1,14 +0,0 @@
|
|||||||
// #docregion
|
|
||||||
import { NgModule } from '@angular/core';
|
|
||||||
import { FormsModule } from '@angular/forms';
|
|
||||||
|
|
||||||
import { SharedModule } from '../shared/shared.module';
|
|
||||||
import { HeroFormTemplate1Component } from './hero-form-template1.component';
|
|
||||||
import { HeroFormTemplate2Component } from './hero-form-template2.component';
|
|
||||||
|
|
||||||
@NgModule({
|
|
||||||
imports: [ SharedModule, FormsModule ],
|
|
||||||
declarations: [ HeroFormTemplate1Component, HeroFormTemplate2Component ],
|
|
||||||
exports: [ HeroFormTemplate1Component, HeroFormTemplate2Component ]
|
|
||||||
})
|
|
||||||
export class HeroFormTemplateModule { }
|
|
@ -1,61 +0,0 @@
|
|||||||
<!-- #docregion -->
|
|
||||||
<div class="container">
|
|
||||||
<div [hidden]="submitted">
|
|
||||||
<h1>Hero Form 1 (Template)</h1>
|
|
||||||
<!-- #docregion form-tag-->
|
|
||||||
<form #heroForm="ngForm" *ngIf="active" (ngSubmit)="onSubmit()">
|
|
||||||
<!-- #enddocregion form-tag-->
|
|
||||||
<div class="form-group">
|
|
||||||
<!-- #docregion name-with-error-msg -->
|
|
||||||
<label for="name">Name</label>
|
|
||||||
|
|
||||||
<input type="text" id="name" class="form-control"
|
|
||||||
required minlength="4" maxlength="24"
|
|
||||||
name="name" [(ngModel)]="hero.name"
|
|
||||||
#name="ngModel" >
|
|
||||||
|
|
||||||
<div *ngIf="name.errors && (name.dirty || name.touched)"
|
|
||||||
class="alert alert-danger">
|
|
||||||
<div [hidden]="!name.errors.required">
|
|
||||||
Name is required
|
|
||||||
</div>
|
|
||||||
<div [hidden]="!name.errors.minlength">
|
|
||||||
Name must be at least 4 characters long.
|
|
||||||
</div>
|
|
||||||
<div [hidden]="!name.errors.maxlength">
|
|
||||||
Name cannot be more than 24 characters long.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- #enddocregion name-with-error-msg -->
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="alterEgo">Alter Ego</label>
|
|
||||||
<input type="text" id="alterEgo" class="form-control"
|
|
||||||
name="alterEgo"
|
|
||||||
[(ngModel)]="hero.alterEgo" >
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="power">Hero Power</label>
|
|
||||||
<select id="power" class="form-control"
|
|
||||||
name="power"
|
|
||||||
[(ngModel)]="hero.power" required
|
|
||||||
#power="ngModel" >
|
|
||||||
<option *ngFor="let p of powers" [value]="p">{{p}}</option>
|
|
||||||
</select>
|
|
||||||
|
|
||||||
<div *ngIf="power.errors && power.touched" class="alert alert-danger">
|
|
||||||
<div [hidden]="!power.errors.required">Power is required</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button type="submit" class="btn btn-default"
|
|
||||||
[disabled]="!heroForm.form.valid">Submit</button>
|
|
||||||
<button type="button" class="btn btn-default"
|
|
||||||
(click)="addHero()">New Hero</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<hero-submitted [hero]="hero" [(submitted)]="submitted"></hero-submitted>
|
|
||||||
</div>
|
|
@ -1,47 +0,0 @@
|
|||||||
/* tslint:disable: member-ordering */
|
|
||||||
// #docplaster
|
|
||||||
// #docregion
|
|
||||||
import { Component } from '@angular/core';
|
|
||||||
|
|
||||||
|
|
||||||
import { Hero } from '../shared/hero';
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'hero-form-template1',
|
|
||||||
templateUrl: './hero-form-template1.component.html'
|
|
||||||
})
|
|
||||||
// #docregion class
|
|
||||||
export class HeroFormTemplate1Component {
|
|
||||||
|
|
||||||
powers = ['Really Smart', 'Super Flexible', 'Weather Changer'];
|
|
||||||
|
|
||||||
hero = new Hero(18, 'Dr. WhatIsHisWayTooLongName', this.powers[0], 'Dr. What');
|
|
||||||
|
|
||||||
submitted = false;
|
|
||||||
|
|
||||||
onSubmit() {
|
|
||||||
this.submitted = true;
|
|
||||||
}
|
|
||||||
// #enddocregion class
|
|
||||||
// #enddocregion
|
|
||||||
// Reset the form with a new hero AND restore 'pristine' class state
|
|
||||||
// by toggling 'active' flag which causes the form
|
|
||||||
// to be removed/re-added in a tick via NgIf
|
|
||||||
// TODO: Workaround until NgForm has a reset method (#6822)
|
|
||||||
active = true;
|
|
||||||
// #docregion
|
|
||||||
// #docregion class
|
|
||||||
|
|
||||||
addHero() {
|
|
||||||
this.hero = new Hero(42, '', '');
|
|
||||||
// #enddocregion class
|
|
||||||
// #enddocregion
|
|
||||||
|
|
||||||
this.active = false;
|
|
||||||
setTimeout(() => this.active = true, 0);
|
|
||||||
// #docregion
|
|
||||||
// #docregion class
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// #enddocregion class
|
|
||||||
// #enddocregion
|
|
@ -1,52 +0,0 @@
|
|||||||
<!-- #docregion -->
|
|
||||||
<div class="container">
|
|
||||||
<div [hidden]="submitted">
|
|
||||||
<h1>Hero Form 2 (Template & Messages)</h1>
|
|
||||||
<!-- #docregion form-tag-->
|
|
||||||
<form #heroForm="ngForm" *ngIf="active" (ngSubmit)="onSubmit()">
|
|
||||||
<!-- #enddocregion form-tag-->
|
|
||||||
<div class="form-group">
|
|
||||||
<!-- #docregion name-with-error-msg -->
|
|
||||||
<label for="name">Name</label>
|
|
||||||
|
|
||||||
<!-- #docregion name-input -->
|
|
||||||
<input type="text" id="name" class="form-control"
|
|
||||||
required minlength="4" maxlength="24" forbiddenName="bob"
|
|
||||||
name="name" [(ngModel)]="hero.name" >
|
|
||||||
<!-- #enddocregion name-input -->
|
|
||||||
|
|
||||||
<div *ngIf="formErrors.name" class="alert alert-danger">
|
|
||||||
{{ formErrors.name }}
|
|
||||||
</div>
|
|
||||||
<!-- #enddocregion name-with-error-msg -->
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="alterEgo">Alter Ego</label>
|
|
||||||
<input type="text" id="alterEgo" class="form-control"
|
|
||||||
name="alterEgo"
|
|
||||||
[(ngModel)]="hero.alterEgo" >
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="power">Hero Power</label>
|
|
||||||
<select id="power" class="form-control"
|
|
||||||
name="power"
|
|
||||||
[(ngModel)]="hero.power" required >
|
|
||||||
<option *ngFor="let p of powers" [value]="p">{{p}}</option>
|
|
||||||
</select>
|
|
||||||
|
|
||||||
<div *ngIf="formErrors.power" class="alert alert-danger">
|
|
||||||
{{ formErrors.power }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button type="submit" class="btn btn-default"
|
|
||||||
[disabled]="!heroForm.form.valid">Submit</button>
|
|
||||||
<button type="button" class="btn btn-default"
|
|
||||||
(click)="addHero()">New Hero</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<hero-submitted [hero]="hero" [(submitted)]="submitted"></hero-submitted>
|
|
||||||
</div>
|
|
@ -1,99 +0,0 @@
|
|||||||
/* tslint:disable: member-ordering forin */
|
|
||||||
// #docplaster
|
|
||||||
// #docregion
|
|
||||||
import { Component, AfterViewChecked, ViewChild } from '@angular/core';
|
|
||||||
import { NgForm } from '@angular/forms';
|
|
||||||
|
|
||||||
import { Hero } from '../shared/hero';
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'hero-form-template2',
|
|
||||||
templateUrl: './hero-form-template2.component.html'
|
|
||||||
})
|
|
||||||
export class HeroFormTemplate2Component implements AfterViewChecked {
|
|
||||||
|
|
||||||
powers = ['Really Smart', 'Super Flexible', 'Weather Changer'];
|
|
||||||
|
|
||||||
hero = new Hero(18, 'Dr. WhatIsHisWayTooLongName', this.powers[0], 'Dr. What');
|
|
||||||
|
|
||||||
submitted = false;
|
|
||||||
|
|
||||||
onSubmit() {
|
|
||||||
this.submitted = true;
|
|
||||||
}
|
|
||||||
// #enddocregion
|
|
||||||
|
|
||||||
// Reset the form with a new hero AND restore 'pristine' class state
|
|
||||||
// by toggling 'active' flag which causes the form
|
|
||||||
// to be removed/re-added in a tick via NgIf
|
|
||||||
// TODO: Workaround until NgForm has a reset method (#6822)
|
|
||||||
active = true;
|
|
||||||
// #docregion
|
|
||||||
|
|
||||||
addHero() {
|
|
||||||
this.hero = new Hero(42, '', '');
|
|
||||||
// #enddocregion
|
|
||||||
|
|
||||||
this.active = false;
|
|
||||||
setTimeout(() => this.active = true, 0);
|
|
||||||
// #docregion
|
|
||||||
}
|
|
||||||
|
|
||||||
// #docregion view-child
|
|
||||||
heroForm: NgForm;
|
|
||||||
@ViewChild('heroForm') currentForm: NgForm;
|
|
||||||
|
|
||||||
ngAfterViewChecked() {
|
|
||||||
this.formChanged();
|
|
||||||
}
|
|
||||||
|
|
||||||
formChanged() {
|
|
||||||
if (this.currentForm === this.heroForm) { return; }
|
|
||||||
this.heroForm = this.currentForm;
|
|
||||||
if (this.heroForm) {
|
|
||||||
this.heroForm.valueChanges
|
|
||||||
.subscribe(data => this.onValueChanged(data));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// #enddocregion view-child
|
|
||||||
|
|
||||||
// #docregion handler
|
|
||||||
onValueChanged(data?: any) {
|
|
||||||
if (!this.heroForm) { return; }
|
|
||||||
const form = this.heroForm.form;
|
|
||||||
|
|
||||||
for (const field in this.formErrors) {
|
|
||||||
// clear previous error message (if any)
|
|
||||||
this.formErrors[field] = '';
|
|
||||||
const control = form.get(field);
|
|
||||||
|
|
||||||
if (control && control.dirty && !control.valid) {
|
|
||||||
const messages = this.validationMessages[field];
|
|
||||||
for (const key in control.errors) {
|
|
||||||
this.formErrors[field] += messages[key] + ' ';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
formErrors = {
|
|
||||||
'name': '',
|
|
||||||
'power': ''
|
|
||||||
};
|
|
||||||
// #enddocregion handler
|
|
||||||
|
|
||||||
// #docregion messages
|
|
||||||
validationMessages = {
|
|
||||||
'name': {
|
|
||||||
'required': 'Name is required.',
|
|
||||||
'minlength': 'Name must be at least 4 characters long.',
|
|
||||||
'maxlength': 'Name cannot be more than 24 characters long.',
|
|
||||||
'forbiddenName': 'Someone named "Bob" cannot be a hero.'
|
|
||||||
},
|
|
||||||
'power': {
|
|
||||||
'required': 'Power is required.'
|
|
||||||
}
|
|
||||||
};
|
|
||||||
// #enddocregion messages
|
|
||||||
}
|
|
||||||
// #enddocregion
|
|
@ -1,3 +1,4 @@
|
|||||||
|
|
||||||
.ng-valid[required], .ng-valid.required {
|
.ng-valid[required], .ng-valid.required {
|
||||||
border-left: 5px solid #42A948; /* green */
|
border-left: 5px solid #42A948; /* green */
|
||||||
}
|
}
|
||||||
|
@ -9,7 +9,7 @@ export class AppComponent {
|
|||||||
wolves = 0;
|
wolves = 0;
|
||||||
gender = 'f';
|
gender = 'f';
|
||||||
fly = true;
|
fly = true;
|
||||||
logo = 'https://angular.io/resources/images/logos/angular/angular.png';
|
logo = 'https://angular.io/assets/images/logos/angular/angular.png';
|
||||||
count = 3;
|
count = 3;
|
||||||
heroes: string[] = ['Magneta', 'Celeritas', 'Dynama'];
|
heroes: string[] = ['Magneta', 'Celeritas', 'Dynama'];
|
||||||
inc(i: number) {
|
inc(i: number) {
|
||||||
|
@ -1,116 +0,0 @@
|
|||||||
/* #docregion , quickstart, toh */
|
|
||||||
/* Master Styles */
|
|
||||||
h1 {
|
|
||||||
color: #369;
|
|
||||||
font-family: Arial, Helvetica, sans-serif;
|
|
||||||
font-size: 250%;
|
|
||||||
}
|
|
||||||
h2, h3 {
|
|
||||||
color: #444;
|
|
||||||
font-family: Arial, Helvetica, sans-serif;
|
|
||||||
font-weight: lighter;
|
|
||||||
}
|
|
||||||
body {
|
|
||||||
margin: 2em;
|
|
||||||
}
|
|
||||||
/* #enddocregion quickstart */
|
|
||||||
body, input[text], button {
|
|
||||||
color: #888;
|
|
||||||
font-family: Cambria, Georgia;
|
|
||||||
}
|
|
||||||
/* #enddocregion toh */
|
|
||||||
a {
|
|
||||||
cursor: pointer;
|
|
||||||
cursor: hand;
|
|
||||||
}
|
|
||||||
button {
|
|
||||||
font-family: Arial;
|
|
||||||
background-color: #eee;
|
|
||||||
border: none;
|
|
||||||
padding: 5px 10px;
|
|
||||||
border-radius: 4px;
|
|
||||||
cursor: pointer;
|
|
||||||
cursor: hand;
|
|
||||||
}
|
|
||||||
button:hover {
|
|
||||||
background-color: #cfd8dc;
|
|
||||||
}
|
|
||||||
button:disabled {
|
|
||||||
background-color: #eee;
|
|
||||||
color: #aaa;
|
|
||||||
cursor: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Navigation link styles */
|
|
||||||
nav a {
|
|
||||||
padding: 5px 10px;
|
|
||||||
text-decoration: none;
|
|
||||||
margin-right: 10px;
|
|
||||||
margin-top: 10px;
|
|
||||||
display: inline-block;
|
|
||||||
background-color: #eee;
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
nav a:visited, a:link {
|
|
||||||
color: #607D8B;
|
|
||||||
}
|
|
||||||
nav a:hover {
|
|
||||||
color: #039be5;
|
|
||||||
background-color: #CFD8DC;
|
|
||||||
}
|
|
||||||
nav a.active {
|
|
||||||
color: #039be5;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* items class */
|
|
||||||
.items {
|
|
||||||
margin: 0 0 2em 0;
|
|
||||||
list-style-type: none;
|
|
||||||
padding: 0;
|
|
||||||
width: 24em;
|
|
||||||
}
|
|
||||||
.items li {
|
|
||||||
cursor: pointer;
|
|
||||||
position: relative;
|
|
||||||
left: 0;
|
|
||||||
background-color: #EEE;
|
|
||||||
margin: .5em;
|
|
||||||
padding: .3em 0;
|
|
||||||
height: 1.6em;
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
.items li:hover {
|
|
||||||
color: #607D8B;
|
|
||||||
background-color: #DDD;
|
|
||||||
left: .1em;
|
|
||||||
}
|
|
||||||
.items li.selected {
|
|
||||||
background-color: #CFD8DC;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
.items li.selected:hover {
|
|
||||||
background-color: #BBD8DC;
|
|
||||||
}
|
|
||||||
.items .text {
|
|
||||||
position: relative;
|
|
||||||
top: -3px;
|
|
||||||
}
|
|
||||||
.items .badge {
|
|
||||||
display: inline-block;
|
|
||||||
font-size: small;
|
|
||||||
color: white;
|
|
||||||
padding: 0.8em 0.7em 0 0.7em;
|
|
||||||
background-color: #607D8B;
|
|
||||||
line-height: 1em;
|
|
||||||
position: relative;
|
|
||||||
left: -1px;
|
|
||||||
top: -4px;
|
|
||||||
height: 1.8em;
|
|
||||||
margin-right: .8em;
|
|
||||||
border-radius: 4px 0 0 4px;
|
|
||||||
}
|
|
||||||
/* #docregion toh */
|
|
||||||
/* everywhere else */
|
|
||||||
* {
|
|
||||||
font-family: Arial, Helvetica, sans-serif;
|
|
||||||
}
|
|
@ -9,7 +9,8 @@ describe('PhoneCat Application', function() {
|
|||||||
|
|
||||||
it('should redirect `index.html` to `index.html#!/phones', function() {
|
it('should redirect `index.html` to `index.html#!/phones', function() {
|
||||||
browser.get('index.html');
|
browser.get('index.html');
|
||||||
expect(browser.getLocationAbsUrl()).toBe('/phones');
|
browser.sleep(1000); // Not sure why this is needed but it is. The route change works fine.
|
||||||
|
expect(browser.getCurrentUrl()).toMatch(/\/phones$/);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('View: Phone list', function() {
|
describe('View: Phone list', function() {
|
||||||
@ -65,7 +66,7 @@ describe('PhoneCat Application', function() {
|
|||||||
|
|
||||||
element.all(by.css('.phones li a')).first().click();
|
element.all(by.css('.phones li a')).first().click();
|
||||||
browser.sleep(1000); // Not sure why this is needed but it is. The route change works fine.
|
browser.sleep(1000); // Not sure why this is needed but it is. The route change works fine.
|
||||||
expect(browser.getLocationAbsUrl()).toBe('/phones/nexus-s');
|
expect(browser.getCurrentUrl()).toMatch(/\/phones\/nexus-s$/);
|
||||||
});
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
|
@ -528,6 +528,11 @@ Compiling with AOT presupposes certain supporting files, most of them discussed
|
|||||||
|
|
||||||
Extend the `scripts` section of the `package.json` with these npm scripts:
|
Extend the `scripts` section of the `package.json` with these npm scripts:
|
||||||
|
|
||||||
|
<code-example language="json">
|
||||||
|
"build:aot": "ngc -p tsconfig-aot.json && rollup -c rollup-config.js",
|
||||||
|
"serve:aot": "lite-server -c bs-config.aot.json",
|
||||||
|
</code-example>
|
||||||
|
|
||||||
Copy the AOT distribution files into the `/aot` folder with the node script:
|
Copy the AOT distribution files into the `/aot` folder with the node script:
|
||||||
|
|
||||||
<code-example language="none" class="code-shell">
|
<code-example language="none" class="code-shell">
|
||||||
|
@ -6,75 +6,50 @@
|
|||||||
Improve overall data quality by validating user input for accuracy and completeness.
|
Improve overall data quality by validating user input for accuracy and completeness.
|
||||||
|
|
||||||
This page shows how to validate user input in the UI and display useful validation messages
|
This page shows how to validate user input in the UI and display useful validation messages
|
||||||
using first the Template Driven Forms and then the Reactive Forms approach.
|
using both reactive and template-driven forms. It assumes some basic knowledge of the two
|
||||||
|
forms modules.
|
||||||
|
|
||||||
<div class="l-sub-section">
|
<div class="l-sub-section">
|
||||||
|
|
||||||
Read more about these choices in the [Forms](guide/forms)
|
If you're new to forms, start by reviewing the [Forms](guide/forms) and
|
||||||
and the [Reactive Forms](guide/reactive-forms) guides.
|
[Reactive Forms](guide/reactive-forms) guides.
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
{@a live-example}
|
## Template-driven validation
|
||||||
|
|
||||||
|
To add validation to a template-driven form, you add the same validation attributes as you
|
||||||
|
would with [native HTML form validation](https://developer.mozilla.org/en-US/docs/Web/Guide/HTML/HTML5/Constraint_validation).
|
||||||
|
Angular uses directives to match these attributes with validator functions in the framework.
|
||||||
|
|
||||||
**Try the live example to see and download the full cookbook source code.**
|
Every time the value of a form control changes, Angular runs validation and generates
|
||||||
|
either a list of validation errors, which results in an INVALID status, or null, which results in a VALID status.
|
||||||
|
|
||||||
<live-example name="form-validation" embedded=true img="guide/form-validation/plunker.png">
|
You can then inspect the control's state by exporting `ngModel` to a local template variable.
|
||||||
|
The following example exports `NgModel` into a variable called `name`:
|
||||||
|
|
||||||
</live-example>
|
<code-example path="form-validation/src/app/template/hero-form-template.component.html" region="name-with-error-msg" title="template/hero-form-template.component.html (name)" linenums="false">
|
||||||
|
|
||||||
## Simple Template Driven Forms
|
|
||||||
|
|
||||||
In the Template Driven approach, you arrange
|
|
||||||
[form elements](https://developer.mozilla.org/en-US/docs/Web/Guide/HTML/Forms_in_HTML) in the component's template.
|
|
||||||
|
|
||||||
You add Angular form directives (mostly directives beginning `ng...`) to help
|
|
||||||
Angular construct a corresponding internal control model that implements form functionality.
|
|
||||||
In Template Driven forms, the control model is _implicit_ in the template.
|
|
||||||
|
|
||||||
To validate user input, you add [HTML validation attributes](https://developer.mozilla.org/en-US/docs/Web/Guide/HTML/HTML5/Constraint_validation)
|
|
||||||
to the elements. Angular interprets those as well, adding validator functions to the control model.
|
|
||||||
|
|
||||||
Angular exposes information about the state of the controls including
|
|
||||||
whether the user has "touched" the control or made changes and if the control values are valid.
|
|
||||||
|
|
||||||
In this first template validation example,
|
|
||||||
notice the HTML that reads the control state and updates the display appropriately.
|
|
||||||
Here's an excerpt from the template HTML for a single input control bound to the hero name:
|
|
||||||
|
|
||||||
<code-example path="form-validation/src/app/template/hero-form-template1.component.html" region="name-with-error-msg" title="template/hero-form-template1.component.html (Hero name)" linenums="false">
|
|
||||||
|
|
||||||
</code-example>
|
</code-example>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Note the following:
|
Note the following:
|
||||||
|
|
||||||
* The `<input>` element carries the HTML validation attributes: `required`, `minlength`, and `maxlength`.
|
* The `<input>` element carries the HTML validation attributes: `required` and `minlength`. It
|
||||||
|
also carries a custom validator directive, `forbiddenName`. For more
|
||||||
|
information, see [Custom validators](guide/form-validation#custom-validators) section.
|
||||||
|
|
||||||
* The `name` attribute of the input is set to `"name"` so Angular can track this input element and associate it
|
* `#name="ngModel"` exports `NgModel` into a local variable callled `name`. `NgModel` mirrors many of the properties of its underlying
|
||||||
with an Angular form control called `name` in its internal control model.
|
`FormControl` instance, so you can use this in the template to check for control states such as `valid` and `dirty`. For a full list of control properties, see the [AbstractControl](api/forms/AbstractControl)
|
||||||
|
API reference.
|
||||||
* The `[(ngModel)]` directive allows two-way data binding between the input box to the `hero.name` property.
|
|
||||||
|
|
||||||
* The template variable (`#name`) has the value `"ngModel"` (always `ngModel`).
|
|
||||||
This gives you a reference to the Angular `NgModel` directive
|
|
||||||
associated with this control that you can use _in the template_
|
|
||||||
to check for control states such as `valid` and `dirty`.
|
|
||||||
|
|
||||||
* The `*ngIf` on the `<div>` element reveals a set of nested message `divs`
|
* The `*ngIf` on the `<div>` element reveals a set of nested message `divs`
|
||||||
but only if there are `name` errors and
|
but only if the `name` is invalid and the control is either `dirty` or `touched`.
|
||||||
the control is either `dirty` or `touched`.
|
|
||||||
|
|
||||||
* Each nested `<div>` can present a custom message for one of the possible validation errors.
|
* Each nested `<div>` can present a custom message for one of the possible validation errors.
|
||||||
There are messages for `required`, `minlength`, and `maxlength`.
|
There are messages for `required`, `minlength`, and `forbiddenName`.
|
||||||
|
|
||||||
The full template repeats this kind of layout for each data entry control on the form.
|
|
||||||
|
|
||||||
{@a why-check}
|
|
||||||
|
|
||||||
|
|
||||||
<div class="l-sub-section">
|
<div class="l-sub-section">
|
||||||
|
|
||||||
@ -82,567 +57,152 @@ The full template repeats this kind of layout for each data entry control on the
|
|||||||
|
|
||||||
#### Why check _dirty_ and _touched_?
|
#### Why check _dirty_ and _touched_?
|
||||||
|
|
||||||
The app shouldn't show errors for a new hero before the user has had a chance to edit the value.
|
You may not want your application to display errors before the user has a chance to edit the form.
|
||||||
The checks for `dirty` and `touched` prevent premature display of errors.
|
The checks for `dirty` and `touched` prevent errors from showing until the user
|
||||||
|
does one of two things: changes the value,
|
||||||
Learn about `dirty` and `touched` in the [Forms](guide/forms) guide.
|
turning the control dirty; or blurs the form control element, setting the control to touched.
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
## Reactive form validation
|
||||||
|
|
||||||
|
In a reactive form, the source of truth is the component class. Instead of adding validators through attributes in the template, you add validator functions directly to the form control model in the component class. Angular then calls these functions whenever the value of the control changes.
|
||||||
|
|
||||||
The component class manages the hero model used in the data binding
|
### Validator functions
|
||||||
as well as other code to support the view.
|
|
||||||
|
|
||||||
|
There are two types of validator functions: sync validators and async validators.
|
||||||
|
|
||||||
<code-example path="form-validation/src/app/template/hero-form-template1.component.ts" region="class" title="template/hero-form-template1.component.ts (class)">
|
* **Sync validators**: functions that take a control instance and immediately return either a set of validation errors or `null`. You can pass these in as the second argument when you instantiate a `FormControl`.
|
||||||
|
|
||||||
</code-example>
|
* **Async validators**: functions that take a control instance and return a Promise
|
||||||
|
or Observable that later emits a set of validation errors or `null`. You can
|
||||||
|
pass these in as the third argument when you instantiate a `FormControl`.
|
||||||
|
|
||||||
|
Note: for performance reasons, Angular only runs async validators if all sync validators pass. Each must complete before errors are set.
|
||||||
|
|
||||||
|
### Built-in validators
|
||||||
|
|
||||||
Use this Template Driven validation technique when working with static forms with simple, standard validation rules.
|
You can choose to [write your own validator functions](guide/form-validation#custom-validators), or you can use some of
|
||||||
|
Angular's built-in validators.
|
||||||
Here are the complete files for the first version of `HeroFormTemplateCompononent` in the Template Driven approach:
|
|
||||||
|
|
||||||
|
|
||||||
<code-tabs>
|
|
||||||
|
|
||||||
<code-pane title="template/hero-form-template1.component.html" path="form-validation/src/app/template/hero-form-template1.component.html">
|
|
||||||
|
|
||||||
</code-pane>
|
|
||||||
|
|
||||||
<code-pane title="template/hero-form-template1.component.ts" path="form-validation/src/app/template/hero-form-template1.component.ts">
|
|
||||||
|
|
||||||
</code-pane>
|
|
||||||
|
|
||||||
</code-tabs>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## Template Driven Forms with validation messages in code
|
|
||||||
|
|
||||||
While the layout is straightforward,
|
|
||||||
there are obvious shortcomings with the way it's handling validation messages:
|
|
||||||
|
|
||||||
* It takes a lot of HTML to represent all possible error conditions.
|
|
||||||
This gets out of hand when there are many controls and many validation rules.
|
|
||||||
|
|
||||||
* There's a lot of JavaScript logic in the HTML.
|
|
||||||
|
|
||||||
* The messages are static strings, hard-coded into the template.
|
|
||||||
It's easier to maintain _dynamic_ messages in the component class.
|
|
||||||
|
|
||||||
In this example, you can move the logic and the messages into the component with a few changes to
|
|
||||||
the template and component.
|
|
||||||
|
|
||||||
Here's the hero name again, excerpted from the revised template
|
|
||||||
(template 2), next to the original version:
|
|
||||||
|
|
||||||
<code-tabs>
|
|
||||||
|
|
||||||
<code-pane title="hero-form-template2.component.html (name #2)" path="form-validation/src/app/template/hero-form-template2.component.html" region="name-with-error-msg">
|
|
||||||
|
|
||||||
</code-pane>
|
|
||||||
|
|
||||||
<code-pane title="hero-form-template1.component.html (name #1)" path="form-validation/src/app/template/hero-form-template1.component.html" region="name-with-error-msg">
|
|
||||||
|
|
||||||
</code-pane>
|
|
||||||
|
|
||||||
</code-tabs>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
The `<input>` element HTML is almost the same. There are noteworthy differences:
|
|
||||||
|
|
||||||
* The hard-code error message `<divs>` are gone.
|
|
||||||
|
|
||||||
* There's a new attribute, `forbiddenName`, that is actually a custom validation directive.
|
|
||||||
It invalidates the control if the user enters "bob" in the name `<input>`([try it](guide/form-validation#live-example)).
|
|
||||||
See the [custom validation](guide/form-validation#custom-validation) section later in this page for more information
|
|
||||||
on custom validation directives.
|
|
||||||
|
|
||||||
* The `#name` template variable is gone because the app no longer refers to the Angular control for this element.
|
|
||||||
|
|
||||||
* Binding to the new `formErrors.name` property is sufficient to display all name validation error messages.
|
|
||||||
|
|
||||||
{@a component-class}
|
|
||||||
|
|
||||||
### Component class
|
|
||||||
The original component code for Template 1 stayed the same; however,
|
|
||||||
Template 2 requires some changes in the component. This section covers the code
|
|
||||||
necessary in Template 2's component class to acquire the Angular
|
|
||||||
form control and compose error messages.
|
|
||||||
|
|
||||||
The first step is to acquire the form control that Angular created from the template by querying for it.
|
|
||||||
|
|
||||||
Look back at the top of the component template at the
|
|
||||||
`#heroForm` template variable in the `<form>` element:
|
|
||||||
|
|
||||||
<code-example path="form-validation/src/app/template/hero-form-template1.component.html" region="form-tag" title="template/hero-form-template1.component.html (form tag)" linenums="false">
|
|
||||||
|
|
||||||
</code-example>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
The `heroForm` variable is a reference to the control model that Angular derived from the template.
|
|
||||||
Tell Angular to inject that model into the component class's `currentForm` property using a `@ViewChild` query:
|
|
||||||
|
|
||||||
<code-example path="form-validation/src/app/template/hero-form-template2.component.ts" region="view-child" title="template/hero-form-template2.component.ts (heroForm)" linenums="false">
|
|
||||||
|
|
||||||
</code-example>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Some observations:
|
|
||||||
|
|
||||||
* Angular `@ViewChild` queries for a template variable when you pass it
|
|
||||||
the name of that variable as a string (`'heroForm'` in this case).
|
|
||||||
|
|
||||||
* The `heroForm` object changes several times during the life of the component, most notably when you add a new hero.
|
|
||||||
Periodically inspecting it reveals these changes.
|
|
||||||
|
|
||||||
* Angular calls the `ngAfterViewChecked()` [lifecycle hook method](guide/lifecycle-hooks#afterview)
|
|
||||||
when anything changes in the view.
|
|
||||||
That's the right time to see if there's a new `heroForm` object.
|
|
||||||
|
|
||||||
* When there _is_ a new `heroForm` model, `formChanged()` subscribes to its `valueChanges` _Observable_ property.
|
|
||||||
The `onValueChanged` handler looks for validation errors after every keystroke.
|
|
||||||
|
|
||||||
<code-example path="form-validation/src/app/template/hero-form-template2.component.ts" region="handler" title="template/hero-form-template2.component.ts (handler)" linenums="false">
|
|
||||||
|
|
||||||
</code-example>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
The `onValueChanged` handler interprets user data entry.
|
|
||||||
The `data` object passed into the handler contains the current element values.
|
|
||||||
The handler ignores them. Instead, it iterates over the fields of the component's `formErrors` object.
|
|
||||||
|
|
||||||
The `formErrors` is a dictionary of the hero fields that have validation rules and their current error messages.
|
|
||||||
Only two hero properties have validation rules, `name` and `power`.
|
|
||||||
The messages are empty strings when the hero data are valid.
|
|
||||||
|
|
||||||
For each field, the `onValueChanged` handler does the following:
|
|
||||||
* Clears the prior error message, if any.
|
|
||||||
* Acquires the field's corresponding Angular form control.
|
|
||||||
* If such a control exists _and_ it's been changed ("dirty")
|
|
||||||
_and_ it's invalid, the handler composes a consolidated error message for all of the control's errors.
|
|
||||||
|
|
||||||
Next, the component needs some error messages—a set for each validated property with
|
|
||||||
one message per validation rule:
|
|
||||||
|
|
||||||
<code-example path="form-validation/src/app/template/hero-form-template2.component.ts" region="messages" title="template/hero-form-template2.component.ts (messages)" linenums="false">
|
|
||||||
|
|
||||||
</code-example>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Now every time the user makes a change, the `onValueChanged` handler checks for validation errors and produces messages accordingly.
|
|
||||||
|
|
||||||
|
|
||||||
{@a improvement}
|
|
||||||
|
|
||||||
|
|
||||||
### The benefits of messages in code
|
|
||||||
|
|
||||||
Clearly the template got substantially smaller while the component code got substantially larger.
|
|
||||||
It's not easy to see the benefit when there are just three fields and only two of them have validation rules.
|
|
||||||
|
|
||||||
Consider what happens as the number of validated
|
|
||||||
fields and rules increases.
|
|
||||||
In general, HTML is harder to read and maintain than code.
|
|
||||||
The initial template was already large and threatening to get rapidly worse
|
|
||||||
with the addition of more validation message `<div>` elements.
|
|
||||||
|
|
||||||
After moving the validation messaging to the component,
|
|
||||||
the template grows more slowly and proportionally.
|
|
||||||
Each field has approximately the same number of lines no matter its number of validation rules.
|
|
||||||
The component also grows proportionally, at the rate of one line per validated field
|
|
||||||
and one line per validation message.
|
|
||||||
|
|
||||||
Now that the messages are in code, you have more flexibility and can compose messages more efficiently.
|
|
||||||
You can refactor the messages out of the component, perhaps to a service class that retrieves them from the server.
|
|
||||||
In short, there are more opportunities to improve message handling now that text and logic have moved from template to code.
|
|
||||||
|
|
||||||
|
|
||||||
{@a formmodule}
|
|
||||||
|
|
||||||
|
|
||||||
### _FormModule_ and Template Driven forms
|
|
||||||
|
|
||||||
Angular has two different forms modules—`FormsModule` and
|
|
||||||
`ReactiveFormsModule`—that correspond with the
|
|
||||||
two approaches to form development. Both modules come
|
|
||||||
from the same `@angular/forms` library package.
|
|
||||||
|
|
||||||
You've been reviewing the Template Driven approach which requires the `FormsModule`.
|
|
||||||
Here's how you imported it in the `HeroFormTemplateModule`.
|
|
||||||
|
|
||||||
|
|
||||||
<code-example path="form-validation/src/app/template/hero-form-template.module.ts" title="template/hero-form-template.module.ts" linenums="false">
|
|
||||||
|
|
||||||
</code-example>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<div class="l-sub-section">
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
This guide hasn't talked about the `SharedModule` or its `SubmittedComponent` which appears at the bottom of every
|
|
||||||
form template in this cookbook.
|
|
||||||
|
|
||||||
They're not germane to the validation story. Look at the [live example](guide/form-validation#live-example) if you're interested.
|
|
||||||
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
{@a reactive}
|
|
||||||
|
|
||||||
|
|
||||||
## Reactive Forms with validation in code
|
|
||||||
|
|
||||||
In the Template Driven approach, you mark up the template with form elements, validation attributes,
|
|
||||||
and `ng...` directives from the Angular `FormsModule`.
|
|
||||||
At runtime, Angular interprets the template and derives its _form control model_.
|
|
||||||
|
|
||||||
**Reactive Forms** takes a different approach.
|
|
||||||
You create the form control model in code. You write the template with form elements
|
|
||||||
and `form...` directives from the Angular `ReactiveFormsModule`.
|
|
||||||
At runtime, Angular binds the template elements to your control model based on your instructions.
|
|
||||||
|
|
||||||
This allows you to do the following:
|
|
||||||
|
|
||||||
* Add, change, and remove validation functions on the fly.
|
|
||||||
* Manipulate the control model dynamically from within the component.
|
|
||||||
* [Test](guide/form-validation#testing-considerations) validation and control logic with isolated unit tests.
|
|
||||||
|
|
||||||
The following sample re-writes the hero form in Reactive Forms style.
|
|
||||||
|
|
||||||
|
|
||||||
{@a reactive-forms-module}
|
|
||||||
|
|
||||||
|
|
||||||
### Switch to the _ReactiveFormsModule_
|
|
||||||
The Reactive Forms classes and directives come from the Angular `ReactiveFormsModule`, not the `FormsModule`.
|
|
||||||
The application module for the Reactive Forms feature in this sample looks like this:
|
|
||||||
|
|
||||||
<code-example path="form-validation/src/app/reactive/hero-form-reactive.module.ts" title="src/app/reactive/hero-form-reactive.module.ts" linenums="false">
|
|
||||||
|
|
||||||
</code-example>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
The Reactive Forms feature module and component are in the `src/app/reactive` folder.
|
|
||||||
Focus on the `HeroFormReactiveComponent` there, starting with its template.
|
|
||||||
|
|
||||||
|
|
||||||
{@a reactive-component-template}
|
|
||||||
|
|
||||||
|
|
||||||
### Component template
|
|
||||||
|
|
||||||
Begin by changing the `<form>` tag so that it binds the Angular `formGroup` directive in the template
|
|
||||||
to the `heroForm` property in the component class.
|
|
||||||
The `heroForm` is the control model that the component class builds and maintains.
|
|
||||||
|
|
||||||
|
|
||||||
<code-example path="form-validation/src/app/reactive/hero-form-reactive.component.html" region="form-tag" title="form-validation/src/app/reactive/hero-form-reactive.component.html" linenums="false">
|
|
||||||
|
|
||||||
</code-example>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Next, modify the template HTML elements to match the Reactive Forms style.
|
|
||||||
Here is the "name" portion of the template again, revised for Reactive Forms and compared with the Template Driven version:
|
|
||||||
|
|
||||||
<code-tabs>
|
|
||||||
|
|
||||||
<code-pane title="hero-form-reactive.component.html (name #3)" path="form-validation/src/app/reactive/hero-form-reactive.component.html" region="name-with-error-msg">
|
|
||||||
|
|
||||||
</code-pane>
|
|
||||||
|
|
||||||
<code-pane title="hero-form-template1.component.html (name #2)" path="form-validation/src/app/template/hero-form-template2.component.html" region="name-with-error-msg">
|
|
||||||
|
|
||||||
</code-pane>
|
|
||||||
|
|
||||||
</code-tabs>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Key changes are:
|
|
||||||
* The validation attributes are gone (except `required`) because
|
|
||||||
validating happens in code.
|
|
||||||
|
|
||||||
* `required` remains, not for validation purposes (that's in the code),
|
|
||||||
but rather for css styling and accessibility.
|
|
||||||
|
|
||||||
|
|
||||||
<div class="l-sub-section">
|
|
||||||
|
|
||||||
Currently, Reactive Forms doesn't add the `required` or `aria-required`
|
|
||||||
HTML validation attribute to the DOM element
|
|
||||||
when the control has the `required` validator function.
|
|
||||||
|
|
||||||
Until then, apply the `required` attribute _and_ add the `Validator.required` function
|
|
||||||
to the control model, as you'll see below.
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
* The `formControlName` replaces the `name` attribute; it serves the same
|
|
||||||
purpose of correlating the input with the Angular form control.
|
|
||||||
|
|
||||||
* The two-way `[(ngModel)]` binding is gone.
|
|
||||||
The reactive approach does not use data binding to move data into and out of the form controls.
|
|
||||||
That's all in code.
|
|
||||||
|
|
||||||
|
The same built-in validators that are available as attributes in template-driven forms, such as `required` and `minlength`, are all available to use as functions from the `Validators` class. For a full list of built-in validators, see the [Validators](api/forms/Validators) API reference.
|
||||||
|
|
||||||
|
To update the hero form to be a reactive form, you can use some of the same
|
||||||
|
built-in validators—this time, in function form. See below:
|
||||||
|
|
||||||
{@a reactive-component-class}
|
{@a reactive-component-class}
|
||||||
|
|
||||||
|
<code-example path="form-validation/src/app/reactive/hero-form-reactive.component.ts" region="form-group" title="reactive/hero-form-reactive.component.ts (validator functions)" linenums="false">
|
||||||
### Component class
|
|
||||||
|
|
||||||
The component class is now responsible for defining and managing the form control model.
|
|
||||||
|
|
||||||
Angular no longer derives the control model from the template so you can no longer query for it.
|
|
||||||
You can create the Angular form control model explicitly with
|
|
||||||
the help of the `FormBuilder` class.
|
|
||||||
|
|
||||||
Here's the section of code devoted to that process, paired with the Template Driven code it replaces:
|
|
||||||
|
|
||||||
<code-tabs>
|
|
||||||
|
|
||||||
<code-pane title="reactive/hero-form-reactive.component.ts (FormBuilder)" path="form-validation/src/app/reactive/hero-form-reactive.component.ts" region="form-builder">
|
|
||||||
|
|
||||||
</code-pane>
|
|
||||||
|
|
||||||
<code-pane title="template/hero-form-template2.component.ts (ViewChild)" path="form-validation/src/app/template/hero-form-template2.component.ts" region="view-child">
|
|
||||||
|
|
||||||
</code-pane>
|
|
||||||
|
|
||||||
</code-tabs>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
* Inject `FormBuilder` in a constructor.
|
|
||||||
|
|
||||||
* Call a `buildForm` method in the `ngOnInit` [lifecycle hook method](guide/lifecycle-hooks#hooks-overview)
|
|
||||||
because that's when you'll have the hero data. Call it again in the `addHero` method.
|
|
||||||
|
|
||||||
<div class="l-sub-section">
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
A real app would retrieve the hero asynchronously from a data service, a task best performed in the `ngOnInit` hook.
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
* The `buildForm` method uses the `FormBuilder`, `fb`, to declare the form control model.
|
|
||||||
Then it attaches the same `onValueChanged` handler (there's a one line difference)
|
|
||||||
to the form's `valueChanges` event and calls it immediately
|
|
||||||
to set error messages for the new control model.
|
|
||||||
|
|
||||||
## Built-in validators
|
|
||||||
|
|
||||||
Angular forms include a number of built-in validator functions, which are functions
|
|
||||||
that help you check common user input in forms. In addition to the built-in
|
|
||||||
validators covered here of `minlength`, `maxlength`,
|
|
||||||
and `required`, there are others such as `email` and `pattern`
|
|
||||||
for Reactive Forms.
|
|
||||||
For a full list of built-in validators,
|
|
||||||
see the [Validators](api/forms/Validators) API reference.
|
|
||||||
|
|
||||||
|
|
||||||
#### _FormBuilder_ declaration
|
|
||||||
The `FormBuilder` declaration object specifies the three controls of the sample's hero form.
|
|
||||||
|
|
||||||
Each control spec is a control name with an array value.
|
|
||||||
The first array element is the current value of the corresponding hero field.
|
|
||||||
The optional second value is a validator function or an array of validator functions.
|
|
||||||
|
|
||||||
Most of the validator functions are stock validators provided by Angular as static methods of the `Validators` class.
|
|
||||||
Angular has stock validators that correspond to the standard HTML validation attributes.
|
|
||||||
|
|
||||||
The `forbiddenName` validator on the `"name"` control is a custom validator,
|
|
||||||
discussed in a separate [section below](guide/form-validation#custom-validation).
|
|
||||||
|
|
||||||
<div class="l-sub-section">
|
|
||||||
|
|
||||||
Learn more about `FormBuilder` in the [Introduction to FormBuilder](guide/reactive-forms#formbuilder) section of Reactive Forms guide.
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
#### Committing hero value changes
|
|
||||||
|
|
||||||
In two-way data binding, the user's changes flow automatically from the controls back to the data model properties.
|
|
||||||
A Reactive Forms component should not use data binding to
|
|
||||||
automatically update data model properties.
|
|
||||||
The developer decides _when and how_ to update the data model from control values.
|
|
||||||
|
|
||||||
This sample updates the model twice:
|
|
||||||
|
|
||||||
1. When the user submits the form.
|
|
||||||
1. When the user adds a new hero.
|
|
||||||
|
|
||||||
The `onSubmit()` method simply replaces the `hero` object with the combined values of the form:
|
|
||||||
|
|
||||||
<code-example path="form-validation/src/app/reactive/hero-form-reactive.component.ts" region="on-submit" title="form-validation/src/app/reactive/hero-form-reactive.component.ts" linenums="false">
|
|
||||||
</code-example>
|
</code-example>
|
||||||
|
|
||||||
|
Note that:
|
||||||
|
|
||||||
The `addHero()` method discards pending changes and creates a brand new `hero` model object.
|
* The name control sets up two built-in validators—`Validators.required` and `Validators.minLength(4)`—and one custom validator, `forbiddenNameValidator`. For more details see the [Custom validators](guide/form-validation#custom-validators) section in this guide.
|
||||||
|
* As these validators are all sync validators, you pass them in as the second argument.
|
||||||
|
* Support multiple validators by passing the functions in as an array.
|
||||||
|
* This example adds a few getter methods. In a reactive form, you can always access any form control through the `get` method on its parent group, but sometimes it's useful to define getters as shorthands
|
||||||
|
for the template.
|
||||||
|
|
||||||
<code-example path="form-validation/src/app/reactive/hero-form-reactive.component.ts" region="add-hero" title="form-validation/src/app/reactive/hero-form-reactive.component.ts" linenums="false">
|
|
||||||
|
If you look at the template for the name input again, it is fairly similar to the template-driven example.
|
||||||
|
|
||||||
|
<code-example path="form-validation/src/app/reactive/hero-form-reactive.component.html" region="name-with-error-msg" title="reactive/hero-form-reactive.component.html (name with error msg)" linenums="false">
|
||||||
</code-example>
|
</code-example>
|
||||||
|
|
||||||
|
Key takeaways:
|
||||||
|
|
||||||
|
* The form no longer exports any directives, and instead uses the `name` getter defined in
|
||||||
|
the component class.
|
||||||
|
* The `required` attribute is still present. While it's not necessary for validation purposes,
|
||||||
|
you may want to keep it in your template for CSS styling or accessibility reasons.
|
||||||
|
|
||||||
|
|
||||||
Then it calls `buildForm()` again which replaces the previous `heroForm` control model with a new one.
|
## Custom validators
|
||||||
The `<form>` tag's `[formGroup]` binding refreshes the page with the new control model.
|
|
||||||
|
|
||||||
Here's the complete reactive component file, compared to the two Template Driven component files.
|
Since the built-in validators won't always match the exact use case of your application, sometimes you'll want to create a custom validator.
|
||||||
|
|
||||||
<code-tabs>
|
Consider the `forbiddenNameValidator` function from previous
|
||||||
|
[examples](guide/form-validation#reactive-component-class) in
|
||||||
<code-pane title="reactive/hero-form-reactive.component.ts (#3)" path="form-validation/src/app/reactive/hero-form-reactive.component.ts">
|
this guide. Here's what the definition of that function looks like:
|
||||||
|
|
||||||
</code-pane>
|
|
||||||
|
|
||||||
<code-pane title="template/hero-form-template2.component.ts (#2)" path="form-validation/src/app/template/hero-form-template2.component.ts">
|
|
||||||
|
|
||||||
</code-pane>
|
|
||||||
|
|
||||||
<code-pane title="template/hero-form-template1.component.ts (#1)" path="form-validation/src/app/template/hero-form-template1.component.ts">
|
|
||||||
|
|
||||||
</code-pane>
|
|
||||||
|
|
||||||
</code-tabs>
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<div class="l-sub-section">
|
|
||||||
|
|
||||||
Run the [live example](guide/form-validation#live-example) to see how the reactive form behaves,
|
|
||||||
and to compare all of the files in this sample.
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
## Custom validation
|
|
||||||
This cookbook sample has a custom `forbiddenNameValidator()` function that's applied to both the
|
|
||||||
Template Driven and the reactive form controls. It's in the `src/app/shared` folder
|
|
||||||
and declared in the `SharedModule`.
|
|
||||||
|
|
||||||
Here's the `forbiddenNameValidator()` function:
|
|
||||||
|
|
||||||
<code-example path="form-validation/src/app/shared/forbidden-name.directive.ts" region="custom-validator" title="shared/forbidden-name.directive.ts (forbiddenNameValidator)" linenums="false">
|
<code-example path="form-validation/src/app/shared/forbidden-name.directive.ts" region="custom-validator" title="shared/forbidden-name.directive.ts (forbiddenNameValidator)" linenums="false">
|
||||||
</code-example>
|
</code-example>
|
||||||
|
|
||||||
|
The function is actually a factory that takes a regular expression to detect a _specific_ forbidden name and returns a validator function.
|
||||||
|
|
||||||
|
In this sample, the forbidden name is "bob", so the validator will reject any hero name containing "bob".
|
||||||
The function is actually a factory that takes a regular expression to detect a _specific_ forbidden name
|
|
||||||
and returns a validator function.
|
|
||||||
|
|
||||||
In this sample, the forbidden name is "bob";
|
|
||||||
the validator rejects any hero name containing "bob".
|
|
||||||
Elsewhere it could reject "alice" or any name that the configuring regular expression matches.
|
Elsewhere it could reject "alice" or any name that the configuring regular expression matches.
|
||||||
|
|
||||||
The `forbiddenNameValidator` factory returns the configured validator function.
|
The `forbiddenNameValidator` factory returns the configured validator function.
|
||||||
That function takes an Angular control object and returns _either_
|
That function takes an Angular control object and returns _either_
|
||||||
null if the control value is valid _or_ a validation error object.
|
null if the control value is valid _or_ a validation error object.
|
||||||
The validation error object typically has a property whose name is the validation key, `'forbiddenName'`,
|
The validation error object typically has a property whose name is the validation key, `'forbiddenName'`,
|
||||||
and whose value is an arbitrary dictionary of values that you could insert into an error message (`{name}`).
|
and whose value is an arbitrary dictionary of values that you could insert into an error message, `{name}`.
|
||||||
|
|
||||||
|
### Adding to reactive forms
|
||||||
|
|
||||||
|
In reactive forms, custom validators are fairly simple to add. All you have to do is pass the function directly
|
||||||
|
to the `FormControl`.
|
||||||
|
|
||||||
### Custom validation directive
|
<code-example path="form-validation/src/app/reactive/hero-form-reactive.component.ts" region="custom-validator" title="reactive/hero-form-reactive.component.ts (validator functions)" linenums="false">
|
||||||
In the Reactive Forms component, the `'name'` control's validator function list
|
|
||||||
has a `forbiddenNameValidator` at the bottom.
|
|
||||||
|
|
||||||
<code-example path="form-validation/src/app/reactive/hero-form-reactive.component.ts" region="name-validators" title="reactive/hero-form-reactive.component.ts (name validators)" linenums="false">
|
|
||||||
</code-example>
|
</code-example>
|
||||||
|
|
||||||
|
### Adding to template-driven forms
|
||||||
|
|
||||||
|
In template-driven forms, you don't have direct access to the `FormControl` instance, so you can't pass the
|
||||||
|
validator in like you can for reactive forms. Instead, you need to add a directive to the template.
|
||||||
|
|
||||||
In the Template Driven example, the `<input>` has the selector (`forbiddenName`)
|
The corresponding `ForbiddenValidatorDirective` serves as a wrapper around the `forbiddenNameValidator`.
|
||||||
of a custom _attribute directive_, which rejects "bob".
|
|
||||||
|
|
||||||
<code-example path="form-validation/src/app/template/hero-form-template2.component.html" region="name-input" title="template/hero-form-template2.component.html (name input)" linenums="false">
|
Angular recognizes the directive's role in the validation process because the directive registers itself
|
||||||
</code-example>
|
with the `NG_VALIDATORS` provider, a provider with an extensible collection of validators.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
The corresponding `ForbiddenValidatorDirective` is a wrapper around the `forbiddenNameValidator`.
|
|
||||||
|
|
||||||
Angular `forms` recognizes the directive's role in the validation process because the directive registers itself
|
|
||||||
with the `NG_VALIDATORS` provider, a provider with an extensible collection of validation directives.
|
|
||||||
|
|
||||||
<code-example path="form-validation/src/app/shared/forbidden-name.directive.ts" region="directive-providers" title="shared/forbidden-name.directive.ts (providers)" linenums="false">
|
<code-example path="form-validation/src/app/shared/forbidden-name.directive.ts" region="directive-providers" title="shared/forbidden-name.directive.ts (providers)" linenums="false">
|
||||||
</code-example>
|
</code-example>
|
||||||
|
|
||||||
|
The directive class then implements the `Validator` interface, so that it can easily integrate
|
||||||
|
with Angular forms. Here is the rest of the directive to help you get an idea of how it all
|
||||||
Here is the rest of the directive to help you get an idea of how it all comes together:
|
comes together:
|
||||||
|
|
||||||
<code-example path="form-validation/src/app/shared/forbidden-name.directive.ts" region="directive" title="shared/forbidden-name.directive.ts (directive)">
|
<code-example path="form-validation/src/app/shared/forbidden-name.directive.ts" region="directive" title="shared/forbidden-name.directive.ts (directive)">
|
||||||
</code-example>
|
</code-example>
|
||||||
|
|
||||||
|
Once the `ForbiddenValidatorDirective` is ready, you can simply add its selector, `forbiddenName`, to any input element to activate it. For example:
|
||||||
|
|
||||||
|
<code-example path="form-validation/src/app/template/hero-form-template.component.html" region="name-input" title="template/hero-form-template.component.html (forbidden-name-input)" linenums="false">
|
||||||
|
|
||||||
|
</code-example>
|
||||||
|
|
||||||
|
|
||||||
<div class="l-sub-section">
|
<div class="l-sub-section">
|
||||||
|
|
||||||
If you are familiar with Angular validations, you may have noticed
|
You may have noticed that the custom validation directive is instantiated with `useExisting`
|
||||||
that the custom validation directive is instantiated with `useExisting`
|
|
||||||
rather than `useClass`. The registered validator must be _this instance_ of
|
rather than `useClass`. The registered validator must be _this instance_ of
|
||||||
the `ForbiddenValidatorDirective`—the instance in the form with
|
the `ForbiddenValidatorDirective`—the instance in the form with
|
||||||
its `forbiddenName` property bound to “bob". If you were to replace
|
its `forbiddenName` property bound to “bob". If you were to replace
|
||||||
`useExisting` with `useClass`, then you’d be registering a new class instance, one that
|
`useExisting` with `useClass`, then you’d be registering a new class instance, one that
|
||||||
doesn’t have a `forbiddenName`.
|
doesn’t have a `forbiddenName`.
|
||||||
|
|
||||||
To see this in action, run the example and then type “bob” in the name of Hero Form 2.
|
|
||||||
Notice that you get a validation error. Now change from `useExisting` to `useClass` and try again.
|
|
||||||
This time, when you type “bob”, there's no "bob" error message.
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
## Control status CSS classes
|
||||||
|
|
||||||
<div class="l-sub-section">
|
Like in AngularJS, Angular automatically mirrors many control properties onto the form control element as CSS classes. You can use these classes to style form control elements according to the state of the form. The following classes are currently supported:
|
||||||
|
|
||||||
For more information on attaching behavior to elements,
|
* `.ng-valid`
|
||||||
see [Attribute Directives](guide/attribute-directives).
|
* `.ng-invalid`
|
||||||
|
* `.ng-pending`
|
||||||
|
* `.ng-pristine`
|
||||||
|
* `.ng-dirty`
|
||||||
|
* `.ng-untouched`
|
||||||
|
* `.ng-touched`
|
||||||
|
|
||||||
</div>
|
The hero form uses the `.ng-valid` and `.ng-invalid` classes to
|
||||||
|
set the color of each form control's border.
|
||||||
|
|
||||||
|
<code-example path="form-validation/src/forms.css" title="forms.css (status classes)">
|
||||||
|
|
||||||
|
</code-example>
|
||||||
|
|
||||||
|
|
||||||
|
**You can run the <live-example></live-example> to see the complete reactive and template-driven example code.**
|
||||||
## Testing Considerations
|
|
||||||
|
|
||||||
You can write _isolated unit tests_ of validation and control logic in Reactive Forms.
|
|
||||||
|
|
||||||
_Isolated unit tests_ probe the component class directly, independent of its
|
|
||||||
interactions with its template, the DOM, other dependencies, or Angular itself.
|
|
||||||
|
|
||||||
Such tests have minimal setup, are quick to write, and easy to maintain.
|
|
||||||
They do not require the `Angular TestBed` or asynchronous testing practices.
|
|
||||||
|
|
||||||
That's not possible with Template Driven forms.
|
|
||||||
The Template Driven approach relies on Angular to produce the control model and
|
|
||||||
to derive validation rules from the HTML validation attributes.
|
|
||||||
You must use the `Angular TestBed` to create component test instances,
|
|
||||||
write asynchronous tests, and interact with the DOM.
|
|
||||||
|
|
||||||
While not difficult, this takes more time, work and
|
|
||||||
skill—factors that tend to diminish test code
|
|
||||||
coverage and quality.
|
|
||||||
|
@ -408,7 +408,7 @@ This XML element represents the translation of the `<h1>` greeting tag you marke
|
|||||||
|
|
||||||
<div class="l-sub-section">
|
<div class="l-sub-section">
|
||||||
|
|
||||||
Note that the translation unit `id=introductionHeader` is derived from the _custom_ `id`](#custom-id "Set a custom id") that you set earlier, but **without the `@@` prefix** required in the source HTML.
|
Note that the translation unit `id=introductionHeader` is derived from the [_custom_ `id`](#custom-id "Set a custom id") that you set earlier, but **without the `@@` prefix** required in the source HTML.
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -1080,8 +1080,11 @@ To get access to the `FormArray` class, import it into `hero-detail.component.ts
|
|||||||
|
|
||||||
|
|
||||||
To _work_ with a `FormArray` you do the following:
|
To _work_ with a `FormArray` you do the following:
|
||||||
|
|
||||||
1. Define the items (`FormControls` or `FormGroups`) in the array.
|
1. Define the items (`FormControls` or `FormGroups`) in the array.
|
||||||
|
|
||||||
1. Initialize the array with items created from data in the _data model_.
|
1. Initialize the array with items created from data in the _data model_.
|
||||||
|
|
||||||
1. Add and remove items as the user requires.
|
1. Add and remove items as the user requires.
|
||||||
|
|
||||||
In this guide, you define a `FormArray` for `Hero.addresses` and
|
In this guide, you define a `FormArray` for `Hero.addresses` and
|
||||||
|
@ -1830,7 +1830,7 @@ Finally, you activate the observable with `subscribe` method and (re)set the com
|
|||||||
|
|
||||||
#### _ParamMap_ API
|
#### _ParamMap_ API
|
||||||
|
|
||||||
The `ParamMap` API is inspired by the [URLSearchParams interface](https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParamsOPut). It provides methods
|
The `ParamMap` API is inspired by the [URLSearchParams interface](https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams). It provides methods
|
||||||
to handle parameter access for both route parameters (`paramMap`) and query parameters (`queryParamMap`).
|
to handle parameter access for both route parameters (`paramMap`) and query parameters (`queryParamMap`).
|
||||||
|
|
||||||
<table>
|
<table>
|
||||||
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.4 KiB |
Before Width: | Height: | Size: 6.0 KiB After Width: | Height: | Size: 6.0 KiB |
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
Before Width: | Height: | Size: 85 KiB After Width: | Height: | Size: 85 KiB |
Before Width: | Height: | Size: 257 KiB After Width: | Height: | Size: 257 KiB |
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 17 KiB |
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 27 KiB |
@ -342,7 +342,7 @@
|
|||||||
"name": "Ralph Wang",
|
"name": "Ralph Wang",
|
||||||
"picture": "ralph.jpg",
|
"picture": "ralph.jpg",
|
||||||
"twitter": "ralph_wang_gde",
|
"twitter": "ralph_wang_gde",
|
||||||
"bio": "Ralph(Zhicheng Wang) is a senior consultant at ThoughWorks and also a GDE. He is a technology enthusiast and he is a passionate advocate of 'Simplicity, Professionalism and Sharing'. In his eighteen years of R&D career, he worked as tester, R&D engineer, project manager, product manager and CTO. He is looking forward to the birth of his baby.",
|
"bio": "Ralph(Zhicheng Wang) is a senior consultant at ThoughtWorks and also a GDE. He is a technology enthusiast and he is a passionate advocate of 'Simplicity, Professionalism and Sharing'. In his eighteen years of R&D career, he worked as tester, R&D engineer, project manager, product manager and CTO. He is immersed in the excitement of the arrival of the baby.",
|
||||||
"group": "GDE"
|
"group": "GDE"
|
||||||
},
|
},
|
||||||
|
|
||||||
|
@ -8,7 +8,7 @@
|
|||||||
<div class="feature-section">
|
<div class="feature-section">
|
||||||
<div class="feature-header">
|
<div class="feature-header">
|
||||||
<div class="text-headline">Cross Platform</div>
|
<div class="text-headline">Cross Platform</div>
|
||||||
<img src="assets/images/icons/feature-icon.svg" height="70px">
|
<img src="generated/images/marketing/features/feature-icon.svg" height="70px">
|
||||||
</div>
|
</div>
|
||||||
<div class="feature-row">
|
<div class="feature-row">
|
||||||
|
|
||||||
@ -34,7 +34,7 @@
|
|||||||
<div class="feature-section">
|
<div class="feature-section">
|
||||||
<div class="feature-header">
|
<div class="feature-header">
|
||||||
<div class="text-headline">Speed and Performance</div>
|
<div class="text-headline">Speed and Performance</div>
|
||||||
<img src="assets/images/icons/feature-icon.svg" height="70px">
|
<img src="generated/images/marketing/features/feature-icon.svg" height="70px">
|
||||||
</div>
|
</div>
|
||||||
<div class="feature-row">
|
<div class="feature-row">
|
||||||
|
|
||||||
@ -59,7 +59,7 @@
|
|||||||
<div class="feature-section">
|
<div class="feature-section">
|
||||||
<div class="feature-header">
|
<div class="feature-header">
|
||||||
<div class="text-headline">Productivity</div>
|
<div class="text-headline">Productivity</div>
|
||||||
<img src="assets/images/icons/feature-icon.svg" height="70px">
|
<img src="generated/images/marketing/features/feature-icon.svg" height="70px">
|
||||||
</div>
|
</div>
|
||||||
<div class="feature-row">
|
<div class="feature-row">
|
||||||
|
|
||||||
@ -84,7 +84,7 @@
|
|||||||
<div class="feature-section">
|
<div class="feature-section">
|
||||||
<div class="feature-header">
|
<div class="feature-header">
|
||||||
<div class="text-headline">Full Development Story</div>
|
<div class="text-headline">Full Development Story</div>
|
||||||
<img src="assets/images/icons/feature-icon.svg" height="70px">
|
<img src="generated/images/marketing/features/feature-icon.svg" height="70px">
|
||||||
</div>
|
</div>
|
||||||
<div class="feature-row">
|
<div class="feature-row">
|
||||||
|
|
||||||
|
@ -30,7 +30,7 @@
|
|||||||
<!--Announcement Bar-->
|
<!--Announcement Bar-->
|
||||||
<div class="homepage-container">
|
<div class="homepage-container">
|
||||||
<div class="announcement-bar">
|
<div class="announcement-bar">
|
||||||
<img src="generated/images/marketing/angular-mix.png" height="40" width="151">
|
<img src="generated/images/marketing/home/angular-mix.png" height="40" width="151">
|
||||||
<p>Join us at our newest event, October 2017</p>
|
<p>Join us at our newest event, October 2017</p>
|
||||||
<a class="button" href="https://angularmix.com/">Learn More</a>
|
<a class="button" href="https://angularmix.com/">Learn More</a>
|
||||||
</div>
|
</div>
|
||||||
@ -40,7 +40,7 @@
|
|||||||
<div layout="row" layout-xs="column" class="home-row homepage-container">
|
<div layout="row" layout-xs="column" class="home-row homepage-container">
|
||||||
<div class="promo-img-container promo-1">
|
<div class="promo-img-container promo-1">
|
||||||
<div>
|
<div>
|
||||||
<img height="222" width="340" src="assets/images/home/responsive-framework.svg" alt="responsive framework">
|
<img height="222" width="340" src="generated/images/marketing/home/responsive-framework.svg" alt="responsive framework">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -65,7 +65,7 @@
|
|||||||
|
|
||||||
<div class="promo-img-container promo-2">
|
<div class="promo-img-container promo-2">
|
||||||
<div>
|
<div>
|
||||||
<img height="222" width="323" src="assets/images/home/speed-performance.svg" alt="speed and performance">
|
<img height="222" width="323" src="generated/images/marketing/home/speed-performance.svg" alt="speed and performance">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -74,7 +74,7 @@
|
|||||||
<!-- Group 3-->
|
<!-- Group 3-->
|
||||||
<div layout="row" layout-xs="column" class="home-row">
|
<div layout="row" layout-xs="column" class="home-row">
|
||||||
<div class="promo-img-container promo-3">
|
<div class="promo-img-container promo-3">
|
||||||
<div><img src="assets/images/home/joyful-development.png" alt="IDE example"></div>
|
<div><img src="generated/images/marketing/home/joyful-development.svg" alt="IDE example"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="text-container">
|
<div class="text-container">
|
||||||
@ -100,7 +100,7 @@
|
|||||||
|
|
||||||
<div class="promo-img-container promo-4">
|
<div class="promo-img-container promo-4">
|
||||||
<div>
|
<div>
|
||||||
<img src="assets/images/home/loved-by-millions.png" alt="angular on the map" width="455" height="228">
|
<img src="generated/images/marketing/home/loved-by-millions.svg" alt="angular on the map" width="455" height="228">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -110,7 +110,7 @@
|
|||||||
|
|
||||||
<a href="guide/quickstart">
|
<a href="guide/quickstart">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<img src="assets/images/icons/code-icon.svg" height="70px">
|
<img src="generated/images/marketing/home/code-icon.svg" height="70px">
|
||||||
<div class="card-text-container">
|
<div class="card-text-container">
|
||||||
<div class="text-headline">Get Started</div>
|
<div class="text-headline">Get Started</div>
|
||||||
<p>Start building your Angular application.</p>
|
<p>Start building your Angular application.</p>
|
||||||
|
@ -61,6 +61,12 @@
|
|||||||
"hidden": true
|
"hidden": true
|
||||||
},
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
"url": "guide/webpack",
|
||||||
|
"title": "Webpack: An Introduction",
|
||||||
|
"hidden": true
|
||||||
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
"url": "guide/quickstart",
|
"url": "guide/quickstart",
|
||||||
"title": "Getting Started",
|
"title": "Getting Started",
|
||||||
@ -397,7 +403,7 @@
|
|||||||
"tooltip": "Press contacts, logos, and branding."
|
"tooltip": "Press contacts, logos, and branding."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"url": "https://blog.angularjs.org/",
|
"url": "https://blog.angular.io/",
|
||||||
"title": "Blog",
|
"title": "Blog",
|
||||||
"tooltip": "Angular Blog"
|
"tooltip": "Angular Blog"
|
||||||
}
|
}
|
||||||
|
@ -6,26 +6,27 @@
|
|||||||
"public": "dist",
|
"public": "dist",
|
||||||
"cleanUrls": true,
|
"cleanUrls": true,
|
||||||
"redirects": [
|
"redirects": [
|
||||||
// cli-quickstart.html glossary.html, quickstart.html, http.html, style-guide.html, styleguide
|
// cli-quickstart.html, glossary.html, quickstart.html, server-communication.html, style-guide.html
|
||||||
{"type": 301, "source": "/docs/ts/latest/cli-quickstart.html", "destination": "/guide/quickstart"},
|
{"type": 301, "source": "/docs/ts/latest/cli-quickstart.html", "destination": "/guide/quickstart"},
|
||||||
{"type": 301, "source": "/guide/cli-quickstart", "destination": "/guide/quickstart"},
|
|
||||||
{"type": 301, "source": "/docs/ts/latest/glossary.html", "destination": "/guide/glossary"},
|
{"type": 301, "source": "/docs/ts/latest/glossary.html", "destination": "/guide/glossary"},
|
||||||
{"type": 301, "source": "/docs/ts/latest/quickstart.html", "destination": "/guide/quickstart"},
|
{"type": 301, "source": "/docs/ts/latest/quickstart.html", "destination": "/guide/quickstart"},
|
||||||
{"type": 301, "source": "/docs/ts/latest/guide/server-communication.html", "destination": "/guide/http"},
|
{"type": 301, "source": "/docs/ts/latest/guide/server-communication.html", "destination": "/guide/http"},
|
||||||
{"type": 301, "source": "/docs/ts/latest/guide/style-guide.html", "destination": "/guide/styleguide"},
|
{"type": 301, "source": "/docs/ts/latest/guide/style-guide.html", "destination": "/guide/styleguide"},
|
||||||
{"type": 301, "source": "/styleguide", "destination": "/guide/styleguide"},
|
|
||||||
|
|
||||||
// cookbook/component-communication.html
|
// guide/cli-quickstart, styleguide
|
||||||
|
{"type": 301, "source": "/guide/cli-quickstart", "destination": "/guide/quickstart"},
|
||||||
|
{"type": 301, "source": "/styleguide", "destination": "/guide/styleguide"},
|
||||||
|
|
||||||
|
// cookbook/a1-a2-quick-reference.html, cookbook/component-communication.html, cookbook/dependency-injection.html
|
||||||
|
{"type": 301, "source": "/docs/ts/latest/cookbook/a1-a2-quick-reference.html", "destination": "/guide/ajs-quick-reference"},
|
||||||
{"type": 301, "source": "/docs/ts/latest/cookbook/component-communication.html", "destination": "/guide/component-interaction"},
|
{"type": 301, "source": "/docs/ts/latest/cookbook/component-communication.html", "destination": "/guide/component-interaction"},
|
||||||
|
{"type": 301, "source": "/docs/ts/latest/cookbook/dependency-injection.html", "destination": "/guide/dependency-injection-in-action"},
|
||||||
|
|
||||||
// cookbook, cookbook/, cookbook/index.html
|
// cookbook, cookbook/, cookbook/index.html
|
||||||
{"type": 301, "source": "/docs/ts/latest/cookbook", "destination": "/docs"},
|
{"type": 301, "source": "/docs/ts/latest/cookbook", "destination": "/docs"},
|
||||||
{"type": 301, "source": "/docs/ts/latest/cookbook/", "destination": "/docs"},
|
{"type": 301, "source": "/docs/ts/latest/cookbook/", "destination": "/docs"},
|
||||||
{"type": 301, "source": "/docs/ts/latest/cookbook/index.html", "destination": "/docs"},
|
{"type": 301, "source": "/docs/ts/latest/cookbook/index.html", "destination": "/docs"},
|
||||||
|
|
||||||
// cookbook/dependency-injection.html
|
|
||||||
{"type": 301, "source": "/docs/ts/latest/cookbook/dependency-injection.html", "destination": "/guide/dependency-injection-in-action"},
|
|
||||||
|
|
||||||
// cookbook/*.html
|
// cookbook/*.html
|
||||||
{"type": 301, "source": "/docs/ts/latest/cookbook/:cookbook.html", "destination": "/guide/:cookbook"},
|
{"type": 301, "source": "/docs/ts/latest/cookbook/:cookbook.html", "destination": "/guide/:cookbook"},
|
||||||
|
|
||||||
|
@ -16,15 +16,8 @@ module.exports = function (config) {
|
|||||||
clearContext: false // leave Jasmine Spec Runner output visible in browser
|
clearContext: false // leave Jasmine Spec Runner output visible in browser
|
||||||
},
|
},
|
||||||
files: [
|
files: [
|
||||||
{ pattern: './node_modules/@angular/material/prebuilt-themes/indigo-pink.css', included: true },
|
{ pattern: './node_modules/@angular/material/prebuilt-themes/indigo-pink.css', included: true }
|
||||||
{ pattern: './src/test.ts', watched: false }
|
|
||||||
],
|
],
|
||||||
preprocessors: {
|
|
||||||
'./src/test.ts': ['@angular/cli']
|
|
||||||
},
|
|
||||||
mime: {
|
|
||||||
'text/x-typescript': ['ts','tsx']
|
|
||||||
},
|
|
||||||
coverageIstanbulReporter: {
|
coverageIstanbulReporter: {
|
||||||
reports: [ 'html', 'lcovonly' ],
|
reports: [ 'html', 'lcovonly' ],
|
||||||
fixWebpackSourcePaths: true
|
fixWebpackSourcePaths: true
|
||||||
@ -32,14 +25,13 @@ module.exports = function (config) {
|
|||||||
angularCli: {
|
angularCli: {
|
||||||
environment: 'dev'
|
environment: 'dev'
|
||||||
},
|
},
|
||||||
reporters: config.angularCli && config.angularCli.codeCoverage
|
reporters: ['progress', 'kjhtml'],
|
||||||
? ['progress', 'coverage-istanbul']
|
|
||||||
: ['progress', 'kjhtml'],
|
|
||||||
port: 9876,
|
port: 9876,
|
||||||
colors: true,
|
colors: true,
|
||||||
logLevel: config.LOG_INFO,
|
logLevel: config.LOG_INFO,
|
||||||
autoWatch: true,
|
autoWatch: true,
|
||||||
browsers: ['Chrome'],
|
browsers: ['Chrome'],
|
||||||
|
browserNoActivityTimeout: 60000,
|
||||||
singleRun: false
|
singleRun: false
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
@ -10,8 +10,8 @@
|
|||||||
},
|
},
|
||||||
"static.ignore": [
|
"static.ignore": [
|
||||||
"\\.js\\.map$",
|
"\\.js\\.map$",
|
||||||
"^/assets/images/.*/unused/",
|
"^(?:/|\\\\)assets(?:/|\\\\)images(?:/|\\\\).*(?:/|\\\\)_unused(?:/|\\\\)",
|
||||||
"^/generated/(?:docs/(?!api/api-list\\.json).*|images|live-examples|zips)/"
|
"^(?:/|\\\\)generated(?:/|\\\\)(?:docs(?:/|\\\\)(?!api(?:/|\\\\)api-list\\.json).*|images(?:/|\\\\)(?!marketing(?:/|\\\\)).*|live-examples|zips)(?:/|\\\\)"
|
||||||
],
|
],
|
||||||
"static.versioned": [
|
"static.versioned": [
|
||||||
"\\.[0-9a-z]{20}\\."
|
"\\.[0-9a-z]{20}\\."
|
||||||
|
@ -9,7 +9,7 @@
|
|||||||
"ng": "yarn check-env && ng",
|
"ng": "yarn check-env && ng",
|
||||||
"start": "yarn check-env && ng serve",
|
"start": "yarn check-env && ng serve",
|
||||||
"prebuild": "yarn check-env && yarn setup",
|
"prebuild": "yarn check-env && yarn setup",
|
||||||
"build": "ng build -prod -sm -vc=false",
|
"build": "ng build --target=production --environment=stable -sm -bo",
|
||||||
"postbuild": "yarn sw-manifest && yarn sw-copy",
|
"postbuild": "yarn sw-manifest && yarn sw-copy",
|
||||||
"lint": "yarn check-env && yarn docs-lint && ng lint && yarn example-lint",
|
"lint": "yarn check-env && yarn docs-lint && ng lint && yarn example-lint",
|
||||||
"test": "yarn check-env && ng test",
|
"test": "yarn check-env && ng test",
|
||||||
@ -22,8 +22,7 @@
|
|||||||
"example-e2e": "node ./tools/examples/run-example-e2e",
|
"example-e2e": "node ./tools/examples/run-example-e2e",
|
||||||
"example-lint": "tslint -c \"content/examples/tslint.json\" \"content/examples/**/*.ts\" -e \"content/examples/styleguide/**/*.avoid.ts\"",
|
"example-lint": "tslint -c \"content/examples/tslint.json\" \"content/examples/**/*.ts\" -e \"content/examples/styleguide/**/*.avoid.ts\"",
|
||||||
"deploy-preview": "scripts/deploy-preview.sh",
|
"deploy-preview": "scripts/deploy-preview.sh",
|
||||||
"deploy-staging": "scripts/deploy-to-firebase.sh staging",
|
"deploy-production": "scripts/deploy-to-firebase.sh",
|
||||||
"deploy-production": "scripts/deploy-to-firebase.sh production",
|
|
||||||
"check-env": "node scripts/check-environment",
|
"check-env": "node scripts/check-environment",
|
||||||
"payload-size": "scripts/payload.sh",
|
"payload-size": "scripts/payload.sh",
|
||||||
"predocs": "rimraf src/generated/{docs,*.json}",
|
"predocs": "rimraf src/generated/{docs,*.json}",
|
||||||
@ -31,15 +30,17 @@
|
|||||||
"docs-watch": "node tools/transforms/authors-package/watchr.js",
|
"docs-watch": "node tools/transforms/authors-package/watchr.js",
|
||||||
"docs-lint": "eslint --ignore-path=\"tools/transforms/.eslintignore\" tools/transforms",
|
"docs-lint": "eslint --ignore-path=\"tools/transforms/.eslintignore\" tools/transforms",
|
||||||
"docs-test": "node tools/transforms/test.js",
|
"docs-test": "node tools/transforms/test.js",
|
||||||
|
"tools-test": "./scripts/deploy-to-firebase.test.sh && yarn docs-test",
|
||||||
"serve-and-sync": "concurrently --kill-others \"yarn docs-watch\" \"yarn start\"",
|
"serve-and-sync": "concurrently --kill-others \"yarn docs-watch\" \"yarn start\"",
|
||||||
"~~update-webdriver": "webdriver-manager update --standalone false --gecko false",
|
"~~update-webdriver": "webdriver-manager update --standalone false --gecko false",
|
||||||
"boilerplate:add": "node ./tools/examples/add-example-boilerplate add",
|
"boilerplate:add": "node ./tools/examples/example-boilerplate add",
|
||||||
"boilerplate:remove": "node ./tools/examples/add-example-boilerplate remove",
|
"boilerplate:remove": "node ./tools/examples/example-boilerplate remove",
|
||||||
|
"boilerplate:test": "node tools/examples/test.js",
|
||||||
"generate-plunkers": "node ./tools/plunker-builder/generatePlunkers",
|
"generate-plunkers": "node ./tools/plunker-builder/generatePlunkers",
|
||||||
"generate-zips": "node ./tools/example-zipper/generateZips",
|
"generate-zips": "node ./tools/example-zipper/generateZips",
|
||||||
"sw-manifest": "ngu-sw-manifest --dist dist --in ngsw-manifest.json --out dist/ngsw-manifest.json",
|
"sw-manifest": "ngu-sw-manifest --dist dist --in ngsw-manifest.json --out dist/ngsw-manifest.json",
|
||||||
"sw-copy": "cp node_modules/@angular/service-worker/bundles/worker-basic.min.js dist/",
|
"sw-copy": "cp node_modules/@angular/service-worker/bundles/worker-basic.min.js dist/",
|
||||||
"postinstall": "node tools/cli-patches/patch.js && uglifyjs node_modules/lunr/lunr.js -c -m -o src/assets/js/lunr.min.js --source-map",
|
"postinstall": "uglifyjs node_modules/lunr/lunr.js -c -m -o src/assets/js/lunr.min.js --source-map",
|
||||||
"build-ie-polyfills": "node node_modules/webpack/bin/webpack.js -p src/ie-polyfills.js src/generated/ie-polyfills.min.js"
|
"build-ie-polyfills": "node node_modules/webpack/bin/webpack.js -p src/ie-polyfills.js src/generated/ie-polyfills.min.js"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@ -48,31 +49,31 @@
|
|||||||
},
|
},
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@angular/animations": "^4.2.4",
|
"@angular/animations": "^4.3.1",
|
||||||
"@angular/common": "^4.2.4",
|
"@angular/cdk": "^2.0.0-beta.8",
|
||||||
"@angular/compiler": "^4.2.4",
|
"@angular/common": "^4.3.1",
|
||||||
"@angular/core": "^4.2.4",
|
"@angular/compiler": "^4.3.1",
|
||||||
"@angular/forms": "^4.2.4",
|
"@angular/core": "^4.3.1",
|
||||||
"@angular/http": "^4.2.4",
|
"@angular/forms": "^4.3.1",
|
||||||
"@angular/material": "^2.0.0-beta.7",
|
"@angular/http": "^4.3.1",
|
||||||
"@angular/platform-browser": "^4.2.4",
|
"@angular/material": "^2.0.0-beta.8",
|
||||||
"@angular/platform-browser-dynamic": "^4.2.4",
|
"@angular/platform-browser": "^4.3.1",
|
||||||
"@angular/platform-server": "^4.2.4",
|
"@angular/platform-browser-dynamic": "^4.3.1",
|
||||||
"@angular/router": "^4.2.4",
|
"@angular/platform-server": "^4.3.1",
|
||||||
|
"@angular/router": "^4.3.1",
|
||||||
"@angular/service-worker": "^1.0.0-beta.16",
|
"@angular/service-worker": "^1.0.0-beta.16",
|
||||||
"classlist.js": "^1.1.20150312",
|
"classlist.js": "^1.1.20150312",
|
||||||
"core-js": "^2.4.1",
|
"core-js": "^2.4.1",
|
||||||
"jasmine": "^2.6.0",
|
"jasmine": "^2.6.0",
|
||||||
"ng-pwa-tools": "^0.0.10",
|
"ng-pwa-tools": "^0.0.10",
|
||||||
"ngo": "angular/ngo",
|
|
||||||
"rxjs": "^5.2.0",
|
"rxjs": "^5.2.0",
|
||||||
"tslib": "^1.7.1",
|
"tslib": "^1.7.1",
|
||||||
"web-animations-js": "^2.2.5",
|
"web-animations-js": "^2.2.5",
|
||||||
"zone.js": "^0.8.12"
|
"zone.js": "^0.8.12"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@angular/cli": "angular/cli-builds#webpack-next",
|
"@angular/cli": "1.3.0-rc.3",
|
||||||
"@angular/compiler-cli": "^4.2.4",
|
"@angular/compiler-cli": "^4.3.1",
|
||||||
"@types/jasmine": "^2.5.52",
|
"@types/jasmine": "^2.5.52",
|
||||||
"@types/node": "~6.0.60",
|
"@types/node": "~6.0.60",
|
||||||
"archiver": "^1.3.0",
|
"archiver": "^1.3.0",
|
||||||
@ -81,7 +82,7 @@
|
|||||||
"concurrently": "^3.4.0",
|
"concurrently": "^3.4.0",
|
||||||
"cross-spawn": "^5.1.0",
|
"cross-spawn": "^5.1.0",
|
||||||
"dgeni": "^0.4.7",
|
"dgeni": "^0.4.7",
|
||||||
"dgeni-packages": "^0.20.0-rc.6",
|
"dgeni-packages": "^0.20.0",
|
||||||
"entities": "^1.1.1",
|
"entities": "^1.1.1",
|
||||||
"eslint": "^3.19.0",
|
"eslint": "^3.19.0",
|
||||||
"eslint-plugin-jasmine": "^2.2.0",
|
"eslint-plugin-jasmine": "^2.2.0",
|
||||||
|
@ -3,32 +3,82 @@
|
|||||||
# WARNING: FIREBASE_TOKEN should NOT be printed.
|
# WARNING: FIREBASE_TOKEN should NOT be printed.
|
||||||
set +x -eu -o pipefail
|
set +x -eu -o pipefail
|
||||||
|
|
||||||
|
# Only deploy if this not a PR. PRs are deployed early in `build.sh`.
|
||||||
|
if [[ $TRAVIS_PULL_REQUEST != "false" ]]; then
|
||||||
|
echo "Skipping deploy because this is a PR build."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
readonly deployEnv=$1
|
# Do not deploy if the current commit is not the latest on its branch.
|
||||||
|
readonly LATEST_COMMIT=$(git ls-remote origin $TRAVIS_BRANCH | cut -c1-40)
|
||||||
|
if [[ $TRAVIS_COMMIT != $LATEST_COMMIT ]]; then
|
||||||
|
echo "Skipping deploy because $TRAVIS_COMMIT is not the latest commit ($LATEST_COMMIT)."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# The deployment mode is computed based on the branch we are building
|
||||||
|
if [[ $TRAVIS_BRANCH == master ]]; then
|
||||||
|
readonly deployEnv=next
|
||||||
|
elif [[ $TRAVIS_BRANCH == $STABLE_BRANCH ]]; then
|
||||||
|
readonly deployEnv=stable
|
||||||
|
else
|
||||||
|
# Extract the major versions from the branches, e.g. the 4 from 4.3.x
|
||||||
|
readonly majorVersion=${TRAVIS_BRANCH%%.*}
|
||||||
|
readonly majorVersionStable=${STABLE_BRANCH%%.*}
|
||||||
|
|
||||||
|
# Do not deploy if the major version is not less than the stable branch major version
|
||||||
|
if [[ $majorVersion -ge $majorVersionStable ]]; then
|
||||||
|
echo "Skipping deploy of branch \"${TRAVIS_BRANCH}\" to firebase."
|
||||||
|
echo "We only deploy archive branches with the major version less than the stable branch: \"${STABLE_BRANCH}\""
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Find the branch that has highest minor version for the given `$majorVersion`
|
||||||
|
readonly mostRecentMinorVersion=$(
|
||||||
|
# List the branches that start with the major version
|
||||||
|
git ls-remote origin refs/heads/${majorVersion}.*.x |
|
||||||
|
# Extract the version number
|
||||||
|
awk -F'/' '{print $3}' |
|
||||||
|
# Sort by the minor version
|
||||||
|
sort -t. -k 2,2n |
|
||||||
|
# Get the highest version
|
||||||
|
tail -n1
|
||||||
|
)
|
||||||
|
|
||||||
|
# Do not deploy as it is not the latest branch for the given major version
|
||||||
|
if [[ $TRAVIS_BRANCH != $mostRecentMinorVersion ]]; then
|
||||||
|
echo "Skipping deploy of branch \"${TRAVIS_BRANCH}\" to firebase."
|
||||||
|
echo "There is a more recent branch with the same major version: \"${mostRecentMinorVersion}\""
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
readonly deployEnv=archive
|
||||||
|
fi
|
||||||
|
|
||||||
case $deployEnv in
|
case $deployEnv in
|
||||||
staging)
|
next)
|
||||||
readonly buildEnv=stage
|
|
||||||
readonly projectId=aio-staging
|
readonly projectId=aio-staging
|
||||||
readonly deployedUrl=https://$projectId.firebaseapp.com/
|
readonly deployedUrl=https://next.angular.io/
|
||||||
readonly firebaseToken=$FIREBASE_TOKEN
|
readonly firebaseToken=$FIREBASE_TOKEN
|
||||||
;;
|
;;
|
||||||
production)
|
stable)
|
||||||
readonly buildEnv=prod
|
|
||||||
readonly projectId=angular-io
|
readonly projectId=angular-io
|
||||||
readonly deployedUrl=https://angular.io/
|
readonly deployedUrl=https://angular.io/
|
||||||
readonly firebaseToken=$FIREBASE_TOKEN
|
readonly firebaseToken=$FIREBASE_TOKEN
|
||||||
;;
|
;;
|
||||||
*)
|
archive)
|
||||||
echo "Unknown deployment environment ('$deployEnv'). Expected 'staging' or 'production'."
|
readonly projectId=angular-io-${majorVersion}
|
||||||
exit 1
|
readonly deployedUrl=https://v${majorVersion}.angular.io/
|
||||||
|
readonly firebaseToken=$FIREBASE_TOKEN
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
# Do not deploy if the current commit is not the latest on its branch.
|
echo "Git branch : $TRAVIS_BRANCH"
|
||||||
readonly LATEST_COMMIT=$(git ls-remote origin $TRAVIS_BRANCH | cut -c1-40)
|
echo "Build/deploy mode : $deployEnv"
|
||||||
if [ $TRAVIS_COMMIT != $LATEST_COMMIT ]; then
|
echo "Firebase project : $projectId"
|
||||||
echo "Skipping deploy because $TRAVIS_COMMIT is not the latest commit ($LATEST_COMMIT)."
|
echo "Deployment URL : $deployedUrl"
|
||||||
|
|
||||||
|
if [[ ${1:-} == "--dry-run" ]]; then
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@ -37,7 +87,10 @@ fi
|
|||||||
cd "`dirname $0`/.."
|
cd "`dirname $0`/.."
|
||||||
|
|
||||||
# Build the app
|
# Build the app
|
||||||
yarn build -- --env=$buildEnv
|
yarn build -- --env=$deployEnv
|
||||||
|
|
||||||
|
# Include any mode-specific files
|
||||||
|
cp -rf src/extra-files/$deployEnv/. dist/
|
||||||
|
|
||||||
# Check payload size
|
# Check payload size
|
||||||
yarn payload-size
|
yarn payload-size
|
||||||
|
159
aio/scripts/deploy-to-firebase.test.sh
Executable file
@ -0,0 +1,159 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set +x -eu -o pipefail
|
||||||
|
|
||||||
|
function check {
|
||||||
|
if [[ $1 == $2 ]]; then
|
||||||
|
echo Pass
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
echo Fail
|
||||||
|
echo ---- Expected ----
|
||||||
|
echo "$2"
|
||||||
|
echo ---- Actual ----
|
||||||
|
echo "$1"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
(
|
||||||
|
echo ===== master - skip deploy - pull request
|
||||||
|
actual=$(
|
||||||
|
export TRAVIS_PULL_REQUEST=true
|
||||||
|
`dirname $0`/deploy-to-firebase.sh --dry-run
|
||||||
|
)
|
||||||
|
expected="Skipping deploy because this is a PR build."
|
||||||
|
check "$actual" "$expected"
|
||||||
|
)
|
||||||
|
|
||||||
|
(
|
||||||
|
echo ===== master - deploy success
|
||||||
|
actual=$(
|
||||||
|
export TRAVIS_PULL_REQUEST=false
|
||||||
|
export TRAVIS_BRANCH=master
|
||||||
|
export TRAVIS_COMMIT=$(git ls-remote origin master | cut -c-40)
|
||||||
|
export FIREBASE_TOKEN=XXXXX
|
||||||
|
`dirname $0`/deploy-to-firebase.sh --dry-run
|
||||||
|
)
|
||||||
|
expected="Git branch : master
|
||||||
|
Build/deploy mode : next
|
||||||
|
Firebase project : aio-staging
|
||||||
|
Deployment URL : https://next.angular.io/"
|
||||||
|
check "$actual" "$expected"
|
||||||
|
)
|
||||||
|
|
||||||
|
(
|
||||||
|
echo ===== master - skip deploy - commit not HEAD
|
||||||
|
actual=$(
|
||||||
|
export TRAVIS_PULL_REQUEST=false
|
||||||
|
export TRAVIS_BRANCH=master
|
||||||
|
export TRAVIS_COMMIT=DUMMY_TEST_COMMIT
|
||||||
|
`dirname $0`/deploy-to-firebase.sh --dry-run
|
||||||
|
)
|
||||||
|
expected="Skipping deploy because DUMMY_TEST_COMMIT is not the latest commit ($(git ls-remote origin master | cut -c1-40))."
|
||||||
|
check "$actual" "$expected"
|
||||||
|
)
|
||||||
|
|
||||||
|
(
|
||||||
|
echo ===== stable - deploy success
|
||||||
|
actual=$(
|
||||||
|
export TRAVIS_PULL_REQUEST=false
|
||||||
|
export TRAVIS_BRANCH=4.3.x
|
||||||
|
export STABLE_BRANCH=4.3.x
|
||||||
|
export TRAVIS_COMMIT=$(git ls-remote origin 4.3.x | cut -c-40)
|
||||||
|
export FIREBASE_TOKEN=XXXXX
|
||||||
|
`dirname $0`/deploy-to-firebase.sh --dry-run
|
||||||
|
)
|
||||||
|
expected="Git branch : 4.3.x
|
||||||
|
Build/deploy mode : stable
|
||||||
|
Firebase project : angular-io
|
||||||
|
Deployment URL : https://angular.io/"
|
||||||
|
check "$actual" "$expected"
|
||||||
|
)
|
||||||
|
|
||||||
|
(
|
||||||
|
echo ===== stable - skip deploy - commit not HEAD
|
||||||
|
actual=$(
|
||||||
|
export TRAVIS_PULL_REQUEST=false
|
||||||
|
export TRAVIS_BRANCH=4.3.x
|
||||||
|
export STABLE_BRANCH=4.3.x
|
||||||
|
export TRAVIS_COMMIT=DUMMY_TEST_COMMIT
|
||||||
|
`dirname $0`/deploy-to-firebase.sh --dry-run
|
||||||
|
)
|
||||||
|
expected="Skipping deploy because DUMMY_TEST_COMMIT is not the latest commit ($(git ls-remote origin 4.3.x | cut -c1-40))."
|
||||||
|
check "$actual" "$expected"
|
||||||
|
)
|
||||||
|
|
||||||
|
(
|
||||||
|
echo ===== archive - deploy success
|
||||||
|
actual=$(
|
||||||
|
export TRAVIS_PULL_REQUEST=false
|
||||||
|
export TRAVIS_BRANCH=2.4.x
|
||||||
|
export STABLE_BRANCH=4.3.x
|
||||||
|
export TRAVIS_COMMIT=$(git ls-remote origin 2.4.x | cut -c-40)
|
||||||
|
export FIREBASE_TOKEN=XXXXX
|
||||||
|
`dirname $0`/deploy-to-firebase.sh --dry-run
|
||||||
|
)
|
||||||
|
expected="Git branch : 2.4.x
|
||||||
|
Build/deploy mode : archive
|
||||||
|
Firebase project : angular-io-2
|
||||||
|
Deployment URL : https://v2.angular.io/"
|
||||||
|
check "$actual" "$expected"
|
||||||
|
)
|
||||||
|
|
||||||
|
(
|
||||||
|
echo ===== archive - skip deploy - commit not HEAD
|
||||||
|
actual=$(
|
||||||
|
export TRAVIS_PULL_REQUEST=false
|
||||||
|
export TRAVIS_BRANCH=2.4.x
|
||||||
|
export STABLE_BRANCH=4.3.x
|
||||||
|
export TRAVIS_COMMIT=DUMMY_TEST_COMMIT
|
||||||
|
export FIREBASE_TOKEN=XXXXX
|
||||||
|
`dirname $0`/deploy-to-firebase.sh --dry-run
|
||||||
|
)
|
||||||
|
expected="Skipping deploy because DUMMY_TEST_COMMIT is not the latest commit ($(git ls-remote origin 2.4.x | cut -c1-40))."
|
||||||
|
check "$actual" "$expected"
|
||||||
|
)
|
||||||
|
|
||||||
|
(
|
||||||
|
echo ===== archive - skip deploy - major version too high, lower minor
|
||||||
|
actual=$(
|
||||||
|
export TRAVIS_PULL_REQUEST=false
|
||||||
|
export TRAVIS_BRANCH=2.1.x
|
||||||
|
export STABLE_BRANCH=2.2.x
|
||||||
|
export TRAVIS_COMMIT=$(git ls-remote origin 2.1.x | cut -c-40)
|
||||||
|
export FIREBASE_TOKEN=XXXXX
|
||||||
|
`dirname $0`/deploy-to-firebase.sh --dry-run
|
||||||
|
)
|
||||||
|
expected="Skipping deploy of branch \"2.1.x\" to firebase.
|
||||||
|
We only deploy archive branches with the major version less than the stable branch: \"2.2.x\""
|
||||||
|
check "$actual" "$expected"
|
||||||
|
)
|
||||||
|
|
||||||
|
(
|
||||||
|
echo ===== archive - skip deploy - major version too high, higher minor
|
||||||
|
actual=$(
|
||||||
|
export TRAVIS_PULL_REQUEST=false
|
||||||
|
export TRAVIS_BRANCH=2.4.x
|
||||||
|
export STABLE_BRANCH=2.2.x
|
||||||
|
export TRAVIS_COMMIT=$(git ls-remote origin 2.4.x | cut -c-40)
|
||||||
|
export FIREBASE_TOKEN=XXXXX
|
||||||
|
`dirname $0`/deploy-to-firebase.sh --dry-run
|
||||||
|
)
|
||||||
|
expected="Skipping deploy of branch \"2.4.x\" to firebase.
|
||||||
|
We only deploy archive branches with the major version less than the stable branch: \"2.2.x\""
|
||||||
|
check "$actual" "$expected"
|
||||||
|
)
|
||||||
|
|
||||||
|
(
|
||||||
|
echo ===== archive - skip deploy - minor version too low
|
||||||
|
actual=$(
|
||||||
|
export TRAVIS_PULL_REQUEST=false
|
||||||
|
export TRAVIS_BRANCH=2.1.x
|
||||||
|
export STABLE_BRANCH=4.3.x
|
||||||
|
export TRAVIS_COMMIT=$(git ls-remote origin 2.1.x | cut -c-40)
|
||||||
|
export FIREBASE_TOKEN=XXXXX
|
||||||
|
`dirname $0`/deploy-to-firebase.sh --dry-run
|
||||||
|
)
|
||||||
|
expected="Skipping deploy of branch \"2.1.x\" to firebase.
|
||||||
|
There is a more recent branch with the same major version: \"2.4.x\""
|
||||||
|
check "$actual" "$expected"
|
||||||
|
)
|
@ -61,7 +61,8 @@ else
|
|||||||
# Nothing changed in aio/
|
# Nothing changed in aio/
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
payloadData="$payloadData\"change\": \"$change\""
|
message=$(echo $TRAVIS_COMMIT_MESSAGE | sed 's/"/\\"/g' | sed 's/\\/\\\\/g')
|
||||||
|
payloadData="$payloadData\"change\": \"$change\", \"message\": \"$message\""
|
||||||
|
|
||||||
payloadData="{${payloadData}}"
|
payloadData="{${payloadData}}"
|
||||||
|
|
||||||
|
@ -21,12 +21,13 @@
|
|||||||
<aio-nav-menu *ngIf="!isSideBySide" [nodes]="topMenuNarrowNodes" [currentNode]="currentNodes?.TopBarNarrow" [isWide]="false"></aio-nav-menu>
|
<aio-nav-menu *ngIf="!isSideBySide" [nodes]="topMenuNarrowNodes" [currentNode]="currentNodes?.TopBarNarrow" [isWide]="false"></aio-nav-menu>
|
||||||
<aio-nav-menu [nodes]="sideNavNodes" [currentNode]="currentNodes?.SideNav" [isWide]="isSideBySide"></aio-nav-menu>
|
<aio-nav-menu [nodes]="sideNavNodes" [currentNode]="currentNodes?.SideNav" [isWide]="isSideBySide"></aio-nav-menu>
|
||||||
|
|
||||||
<div class="doc-version" title="Angular docs version {{currentDocVersion?.title}}">
|
<div class="doc-version">
|
||||||
<aio-select (change)="onDocVersionChange($event.index)" [options]="docVersions" [selected]="docVersions && docVersions[0]"></aio-select>
|
<aio-select (change)="onDocVersionChange($event.index)" [options]="docVersions" [selected]="currentDocVersion"></aio-select>
|
||||||
</div>
|
</div>
|
||||||
</md-sidenav>
|
</md-sidenav>
|
||||||
|
|
||||||
<section class="sidenav-content" [id]="pageId" role="content">
|
<section class="sidenav-content" [id]="pageId" role="content">
|
||||||
|
<aio-mode-banner [mode]="deployment.mode" [version]="versionInfo"></aio-mode-banner>
|
||||||
<aio-doc-viewer [doc]="currentDocument" (docRendered)="onDocRendered()"></aio-doc-viewer>
|
<aio-doc-viewer [doc]="currentDocument" (docRendered)="onDocRendered()"></aio-doc-viewer>
|
||||||
<aio-dt [on]="dtOn" [(doc)]="currentDocument"></aio-dt>
|
<aio-dt [on]="dtOn" [(doc)]="currentDocument"></aio-dt>
|
||||||
</section>
|
</section>
|
||||||
|
@ -12,6 +12,7 @@ import { of } from 'rxjs/observable/of';
|
|||||||
import { AppComponent } from './app.component';
|
import { AppComponent } from './app.component';
|
||||||
import { AppModule } from './app.module';
|
import { AppModule } from './app.module';
|
||||||
import { DocViewerComponent } from 'app/layout/doc-viewer/doc-viewer.component';
|
import { DocViewerComponent } from 'app/layout/doc-viewer/doc-viewer.component';
|
||||||
|
import { Deployment } from 'app/shared/deployment.service';
|
||||||
import { GaService } from 'app/shared/ga.service';
|
import { GaService } from 'app/shared/ga.service';
|
||||||
import { LocationService } from 'app/shared/location.service';
|
import { LocationService } from 'app/shared/location.service';
|
||||||
import { Logger } from 'app/shared/logger.service';
|
import { Logger } from 'app/shared/logger.service';
|
||||||
@ -273,26 +274,49 @@ describe('AppComponent', () => {
|
|||||||
describe('SideNav version selector', () => {
|
describe('SideNav version selector', () => {
|
||||||
let selectElement: DebugElement;
|
let selectElement: DebugElement;
|
||||||
let selectComponent: SelectComponent;
|
let selectComponent: SelectComponent;
|
||||||
beforeEach(() => {
|
|
||||||
|
function setupSelectorForTesting(mode?: string) {
|
||||||
|
createTestingModule('a/b', mode);
|
||||||
|
initializeTest();
|
||||||
component.onResize(sideBySideBreakPoint + 1); // side-by-side
|
component.onResize(sideBySideBreakPoint + 1); // side-by-side
|
||||||
selectElement = fixture.debugElement.query(By.directive(SelectComponent));
|
selectElement = fixture.debugElement.query(By.directive(SelectComponent));
|
||||||
selectComponent = selectElement.componentInstance;
|
selectComponent = selectElement.componentInstance;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('should select the version that matches the deploy mode', () => {
|
||||||
|
setupSelectorForTesting();
|
||||||
|
expect(selectComponent.selected.title).toContain('stable');
|
||||||
|
setupSelectorForTesting('next');
|
||||||
|
expect(selectComponent.selected.title).toContain('next');
|
||||||
|
setupSelectorForTesting('archive');
|
||||||
|
expect(selectComponent.selected.title).toContain('v4');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should pick first (current) version by default', () => {
|
it('should add the current raw version string to the selected version', () => {
|
||||||
expect(selectComponent.selected.title).toEqual(component.versionInfo.raw);
|
setupSelectorForTesting();
|
||||||
|
expect(selectComponent.selected.title).toContain(`(v${component.versionInfo.raw})`);
|
||||||
|
setupSelectorForTesting('next');
|
||||||
|
expect(selectComponent.selected.title).toContain(`(v${component.versionInfo.raw})`);
|
||||||
|
setupSelectorForTesting('archive');
|
||||||
|
expect(selectComponent.selected.title).toContain(`(v${component.versionInfo.raw})`);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Older docs versions have an href
|
// Older docs versions have an href
|
||||||
it('should navigate when change to a version with an href', () => {
|
it('should navigate when change to a version with a url', () => {
|
||||||
selectElement.triggerEventHandler('change', { option: component.docVersions[1] as Option, index: 1});
|
setupSelectorForTesting();
|
||||||
expect(locationService.go).toHaveBeenCalledWith(TestHttp.docVersions[0].url);
|
const versionWithUrlIndex = component.docVersions.findIndex(v => !!v.url);
|
||||||
|
const versionWithUrl = component.docVersions[versionWithUrlIndex];
|
||||||
|
selectElement.triggerEventHandler('change', { option: versionWithUrl, index: versionWithUrlIndex});
|
||||||
|
expect(locationService.go).toHaveBeenCalledWith(versionWithUrl.url);
|
||||||
});
|
});
|
||||||
|
|
||||||
// The current docs version should not have an href
|
// The current docs version should not have an href
|
||||||
// This may change when we perfect our docs versioning approach
|
// This may change when we perfect our docs versioning approach
|
||||||
it('should not navigate when change to a version without an href', () => {
|
it('should not navigate when change to a version without a url', () => {
|
||||||
selectElement.triggerEventHandler('change', { option: component.docVersions[0] as Option, index: 0});
|
setupSelectorForTesting();
|
||||||
|
const versionWithoutUrlIndex = component.docVersions.findIndex(v => !v.url);
|
||||||
|
const versionWithoutUrl = component.docVersions[versionWithoutUrlIndex];
|
||||||
|
selectElement.triggerEventHandler('change', { option: versionWithoutUrl, index: versionWithoutUrlIndex});
|
||||||
expect(locationService.go).not.toHaveBeenCalled();
|
expect(locationService.go).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@ -332,10 +356,6 @@ describe('AppComponent', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('hostClasses', () => {
|
describe('hostClasses', () => {
|
||||||
let host: DebugElement;
|
|
||||||
beforeEach(() => {
|
|
||||||
host = fixture.debugElement;
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should set the css classes of the host container based on the current doc and navigation view', () => {
|
it('should set the css classes of the host container based on the current doc and navigation view', () => {
|
||||||
locationService.go('guide/pipes');
|
locationService.go('guide/pipes');
|
||||||
@ -359,7 +379,7 @@ describe('AppComponent', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should set the css class of the host container based on the open/closed state of the side nav', () => {
|
it('should set the css class of the host container based on the open/closed state of the side nav', () => {
|
||||||
const sideNav = host.query(By.directive(MdSidenav));
|
const sideNav = fixture.debugElement.query(By.directive(MdSidenav));
|
||||||
|
|
||||||
locationService.go('guide/pipes');
|
locationService.go('guide/pipes');
|
||||||
fixture.detectChanges();
|
fixture.detectChanges();
|
||||||
@ -376,7 +396,14 @@ describe('AppComponent', () => {
|
|||||||
checkHostClass('sidenav', 'open');
|
checkHostClass('sidenav', 'open');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should set the css class of the host container based on the initial deployment mode', () => {
|
||||||
|
createTestingModule('a/b', 'archive');
|
||||||
|
initializeTest();
|
||||||
|
checkHostClass('mode', 'archive');
|
||||||
|
});
|
||||||
|
|
||||||
function checkHostClass(type, value) {
|
function checkHostClass(type, value) {
|
||||||
|
const host = fixture.debugElement;
|
||||||
const classes = host.properties['className'];
|
const classes = host.properties['className'];
|
||||||
const classArray = classes.split(' ').filter(c => c.indexOf(`${type}-`) === 0);
|
const classArray = classes.split(' ').filter(c => c.indexOf(`${type}-`) === 0);
|
||||||
expect(classArray.length).toBeLessThanOrEqual(1, `"${classes}" should have only one class matching ${type}-*`);
|
expect(classArray.length).toBeLessThanOrEqual(1, `"${classes}" should have only one class matching ${type}-*`);
|
||||||
@ -623,7 +650,25 @@ describe('AppComponent', () => {
|
|||||||
describe('footer', () => {
|
describe('footer', () => {
|
||||||
it('should have version number', () => {
|
it('should have version number', () => {
|
||||||
const versionEl: HTMLElement = fixture.debugElement.query(By.css('aio-footer')).nativeElement;
|
const versionEl: HTMLElement = fixture.debugElement.query(By.css('aio-footer')).nativeElement;
|
||||||
expect(versionEl.textContent).toContain(TestHttp.versionFull);
|
expect(versionEl.textContent).toContain(TestHttp.versionInfo.full);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('deployment banner', () => {
|
||||||
|
it('should show a message if the deployment mode is "archive"', () => {
|
||||||
|
createTestingModule('a/b', 'archive');
|
||||||
|
initializeTest();
|
||||||
|
fixture.detectChanges();
|
||||||
|
const banner: HTMLElement = fixture.debugElement.query(By.css('aio-mode-banner')).nativeElement;
|
||||||
|
expect(banner.textContent).toContain('archived documentation for Angular v4');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show no message if the deployment mode is not "archive"', () => {
|
||||||
|
createTestingModule('a/b', 'stable');
|
||||||
|
initializeTest();
|
||||||
|
fixture.detectChanges();
|
||||||
|
const banner: HTMLElement = fixture.debugElement.query(By.css('aio-mode-banner')).nativeElement;
|
||||||
|
expect(banner.textContent.trim()).toEqual('');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -720,6 +765,97 @@ describe('AppComponent', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('archive redirection', () => {
|
||||||
|
it('should redirect to `docs` if deployment mode is `archive` and not at a docs page', () => {
|
||||||
|
createTestingModule('', 'archive');
|
||||||
|
initializeTest();
|
||||||
|
expect(TestBed.get(LocationService).replace).toHaveBeenCalledWith('docs');
|
||||||
|
|
||||||
|
createTestingModule('resources', 'archive');
|
||||||
|
initializeTest();
|
||||||
|
expect(TestBed.get(LocationService).replace).toHaveBeenCalledWith('docs');
|
||||||
|
|
||||||
|
createTestingModule('guide/aot-compiler', 'archive');
|
||||||
|
initializeTest();
|
||||||
|
expect(TestBed.get(LocationService).replace).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
createTestingModule('tutorial', 'archive');
|
||||||
|
initializeTest();
|
||||||
|
expect(TestBed.get(LocationService).replace).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
createTestingModule('tutorial/toh-pt1', 'archive');
|
||||||
|
initializeTest();
|
||||||
|
expect(TestBed.get(LocationService).replace).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
createTestingModule('docs', 'archive');
|
||||||
|
initializeTest();
|
||||||
|
expect(TestBed.get(LocationService).replace).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
createTestingModule('api', 'archive');
|
||||||
|
initializeTest();
|
||||||
|
expect(TestBed.get(LocationService).replace).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should redirect to `docs` if deployment mode is `next` and not at a docs page', () => {
|
||||||
|
createTestingModule('', 'next');
|
||||||
|
initializeTest();
|
||||||
|
expect(TestBed.get(LocationService).replace).toHaveBeenCalledWith('docs');
|
||||||
|
|
||||||
|
createTestingModule('resources', 'next');
|
||||||
|
initializeTest();
|
||||||
|
expect(TestBed.get(LocationService).replace).toHaveBeenCalledWith('docs');
|
||||||
|
|
||||||
|
createTestingModule('guide/aot-compiler', 'next');
|
||||||
|
initializeTest();
|
||||||
|
expect(TestBed.get(LocationService).replace).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
createTestingModule('tutorial', 'next');
|
||||||
|
initializeTest();
|
||||||
|
expect(TestBed.get(LocationService).replace).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
createTestingModule('tutorial/toh-pt1', 'next');
|
||||||
|
initializeTest();
|
||||||
|
expect(TestBed.get(LocationService).replace).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
createTestingModule('docs', 'next');
|
||||||
|
initializeTest();
|
||||||
|
expect(TestBed.get(LocationService).replace).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
createTestingModule('api', 'next');
|
||||||
|
initializeTest();
|
||||||
|
expect(TestBed.get(LocationService).replace).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not redirect to `docs` if deployment mode is `stable` and not at a docs page', () => {
|
||||||
|
createTestingModule('', 'stable');
|
||||||
|
initializeTest();
|
||||||
|
expect(TestBed.get(LocationService).replace).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
createTestingModule('resources', 'stable');
|
||||||
|
initializeTest();
|
||||||
|
expect(TestBed.get(LocationService).replace).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
createTestingModule('guide/aot-compiler', 'stable');
|
||||||
|
initializeTest();
|
||||||
|
expect(TestBed.get(LocationService).replace).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
createTestingModule('tutorial', 'stable');
|
||||||
|
initializeTest();
|
||||||
|
expect(TestBed.get(LocationService).replace).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
createTestingModule('tutorial/toh-pt1', 'stable');
|
||||||
|
initializeTest();
|
||||||
|
expect(TestBed.get(LocationService).replace).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
createTestingModule('docs', 'stable');
|
||||||
|
initializeTest();
|
||||||
|
expect(TestBed.get(LocationService).replace).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
createTestingModule('api', 'stable');
|
||||||
|
initializeTest();
|
||||||
|
expect(TestBed.get(LocationService).replace).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('with mocked DocViewer', () => {
|
describe('with mocked DocViewer', () => {
|
||||||
@ -883,7 +1019,8 @@ describe('AppComponent', () => {
|
|||||||
|
|
||||||
//// test helpers ////
|
//// test helpers ////
|
||||||
|
|
||||||
function createTestingModule(initialUrl: string) {
|
function createTestingModule(initialUrl: string, mode: string = 'stable') {
|
||||||
|
const mockLocationService = new MockLocationService(initialUrl);
|
||||||
TestBed.resetTestingModule();
|
TestBed.resetTestingModule();
|
||||||
TestBed.configureTestingModule({
|
TestBed.configureTestingModule({
|
||||||
imports: [ AppModule ],
|
imports: [ AppModule ],
|
||||||
@ -891,9 +1028,14 @@ function createTestingModule(initialUrl: string) {
|
|||||||
{ provide: APP_BASE_HREF, useValue: '/' },
|
{ provide: APP_BASE_HREF, useValue: '/' },
|
||||||
{ provide: GaService, useClass: TestGaService },
|
{ provide: GaService, useClass: TestGaService },
|
||||||
{ provide: Http, useClass: TestHttp },
|
{ provide: Http, useClass: TestHttp },
|
||||||
{ provide: LocationService, useFactory: () => new MockLocationService(initialUrl) },
|
{ provide: LocationService, useFactory: () => mockLocationService },
|
||||||
{ provide: Logger, useClass: MockLogger },
|
{ provide: Logger, useClass: MockLogger },
|
||||||
{ provide: SearchService, useClass: MockSearchService },
|
{ provide: SearchService, useClass: MockSearchService },
|
||||||
|
{ provide: Deployment, useFactory: () => {
|
||||||
|
const deployment = new Deployment(mockLocationService as any);
|
||||||
|
deployment.mode = mode;
|
||||||
|
return deployment;
|
||||||
|
}},
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -908,7 +1050,21 @@ class TestSearchService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class TestHttp {
|
class TestHttp {
|
||||||
static versionFull = '4.0.0-local+sha.73808dd';
|
|
||||||
|
static versionInfo = {
|
||||||
|
raw: '4.0.0-rc.6',
|
||||||
|
major: 4,
|
||||||
|
minor: 0,
|
||||||
|
patch: 0,
|
||||||
|
prerelease: [ 'local' ],
|
||||||
|
build: 'sha.73808dd',
|
||||||
|
version: '4.0.0-local',
|
||||||
|
codeName: 'snapshot',
|
||||||
|
isSnapshot: true,
|
||||||
|
full: '4.0.0-local+sha.73808dd',
|
||||||
|
branch: 'master',
|
||||||
|
commitSHA: '73808dd38b5ccd729404936834d1568bd066de81'
|
||||||
|
};
|
||||||
|
|
||||||
static docVersions: NavigationNode[] = [
|
static docVersions: NavigationNode[] = [
|
||||||
{ title: 'v2', url: 'https://v2.angular.io' }
|
{ title: 'v2', url: 'https://v2.angular.io' }
|
||||||
@ -951,22 +1107,7 @@ class TestHttp {
|
|||||||
],
|
],
|
||||||
"docVersions": TestHttp.docVersions,
|
"docVersions": TestHttp.docVersions,
|
||||||
|
|
||||||
"__versionInfo": {
|
"__versionInfo": TestHttp.versionInfo,
|
||||||
"raw": "4.0.0-rc.6",
|
|
||||||
"major": 4,
|
|
||||||
"minor": 0,
|
|
||||||
"patch": 0,
|
|
||||||
"prerelease": [
|
|
||||||
"local"
|
|
||||||
],
|
|
||||||
"build": "sha.73808dd",
|
|
||||||
"version": "4.0.0-local",
|
|
||||||
"codeName": "snapshot",
|
|
||||||
"isSnapshot": true,
|
|
||||||
"full": TestHttp.versionFull,
|
|
||||||
"branch": "master",
|
|
||||||
"commitSHA": "73808dd38b5ccd729404936834d1568bd066de81"
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
get(url: string) {
|
get(url: string) {
|
||||||
|
@ -5,6 +5,7 @@ import { MdSidenav } from '@angular/material';
|
|||||||
import { CurrentNodes, NavigationService, NavigationViews, NavigationNode, VersionInfo } from 'app/navigation/navigation.service';
|
import { CurrentNodes, NavigationService, NavigationViews, NavigationNode, VersionInfo } from 'app/navigation/navigation.service';
|
||||||
import { DocumentService, DocumentContents } from 'app/documents/document.service';
|
import { DocumentService, DocumentContents } from 'app/documents/document.service';
|
||||||
import { DocViewerComponent } from 'app/layout/doc-viewer/doc-viewer.component';
|
import { DocViewerComponent } from 'app/layout/doc-viewer/doc-viewer.component';
|
||||||
|
import { Deployment } from 'app/shared/deployment.service';
|
||||||
import { LocationService } from 'app/shared/location.service';
|
import { LocationService } from 'app/shared/location.service';
|
||||||
import { NavMenuComponent } from 'app/layout/nav-menu/nav-menu.component';
|
import { NavMenuComponent } from 'app/layout/nav-menu/nav-menu.component';
|
||||||
import { ScrollService } from 'app/shared/scroll.service';
|
import { ScrollService } from 'app/shared/scroll.service';
|
||||||
@ -76,8 +77,8 @@ export class AppComponent implements OnInit {
|
|||||||
|
|
||||||
get homeImageUrl() {
|
get homeImageUrl() {
|
||||||
return this.isSideBySide ?
|
return this.isSideBySide ?
|
||||||
'assets/images/logos/standard/logo-nav@2x.png' :
|
'assets/images/logos/angular/logo-nav@2x.png' :
|
||||||
'assets/images/logos/standard/shield-large.svg';
|
'assets/images/logos/angular/shield-large.svg';
|
||||||
}
|
}
|
||||||
get isOpened() { return this.isSideBySide && this.isSideNavDoc; }
|
get isOpened() { return this.isSideBySide && this.isSideNavDoc; }
|
||||||
get mode() { return this.isSideBySide ? 'side' : 'over'; }
|
get mode() { return this.isSideBySide ? 'side' : 'over'; }
|
||||||
@ -99,6 +100,7 @@ export class AppComponent implements OnInit {
|
|||||||
sidenav: MdSidenav;
|
sidenav: MdSidenav;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
|
public deployment: Deployment,
|
||||||
private documentService: DocumentService,
|
private documentService: DocumentService,
|
||||||
private hostElement: ElementRef,
|
private hostElement: ElementRef,
|
||||||
private locationService: LocationService,
|
private locationService: LocationService,
|
||||||
@ -127,6 +129,11 @@ export class AppComponent implements OnInit {
|
|||||||
});
|
});
|
||||||
|
|
||||||
this.locationService.currentPath.subscribe(path => {
|
this.locationService.currentPath.subscribe(path => {
|
||||||
|
// Redirect to docs if we are in not in stable mode and are not hitting a docs page
|
||||||
|
// (i.e. we have arrived at a marketing page)
|
||||||
|
if (this.deployment.mode !== 'stable' && !/^(docs$|api$|guide|tutorial)/.test(path)) {
|
||||||
|
this.locationService.replace('docs');
|
||||||
|
}
|
||||||
if (path === this.currentPath) {
|
if (path === this.currentPath) {
|
||||||
// scroll only if on same page (most likely a change to the hash)
|
// scroll only if on same page (most likely a change to the hash)
|
||||||
this.autoScroll();
|
this.autoScroll();
|
||||||
@ -158,12 +165,24 @@ export class AppComponent implements OnInit {
|
|||||||
|
|
||||||
// Compute the version picker list from the current version and the versions in the navigation map
|
// Compute the version picker list from the current version and the versions in the navigation map
|
||||||
combineLatest(
|
combineLatest(
|
||||||
this.navigationService.versionInfo.map(versionInfo => ({ title: versionInfo.raw, url: null })),
|
this.navigationService.versionInfo,
|
||||||
this.navigationService.navigationViews.map(views => views['docVersions']),
|
this.navigationService.navigationViews.map(views => views['docVersions']))
|
||||||
(currentVersion, otherVersions) => [currentVersion, ...otherVersions])
|
.subscribe(([versionInfo, versions]) => {
|
||||||
.subscribe(versions => {
|
// TODO(pbd): consider whether we can lookup the stable and next versions from the internet
|
||||||
this.docVersions = versions;
|
const computedVersions = [
|
||||||
this.currentDocVersion = this.docVersions[0];
|
{ title: 'next', url: 'https://next.angular.io' },
|
||||||
|
{ title: 'stable', url: 'https://angular.io' },
|
||||||
|
];
|
||||||
|
if (this.deployment.mode === 'archive') {
|
||||||
|
computedVersions.push({ title: `v${versionInfo.major}`, url: null });
|
||||||
|
}
|
||||||
|
this.docVersions = [...computedVersions, ...versions];
|
||||||
|
|
||||||
|
// Find the current version - eithers title matches the current deployment mode
|
||||||
|
// or its title matches the major version of the current version info
|
||||||
|
this.currentDocVersion = this.docVersions.find(version =>
|
||||||
|
version.title === this.deployment.mode || version.title === `v${versionInfo.major}`);
|
||||||
|
this.currentDocVersion.title += ` (v${versionInfo.raw})`;
|
||||||
});
|
});
|
||||||
|
|
||||||
this.navigationService.navigationViews.subscribe(views => {
|
this.navigationService.navigationViews.subscribe(views => {
|
||||||
@ -256,12 +275,13 @@ export class AppComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
updateHostClasses() {
|
updateHostClasses() {
|
||||||
|
const mode = `mode-${this.deployment.mode}`;
|
||||||
const sideNavOpen = `sidenav-${this.sidenav.opened ? 'open' : 'closed'}`;
|
const sideNavOpen = `sidenav-${this.sidenav.opened ? 'open' : 'closed'}`;
|
||||||
const pageClass = `page-${this.pageId}`;
|
const pageClass = `page-${this.pageId}`;
|
||||||
const folderClass = `folder-${this.folderId}`;
|
const folderClass = `folder-${this.folderId}`;
|
||||||
const viewClasses = Object.keys(this.currentNodes || {}).map(view => `view-${view}`).join(' ');
|
const viewClasses = Object.keys(this.currentNodes || {}).map(view => `view-${view}`).join(' ');
|
||||||
|
|
||||||
this.hostClasses = `${sideNavOpen} ${pageClass} ${folderClass} ${viewClasses}`;
|
this.hostClasses = `${mode} ${sideNavOpen} ${pageClass} ${folderClass} ${viewClasses}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dynamically change height of table of contents container
|
// Dynamically change height of table of contents container
|
||||||
|
@ -26,8 +26,10 @@ import { SwUpdatesModule } from 'app/sw-updates/sw-updates.module';
|
|||||||
import { AppComponent } from 'app/app.component';
|
import { AppComponent } from 'app/app.component';
|
||||||
import { ApiService } from 'app/embedded/api/api.service';
|
import { ApiService } from 'app/embedded/api/api.service';
|
||||||
import { CustomMdIconRegistry, SVG_ICONS } from 'app/shared/custom-md-icon-registry';
|
import { CustomMdIconRegistry, SVG_ICONS } from 'app/shared/custom-md-icon-registry';
|
||||||
|
import { Deployment } from 'app/shared/deployment.service';
|
||||||
import { DocViewerComponent } from 'app/layout/doc-viewer/doc-viewer.component';
|
import { DocViewerComponent } from 'app/layout/doc-viewer/doc-viewer.component';
|
||||||
import { DtComponent } from 'app/layout/doc-viewer/dt.component';
|
import { DtComponent } from 'app/layout/doc-viewer/dt.component';
|
||||||
|
import { ModeBannerComponent } from 'app/layout/mode-banner/mode-banner.component';
|
||||||
import { EmbeddedModule } from 'app/embedded/embedded.module';
|
import { EmbeddedModule } from 'app/embedded/embedded.module';
|
||||||
import { GaService } from 'app/shared/ga.service';
|
import { GaService } from 'app/shared/ga.service';
|
||||||
import { Logger } from 'app/shared/logger.service';
|
import { Logger } from 'app/shared/logger.service';
|
||||||
@ -90,14 +92,16 @@ export const svgIconProviders = [
|
|||||||
DocViewerComponent,
|
DocViewerComponent,
|
||||||
DtComponent,
|
DtComponent,
|
||||||
FooterComponent,
|
FooterComponent,
|
||||||
TopMenuComponent,
|
ModeBannerComponent,
|
||||||
NavMenuComponent,
|
NavMenuComponent,
|
||||||
NavItemComponent,
|
NavItemComponent,
|
||||||
SearchResultsComponent,
|
SearchResultsComponent,
|
||||||
SearchBoxComponent,
|
SearchBoxComponent,
|
||||||
|
TopMenuComponent,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
ApiService,
|
ApiService,
|
||||||
|
Deployment,
|
||||||
DocumentService,
|
DocumentService,
|
||||||
GaService,
|
GaService,
|
||||||
Logger,
|
Logger,
|
||||||
|
16
aio/src/app/layout/mode-banner/mode-banner.component.ts
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import { Component, Input } from '@angular/core';
|
||||||
|
import { VersionInfo } from 'app/navigation/navigation.service';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'aio-mode-banner',
|
||||||
|
template: `
|
||||||
|
<div *ngIf="mode == 'archive'" class="mode-banner">
|
||||||
|
This is the <strong>archived documentation for Angular v{{version?.major}}.</strong>
|
||||||
|
Please visit <a href="https://angular.io/">angular.io</a> to see documentation for the current version of Angular.
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
})
|
||||||
|
export class ModeBannerComponent {
|
||||||
|
@Input() mode: string;
|
||||||
|
@Input() version: VersionInfo;
|
||||||
|
}
|
32
aio/src/app/shared/deployment.service.spec.ts
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import { ReflectiveInjector } from '@angular/core';
|
||||||
|
import { environment } from 'environments/environment';
|
||||||
|
import { LocationService } from 'app/shared/location.service';
|
||||||
|
import { MockLocationService } from 'testing/location.service';
|
||||||
|
import { Deployment } from './deployment.service';
|
||||||
|
|
||||||
|
describe('Deployment service', () => {
|
||||||
|
describe('mode', () => {
|
||||||
|
it('should get the mode from the environment', () => {
|
||||||
|
environment.mode = 'foo';
|
||||||
|
const deployment = getInjector().get(Deployment);
|
||||||
|
expect(deployment.mode).toEqual('foo');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should get the mode from the `mode` query parameter if available', () => {
|
||||||
|
const injector = getInjector();
|
||||||
|
|
||||||
|
const locationService: MockLocationService = injector.get(LocationService);
|
||||||
|
locationService.search.and.returnValue({ mode: 'bar' });
|
||||||
|
|
||||||
|
const deployment = injector.get(Deployment);
|
||||||
|
expect(deployment.mode).toEqual('bar');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function getInjector() {
|
||||||
|
return ReflectiveInjector.resolveAndCreate([
|
||||||
|
Deployment,
|
||||||
|
{ provide: LocationService, useFactory: () => new MockLocationService('') }
|
||||||
|
]);
|
||||||
|
}
|
17
aio/src/app/shared/deployment.service.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import { LocationService } from 'app/shared/location.service';
|
||||||
|
import { environment } from 'environments/environment';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Information about the deployment of this application.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class Deployment {
|
||||||
|
/**
|
||||||
|
* The deployment mode set from the environment provided at build time;
|
||||||
|
* or overridden by the `mode` query parameter: e.g. `...?mode=archive`
|
||||||
|
*/
|
||||||
|
mode: string = this.location.search()['mode'] || environment.mode;
|
||||||
|
|
||||||
|
constructor(private location: LocationService) {}
|
||||||
|
};
|
@ -55,6 +55,10 @@ export class LocationService {
|
|||||||
window.location.assign(url);
|
window.location.assign(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
replace(url: string) {
|
||||||
|
window.location.replace(url);
|
||||||
|
}
|
||||||
|
|
||||||
private stripSlashes(url: string) {
|
private stripSlashes(url: string) {
|
||||||
return url.replace(/^\/+/, '').replace(/\/+(\?|#|$)/, '$1');
|
return url.replace(/^\/+/, '').replace(/\/+(\?|#|$)/, '$1');
|
||||||
}
|
}
|
||||||
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 3.0 KiB |
Before Width: | Height: | Size: 113 KiB After Width: | Height: | Size: 113 KiB |
Before Width: | Height: | Size: 178 KiB After Width: | Height: | Size: 178 KiB |
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 4.0 KiB |
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 59 KiB |
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 3.8 KiB |
Before Width: | Height: | Size: 80 KiB After Width: | Height: | Size: 80 KiB |
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 28 KiB |
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 12 KiB |
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 17 KiB |
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
Before Width: | Height: | Size: 1.7 MiB After Width: | Height: | Size: 1.7 MiB |
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 26 KiB |
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 21 KiB |
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 24 KiB |
Before Width: | Height: | Size: 519 B After Width: | Height: | Size: 519 B |
Before Width: | Height: | Size: 5.3 KiB After Width: | Height: | Size: 5.3 KiB |
Before Width: | Height: | Size: 213 B After Width: | Height: | Size: 213 B |
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 2.4 KiB |
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 3.0 KiB |
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 3.9 KiB |
Before Width: | Height: | Size: 481 B After Width: | Height: | Size: 481 B |
Before Width: | Height: | Size: 7.0 KiB After Width: | Height: | Size: 7.0 KiB |
Before Width: | Height: | Size: 4.6 KiB After Width: | Height: | Size: 4.6 KiB |
Before Width: | Height: | Size: 6.4 KiB After Width: | Height: | Size: 6.4 KiB |
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 16 KiB |
Before Width: | Height: | Size: 131 KiB After Width: | Height: | Size: 131 KiB |
Before Width: | Height: | Size: 43 KiB After Width: | Height: | Size: 43 KiB |
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 2.3 KiB |
Before Width: | Height: | Size: 4.6 KiB After Width: | Height: | Size: 4.6 KiB |
Before Width: | Height: | Size: 422 B After Width: | Height: | Size: 422 B |
Before Width: | Height: | Size: 783 B After Width: | Height: | Size: 783 B |
Before Width: | Height: | Size: 452 B After Width: | Height: | Size: 452 B |
Before Width: | Height: | Size: 864 B After Width: | Height: | Size: 864 B |
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.6 KiB |
Before Width: | Height: | Size: 4.6 KiB After Width: | Height: | Size: 4.6 KiB |
Before Width: | Height: | Size: 9.6 KiB After Width: | Height: | Size: 9.6 KiB |
Before Width: | Height: | Size: 782 B After Width: | Height: | Size: 782 B |
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.2 KiB |
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.7 KiB |
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 3.0 KiB |