Skip to content

Commit

Permalink
Merge pull request #165 from GravlLift/infinite-depth-configuration
Browse files Browse the repository at this point in the history
Infinite depth configuration
  • Loading branch information
BBlackwo authored Oct 10, 2020
2 parents 227c5f2 + 652b049 commit 37bf2bf
Show file tree
Hide file tree
Showing 6 changed files with 416 additions and 338 deletions.
10 changes: 10 additions & 0 deletions .editorconfig
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
[*]
charset = utf-8
indent_style = space
indent_size = 4
insert_final_newline = true
trim_trailing_whitespace = true

[*.ts]
quote_type = single
max_line_length = 120
21 changes: 19 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ An interface defining the configuration attributes to bootstrap `localStorageSyn

* `object[]`: Array of objects where for each object the key represents the state key and the value represents custom serialize/deserialize options. This can be one of the following:

* An array of properties which should be synced. This allows for the partial state sync (e.g. `localStorageSync({keys: [{todos: ['name', 'status'] }, ... ]})`). Note: this config cannot go any deeper. So you cannot specify another object inside of the `todos` array for example.
* An array of properties which should be synced. This allows for the partial state sync (e.g. `localStorageSync({keys: [{todos: ['name', 'status'] }, ... ]})`).

* A reviver function as specified in the [JSON.parse documentation](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/parse).

Expand Down Expand Up @@ -96,7 +96,24 @@ An interface defining the configuration attributes to bootstrap `localStorageSyn
* `checkStorageAvailability` \(*boolean? = false*): Specify if the storage availability checking is expected, i.e. for server side rendering / Universal.
* `mergeReducer` (optional) `(state: any, rehydratedState: any, action: any) => any`: Defines the reducer to use to merge the rehydrated state from storage with the state from the ngrx store. If unspecified, defaults to performing a full deepmerge on an `INIT_ACTION` or an `UPDATE_ACTION`.

Usage: `localStorageSync({keys: ['todos', 'visibilityFilter'], storageKeySerializer: (key) => 'cool_' + key, ... })`. In this example `Storage` will use keys `cool_todos` and `cool_visibilityFilter` keys to store `todos` and `visibilityFilter` slices of state). The key itself is used by default - `(key) => key`.
### Usage
#### Key Prefix
```ts
localStorageSync({keys: ['todos', 'visibilityFilter'], storageKeySerializer: (key) => 'cool_' + key, ... });
```
In above example `Storage` will use keys `cool_todos` and `cool_visibilityFilter` keys to store `todos` and `visibilityFilter` slices of state). The key itself is used by default - `(key) => key`.

#### Target Depth Configuration

```ts
localStorageSync({
keys: [
{ feature1: [{ slice11: ['slice11_1'], slice14: ['slice14_2'] }] },
{ feature2: ['slice21'] }
],
});
```
In this example, `feature1.slice11.slice11_1`, `feature1.slice14.slice14_2`, and `feature2.slice21` will be synced to `localStorage.feature1` and `localStorage.feature2`.

## Release Notes / Changelog

