Skip to content

Commit

Permalink
feat(rating): enable form integration
Browse files Browse the repository at this point in the history
BREAKING CHANGES: event emitter behind the 'rateChange' output emits asynchronously now

Fixes #1087 
Closes #1097
  • Loading branch information
maxokorokov committed Dec 13, 2016
1 parent 97f116f commit c090a5a
Show file tree
Hide file tree
Showing 6 changed files with 220 additions and 40 deletions.
16 changes: 16 additions & 0 deletions demo/src/app/components/rating/demos/form/rating-form.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<p>NgModel and reactive forms can be used without the 'rate' binding</p>

<div class="form-group" [class.has-success]="ctrl.valid" [class.has-danger]="ctrl.invalid">
<ngb-rating [formControl]="ctrl"></ngb-rating>
<div class="form-control-feedback">
<div *ngIf="ctrl.valid">Thanks!</div>
<div *ngIf="ctrl.errors">Please rate us</div>
</div>
</div>

<hr>
<pre>Model: <b>{{ ctrl.value }}</b></pre>
<button class="btn btn-sm btn-outline-{{ ctrl.disabled ? 'danger' : 'success'}}" (click)="toggle()">
{{ ctrl.disabled ? "control disabled" : " control enabled" }}
</button>
<button class="btn btn-sm btn-outline-primary" (click)="ctrl.setValue(null)">Clear</button>
18 changes: 18 additions & 0 deletions demo/src/app/components/rating/demos/form/rating-form.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import {Component} from '@angular/core';
import {FormControl, Validators} from '@angular/forms';

@Component({
selector: 'ngbd-rating-form',
templateUrl: './rating-form.html'
})
export class NgbdRatingForm {
ctrl = new FormControl(null, Validators.required);

toggle() {
if (this.ctrl.disabled) {
this.ctrl.enable();
} else {
this.ctrl.disable();
}
}
}
7 changes: 6 additions & 1 deletion demo/src/app/components/rating/demos/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@ import {NgbdRatingConfig} from './config/rating-config';
import {NgbdRatingTemplate} from './template/rating-template';
import {NgbdRatingEvents} from './events/rating-events';
import {NgbdRatingDecimal} from './decimal/rating-decimal';
import {NgbdRatingForm} from './form/rating-form';

export const DEMO_DIRECTIVES = [NgbdRatingBasic, NgbdRatingConfig,
NgbdRatingTemplate, NgbdRatingEvents, NgbdRatingDecimal];
NgbdRatingTemplate, NgbdRatingEvents, NgbdRatingDecimal, NgbdRatingForm];

