Skip to content

Commit

Permalink
feat(timepicker): add custom time adapter support
Browse files Browse the repository at this point in the history
This makes the timepicker consistent with the datepicker, by allowing
to use something other than an NgbTimeStruct as the model.

fix #545

Closes #2347
  • Loading branch information
jnizet authored and pkozlowski-opensource committed Jun 22, 2018
1 parent 8d54cac commit 7eaa7e7
Show file tree
Hide file tree
Showing 10 changed files with 277 additions and 12 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<p>This timepicker uses a custom Time adapter that lets you use your own model implementation.
In this example we are converting from and to an ISO string (with the format <code>HH:mm:ss</code>)</p>

<ngb-timepicker [(ngModel)]="time"></ngb-timepicker>
<hr>
<pre>Selected time: {{ time }}</pre>
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import {Component, Injectable} from '@angular/core';
import {NgbTimeStruct, NgbTimeAdapter} from '@ng-bootstrap/ng-bootstrap';
import {NgbDateNativeAdapter} from '../../../datepicker/demos/adapter/datepicker-adapter';

/**
* Example of a String Time adapter
*/
@Injectable()
export class NgbTimeStringAdapter extends NgbTimeAdapter<string> {

fromModel(value: string): NgbTimeStruct {
if (!value) {
return null;
}
const split = value.split(':');
return {
hour: parseInt(split[0], 10),
minute: parseInt(split[1], 10),
second: parseInt(split[2], 10)
};
}

toModel(time: NgbTimeStruct): string {
if (!time) {
return null;
}
return `${this.pad(time.hour)}:${this.pad(time.minute)}:${this.pad(time.second)}`;
}

private pad(i: number): string {
return i < 10 ? `0${i}` : `${i}`;
}
}

@Component({
selector: 'ngbd-timepicker-adapter',
templateUrl: './timepicker-adapter.html',
// NOTE: For this example we are only providing current component, but probably
// NOTE: you will want to provide your main App Module
providers: [{provide: NgbTimeAdapter, useClass: NgbTimeStringAdapter}]
})
export class NgbdTimepickerAdapter {
time: '13:30:00';
}
7 changes: 6 additions & 1 deletion demo/src/app/components/timepicker/demos/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,11 @@ import {NgbdTimepickerSteps} from './steps/timepicker-steps';
import {NgbdTimepickerValidation} from './validation/timepicker-validation';
import {NgbdTimepickerSpinners} from './spinners/timepicker-spinners';
import {NgbdTimepickerConfig} from './config/timepicker-config';
import {NgbdTimepickerAdapter} from './adapter/timepicker-adapter';

export const DEMO_DIRECTIVES = [
NgbdTimepickerBasic, NgbdTimepickerMeridian, NgbdTimepickerSeconds, NgbdTimepickerSpinners, NgbdTimepickerSteps,
NgbdTimepickerValidation, NgbdTimepickerConfig
NgbdTimepickerValidation, NgbdTimepickerAdapter, NgbdTimepickerConfig
];