Expand Down
6 changes: 3 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@
"semantic-release": "^17.1.1",
"ts-node": "^8.8.2",
"tslint": "^6.1.3",
"typescript": "^3.9.2",
"typescript": "^3.9.7",
"zone.js": "^0.7.7"
},
"ngPackage": {
Expand Down
178 changes: 109 additions & 69 deletions spec/index_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,15 @@ const INIT_ACTION = '@ngrx/store/init';
// Very simple classes to test serialization options. They cover string, number, date, and nested classes
// The top level class has static functions to help test reviver, replacer, serialize and deserialize
class TypeB {
constructor(public afield: string) { }
constructor(public afield: string) {}
}

class TypeA {
static reviver(key: string, value: any): any {
if (typeof value === 'object') {
if (value.afield) {
return new TypeB(value.afield);
}
else {
} else {
return new TypeA(value.astring, value.anumber, value.adate, value.aclass);
}
}
Expand All @@ -45,7 +44,7 @@ class TypeA {
public anumber: number = undefined,
public adate: Date = undefined,
public aclass: TypeB = undefined
) { }
) {}
}

class TypeC extends TypeA {
Expand All @@ -58,44 +57,41 @@ class TypeC extends TypeA {

static decrypt(message: string) {
let decoded = CryptoJS.AES.decrypt(message, TypeC.key);
decoded = decoded.toString(CryptoJS.enc.Utf8);

return decoded;
return decoded.toString(CryptoJS.enc.Utf8);
}
}

class MockStorage implements Storage {
public length: number;
public clear(): void { throw 'Not Implemented'; }
public clear(): void {
throw 'Not Implemented';
}
public getItem(key: string): string | null {
return this[key] ? this[key] : null;
}
key(index: number): string | null { throw 'Not Implemented'; }
removeItem(key: string): void { this[key] = undefined; }
key(index: number): string | null {
throw 'Not Implemented';
}
removeItem(key: string): void {
this[key] = undefined;
}
setItem(key: string, data: string): void {
this[key] = data;
}
[key: string]: any;
[index: number]: string;
}

function mockStorageKeySerializer(key) { return key; }

function mockStorageKeySerializer(key) {
return key;
}

describe('ngrxLocalStorage', () => {
let t1 = new TypeA(
'Testing',
3.14159,
new Date('1968-11-16T12:30:00Z'),
new TypeB('Nested Class'));
let t1 = new TypeA('Testing', 3.14159, new Date('1968-11-16T12:30:00Z'), new TypeB('Nested Class'));

let t1Json = JSON.stringify(t1);

let t1Filtered = new TypeA(
'Testing',
undefined,
undefined,
new TypeB('Nested Class'));
let t1Filtered = new TypeA('Testing', undefined, undefined, new TypeB('Nested Class'));

let t1FilteredJson = JSON.stringify(t1Filtered);

Expand All @@ -112,6 +108,10 @@ describe('ngrxLocalStorage', () => {
const primitiveStr = 'string is not an object';
const initialStatePrimitiveStr = { state: primitiveStr };

beforeEach(() => {
localStorage.clear();
});

it('simple', () => {
// This tests a very simple state object syncing to mock Storage
// Since we're not specifiying anything for rehydration, the roundtrip
Expand Down Expand Up @@ -180,10 +180,13 @@ describe('ngrxLocalStorage', () => {
};

// test selective write to storage
syncStateUpdate(nestedState, [
{ 'feature1': ['slice11', 'slice12'] },
{ 'feature2': ['slice21', 'slice22'] },
], s, skr, false);
syncStateUpdate(
nestedState,
[{ feature1: ['slice11', 'slice12'] }, { feature2: ['slice21', 'slice22'] }],
s,
skr,
false
);

const raw1 = s.getItem('feature1');
expect(raw1).toEqual(jasmine.arrayContaining(['slice11', 'slice12']));
Expand Down Expand Up @@ -279,11 +282,15 @@ describe('ngrxLocalStorage', () => {
// We want to validate the space parameter, but don't want to trip up on OS specific newlines, so filter the newlines out and
// compare against the literal string.
let raw = s.getItem('replacer');
expect(raw.replace(/\r?\n|\r/g, '')).toEqual('{ "astring": "Testing", "adate": "1968-11-16T12:30:00.000Z", "anumber": 3.14159\}');
expect(raw.replace(/\r?\n|\r/g, '')).toEqual(
'{ "astring": "Testing", "adate": "1968-11-16T12:30:00.000Z", "anumber": 3.14159}'
);

let finalState: any = rehydrateApplicationState(keys, s, skr, true);

expect(JSON.stringify(finalState)).toEqual('{"replacer":{"astring":"Testing","adate":"1968-11-16T12:30:00.000Z","anumber":3.14159}}');
expect(JSON.stringify(finalState)).toEqual(
'{"replacer":{"astring":"Testing","adate":"1968-11-16T12:30:00.000Z","anumber":3.14159}}'
);

expect(t1 instanceof TypeA).toBeTruthy();
expect(finalState.replacer instanceof TypeA).toBeFalsy();
Expand Down Expand Up @@ -342,7 +349,7 @@ describe('ngrxLocalStorage', () => {

let s = new MockStorage();
let skr = mockStorageKeySerializer;
const initalState = {state: t1Simple};
const initalState = { state: t1Simple };

syncStateUpdate(initalState, ['state'], s, skr, false);

Expand Down Expand Up @@ -446,12 +453,12 @@ describe('ngrxLocalStorage', () => {
it('merge initial state and rehydrated state', () => {
// localStorage starts out in a "bad" state. This could happen if our application state schema
// changes. End users may have the old schema and a software update has the new schema.
localStorage.setItem('state', JSON.stringify({oldstring: 'foo'}));
localStorage.setItem('state', JSON.stringify({ oldstring: 'foo' }));

// Set up reducers
const reducer = (state = initialState, action) => state;
const metaReducer = localStorageSync({keys: ['state'], rehydrate: true});
const action = {type: INIT_ACTION};
const metaReducer = localStorageSync({ keys: ['state'], rehydrate: true });
const action = { type: INIT_ACTION };

// Resultant state should merge the oldstring state and our initual state
const finalState = metaReducer(reducer)(initialState, action);
Expand All @@ -460,9 +467,9 @@ describe('ngrxLocalStorage', () => {

it('should merge selectively saved state and rehydrated state', () => {
const initialState = {
app: { app1: false, app2: [], app3: {} },
feature1: { slice11: false, slice12: [], slice13: {} },
feature2: { slice21: false, slice22: [], slice23: {} },
app: { app1: false, app2: [], app3: {} },
feature1: { slice11: false, slice12: [], slice13: {} },
feature2: { slice21: false, slice22: [], slice23: {} },
};

// A legit case where state is saved in chunks rather than as a single object
Expand All @@ -471,19 +478,19 @@ describe('ngrxLocalStorage', () => {

// Set up reducers
const reducer = (state = initialState, action) => state;
const metaReducer = localStorageSync({keys: [
{'feature1': ['slice11', 'slice12']},
{'feature2': ['slice21', 'slice22']},
], rehydrate: true});
const metaReducer = localStorageSync({
keys: [{ feature1: ['slice11', 'slice12'] }, { feature2: ['slice21', 'slice22'] }],
rehydrate: true,
});

const action = {type: INIT_ACTION};
const action = { type: INIT_ACTION };

// Resultant state should merge the rehydrated partial state and our initial state
const finalState = metaReducer(reducer)(initialState, action);
expect(finalState).toEqual({
app: { app1: false, app2: [], app3: {} },
feature1: { slice11: true, slice12: [1, 2], slice13: {} },
feature2: { slice21: true, slice22: [1, 2], slice23: {} },
app: { app1: false, app2: [], app3: {} },
feature1: { slice11: true, slice12: [1, 2], slice13: {} },
feature2: { slice21: true, slice22: [1, 2], slice23: {} },
});
});

Expand All @@ -492,39 +499,72 @@ describe('ngrxLocalStorage', () => {
app: { app1: false, app2: [], app3: {} },
feature1: { slice11: false, slice12: [], slice13: {} },
feature2: { slice21: false, slice22: [], slice23: {} },
};
// A legit case where state is saved in chunks rather than as a single object
localStorage.setItem('feature1', JSON.stringify({ slice11: true, slice12: [1, 2] }));
localStorage.setItem('feature2', JSON.stringify({ slice21: true, slice22: [1, 2] }));
// Set up reducers
const reducer = (state = initialState, action) => state;
const mergeReducer = (state, rehydratedState, action) => {
};

// A legit case where state is saved in chunks rather than as a single object
localStorage.setItem('feature1', JSON.stringify({ slice11: true, slice12: [1, 2] }));
localStorage.setItem('feature2', JSON.stringify({ slice21: true, slice22: [1, 2] }));

// Set up reducers
const reducer = (state = initialState, action) => state;
const mergeReducer = (state, rehydratedState, action) => {
// Perform a merge where we only want a single property from feature1
// but a deepmerge with feature2

return {
return {
...state,
feature1: {
slice11: rehydratedState.feature1.slice11
slice11: rehydratedState.feature1.slice11,
},
feature2: deepmerge(state.feature2, rehydratedState.feature2)
}
}
const metaReducer = localStorageSync({keys: [
{'feature1': ['slice11', 'slice12']},
{'feature2': ['slice21', 'slice22']},
], rehydrate: true, mergeReducer});

const action = {type: INIT_ACTION};

// Resultant state should merge the rehydrated partial state and our initial state
const finalState = metaReducer(reducer)(initialState, action);
expect(finalState).toEqual({
feature2: deepmerge(state.feature2, rehydratedState.feature2),
};
};
const metaReducer = localStorageSync({
keys: [{ feature1: ['slice11', 'slice12'] }, { feature2: ['slice21', 'slice22'] }],
rehydrate: true,
mergeReducer,
});

const action = { type: INIT_ACTION };

// Resultant state should merge the rehydrated partial state and our initial state
const finalState = metaReducer(reducer)(initialState, action);
expect(finalState).toEqual({
app: { app1: false, app2: [], app3: {} },
feature1: { slice11: true },
feature2: { slice21: true, slice22: [1, 2], slice23: {} },
});
});
});
});

it('should save targeted infinite depth to localStorage', () => {
// Configure to only save feature1.slice11.slice11_1 and feature2.slice12,
// ignore all other properties
const metaReducer = localStorageSync({
keys: [{ feature1: [{ slice11: ['slice11_1'], slice14: ['slice14_2'] }] }, { feature2: ['slice21'] }],
});

// Excute action
metaReducer((state: any, _action: any) => state)(
// Initial state with lots of unrelated properties
{
feature1: {
slice11: { slice11_1: 'good_value', slice11_2: 'bad_value' },
slice12: [],
slice13: false,
slice14: { slice14_1: true, slice14_2: 'other_good_value' },
},
feature2: {
slice21: 'third_good_value',
},
},
{ type: 'SomeAction' }
);

// Local storage should match expect values
expect(JSON.parse(localStorage['feature1'])).toEqual({
slice11: { slice11_1: 'good_value' },
slice14: { slice14_2: 'other_good_value' },
});
expect(JSON.parse(localStorage['feature2'])).toEqual({ slice21: 'third_good_value' });
});
});
Loading

0 comments on commit 37bf2bf

Please sign in to comment.