Skip to content

Commit d21d93f

Browse files
SethDavenportsmithad15
authored andcommitted
Select path (angular-redux#125)
* Select path `.select` and `@select` will now accept a property path too. * Added ability to select array indices
1 parent 7ff49a8 commit d21d93f

File tree

11 files changed

+198
-25
lines changed

11 files changed

+198
-25
lines changed

packages/store/CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,11 @@
1+
# 3.0.6
2+
3+
### Features
4+
5+
Added a 'path' option to `ngRedux.select()` and `@select()`. Now you can
6+
do stuff like `@select(['foo', 'bar'])` to select `state.foo.bar` into
7+
an observable.
8+
19
# 3.0.0
210

311
### Features

packages/store/README.md

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -89,10 +89,15 @@ The `@select` decorator can be added to the property of any class or angular
8989
component/injectable. It will turn the property into an observable which observes
9090
the Redux Store value which is selected by the decorator's parameter.
9191

92-
The decorator expects to receive a `string`, a `function` or no parameter at all.
93-
94-
- If a `string` is passed the `@select` decorator will attempt to observe a store property whose name matches the value represented by the `string`.
95-
- If a `function` is passed the `@select` decorator will attempt to use that function as a selector on the RxJs observable.
92+
The decorator expects to receive a `string`, an array of `string`s, a `function` or no
93+
parameter at all.
94+
95+
- If a `string` is passed the `@select` decorator will attempt to observe a store
96+
property whose name matches the `string`.
97+
- If an array of strings is passed, the decorator will attempt to match that path
98+
through the store (similar to `immutableJS`'s `getIn`).
99+
- If a `function` is passed the `@select` decorator will attempt to use that function
100+
as a selector on the RxJs observable.
96101
- If nothing is passed then the `@select` decorator will attempt to use the name of the class property to find a matching value in the Redux store. Note that a utility is in place here where any $ characters will be ignored from the class property's name.
97102

98103
```typescript
@@ -124,6 +129,10 @@ export class CounterValue {
124129
// this selects `counter` from the store and attaches it to this property
125130
@select('counter') counterSelectedWithString;
126131

132+
// this selects `pathDemo.foo.bar` from the store and attaches it to this
133+
// property.
134+
@select(['pathDemo', 'foo', 'bar']) pathSelection;
135+
127136
// this selects `counter` from the store and attaches it to this property
128137
@select(state => state.counter) counterSelectedWithFunction;
129138

@@ -473,7 +482,7 @@ this.counterSubscription = this.ngRedux
473482
this.counter$ = this.ngRedux.select('counter');
474483
```
475484

476-
### @select(key | function)
485+
### @select(key | path | function)
477486

478487
Property decorator.
479488

@@ -482,6 +491,7 @@ Attaches an observable to the property which will reflect the latest value in th
482491
__Arguments:__
483492

484493
* `key` \(*string*): A key within the state that you want to subscribe to.
494+
* `path` \(*string[]*): A path of nested keys within the state you want to subscribe to.
485495
* `selector` \(*Function*): A function that accepts the application state, and returns the slice you want to subscribe to for changes.
486496

487497
e.g. see [the @select decorator](#the-select-decorator)

packages/store/examples/counter/components/Counter.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,18 @@ import { RandomNumberService } from '../services/random-number';
1414
<button (click)="actions.incrementIfOdd()">Increment if odd</button>
1515
<button (click)="actions.incrementAsync(2222)">Increment async</button>
1616
<button (click)="actions.randomize()">Set to random number</button>
17+
18+
<br>
19+
foo$: {{ foo$ | async | json }}
20+
<br>
21+
bar$: {{ bar$ | async}}
1722
</p>
1823
`
1924
})
2025
export class Counter {
2126
@select('counter') counter$: any;
27+
@select([ 'pathDemo', 'foo' ]) foo$;
28+
@select([ 'pathDemo', 'foo', 'bar', 0 ]) bar$: number;
2229

2330
constructor(private actions: CounterActions) {}
2431
}

packages/store/examples/counter/containers/App.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { Component } from '@angular/core';
22
import { AsyncPipe } from '@angular/common';
3-
import { NgRedux, select } from 'ng2-redux';
3+
import { NgRedux } from 'ng2-redux';
44

55
import { Counter } from '../components/Counter';
66
import { CounterInfo } from '../components/CounterInfo';
@@ -25,7 +25,7 @@ export class App {
2525
// Do this once in the top-level app component.
2626
this.ngRedux.configureStore(
2727
reducer,
28-
{ counter: 0 },
28+
{},
2929
[ createLogger() ],
3030
enhancers
3131
);

packages/store/examples/counter/reducers/index.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,11 @@ import * as Redux from 'redux';
22
const { combineReducers } = Redux;
33
import { RootState } from '../store';
44
import counter from './counter';
5+
import pathDemo from './path-demo';
56

67
const rootReducer = combineReducers<RootState>({
7-
counter
8+
counter,
9+
pathDemo
810
});
911

1012
export default rootReducer;
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { CounterActions } from '../actions/counter-actions';
2+
3+
export default (state: any = {
4+
foo: {
5+
bar: [ 0 ]
6+
}
7+
}, action:any) => {
8+
switch (action.type) {
9+
case CounterActions.INCREMENT_COUNTER:
10+
return { foo: { bar: [ state.foo.bar[0] + 1 ] } };
11+
case CounterActions.DECREMENT_COUNTER:
12+
return { foo: { bar: [ state.foo.bar[0] - 1 ] } };
13+
default:
14+
return state;
15+
}
16+
}

packages/store/examples/counter/store/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,4 +10,5 @@ if (window.devToolsExtension) {
1010

1111
export interface RootState {
1212
counter: number;
13+
pathDemo: Object;
1314
}
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import { getIn } from '../../utils/get-in';
2+
import { expect } from 'chai';
3+
4+
describe('getIn', () => {
5+
6+
it('should select a first-level prop', () => {
7+
const test = { foo: 1 };
8+
expect(getIn(test, [ 'foo' ])).to.equal(1);
9+
});
10+
11+
it('should select a second-level prop', () => {
12+
const test = { foo: { bar: 2 } };
13+
expect(getIn(test, [ 'foo', 'bar' ])).to.equal(2);
14+
});
15+
16+
it('should select a third-level prop', () => {
17+
const test = { foo: { bar: { quux: 3 } } };
18+
expect(getIn(test, [ 'foo', 'bar', 'quux' ])).to.equal(3);
19+
});
20+
21+
it('should select falsy values properly', () => {
22+
const test = {
23+
a: false,
24+
b: 0,
25+
c: '',
26+
d: undefined
27+
};
28+
expect(getIn(test, [ 'a' ])).to.equal(false);
29+
expect(getIn(test, [ 'b' ])).to.equal(0);
30+
expect(getIn(test, [ 'c' ])).to.equal('');
31+
expect(getIn(test, [ 'd' ])).to.equal(undefined);
32+
});
33+
34+
it('should select nested falsy values properly', () => {
35+
const test = {
36+
foo: {
37+
a: false,
38+
b: 0,
39+
c: '',
40+
d: undefined
41+
}
42+
};
43+
expect(getIn(test, [ 'foo', 'a' ])).to.equal(false);
44+
expect(getIn(test, [ 'foo', 'b' ])).to.equal(0);
45+
expect(getIn(test, [ 'foo', 'c' ])).to.equal('');
46+
expect(getIn(test, [ 'foo', 'd' ])).to.equal(undefined);
47+
});
48+
49+
it('should not freak if the object is null', () => {
50+
expect(getIn(null, [ 'foo', 'd' ])).to.equal(null);
51+
});
52+
53+
it('should not freak if the object is undefined', () => {
54+
expect(getIn(undefined, [ 'foo', 'd' ])).to.equal(undefined);
55+
});
56+
57+
it('should not freak if the object is a primitive', () => {
58+
expect(getIn(42, [ 'foo', 'd' ])).to.equal(undefined);
59+
});
60+
61+
it('should return undefined for a nonexistent prop', () => {
62+
const test = { foo: 1 };
63+
expect(getIn(test, [ 'bar' ])).to.be.undefined;
64+
});
65+
66+
it('should return undefined for a nonexistent path', () => {
67+
const test = { foo: 1 };
68+
expect(getIn(test, [ 'bar', 'quux' ])).to.be.undefined;
69+
});
70+
71+
it('should return undefined for a nested nonexistent prop', () => {
72+
const test = { foo: 1 };
73+
expect(getIn(test, [ 'foo', 'bar' ])).to.be.undefined;
74+
});
75+
76+
it('should select array elements properly', () => {
77+
const test = [ 'foo', 'bar' ];
78+
expect(getIn(test, [0])).to.equal('foo');
79+
expect(getIn(test, ['0'])).to.equal('foo');
80+
expect(getIn(test, [1])).to.equal('bar');
81+
expect(getIn(test, ['1'])).to.equal('bar');
82+
expect(getIn(test, [2])).to.be.undefined;
83+
expect(getIn(test, ['2'])).to.be.undefined;
84+
});
85+
86+
it('should select nested array elements properly', () => {
87+
const test = { 'quux': [ 'foo', 'bar' ] };
88+
expect(getIn(test, ['quux', 0])).to.equal('foo');
89+
expect(getIn(test, ['quux', '0'])).to.equal('foo');
90+
expect(getIn(test, ['quux', 1])).to.equal('bar');
91+
expect(getIn(test, ['quux', '1'])).to.equal('bar');
92+
expect(getIn(test, ['quux', 2])).to.be.undefined;
93+
expect(getIn(test, ['quux', '2'])).to.be.undefined;
94+
});
95+
});

packages/store/src/components/ng-redux.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,13 @@ import wrapActionCreators from '../utils/wrapActionCreators';
1919
import { isObject, isFunction, isPlainObject} from '../utils/type-checks';
2020
import { omit } from '../utils/omit';
2121
import { invariant } from '../utils/invariant';
22+
import { getIn } from '../utils/get-in';
2223

23-
const VALID_SELECTORS = ['string', 'number', 'symbol', 'function'];
24+
const VALID_SELECTORS = ['string', 'string[]', 'number', 'symbol', 'function'];
2425
const ERROR_MESSAGE = `Expected selector to be one of:
2526
${VALID_SELECTORS.join(',')}. Instead recieved %s`;
26-
const checkSelector = (s) => VALID_SELECTORS.indexOf(typeof s, 0) >= 0;
27+
const checkSelector = (s) => VALID_SELECTORS.indexOf(typeof s, 0) >= 0 ||
28+
Array.isArray(s);
2729

2830
@Injectable()
2931
export class NgRedux<RootState> {
@@ -114,7 +116,11 @@ export class NgRedux<RootState> {
114116
* source Observable with distinct values.
115117
*/
116118
select<S>(
117-
selector: string | number | symbol | ((state: RootState) => S),
119+
selector: string |
120+
number |
121+
symbol |
122+
(string | number)[] |
123+
((s: RootState) => S),
118124
comparer?: (x: any, y: any) => boolean): Observable<S> {
119125

120126
invariant(checkSelector(selector), ERROR_MESSAGE, selector);
@@ -126,6 +132,10 @@ export class NgRedux<RootState> {
126132
return this._store$
127133
.map(state => state[selector])
128134
.distinctUntilChanged(comparer);
135+
} else if (Array.isArray(selector)) {
136+
return this._store$
137+
.map(state => getIn(state, selector))
138+
.distinctUntilChanged(comparer);
129139
} else if (typeof selector === 'function') {
130140
return this._store$
131141
.map(selector).distinctUntilChanged(comparer);

packages/store/src/decorators/select.ts

Lines changed: 16 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4,26 +4,28 @@ import { NgRedux } from '../components/ng-redux';
44
* Selects an observable from the store, and attaches it to the decorated
55
* property.
66
*
7-
* @param {string | function} stateKeyOrFunc
7+
* @param {string | string[] | function} statePathOrFunc
8+
* An Rxjs selector function, property name string, or property name path
9+
* (array of strings/array indices) that locates the store data to be
10+
* selected
11+
*
812
* @param {function} comparer function for this selector
9-
* An Rxjs selector function or a string indicating the name of the store
10-
* property to be selected.
11-
* */
13+
*/
1214
export const select = <T>(
13-
stateKeyOrFunc?,
15+
statePathOrFunc?: string |
16+
(string | number)[] |
17+
Function,
1418
comparer?: (x: any, y: any) => boolean) => (target, key) => {
1519

16-
let bindingKey = (key.lastIndexOf('$') === key.length - 1) ?
17-
key.substring(0, key.length - 1) : key;
18-
19-
if (typeof stateKeyOrFunc === 'string') {
20-
bindingKey = stateKeyOrFunc;
20+
let bindingKey = statePathOrFunc;
21+
if (!statePathOrFunc) {
22+
bindingKey = (key.lastIndexOf('$') === key.length - 1) ?
23+
key.substring(0, key.length - 1) :
24+
key;
2125
}
22-
26+
2327
function getter() {
24-
const isFunction = typeof stateKeyOrFunc === 'function';
25-
return NgRedux.instance
26-
.select(isFunction ? stateKeyOrFunc : bindingKey, comparer);
28+
return NgRedux.instance.select(bindingKey, comparer);
2729
}
2830

2931
// Delete property.

packages/store/src/utils/get-in.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
/*
2+
* Gets a deeply-nested property value from an object, given a 'path'
3+
* of property names or array indices.
4+
*/
5+
export function getIn(
6+
v: Object,
7+
pathElems: (string | number)[]): any {
8+
const [ firstElem, ...restElems] = pathElems;
9+
if (!v) {
10+
return v;
11+
}
12+
13+
if (undefined === v[firstElem]) {
14+
return undefined;
15+
}
16+
17+
if (restElems.length === 0) {
18+
return v[firstElem];
19+
}
20+
21+
return getIn(v[firstElem], restElems);
22+
}

0 commit comments

Comments
 (0)