export const DEMO_SNIPPETS = {
Expand Down Expand Up @@ -36,6 +37,10 @@ export const DEMO_SNIPPETS = {
'code': require('!!raw-loader!./validation/timepicker-validation'),
'markup': require('!!raw-loader!./validation/timepicker-validation.html')
},
'adapter': {
'code': require('!!raw-loader!./adapter/timepicker-adapter'),
'markup': require('!!raw-loader!./adapter/timepicker-adapter.html')
},
'config': {
'code': require('!!raw-loader!./config/timepicker-config'),
'markup': require('!!raw-loader!./config/timepicker-config.html')
Expand Down
4 changes: 4 additions & 0 deletions demo/src/app/components/timepicker/timepicker.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {DEMO_SNIPPETS} from './demos';
<ngbd-component-wrapper component="Timepicker">
<ngbd-api-docs directive="NgbTimepicker"></ngbd-api-docs>
<ngbd-api-docs-class type="NgbTimeStruct"></ngbd-api-docs-class>
<ngbd-api-docs-class type="NgbTimeAdapter"></ngbd-api-docs-class>
<ngbd-api-docs-config type="NgbTimepickerConfig"></ngbd-api-docs-config>
<ngbd-example-box demoTitle="Timepicker" [snippets]="snippets" component="timepicker" demo="basic">
<ngbd-timepicker-basic></ngbd-timepicker-basic>
Expand All @@ -26,6 +27,9 @@ import {DEMO_SNIPPETS} from './demos';
<ngbd-example-box demoTitle="Custom validation" [snippets]="snippets" component="timepicker" demo="validation">
<ngbd-timepicker-validation></ngbd-timepicker-validation>
</ngbd-example-box>
<ngbd-example-box demoTitle="Custom time adapter" [snippets]="snippets" component="timepicker" demo="adapter">
<ngbd-timepicker-adapter></ngbd-timepicker-adapter>
</ngbd-example-box>
<ngbd-example-box demoTitle="Global configuration of timepickers" [snippets]="snippets" component="timepicker" demo="config">
<ngbd-timepicker-config></ngbd-timepicker-config>
</ngbd-example-box>
Expand Down
8 changes: 7 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,13 @@ export {
NgbTabContent,
NgbTabTitle
} from './tabset/tabset.module';
export {NgbTimepickerModule, NgbTimepickerConfig, NgbTimeStruct, NgbTimepicker} from './timepicker/timepicker.module';
export {
NgbTimepickerModule,
NgbTimepickerConfig,
NgbTimeStruct,
NgbTimepicker,
NgbTimeAdapter
} from './timepicker/timepicker.module';
export {NgbTooltipModule, NgbTooltipConfig, NgbTooltip} from './tooltip/tooltip.module';
export {
NgbHighlight,
Expand Down
48 changes: 48 additions & 0 deletions src/timepicker/ngb-time-adapter.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import {NgbTimeStructAdapter} from './ngb-time-adapter';

describe('ngb-time model adapter', () => {
let adapter: NgbTimeStructAdapter;

beforeEach(() => { adapter = new NgbTimeStructAdapter(); });

describe('fromModel', () => {

it('should convert invalid and incomplete values to null', () => {
expect(adapter.fromModel(null)).toBeNull();
expect(adapter.fromModel(undefined)).toBeNull();
expect(adapter.fromModel(<any>'')).toBeNull();
expect(adapter.fromModel(<any>'s')).toBeNull();
expect(adapter.fromModel(<any>2)).toBeNull();
expect(adapter.fromModel(<any>{})).toBeNull();
expect(adapter.fromModel(<any>new Date())).toBeNull();
expect(adapter.fromModel(<any>{hour: 20})).toBeNull();
});

it('should convert valid time', () => {
expect(adapter.fromModel({hour: 19, minute: 5, second: 1})).toEqual({hour: 19, minute: 5, second: 1});
expect(adapter.fromModel(<any>{hour: 19, minute: 5})).toEqual({hour: 19, minute: 5, second: null});
expect(adapter.fromModel(<any>{hour: 19, minute: 5, second: null})).toEqual({hour: 19, minute: 5, second: null});
});
});

describe('toModel', () => {

it('should convert invalid and incomplete values to null', () => {
expect(adapter.toModel(null)).toBeNull();
expect(adapter.toModel(undefined)).toBeNull();
expect(adapter.toModel(<any>'')).toBeNull();
expect(adapter.toModel(<any>'s')).toBeNull();
expect(adapter.toModel(<any>2)).toBeNull();
expect(adapter.toModel(<any>{})).toBeNull();
expect(adapter.toModel(<any>new Date())).toBeNull();
expect(adapter.toModel(<any>{hour: 20})).toBeNull();
});

it('should convert a valid time', () => {
expect(adapter.toModel({hour: 19, minute: 5, second: 1})).toEqual({hour: 19, minute: 5, second: 1});
expect(adapter.toModel(<any>{hour: 19, minute: 5})).toEqual({hour: 19, minute: 5, second: null});
expect(adapter.toModel(<any>{hour: 19, minute: 5, second: null})).toEqual({hour: 19, minute: 5, second: null});
});
});

});
52 changes: 52 additions & 0 deletions src/timepicker/ngb-time-adapter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import {Injectable} from '@angular/core';
import {NgbTimeStruct} from './ngb-time-struct';
import {isInteger} from '../util/util';

/**
* Abstract type serving as a DI token for the service converting from your application Time model to internal
* NgbTimeStruct model.
* A default implementation converting from and to NgbTimeStruct is provided for retro-compatibility,
* but you can provide another implementation to use an alternative format, ie for using with native Date Object.
*/
@Injectable()
export abstract class NgbTimeAdapter<T> {
/**
* Converts user-model date into an NgbTimeStruct for internal use in the library
* @param {any} value any value that end user uses as the time model, ie: NgbTimeStruct, Date, "HH:mm:ss"
* @return {NgbTimeStruct}
*/
abstract fromModel(value: T): NgbTimeStruct;

/**
* Converts internal time value NgbTimeStruct to user-model date
* The returned type is suposed to be of the same type as fromModel() input-value param
* @param {NgbTimeStruct} time internal NgbTimeStruct date representation
* @return {any}
*/
abstract toModel(time: NgbTimeStruct): T;
}

@Injectable()
export class NgbTimeStructAdapter extends NgbTimeAdapter<NgbTimeStruct> {
/**
* Converts a NgbTimeStruct value into NgbTimeStruct value
* @param {NgbTimeStruct} value
* @return {NgbTimeStruct}
*/
fromModel(time: NgbTimeStruct): NgbTimeStruct {
return (time && isInteger(time.hour) && isInteger(time.minute)) ?
{hour: time.hour, minute: time.minute, second: isInteger(time.second) ? time.second : null} :
null;
}

/**
* Converts a NgbTimeStruct value into NgbTimeStruct value
* @param {NgbTimeStruct} value
* @return {NgbTimeStruct}
*/
toModel(time: NgbTimeStruct): NgbTimeStruct {
return (time && isInteger(time.hour) && isInteger(time.minute)) ?
{hour: time.hour, minute: time.minute, second: isInteger(time.second) ? time.second : null} :
null;
}
}
9 changes: 8 additions & 1 deletion src/timepicker/timepicker.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,19 @@ import {CommonModule} from '@angular/common';

import {NgbTimepicker} from './timepicker';
import {NgbTimepickerConfig} from './timepicker-config';
import {NgbTimeAdapter, NgbTimeStructAdapter} from './ngb-time-adapter';

export {NgbTimepicker} from './timepicker';
export {NgbTimepickerConfig} from './timepicker-config';
export {NgbTimeStruct} from './ngb-time-struct';
export {NgbTimeAdapter} from './ngb-time-adapter';

@NgModule({declarations: [NgbTimepicker], exports: [NgbTimepicker], imports: [CommonModule]})
export class NgbTimepickerModule {
static forRoot(): ModuleWithProviders { return {ngModule: NgbTimepickerModule, providers: [NgbTimepickerConfig]}; }
static forRoot(): ModuleWithProviders {
return {
ngModule: NgbTimepickerModule,
providers: [NgbTimepickerConfig, {provide: NgbTimeAdapter, useClass: NgbTimeStructAdapter}]
};
}
}
94 changes: 92 additions & 2 deletions src/timepicker/timepicker.spec.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
import {TestBed, ComponentFixture, async, inject} from '@angular/core/testing';
import {createGenericTestComponent} from '../test/common';

import {Component} from '@angular/core';
import {Component, Injectable} from '@angular/core';
import {By} from '@angular/platform-browser';
import {Validators, FormControl, FormGroup, FormsModule, ReactiveFormsModule} from '@angular/forms';

import {NgbTimepickerModule} from './timepicker.module';
import {NgbTimepickerConfig} from './timepicker-config';
import {NgbTimepicker} from './timepicker';
import {NgbTimeAdapter, NgbTimeStructAdapter} from './ngb-time-adapter';
import {NgbTimeStruct} from './ngb-time-struct';

const createTestComponent = (html: string) =>
createGenericTestComponent(html, TestComponent) as ComponentFixture<TestComponent>;
Expand Down Expand Up @@ -83,7 +85,7 @@ describe('ngb-timepicker', () => {
describe('initialization', () => {
it('should initialize inputs with provided config', () => {
const defaultConfig = new NgbTimepickerConfig();
const timepicker = new NgbTimepicker(new NgbTimepickerConfig());
const timepicker = new NgbTimepicker(new NgbTimepickerConfig(), new NgbTimeStructAdapter());
expectSameValues(timepicker, defaultConfig);
});
});
Expand Down Expand Up @@ -1295,6 +1297,74 @@ describe('ngb-timepicker', () => {
});
}));
});

