Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(component-store): add imperative reads #2614

Merged
merged 2 commits into from
Jul 12, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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