Skip to content

Commit

Permalink
feat(component-store): add imperative reads (#2614)
Browse files Browse the repository at this point in the history
  • Loading branch information
alex-okrushko authored Jul 12, 2020
1 parent 4d00bda commit 2146774
Show file tree
Hide file tree
Showing 3 changed files with 197 additions and 14 deletions.
114 changes: 106 additions & 8 deletions modules/component-store/spec/component-store.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ import {
map,
tap,
finalize,
observeOn,
} from 'rxjs/operators';

describe('Component Store', () => {
Expand Down Expand Up @@ -63,12 +62,24 @@ describe('Component Store', () => {
const componentStore = new ComponentStore();

m.expect(componentStore.state$).toBeObservable(
m.hot('#', {}, new Error('ComponentStore has not been initialized'))
m.hot(
'#',
{},
new Error(
'ComponentStore has not been initialized yet. ' +
'Please make sure it is initialized before updating/getting.'
)
)
);

expect(() => {
componentStore.setState(() => ({ setState: 'new state' }));
}).toThrow(new Error('ComponentStore has not been initialized'));
}).toThrow(
new Error(
'ComponentStore has not been initialized yet. ' +
'Please make sure it is initialized before updating/getting.'
)
);
})
);

Expand All @@ -78,14 +89,26 @@ describe('Component Store', () => {
const componentStore = new ComponentStore();

m.expect(componentStore.state$).toBeObservable(
m.hot('#', {}, new Error('ComponentStore has not been initialized'))
m.hot(
'#',
{},
new Error(
'ComponentStore has not been initialized yet. ' +
'Please make sure it is initialized before updating/getting.'
)
)
);

expect(() => {
componentStore.updater((state, value: object) => value)({
updater: 'new state',
});
}).toThrow(new Error('ComponentStore has not been initialized'));
}).toThrow(
new Error(
'ComponentStore has not been initialized yet. ' +
'Please make sure it is initialized before updating/getting.'
)
);
})
);

Expand All @@ -99,14 +122,26 @@ describe('Component Store', () => {
});

m.expect(componentStore.state$).toBeObservable(
m.hot('#', {}, new Error('ComponentStore has not been initialized'))
m.hot(
'#',
{},
new Error(
'ComponentStore has not been initialized yet. ' +
'Please make sure it is initialized before updating/getting.'
)
)
);

expect(() => {
componentStore.updater<object>((state, value) => value)(
syncronousObservable$
);
}).toThrow(new Error('ComponentStore has not been initialized'));
}).toThrow(
new Error(
'ComponentStore has not been initialized yet. ' +
'Please make sure it is initialized before updating/getting.'
)
);
})
);

Expand All @@ -123,7 +158,14 @@ describe('Component Store', () => {
let subscription: Subscription | undefined;

m.expect(componentStore.state$).toBeObservable(
m.hot('-#', {}, new Error('ComponentStore has not been initialized'))
m.hot(
'-#',
{},
new Error(
'ComponentStore has not been initialized yet. ' +
'Please make sure it is initialized before updating/getting.'
)
)
);

expect(() => {
Expand Down Expand Up @@ -1221,4 +1263,60 @@ describe('Component Store', () => {
});
});
});

describe('get', () => {
interface State {
value: string;
}

class ExposedGetComponentStore extends ComponentStore<State> {
get = super.get;
}

let componentStore: ExposedGetComponentStore;

it('throws an Error if called before the state is initialized', () => {
componentStore = new ExposedGetComponentStore();

expect(() => {
componentStore.get((state) => state.value);
}).toThrow(
new Error(
'ExposedGetComponentStore has not been initialized yet. ' +
'Please make sure it is initialized before updating/getting.'
)
);
});

it('does not throw an Error when initialized', () => {
componentStore = new ExposedGetComponentStore();
componentStore.setState({ value: 'init' });

expect(() => {
componentStore.get((state) => state.value);
}).not.toThrow();
});

it('provides values from the state', () => {
componentStore = new ExposedGetComponentStore();
componentStore.setState({ value: 'init' });

expect(componentStore.get((state) => state.value)).toBe('init');

componentStore.updater((state, value: string) => ({ value }))('updated');

expect(componentStore.get((state) => state.value)).toBe('updated');
});

it('provides the entire state when projector fn is not provided', () => {
componentStore = new ExposedGetComponentStore();
componentStore.setState({ value: 'init' });

expect(componentStore.get()).toEqual({ value: 'init' });

componentStore.updater((state, value: string) => ({ value }))('updated');

expect(componentStore.get()).toEqual({ value: 'updated' });
});
});
});
75 changes: 72 additions & 3 deletions modules/component-store/spec/integration.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ import {
tick,
} from '@angular/core/testing';
import { CommonModule } from '@angular/common';
import { interval, Observable } from 'rxjs';
import { tap } from 'rxjs/operators';
import { interval, Observable, of, EMPTY } from 'rxjs';
import { tap, concatMap, catchError } from 'rxjs/operators';
import { By } from '@angular/platform-browser';