export const DEMO_SNIPPETS = {
basic: {
Expand All @@ -24,6 +25,10 @@ export const DEMO_SNIPPETS = {
code: require('!!prismjs-loader?lang=typescript!./decimal/rating-decimal'),
markup: require('!!prismjs-loader?lang=markup!./decimal/rating-decimal.html')
},
form: {
code: require('!!prismjs-loader?lang=typescript!./form/rating-form'),
markup: require('!!prismjs-loader?lang=markup!./form/rating-form.html')
},
config: {
code: require('!!prismjs-loader?lang=typescript!./config/rating-config'),
markup: require('!!prismjs-loader?lang=markup!./config/rating-config.html')
Expand Down
3 changes: 3 additions & 0 deletions demo/src/app/components/rating/rating.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@
<ngbd-example-box demoTitle="Custom decimal rating" [snippets]="snippets" component="rating" demo="decimal">
<ngbd-rating-decimal></ngbd-rating-decimal>
</ngbd-example-box>
<ngbd-example-box demoTitle="Form integration" [snippets]="snippets" component="rating" demo="form">
<ngbd-rating-form></ngbd-rating-form>
</ngbd-example-box>
<ngbd-example-box demoTitle="Global configuration of ratings" [snippets]="snippets" component="rating" demo="config">
<ngbd-rating-config></ngbd-rating-config>
</ngbd-example-box>
Expand Down
173 changes: 143 additions & 30 deletions src/rating/rating.spec.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import {TestBed, ComponentFixture, inject} from '@angular/core/testing';
import {TestBed, ComponentFixture, inject, async, fakeAsync, tick} from '@angular/core/testing';
import {createGenericTestComponent} from '../test/common';

import {Component, DebugElement} from '@angular/core';
import {FormsModule, ReactiveFormsModule, FormGroup, FormControl, Validators} from '@angular/forms';

import {NgbRatingModule} from './rating.module';
import {NgbRating} from './rating';
Expand Down Expand Up @@ -54,12 +55,14 @@ function getStateText(compiled) {
}

describe('ngb-rating', () => {
beforeEach(
() => { TestBed.configureTestingModule({declarations: [TestComponent], imports: [NgbRatingModule.forRoot()]}); });
beforeEach(() => {
TestBed.configureTestingModule(
{declarations: [TestComponent], imports: [NgbRatingModule.forRoot(), FormsModule, ReactiveFormsModule]});
});

it('should initialize inputs with default values', () => {
const defaultConfig = new NgbRatingConfig();
const rating = new NgbRating(new NgbRatingConfig());
const rating = new NgbRating(new NgbRatingConfig(), null);
expect(rating.max).toBe(defaultConfig.max);
expect(rating.readonly).toBe(defaultConfig.readonly);
});
Expand Down Expand Up @@ -108,32 +111,33 @@ describe('ngb-rating', () => {
expect(getState(compiled)).toEqual([true, true, true, true, true]);
});

it('handles correctly the click event', () => {
const fixture = createTestComponent('<ngb-rating [(rate)]="rate" max="5"></ngb-rating>');
const el = fixture.debugElement;
const rating = el.query(By.directive(NgbRating)).children[0];

// 3/5
expect(getState(el)).toEqual([true, true, true, false, false]);

// enter 2 -> 2/5, rate = 3
getDbgStar(el, 2).triggerEventHandler('mouseenter', {});
fixture.detectChanges();
expect(getState(el)).toEqual([true, true, false, false, false]);
expect(fixture.componentInstance.rate).toBe(3);

// click 2 -> 2/5, rate = 2
getStar(el.nativeElement, 2).click();
fixture.detectChanges();
expect(getState(el)).toEqual([true, true, false, false, false]);
expect(fixture.componentInstance.rate).toBe(2);

// leave 2 -> 2/5, rate = 2
rating.triggerEventHandler('mouseleave', {});
fixture.detectChanges();
expect(getState(el)).toEqual([true, true, false, false, false]);
expect(fixture.componentInstance.rate).toBe(2);
});
it('handles correctly the click event', fakeAsync(() => {
const fixture = createTestComponent('<ngb-rating [(rate)]="rate" max="5"></ngb-rating>');
const el = fixture.debugElement;
const rating = el.query(By.directive(NgbRating)).children[0];

// 3/5
expect(getState(el)).toEqual([true, true, true, false, false]);

// enter 2 -> 2/5, rate = 3
getDbgStar(el, 2).triggerEventHandler('mouseenter', {});
fixture.detectChanges();
expect(getState(el)).toEqual([true, true, false, false, false]);
expect(fixture.componentInstance.rate).toBe(3);

// click 2 -> 2/5, rate = 2
getStar(el.nativeElement, 2).click();
fixture.detectChanges();
tick();
expect(getState(el)).toEqual([true, true, false, false, false]);
expect(fixture.componentInstance.rate).toBe(2);

// leave 2 -> 2/5, rate = 2
rating.triggerEventHandler('mouseleave', {});
fixture.detectChanges();
expect(getState(el)).toEqual([true, true, false, false, false]);
expect(fixture.componentInstance.rate).toBe(2);
}));

it('ignores the click event on a readonly rating', () => {
const fixture = createTestComponent('<ngb-rating [(rate)]="rate" max="5" [readonly]="true"></ngb-rating>');
Expand Down Expand Up @@ -418,6 +422,113 @@ describe('ngb-rating', () => {
});
});

describe('forms', () => {

it('should work with template-driven form validation', async(() => {
const html = `
<form>
<ngb-rating [(ngModel)]="model" name="control" max="5" required></ngb-rating>
</form>`;

const fixture = createTestComponent(html);
const element = fixture.debugElement.query(By.directive(NgbRating));

fixture.detectChanges();
fixture.whenStable()
.then(() => {
fixture.detectChanges();
expect(getState(element.nativeElement)).toEqual([false, false, false, false, false]);
expect(element.nativeElement).toHaveCssClass('ng-invalid');

fixture.componentInstance.model = 1;
fixture.detectChanges();
return fixture.whenStable();
})
.then(() => {
fixture.detectChanges();
expect(getState(element.nativeElement)).toEqual([true, false, false, false, false]);
expect(element.nativeElement).toHaveCssClass('ng-valid');

fixture.componentInstance.model = 0;
fixture.detectChanges();
return fixture.whenStable();
})
.then(() => {
fixture.detectChanges();
expect(getState(element.nativeElement)).toEqual([false, false, false, false, false]);
expect(element.nativeElement).toHaveCssClass('ng-valid');
});
}));

it('should work with reactive form validation', () => {
const html = `
<form [formGroup]="form">
<ngb-rating formControlName="rating" max="5"></ngb-rating>
</form>`;

const fixture = createTestComponent(html);
const element = fixture.debugElement.query(By.directive(NgbRating));

expect(getState(element.nativeElement)).toEqual([false, false, false, false, false]);
expect(element.nativeElement).toHaveCssClass('ng-invalid');

fixture.componentInstance.form.patchValue({rating: 3});
fixture.detectChanges();
expect(getState(element.nativeElement)).toEqual([true, true, true, false, false]);
expect(element.nativeElement).toHaveCssClass('ng-valid');

fixture.componentInstance.form.patchValue({rating: 0});
fixture.detectChanges();
expect(getState(element.nativeElement)).toEqual([false, false, false, false, false]);
expect(element.nativeElement).toHaveCssClass('ng-valid');
});

it('should handle clicks and update form control', () => {
const html = `
<form [formGroup]="form">
<ngb-rating formControlName="rating" max="5"></ngb-rating>
</form>`;

const fixture = createTestComponent(html);
const element = fixture.debugElement.query(By.directive(NgbRating));

expect(getState(element.nativeElement)).toEqual([false, false, false, false, false]);
expect(element.nativeElement).toHaveCssClass('ng-invalid');

getStar(element.nativeElement, 3).click();
fixture.detectChanges();
expect(getState(element.nativeElement)).toEqual([true, true, true, false, false]);
expect(element.nativeElement).toHaveCssClass('ng-valid');
});

it('should work with both rate input and form control', fakeAsync(() => {
const html = `
<form [formGroup]="form">
<ngb-rating [(rate)]="rate" formControlName="rating" max="5"></ngb-rating>
</form>`;

const fixture = createTestComponent(html);
const element = fixture.debugElement.query(By.directive(NgbRating));

expect(getState(element.nativeElement)).toEqual([false, false, false, false, false]);
expect(element.nativeElement).toHaveCssClass('ng-invalid');

getStar(element.nativeElement, 2).click();
fixture.detectChanges();
tick();
expect(getState(element.nativeElement)).toEqual([true, true, false, false, false]);
expect(fixture.componentInstance.rate).toBe(2);
expect(element.nativeElement).toHaveCssClass('ng-valid');

fixture.componentInstance.rate = 4;
fixture.detectChanges();
tick();
expect(getState(element.nativeElement)).toEqual([true, true, true, true, false]);
expect(fixture.componentInstance.form.get('rating').value).toBe(4);
expect(element.nativeElement).toHaveCssClass('ng-valid');
}));
});

describe('Custom config', () => {
let config: NgbRatingConfig;

Expand Down Expand Up @@ -462,6 +573,8 @@ describe('ngb-rating', () => {

@Component({selector: 'test-cmp', template: ''})
class TestComponent {
form = new FormGroup({rating: new FormControl(null, Validators.required)});
max = 10;
model;
rate = 3;
}
43 changes: 34 additions & 9 deletions src/rating/rating.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,13 @@ import {
TemplateRef,
OnChanges,
SimpleChanges,
ContentChild
ContentChild,
forwardRef,
ChangeDetectorRef
} from '@angular/core';
import {NgbRatingConfig} from './rating-config';
import {toString, getValueInRange} from '../util/util';
import {ControlValueAccessor, NG_VALUE_ACCESSOR} from '@angular/forms';

enum Key {
End = 35,
Expand All @@ -32,6 +35,12 @@ export interface StarTemplateContext {
fill: number;
}

const NGB_RATING_VALUE_ACCESSOR = {
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => NgbRating),
multi: true
};

/**
* Rating directive that will take care of visualising a star rating bar.
*/
Expand All @@ -51,10 +60,11 @@ export interface StarTemplateContext {
</span>
</template>
</span>
`
`,
providers: [NGB_RATING_VALUE_ACCESSOR]
})
export class NgbRating implements OnInit,
OnChanges {
export class NgbRating implements ControlValueAccessor,
OnInit, OnChanges {
private _oldRate: number;
range: number[] = [];

Expand Down Expand Up @@ -95,9 +105,12 @@ export class NgbRating implements OnInit,
* An event fired when a user selects a new rating.
* Event's payload equals to the newly selected rating.
*/
@Output() rateChange = new EventEmitter<number>();
@Output() rateChange = new EventEmitter<number>(true);

onChange = (_: any) => {};
onTouched = () => {};

constructor(config: NgbRatingConfig) {
constructor(config: NgbRatingConfig, private _changeDetectorRef: ChangeDetectorRef) {
this.max = config.max;
this.readonly = config.readonly;
}
Expand Down Expand Up @@ -149,26 +162,38 @@ export class NgbRating implements OnInit,

ngOnChanges(changes: SimpleChanges) {
if (changes['rate']) {
this.update(this.rate);
this._oldRate = this.rate;
}
}

ngOnInit(): void { this.range = Array.from({length: this.max}, (v, k) => k + 1); }

registerOnChange(fn: (value: any) => any): void { this.onChange = fn; }

registerOnTouched(fn: () => any): void { this.onTouched = fn; }

reset(): void {
this.leave.emit(this.rate);
this.rate = this._oldRate;
}

update(value: number): void {
update(value: number, internalChange = true): void {
if (!this.readonly) {
const newRate = getValueInRange(value, this.max, 0);

const newRate = value ? getValueInRange(value, this.max, 0) : 0;
if (this._oldRate !== newRate) {
this._oldRate = newRate;
this.rate = newRate;
this.rateChange.emit(newRate);
if (internalChange) {
this.onChange(this.rate);
}
}
}
}

writeValue(value) {
this.update(value, false);
this._changeDetectorRef.markForCheck();
}
}

0 comments on commit c090a5a

Please sign in to comment.