diff --git a/packages/common/src/common.ts b/packages/common/src/common.ts index 1ad85ea133..1bfa538ce0 100644 --- a/packages/common/src/common.ts +++ b/packages/common/src/common.ts @@ -21,7 +21,7 @@ export {parseCookieValue as ɵparseCookieValue} from './cookie'; export {CommonModule, DeprecatedI18NPipesModule} from './common_module'; export {NgClass, NgForOf, NgForOfContext, NgIf, NgIfContext, NgPlural, NgPluralCase, NgStyle, NgSwitch, NgSwitchCase, NgSwitchDefault, NgTemplateOutlet, NgComponentOutlet} from './directives/index'; export {DOCUMENT} from './dom_tokens'; -export {AsyncPipe, DatePipe, I18nPluralPipe, I18nSelectPipe, JsonPipe, LowerCasePipe, CurrencyPipe, DecimalPipe, PercentPipe, SlicePipe, UpperCasePipe, TitleCasePipe} from './pipes/index'; +export {AsyncPipe, DatePipe, I18nPluralPipe, I18nSelectPipe, JsonPipe, LowerCasePipe, CurrencyPipe, DecimalPipe, PercentPipe, SlicePipe, UpperCasePipe, TitleCasePipe, KeyValuePipe, KeyValue} from './pipes/index'; export {DeprecatedDatePipe, DeprecatedCurrencyPipe, DeprecatedDecimalPipe, DeprecatedPercentPipe} from './pipes/deprecated/index'; export {PLATFORM_BROWSER_ID as ɵPLATFORM_BROWSER_ID, PLATFORM_SERVER_ID as ɵPLATFORM_SERVER_ID, PLATFORM_WORKER_APP_ID as ɵPLATFORM_WORKER_APP_ID, PLATFORM_WORKER_UI_ID as ɵPLATFORM_WORKER_UI_ID, isPlatformBrowser, isPlatformServer, isPlatformWorkerApp, isPlatformWorkerUi} from './platform_id'; export {VERSION} from './version'; diff --git a/packages/common/src/pipes/index.ts b/packages/common/src/pipes/index.ts index 7014c6275d..9dc1e8d10a 100644 --- a/packages/common/src/pipes/index.ts +++ b/packages/common/src/pipes/index.ts @@ -17,6 +17,7 @@ import {DatePipe} from './date_pipe'; import {I18nPluralPipe} from './i18n_plural_pipe'; import {I18nSelectPipe} from './i18n_select_pipe'; import {JsonPipe} from './json_pipe'; +import {KeyValue, KeyValuePipe} from './keyvalue_pipe'; import {CurrencyPipe, DecimalPipe, PercentPipe} from './number_pipe'; import {SlicePipe} from './slice_pipe'; @@ -25,6 +26,8 @@ export { CurrencyPipe, DatePipe, DecimalPipe, + KeyValue, + KeyValuePipe, I18nPluralPipe, I18nSelectPipe, JsonPipe, @@ -32,7 +35,7 @@ export { PercentPipe, SlicePipe, TitleCasePipe, - UpperCasePipe + UpperCasePipe, }; @@ -52,4 +55,5 @@ export const COMMON_PIPES = [ DatePipe, I18nPluralPipe, I18nSelectPipe, + KeyValuePipe, ]; diff --git a/packages/common/src/pipes/keyvalue_pipe.ts b/packages/common/src/pipes/keyvalue_pipe.ts new file mode 100644 index 0000000000..0c58273af7 --- /dev/null +++ b/packages/common/src/pipes/keyvalue_pipe.ts @@ -0,0 +1,110 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {KeyValueChangeRecord, KeyValueChanges, KeyValueDiffer, KeyValueDiffers, Pipe, PipeTransform} from '@angular/core'; + +function makeKeyValuePair(key: K, value: V): KeyValue { + return {key: key, value: value}; +} + +/** + * A key value pair. + * Usually used to represent the key value pairs from a Map or Object. + */ +export interface KeyValue { + key: K; + value: V; +} + +/** + * @ngModule CommonModule + * @description + * + * Transforms Object or Map into an array of key value pairs. + * + * The output array will be ordered by keys. + * By default the comparator will be by Unicode point value. + * You can optionally pass a compareFn if your keys are complex types. + * + * ## Examples + * + * This examples show how an Object or a Map and be iterated by ngFor with the use of this keyvalue + * pipe. + * + * {@example common/pipes/ts/keyvalue_pipe.ts region='KeyValuePipe'} + */ +@Pipe({name: 'keyvalue', pure: false}) +export class KeyValuePipe implements PipeTransform { + constructor(private readonly differs: KeyValueDiffers) {} + + private differ: KeyValueDiffer; + private keyValues: Array>; + + transform(input: null, compareFn?: (a: KeyValue, b: KeyValue) => number): null; + transform( + input: {[key: string]: V}|Map, + compareFn?: (a: KeyValue, b: KeyValue) => number): + Array>; + transform( + input: {[key: number]: V}|Map, + compareFn?: (a: KeyValue, b: KeyValue) => number): + Array>; + transform(input: Map, compareFn?: (a: KeyValue, b: KeyValue) => number): + Array>; + transform( + input: null|{[key: string]: V, [key: number]: V}|Map, + compareFn: (a: KeyValue, b: KeyValue) => number = defaultComparator): + Array>|null { + if (!input || (!(input instanceof Map) && typeof input !== 'object')) { + return null; + } + + if (!this.differ) { + // make a differ for whatever type we've been passed in + this.differ = this.differs.find(input).create(); + } + + const differChanges: KeyValueChanges|null = this.differ.diff(input as any); + + if (differChanges) { + this.keyValues = []; + differChanges.forEachItem((r: KeyValueChangeRecord) => { + this.keyValues.push(makeKeyValuePair(r.key, r.currentValue !)); + }); + this.keyValues.sort(compareFn); + } + return this.keyValues; + } +} + +export function defaultComparator( + keyValueA: KeyValue, keyValueB: KeyValue): number { + const a = keyValueA.key; + const b = keyValueB.key; + // if same exit with 0; + if (a === b) return 0; + // make sure that undefined are at the end of the sort. + if (a === undefined) return 1; + if (b === undefined) return -1; + // make sure that nulls are at the end of the sort. + if (a === null) return 1; + if (b === null) return -1; + if (typeof a == 'string' && typeof b == 'string') { + return a < b ? -1 : 1; + } + if (typeof a == 'number' && typeof b == 'number') { + return a - b; + } + if (typeof a == 'boolean' && typeof b == 'boolean') { + return a < b ? -1 : 1; + } + // `a` and `b` are of different types. Compare their string values. + const aString = String(a); + const bString = String(b); + return aString == bString ? 0 : aString < bString ? -1 : 1; +} diff --git a/packages/common/test/pipes/keyvalue_pipe_spec.ts b/packages/common/test/pipes/keyvalue_pipe_spec.ts new file mode 100644 index 0000000000..484531dd9e --- /dev/null +++ b/packages/common/test/pipes/keyvalue_pipe_spec.ts @@ -0,0 +1,152 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {KeyValuePipe} from '@angular/common'; +import {EventEmitter, KeyValueDiffers, WrappedValue, ɵdefaultKeyValueDiffers as defaultKeyValueDiffers} from '@angular/core'; +import {AsyncTestCompleter, beforeEach, describe, expect, inject, it} from '@angular/core/testing/src/testing_internal'; +import {getDOM} from '@angular/platform-browser/src/dom/dom_adapter'; +import {browserDetection} from '@angular/platform-browser/testing/src/browser_util'; + +import {defaultComparator} from '../../src/pipes/keyvalue_pipe'; +import {SpyChangeDetectorRef} from '../spies'; + +describe('KeyValuePipe', () => { + it('should return null when given null', () => { + const pipe = new KeyValuePipe(defaultKeyValueDiffers); + expect(pipe.transform(null)).toEqual(null); + }); + it('should return null when given undefined', () => { + const pipe = new KeyValuePipe(defaultKeyValueDiffers); + expect(pipe.transform(undefined as any)).toEqual(null); + }); + it('should return null for an unsupported type', () => { + const pipe = new KeyValuePipe(defaultKeyValueDiffers); + const fn = () => {}; + expect(pipe.transform(fn as any)).toEqual(null); + }); + describe('object dictionary', () => { + it('should transform a basic dictionary', () => { + const pipe = new KeyValuePipe(defaultKeyValueDiffers); + expect(pipe.transform({1: 2})).toEqual([{key: '1', value: 2}]); + }); + it('should order by alpha', () => { + const pipe = new KeyValuePipe(defaultKeyValueDiffers); + expect(pipe.transform({'b': 1, 'a': 1})).toEqual([ + {key: 'a', value: 1}, {key: 'b', value: 1} + ]); + }); + it('should order by numerical', () => { + const pipe = new KeyValuePipe(defaultKeyValueDiffers); + expect(pipe.transform({2: 1, 1: 1})).toEqual([{key: '1', value: 1}, {key: '2', value: 1}]); + }); + it('should order by numerical and alpha', () => { + const pipe = new KeyValuePipe(defaultKeyValueDiffers); + const input = {2: 1, 1: 1, 'b': 1, 0: 1, 3: 1, 'a': 1}; + expect(pipe.transform(input)).toEqual([ + {key: '0', value: 1}, {key: '1', value: 1}, {key: '2', value: 1}, {key: '3', value: 1}, + {key: 'a', value: 1}, {key: 'b', value: 1} + ]); + }); + it('should return the same ref if nothing changes', () => { + const pipe = new KeyValuePipe(defaultKeyValueDiffers); + const transform1 = pipe.transform({1: 2}); + const transform2 = pipe.transform({1: 2}); + expect(transform1 === transform2).toEqual(true); + }); + it('should return a new ref if something changes', () => { + const pipe = new KeyValuePipe(defaultKeyValueDiffers); + const transform1 = pipe.transform({1: 2}); + const transform2 = pipe.transform({1: 3}); + expect(transform1 !== transform2).toEqual(true); + }); + }); + + describe('Map', () => { + it('should transform a basic Map', () => { + const pipe = new KeyValuePipe(defaultKeyValueDiffers); + expect(pipe.transform(new Map([[1, 2]]))).toEqual([{key: 1, value: 2}]); + }); + it('should order by alpha', () => { + const pipe = new KeyValuePipe(defaultKeyValueDiffers); + expect(pipe.transform(new Map([['b', 1], ['a', 1]]))).toEqual([ + {key: 'a', value: 1}, {key: 'b', value: 1} + ]); + }); + it('should order by numerical', () => { + const pipe = new KeyValuePipe(defaultKeyValueDiffers); + expect(pipe.transform(new Map([[2, 1], [1, 1]]))).toEqual([ + {key: 1, value: 1}, {key: 2, value: 1} + ]); + }); + it('should order by numerical and alpha', () => { + const pipe = new KeyValuePipe(defaultKeyValueDiffers); + const input = [[2, 1], [1, 1], ['b', 1], [0, 1], [3, 1], ['a', 1]]; + expect(pipe.transform(new Map(input as any))).toEqual([ + {key: 0, value: 1}, {key: 1, value: 1}, {key: 2, value: 1}, {key: 3, value: 1}, + {key: 'a', value: 1}, {key: 'b', value: 1} + ]); + }); + it('should order by complex types with compareFn', () => { + const pipe = new KeyValuePipe(defaultKeyValueDiffers); + const input = new Map([[{id: 1}, 1], [{id: 0}, 1]]); + expect(pipe.transform<{id: number}, number>(input, (a, b) => a.key.id > b.key.id ? 1 : -1)) + .toEqual([ + {key: {id: 0}, value: 1}, + {key: {id: 1}, value: 1}, + ]); + }); + it('should return the same ref if nothing changes', () => { + const pipe = new KeyValuePipe(defaultKeyValueDiffers); + const transform1 = pipe.transform(new Map([[1, 2]])); + const transform2 = pipe.transform(new Map([[1, 2]])); + expect(transform1 === transform2).toEqual(true); + }); + it('should return a new ref if something changes', () => { + const pipe = new KeyValuePipe(defaultKeyValueDiffers); + const transform1 = pipe.transform(new Map([[1, 2]])); + const transform2 = pipe.transform(new Map([[1, 3]])); + expect(transform1 !== transform2).toEqual(true); + }); + }); +}); + +describe('defaultComparator', () => { + it('should remain the same order when keys are equal', () => { + const key = 1; + const values = [{key, value: 2}, {key, value: 1}]; + expect(values.sort(defaultComparator)).toEqual(values); + }); + it('should sort undefined keys to the end', () => { + const values = [{key: 3, value: 1}, {key: undefined, value: 3}, {key: 1, value: 2}]; + expect(values.sort(defaultComparator)).toEqual([ + {key: 1, value: 2}, {key: 3, value: 1}, {key: undefined, value: 3} + ]); + }); + it('should sort null keys to the end', () => { + const values = [{key: 3, value: 1}, {key: null, value: 3}, {key: 1, value: 2}]; + expect(values.sort(defaultComparator)).toEqual([ + {key: 1, value: 2}, {key: 3, value: 1}, {key: null, value: 3} + ]); + }); + it('should sort strings in alpha ascending', () => { + const values = [{key: 'b', value: 1}, {key: 'a', value: 3}]; + expect(values.sort(defaultComparator)).toEqual([{key: 'a', value: 3}, {key: 'b', value: 1}]); + }); + it('should sort numbers in numerical ascending', () => { + const values = [{key: 2, value: 1}, {key: 1, value: 3}]; + expect(values.sort(defaultComparator)).toEqual([{key: 1, value: 3}, {key: 2, value: 1}]); + }); + it('should sort boolean in false (0) -> true (1)', () => { + const values = [{key: true, value: 3}, {key: false, value: 1}]; + expect(values.sort(defaultComparator)).toEqual([{key: false, value: 1}, {key: true, value: 3}]); + }); + it('should sort numbers as strings in numerical ascending', () => { + const values = [{key: '2', value: 1}, {key: 1, value: 3}]; + expect(values.sort(defaultComparator)).toEqual([{key: 1, value: 3}, {key: '2', value: 1}]); + }); +}); diff --git a/packages/core/src/change_detection/differs/keyvalue_differs.ts b/packages/core/src/change_detection/differs/keyvalue_differs.ts index c8e35cced3..6a9807c587 100644 --- a/packages/core/src/change_detection/differs/keyvalue_differs.ts +++ b/packages/core/src/change_detection/differs/keyvalue_differs.ts @@ -22,7 +22,7 @@ export interface KeyValueDiffer { * @returns an object describing the difference. The return value is only valid until the next * `diff()` invocation. */ - diff(object: Map): KeyValueChanges; + diff(object: Map): KeyValueChanges|null; /** * Compute a difference between the previous state and the new `object` state. @@ -31,7 +31,7 @@ export interface KeyValueDiffer { * @returns an object describing the difference. The return value is only valid until the next * `diff()` invocation. */ - diff(object: {[key: string]: V}): KeyValueChanges; + diff(object: {[key: string]: V}): KeyValueChanges|null; // TODO(TS2.1): diff(this: KeyValueDiffer, object: Record): // KeyValueDiffer; } diff --git a/packages/examples/common/pipes/ts/e2e_test/pipe_spec.ts b/packages/examples/common/pipes/ts/e2e_test/pipe_spec.ts index df4d1e28a8..818679e63e 100644 --- a/packages/examples/common/pipes/ts/e2e_test/pipe_spec.ts +++ b/packages/examples/common/pipes/ts/e2e_test/pipe_spec.ts @@ -57,4 +57,14 @@ describe('pipe', () => { expect(element.all(by.css('titlecase-pipe p')).get(5).getText()).toEqual('Foo-vs-bar'); }); }); + describe('titlecase', () => { + it('should work properly', () => { + browser.get(URL); + waitForElement('keyvalue-pipe'); + expect(element.all(by.css('keyvalue-pipe div')).get(0).getText()).toEqual('1:bar'); + expect(element.all(by.css('keyvalue-pipe div')).get(1).getText()).toEqual('2:foo'); + expect(element.all(by.css('keyvalue-pipe div')).get(2).getText()).toEqual('1:bar'); + expect(element.all(by.css('keyvalue-pipe div')).get(3).getText()).toEqual('2:foo'); + }); + }); }); diff --git a/packages/examples/common/pipes/ts/keyvalue_pipe.ts b/packages/examples/common/pipes/ts/keyvalue_pipe.ts new file mode 100644 index 0000000000..3f482a3b3e --- /dev/null +++ b/packages/examples/common/pipes/ts/keyvalue_pipe.ts @@ -0,0 +1,29 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {Component} from '@angular/core'; + +// #docregion KeyValuePipe +@Component({ + selector: 'keyvalue-pipe', + template: ` +

Object

+
+ {{item.key}}:{{item.value}} +
+

Map

+
+ {{item.key}}:{{item.value}} +
+
` +}) +export class KeyValuePipeComponent { + object: {[key: number]: string} = {2: 'foo', 1: 'bar'}; + map = new Map([[2, 'foo'], [1, 'bar']]); +} +// #enddocregion diff --git a/packages/examples/common/pipes/ts/module.ts b/packages/examples/common/pipes/ts/module.ts index 3c80533dc7..f21d3af8b1 100644 --- a/packages/examples/common/pipes/ts/module.ts +++ b/packages/examples/common/pipes/ts/module.ts @@ -14,6 +14,7 @@ import {CurrencyPipeComponent, DeprecatedCurrencyPipeComponent} from './currency import {DatePipeComponent, DeprecatedDatePipeComponent} from './date_pipe'; import {I18nPluralPipeComponent, I18nSelectPipeComponent} from './i18n_pipe'; import {JsonPipeComponent} from './json_pipe'; +import {KeyValuePipeComponent} from './keyvalue_pipe'; import {LowerUpperPipeComponent} from './lowerupper_pipe'; import {DeprecatedNumberPipeComponent, NumberPipeComponent} from './number_pipe'; import {DeprecatedPercentPipeComponent, PercentPipeComponent} from './percent_pipe'; @@ -31,7 +32,7 @@ import {TitleCasePipeComponent} from './titlecase_pipe';

date

- +

json

@@ -53,6 +54,9 @@ import {TitleCasePipeComponent} from './titlecase_pipe';

i18n

+ +

keyvalue

+ ` }) export class ExampleAppComponent { @@ -64,7 +68,7 @@ export class ExampleAppComponent { DatePipeComponent, DeprecatedDatePipeComponent, LowerUpperPipeComponent, TitleCasePipeComponent, NumberPipeComponent, PercentPipeComponent, DeprecatedPercentPipeComponent, CurrencyPipeComponent, DeprecatedCurrencyPipeComponent, SlicePipeStringComponent, - SlicePipeListComponent, I18nPluralPipeComponent, I18nSelectPipeComponent + SlicePipeListComponent, I18nPluralPipeComponent, I18nSelectPipeComponent, KeyValuePipeComponent ], imports: [BrowserModule], bootstrap: [ExampleAppComponent] diff --git a/tools/public_api_guard/common/common.d.ts b/tools/public_api_guard/common/common.d.ts index 26ab3bd1b2..9c77a29a94 100644 --- a/tools/public_api_guard/common/common.d.ts +++ b/tools/public_api_guard/common/common.d.ts @@ -175,6 +175,23 @@ export declare class JsonPipe implements PipeTransform { transform(value: any): string; } +export interface KeyValue { + key: K; + value: V; +} + +export declare class KeyValuePipe implements PipeTransform { + constructor(differs: KeyValueDiffers); + transform(input: null, compareFn?: (a: KeyValue, b: KeyValue) => number): null; + transform(input: { + [key: string]: V; + } | Map, compareFn?: (a: KeyValue, b: KeyValue) => number): Array>; + transform(input: { + [key: number]: V; + } | Map, compareFn?: (a: KeyValue, b: KeyValue) => number): Array>; + transform(input: Map, compareFn?: (a: KeyValue, b: KeyValue) => number): Array>; +} + export declare class Location { constructor(platformStrategy: LocationStrategy); back(): void;