Skip to content

Commit 2b49bf7

Browse files
Fabian Wilesmhevery
Fabian Wiles
authored andcommitted
feat(common): introduce KeyValuePipe (#24319)
PR Close #24319
1 parent 92b278c commit 2b49bf7

File tree

9 files changed

+332
-6
lines changed

9 files changed

+332
-6
lines changed

packages/common/src/common.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ export {parseCookieValue as ɵparseCookieValue} from './cookie';
2121
export {CommonModule, DeprecatedI18NPipesModule} from './common_module';
2222
export {NgClass, NgForOf, NgForOfContext, NgIf, NgIfContext, NgPlural, NgPluralCase, NgStyle, NgSwitch, NgSwitchCase, NgSwitchDefault, NgTemplateOutlet, NgComponentOutlet} from './directives/index';
2323
export {DOCUMENT} from './dom_tokens';
24-
export {AsyncPipe, DatePipe, I18nPluralPipe, I18nSelectPipe, JsonPipe, LowerCasePipe, CurrencyPipe, DecimalPipe, PercentPipe, SlicePipe, UpperCasePipe, TitleCasePipe} from './pipes/index';
24+
export {AsyncPipe, DatePipe, I18nPluralPipe, I18nSelectPipe, JsonPipe, LowerCasePipe, CurrencyPipe, DecimalPipe, PercentPipe, SlicePipe, UpperCasePipe, TitleCasePipe, KeyValuePipe, KeyValue} from './pipes/index';
2525
export {DeprecatedDatePipe, DeprecatedCurrencyPipe, DeprecatedDecimalPipe, DeprecatedPercentPipe} from './pipes/deprecated/index';
2626
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';
2727
export {VERSION} from './version';

packages/common/src/pipes/index.ts

+5-1
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {DatePipe} from './date_pipe';
1717
import {I18nPluralPipe} from './i18n_plural_pipe';
1818
import {I18nSelectPipe} from './i18n_select_pipe';
1919
import {JsonPipe} from './json_pipe';
20+
import {KeyValue, KeyValuePipe} from './keyvalue_pipe';
2021
import {CurrencyPipe, DecimalPipe, PercentPipe} from './number_pipe';
2122
import {SlicePipe} from './slice_pipe';
2223

@@ -25,14 +26,16 @@ export {
2526
CurrencyPipe,
2627
DatePipe,
2728
DecimalPipe,
29+
KeyValue,
30+
KeyValuePipe,
2831
I18nPluralPipe,
2932
I18nSelectPipe,
3033
JsonPipe,
3134
LowerCasePipe,
3235
PercentPipe,
3336
SlicePipe,
3437
TitleCasePipe,
35-
UpperCasePipe
38+
UpperCasePipe,
3639
};
3740

3841

@@ -52,4 +55,5 @@ export const COMMON_PIPES = [
5255
DatePipe,
5356
I18nPluralPipe,
5457
I18nSelectPipe,
58+
KeyValuePipe,
5559
];
+110
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
/**
2+
* @license
3+
* Copyright Google Inc. All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
9+
import {KeyValueChangeRecord, KeyValueChanges, KeyValueDiffer, KeyValueDiffers, Pipe, PipeTransform} from '@angular/core';
10+
11+
function makeKeyValuePair<K, V>(key: K, value: V): KeyValue<K, V> {
12+
return {key: key, value: value};
13+
}
14+
15+
/**
16+
* A key value pair.
17+
* Usually used to represent the key value pairs from a Map or Object.
18+
*/
19+
export interface KeyValue<K, V> {
20+
key: K;
21+
value: V;
22+
}
23+
24+
/**
25+
* @ngModule CommonModule
26+
* @description
27+
*
28+
* Transforms Object or Map into an array of key value pairs.
29+
*
30+
* The output array will be ordered by keys.
31+
* By default the comparator will be by Unicode point value.
32+
* You can optionally pass a compareFn if your keys are complex types.
33+
*
34+
* ## Examples
35+
*
36+
* This examples show how an Object or a Map and be iterated by ngFor with the use of this keyvalue
37+
* pipe.
38+
*
39+
* {@example common/pipes/ts/keyvalue_pipe.ts region='KeyValuePipe'}
40+
*/
41+
@Pipe({name: 'keyvalue', pure: false})
42+
export class KeyValuePipe implements PipeTransform {
43+
constructor(private readonly differs: KeyValueDiffers) {}
44+
45+
private differ: KeyValueDiffer<any, any>;
46+
private keyValues: Array<KeyValue<any, any>>;
47+
48+
transform<K, V>(input: null, compareFn?: (a: KeyValue<K, V>, b: KeyValue<K, V>) => number): null;
49+
transform<V>(
50+
input: {[key: string]: V}|Map<string, V>,
51+
compareFn?: (a: KeyValue<string, V>, b: KeyValue<string, V>) => number):
52+
Array<KeyValue<string, V>>;
53+
transform<V>(
54+
input: {[key: number]: V}|Map<number, V>,
55+
compareFn?: (a: KeyValue<number, V>, b: KeyValue<number, V>) => number):
56+
Array<KeyValue<number, V>>;
57+
transform<K, V>(input: Map<K, V>, compareFn?: (a: KeyValue<K, V>, b: KeyValue<K, V>) => number):
58+
Array<KeyValue<K, V>>;
59+
transform<K, V>(
60+
input: null|{[key: string]: V, [key: number]: V}|Map<K, V>,
61+
compareFn: (a: KeyValue<K, V>, b: KeyValue<K, V>) => number = defaultComparator):
62+
Array<KeyValue<K, V>>|null {
63+
if (!input || (!(input instanceof Map) && typeof input !== 'object')) {
64+
return null;
65+
}
66+
67+
if (!this.differ) {
68+
// make a differ for whatever type we've been passed in
69+
this.differ = this.differs.find(input).create();
70+
}
71+
72+
const differChanges: KeyValueChanges<K, V>|null = this.differ.diff(input as any);
73+
74+
if (differChanges) {
75+
this.keyValues = [];
76+
differChanges.forEachItem((r: KeyValueChangeRecord<K, V>) => {
77+
this.keyValues.push(makeKeyValuePair(r.key, r.currentValue !));
78+
});
79+
this.keyValues.sort(compareFn);
80+
}
81+
return this.keyValues;
82+
}
83+
}
84+
85+
export function defaultComparator<K, V>(
86+
keyValueA: KeyValue<K, V>, keyValueB: KeyValue<K, V>): number {
87+
const a = keyValueA.key;
88+
const b = keyValueB.key;
89+
// if same exit with 0;
90+
if (a === b) return 0;
91+
// make sure that undefined are at the end of the sort.
92+
if (a === undefined) return 1;
93+
if (b === undefined) return -1;
94+
// make sure that nulls are at the end of the sort.
95+
if (a === null) return 1;
96+
if (b === null) return -1;
97+
if (typeof a == 'string' && typeof b == 'string') {
98+
return a < b ? -1 : 1;
99+
}
100+
if (typeof a == 'number' && typeof b == 'number') {
101+
return a - b;
102+
}
103+
if (typeof a == 'boolean' && typeof b == 'boolean') {
104+
return a < b ? -1 : 1;
105+
}
106+
// `a` and `b` are of different types. Compare their string values.
107+
const aString = String(a);
108+
const bString = String(b);
109+
return aString == bString ? 0 : aString < bString ? -1 : 1;
110+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
/**
2+
* @license
3+
* Copyright Google Inc. All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
9+
import {KeyValuePipe} from '@angular/common';
10+
import {EventEmitter, KeyValueDiffers, WrappedValue, ɵdefaultKeyValueDiffers as defaultKeyValueDiffers} from '@angular/core';
11+
import {AsyncTestCompleter, beforeEach, describe, expect, inject, it} from '@angular/core/testing/src/testing_internal';
12+
import {getDOM} from '@angular/platform-browser/src/dom/dom_adapter';
13+
import {browserDetection} from '@angular/platform-browser/testing/src/browser_util';
14+
15+
import {defaultComparator} from '../../src/pipes/keyvalue_pipe';
16+
import {SpyChangeDetectorRef} from '../spies';
17+
18+
describe('KeyValuePipe', () => {
19+
it('should return null when given null', () => {
20+
const pipe = new KeyValuePipe(defaultKeyValueDiffers);
21+
expect(pipe.transform(null)).toEqual(null);
22+
});
23+
it('should return null when given undefined', () => {
24+
const pipe = new KeyValuePipe(defaultKeyValueDiffers);
25+
expect(pipe.transform(undefined as any)).toEqual(null);
26+
});
27+
it('should return null for an unsupported type', () => {
28+
const pipe = new KeyValuePipe(defaultKeyValueDiffers);
29+
const fn = () => {};
30+
expect(pipe.transform(fn as any)).toEqual(null);
31+
});
32+
describe('object dictionary', () => {
33+
it('should transform a basic dictionary', () => {
34+
const pipe = new KeyValuePipe(defaultKeyValueDiffers);
35+
expect(pipe.transform({1: 2})).toEqual([{key: '1', value: 2}]);
36+
});
37+
it('should order by alpha', () => {
38+
const pipe = new KeyValuePipe(defaultKeyValueDiffers);
39+
expect(pipe.transform({'b': 1, 'a': 1})).toEqual([
40+
{key: 'a', value: 1}, {key: 'b', value: 1}
41+
]);
42+
});
43+
it('should order by numerical', () => {
44+
const pipe = new KeyValuePipe(defaultKeyValueDiffers);
45+
expect(pipe.transform({2: 1, 1: 1})).toEqual([{key: '1', value: 1}, {key: '2', value: 1}]);
46+
});
47+
it('should order by numerical and alpha', () => {
48+
const pipe = new KeyValuePipe(defaultKeyValueDiffers);
49+
const input = {2: 1, 1: 1, 'b': 1, 0: 1, 3: 1, 'a': 1};
50+
expect(pipe.transform(input)).toEqual([
51+
{key: '0', value: 1}, {key: '1', value: 1}, {key: '2', value: 1}, {key: '3', value: 1},
52+
{key: 'a', value: 1}, {key: 'b', value: 1}
53+
]);
54+
});
55+
it('should return the same ref if nothing changes', () => {
56+
const pipe = new KeyValuePipe(defaultKeyValueDiffers);
57+
const transform1 = pipe.transform({1: 2});
58+
const transform2 = pipe.transform({1: 2});
59+
expect(transform1 === transform2).toEqual(true);
60+
});
61+
it('should return a new ref if something changes', () => {
62+
const pipe = new KeyValuePipe(defaultKeyValueDiffers);
63+
const transform1 = pipe.transform({1: 2});
64+
const transform2 = pipe.transform({1: 3});
65+
expect(transform1 !== transform2).toEqual(true);
66+
});
67+
});
68+
69+
describe('Map', () => {
70+
it('should transform a basic Map', () => {
71+
const pipe = new KeyValuePipe(defaultKeyValueDiffers);
72+
expect(pipe.transform(new Map([[1, 2]]))).toEqual([{key: 1, value: 2}]);
73+
});
74+
it('should order by alpha', () => {
75+
const pipe = new KeyValuePipe(defaultKeyValueDiffers);
76+
expect(pipe.transform(new Map([['b', 1], ['a', 1]]))).toEqual([
77+
{key: 'a', value: 1}, {key: 'b', value: 1}
78+
]);
79+
});
80+
it('should order by numerical', () => {
81+
const pipe = new KeyValuePipe(defaultKeyValueDiffers);
82+
expect(pipe.transform(new Map([[2, 1], [1, 1]]))).toEqual([
83+
{key: 1, value: 1}, {key: 2, value: 1}
84+
]);
85+
});
86+
it('should order by numerical and alpha', () => {
87+
const pipe = new KeyValuePipe(defaultKeyValueDiffers);
88+
const input = [[2, 1], [1, 1], ['b', 1], [0, 1], [3, 1], ['a', 1]];
89+
expect(pipe.transform(new Map(input as any))).toEqual([
90+
{key: 0, value: 1}, {key: 1, value: 1}, {key: 2, value: 1}, {key: 3, value: 1},
91+
{key: 'a', value: 1}, {key: 'b', value: 1}
92+
]);
93+
});
94+
it('should order by complex types with compareFn', () => {
95+
const pipe = new KeyValuePipe(defaultKeyValueDiffers);
96+
const input = new Map([[{id: 1}, 1], [{id: 0}, 1]]);
97+
expect(pipe.transform<{id: number}, number>(input, (a, b) => a.key.id > b.key.id ? 1 : -1))
98+
.toEqual([
99+
{key: {id: 0}, value: 1},
100+
{key: {id: 1}, value: 1},
101+
]);
102+
});
103+
it('should return the same ref if nothing changes', () => {
104+
const pipe = new KeyValuePipe(defaultKeyValueDiffers);
105+
const transform1 = pipe.transform(new Map([[1, 2]]));
106+
const transform2 = pipe.transform(new Map([[1, 2]]));
107+
expect(transform1 === transform2).toEqual(true);
108+
});
109+
it('should return a new ref if something changes', () => {
110+
const pipe = new KeyValuePipe(defaultKeyValueDiffers);
111+
const transform1 = pipe.transform(new Map([[1, 2]]));
112+
const transform2 = pipe.transform(new Map([[1, 3]]));
113+
expect(transform1 !== transform2).toEqual(true);
114+
});
115+
});
116+
});
117+
118+
describe('defaultComparator', () => {
119+
it('should remain the same order when keys are equal', () => {
120+
const key = 1;
121+
const values = [{key, value: 2}, {key, value: 1}];
122+
expect(values.sort(defaultComparator)).toEqual(values);
123+
});
124+
it('should sort undefined keys to the end', () => {
125+
const values = [{key: 3, value: 1}, {key: undefined, value: 3}, {key: 1, value: 2}];
126+
expect(values.sort(defaultComparator)).toEqual([
127+
{key: 1, value: 2}, {key: 3, value: 1}, {key: undefined, value: 3}
128+
]);
129+
});
130+
it('should sort null keys to the end', () => {
131+
const values = [{key: 3, value: 1}, {key: null, value: 3}, {key: 1, value: 2}];
132+
expect(values.sort(defaultComparator)).toEqual([
133+
{key: 1, value: 2}, {key: 3, value: 1}, {key: null, value: 3}
134+
]);
135+
});
136+
it('should sort strings in alpha ascending', () => {
137+
const values = [{key: 'b', value: 1}, {key: 'a', value: 3}];
138+
expect(values.sort(defaultComparator)).toEqual([{key: 'a', value: 3}, {key: 'b', value: 1}]);
139+
});
140+
it('should sort numbers in numerical ascending', () => {
141+
const values = [{key: 2, value: 1}, {key: 1, value: 3}];
142+
expect(values.sort(defaultComparator)).toEqual([{key: 1, value: 3}, {key: 2, value: 1}]);
143+
});
144+
it('should sort boolean in false (0) -> true (1)', () => {
145+
const values = [{key: true, value: 3}, {key: false, value: 1}];
146+
expect(values.sort(defaultComparator)).toEqual([{key: false, value: 1}, {key: true, value: 3}]);
147+
});
148+
it('should sort numbers as strings in numerical ascending', () => {
149+
const values = [{key: '2', value: 1}, {key: 1, value: 3}];
150+
expect(values.sort(defaultComparator)).toEqual([{key: 1, value: 3}, {key: '2', value: 1}]);
151+
});
152+
});

packages/core/src/change_detection/differs/keyvalue_differs.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ export interface KeyValueDiffer<K, V> {
2222
* @returns an object describing the difference. The return value is only valid until the next
2323
* `diff()` invocation.
2424
*/
25-
diff(object: Map<K, V>): KeyValueChanges<K, V>;
25+
diff(object: Map<K, V>): KeyValueChanges<K, V>|null;
2626

2727
/**
2828
* Compute a difference between the previous state and the new `object` state.
@@ -31,7 +31,7 @@ export interface KeyValueDiffer<K, V> {
3131
* @returns an object describing the difference. The return value is only valid until the next
3232
* `diff()` invocation.
3333
*/
34-
diff(object: {[key: string]: V}): KeyValueChanges<string, V>;
34+
diff(object: {[key: string]: V}): KeyValueChanges<string, V>|null;
3535
// TODO(TS2.1): diff<KP extends string>(this: KeyValueDiffer<KP, V>, object: Record<KP, V>):
3636
// KeyValueDiffer<KP, V>;
3737
}

packages/examples/common/pipes/ts/e2e_test/pipe_spec.ts

+10
Original file line numberDiff line numberDiff line change
@@ -57,4 +57,14 @@ describe('pipe', () => {
5757
expect(element.all(by.css('titlecase-pipe p')).get(5).getText()).toEqual('Foo-vs-bar');
5858
});
5959
});
60+
describe('titlecase', () => {
61+
it('should work properly', () => {
62+
browser.get(URL);
63+
waitForElement('keyvalue-pipe');
64+
expect(element.all(by.css('keyvalue-pipe div')).get(0).getText()).toEqual('1:bar');
65+
expect(element.all(by.css('keyvalue-pipe div')).get(1).getText()).toEqual('2:foo');
66+
expect(element.all(by.css('keyvalue-pipe div')).get(2).getText()).toEqual('1:bar');
67+
expect(element.all(by.css('keyvalue-pipe div')).get(3).getText()).toEqual('2:foo');
68+
});
69+
});
6070
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
/**
2+
* @license
3+
* Copyright Google Inc. All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
9+
import {Component} from '@angular/core';
10+
11+
// #docregion KeyValuePipe
12+
@Component({
13+
selector: 'keyvalue-pipe',
14+
template: `<span>
15+
<p>Object</p>
16+
<div *ngFor="let item of object | keyvalue">
17+
{{item.key}}:{{item.value}}
18+
</div>
19+
<p>Map</p>
20+
<div *ngFor="let item of map | keyvalue">
21+
{{item.key}}:{{item.value}}
22+
</div>
23+
</span>`
24+
})
25+
export class KeyValuePipeComponent {
26+
object: {[key: number]: string} = {2: 'foo', 1: 'bar'};
27+
map = new Map([[2, 'foo'], [1, 'bar']]);
28+
}
29+
// #enddocregion

0 commit comments

Comments
 (0)