describe('ComponentStore integration', () => {
Expand Down Expand Up @@ -138,6 +138,34 @@ describe('ComponentStore integration', () => {
testWith(setupComponentExtendsService);
});

describe('ComponentStore getter', () => {
let state: ReturnType<typeof setupComponentProvidesService> extends Promise<
infer P
>
? P
: never;
beforeEach(async () => {
state = await setupComponentProvidesService();
});

it('provides correct instant values within effect', fakeAsync(() => {
state.child.init();

tick(40); // Prop2 should be at value '3' now
state.child.call('test one:');

expect(state.serviceCallSpy).toHaveBeenCalledWith('test one:3');

tick(20); // Prop2 should be at value '5' now
state.child.call('test two:');

expect(state.serviceCallSpy).toHaveBeenCalledWith('test two:5');

// clear "Periodic timers in queue"
state.destroy();
}));
});

interface State {
prop: string;
prop2?: number;
Expand Down Expand Up @@ -305,10 +333,22 @@ describe('ComponentStore integration', () => {
}

async function setupComponentProvidesService() {
@Injectable({ providedIn: 'root' })
class Service {
call(arg: string) {
return of('result');
}
}

function getProp2(state: State): number | undefined {
return state.prop2;
}

@Injectable()
class PropsStore extends ComponentStore<State> {
prop$ = this.select((state) => state.prop);
prop2$ = this.select((state) => state.prop2);
// projector function 👇 reused in selector and getter
prop2$ = this.select(getProp2);
propDebounce$ = this.select((state) => state.prop, { debounce: true });

propUpdater = this.updater((state, value: string) => ({
Expand All @@ -327,6 +367,28 @@ describe('ComponentStore integration', () => {
})
)
);

callService = this.effect((strings$: Observable<string>) => {
return strings$.pipe(
// getting value from State imperatively 👇
concatMap((str) =>
this.service.call(str + this.get(getProp2)).pipe(
tap({
next: (v) => this.propUpdater(v),
error: () => {
/* handle error */
},
}),
// make sure to catch errors
catchError((e) => EMPTY)
)
)
);
});

constructor(private readonly service: Service) {
super();
}
}

@Component({
Expand All @@ -350,17 +412,24 @@ describe('ComponentStore integration', () => {
updateProp(value: string): void {
this.propsStore.propUpdater(value);
}

call(str: string) {
this.propsStore.callService(str);
}
}

const setup = await setupTestBed(ChildComponent);
const componentStoreDestroySpy = jest.spyOn(
setup.child.propsStore,
'ngOnDestroy'
);

const serviceCallSpy = jest.spyOn(TestBed.get(Service), 'call');
return {
...setup,
destroy: () => setup.child.propsStore.ngOnDestroy(),
componentStoreDestroySpy,
serviceCallSpy,
};
}

Expand Down
22 changes: 19 additions & 3 deletions modules/component-store/src/component-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
map,
distinctUntilChanged,
shareReplay,
take,
} from 'rxjs/operators';
import { debounceSync } from './debounce-sync';
import {
Expand Down Expand Up @@ -51,6 +52,9 @@ export class ComponentStore<T extends object> implements OnDestroy {

private readonly stateSubject$ = new ReplaySubject<T>(1);
private isInitialized = false;
private notInitializedErrorMessage =
`${this.constructor.name} has not been initialized yet. ` +
`Please make sure it is initialized before updating/getting.`;
// Needs to be after destroy$ is declared because it's used in select.
readonly state$: Observable<T> = this.select((s) => s);

Expand Down Expand Up @@ -102,9 +106,7 @@ export class ComponentStore<T extends object> implements OnDestroy {
withLatestFrom(this.stateSubject$)
)
: // If state was not initialized, we'll throw an error.
throwError(
new Error(`${this.constructor.name} has not been initialized`)
)
throwError(new Error(this.notInitializedErrorMessage))
),
takeUntil(this.destroy$)
)
Expand Down Expand Up @@ -152,6 +154,20 @@ export class ComponentStore<T extends object> implements OnDestroy {
}
}

protected get(): T;
protected get<R>(projector: (s: T) => R): R;
protected get<R>(projector?: (s: T) => R): R | T {
if (!this.isInitialized) {
throw new Error(this.notInitializedErrorMessage);
}
let value: R | T;

this.stateSubject$.pipe(take(1)).subscribe((state) => {
value = projector ? projector(state) : state;
});
return value!;
}

/**
* Creates a selector.
*
Expand Down

0 comments on commit 2146774

Please sign in to comment.