describe('Custom adapter', () => {

beforeEach(() => {
TestBed.configureTestingModule({
declarations: [TestComponent],
imports: [NgbTimepickerModule.forRoot(), FormsModule],
providers: [{provide: NgbTimeAdapter, useClass: StringTimeAdapter}]
});
});

it('should display the right time when model is a string parsed by a custom time adapter', async(() => {
const html = `<ngb-timepicker [(ngModel)]="model"></ngb-timepicker>`;
const fixture = createTestComponent(html);

fixture.componentInstance.model = null;
fixture.detectChanges();

fixture.detectChanges();
fixture.whenStable()
.then(() => {
fixture.detectChanges();
return fixture.whenStable();
})
.then(() => { expectToDisplayTime(fixture.nativeElement, ':'); })
.then(() => {
fixture.componentInstance.model = '09:25:00';
fixture.detectChanges();
return fixture.whenStable()
})
.then(() => {
fixture.detectChanges();
return fixture.whenStable();
})
.then(() => { expectToDisplayTime(fixture.nativeElement, '09:25'); });
}));

it('should write the entered value as a string formatted by a custom time adapter', () => {
const html = `<ngb-timepicker [(ngModel)]="model"></ngb-timepicker>`;

const fixture = createTestComponent(html);
fixture.componentInstance.model = null;
fixture.detectChanges();
fixture.whenStable()
.then(() => {
fixture.detectChanges();
return fixture.whenStable();
})
.then(() => {

const inputs = fixture.debugElement.queryAll(By.css('input'));
inputs[0].triggerEventHandler('change', createChangeEvent('11'));
fixture.detectChanges();
expectToDisplayTime(fixture.nativeElement, '11:');
expect(fixture.componentInstance.model).toBeNull();

inputs[1].triggerEventHandler('change', createChangeEvent('5'));
fixture.detectChanges();
expectToDisplayTime(fixture.nativeElement, '11:05');
expect(fixture.componentInstance.model).toEqual('11:05:00');

inputs[0].triggerEventHandler('change', createChangeEvent('aa'));
fixture.detectChanges();
expectToDisplayTime(fixture.nativeElement, ':05');
expect(fixture.componentInstance.model).toBeNull();
});
});
});
});


Expand All @@ -1311,3 +1381,23 @@ class TestComponent {

onSubmit() { this.submitted = true; }
}

@Injectable()
class StringTimeAdapter extends NgbTimeAdapter<string> {
fromModel(value: string): NgbTimeStruct {
if (!value) {
return null;
}
const split = value.split(':');
return {hour: parseInt(split[0], 10), minute: parseInt(split[1], 10), second: parseInt(split[2], 10)};
}

toModel(time: NgbTimeStruct): string {
if (!time) {
return null;
}
return `${this.pad(time.hour)}:${this.pad(time.minute)}:${this.pad(time.second)}`;
}

private pad(i: number): string { return i < 10 ? `0${i}` : `${i}`; }
}
Loading

0 comments on commit 7eaa7e7

Please sign in to comment.