Skip to content

Commit

Permalink
feat(signal-store): add spreadArrayStore()
Browse files Browse the repository at this point in the history
  • Loading branch information
ersimont committed May 3, 2024
1 parent 055a94d commit 7cc374b
Show file tree
Hide file tree
Showing 7 changed files with 305 additions and 41 deletions.
4 changes: 2 additions & 2 deletions .eslintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,11 @@
"rules": {
"@angular-eslint/directive-selector": [
"error",
{ "type": "attribute", "prefix": "sl", "style": "camelCase" }
{ "type": "attribute", "prefix": ["sl", "app"], "style": "camelCase" }
],
"@angular-eslint/component-selector": [
"error",
{ "type": "element", "prefix": "sl", "style": "kebab-case" }
{ "type": "element", "prefix": ["sl", "app"], "style": "kebab-case" }
]
}
}
Expand Down
97 changes: 69 additions & 28 deletions projects/app-state/src/lib/utils/spread-array-store.spec.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,14 @@
import { AsyncPipe } from '@angular/common';
import { Component, Input, OnChanges } from '@angular/core';
import { ComponentContext, staticTest } from '@s-libs/ng-dev';
import { expectTypeOf } from 'expect-type';
import { Observable } from 'rxjs';
import { RootStore, Store } from '../index';
import { spreadArrayStore$ } from './spread-array-store';

describe('spreadArrayStore$()', () => {
let store: Store<number[]>;

beforeEach(() => {
store = new RootStore([1, 2]);
});

it('emits a separate store object for each element in the array', () => {
store.set([1, 2]);
const store = new RootStore([1, 2]);
let emitted!: Array<Store<number>>;
spreadArrayStore$(store).subscribe((stores) => {
emitted = stores;
Expand All @@ -35,7 +32,7 @@ describe('spreadArrayStore$()', () => {
});

it('only emits when the length of the array changes', () => {
store.set([1, 2]);
const store = new RootStore([1, 2]);
const spy = jasmine.createSpy();
spreadArrayStore$(store).subscribe(spy);
expect(spy).toHaveBeenCalledTimes(1);
Expand All @@ -49,7 +46,7 @@ describe('spreadArrayStore$()', () => {

// this makes it nice for use in templates that use OnPush change detection
it('emits the same object reference for indexes that remain', () => {
store.set([1, 2]);
const store = new RootStore([1, 2]);
let lastEmit: Array<Store<number>>;
let previousEmit: Array<Store<number>>;
spreadArrayStore$(store).subscribe((stores) => {
Expand Down Expand Up @@ -84,24 +81,68 @@ describe('spreadArrayStore$()', () => {
});

it('has fancy typing', () => {
expect().nothing();

const array = store;
const arrayOrNull = array as Store<number[] | null>;
const arrayOrUndefined = array as Store<number[] | undefined>;
const arrayOrNil = array as Store<number[] | null | undefined>;

expectTypeOf(spreadArrayStore$(array)).toEqualTypeOf<
Observable<Array<Store<number>>>
>();
expectTypeOf(spreadArrayStore$(arrayOrNull)).toEqualTypeOf<
Observable<Array<Store<number>>>
>();
expectTypeOf(spreadArrayStore$(arrayOrUndefined)).toEqualTypeOf<
Observable<Array<Store<number>>>
>();
expectTypeOf(spreadArrayStore$(arrayOrNil)).toEqualTypeOf<
Observable<Array<Store<number>>>
>();
staticTest(() => {
const array = new RootStore([1]);
const arrayOrNull = array as Store<number[] | null>;
const arrayOrUndefined = array as Store<number[] | undefined>;
const arrayOrNil = array as Store<number[] | null | undefined>;

expectTypeOf(spreadArrayStore$(array)).toEqualTypeOf<
Observable<Array<Store<number>>>
>();
expectTypeOf(spreadArrayStore$(arrayOrNull)).toEqualTypeOf<
Observable<Array<Store<number>>>
>();
expectTypeOf(spreadArrayStore$(arrayOrUndefined)).toEqualTypeOf<
Observable<Array<Store<number>>>
>();
expectTypeOf(spreadArrayStore$(arrayOrNil)).toEqualTypeOf<
Observable<Array<Store<number>>>
>();
});
});

describe('documentation', () => {
it('is working', async () => {
interface Hero {
name: string;
}

@Component({
standalone: true,
selector: 'app-hero',
template: `{{ heroStore('name').$ | async }}`,
imports: [AsyncPipe],
})
class HeroComponent {
@Input() heroStore!: Store<Hero>;
}

// vvvv documentation below
@Component({
standalone: true,
template: `
@for (heroStore of heroStores$ | async; track heroStore) {
<app-hero [heroStore]="heroStore" />
}
`,
imports: [HeroComponent, AsyncPipe],
})
class HeroListComponent implements OnChanges {
@Input() heroesStore!: Store<Hero[]>;
protected heroStores$!: Observable<Array<Store<Hero>>>;

ngOnChanges(): void {
this.heroStores$ = spreadArrayStore$(this.heroesStore);
}
}
// ^^^^ documentation above

const ctx = new ComponentContext(HeroListComponent);
ctx.assignInputs({ heroesStore: new RootStore([{ name: 'Alice' }]) });
ctx.run(() => {
expect(ctx.fixture.nativeElement.textContent.trim()).toBe('Alice');
});
});
});
});
21 changes: 11 additions & 10 deletions projects/app-state/src/lib/utils/spread-array-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,23 +3,24 @@ import { distinctUntilChanged, map } from 'rxjs/operators';
import { Store } from '../index';

/**
* Returns an observable that emits an array of store objects, one for each element in `source`'s state. The resulting arrays will have references to the exact store objects included in the previous emission when possible, making them performant to use in `*ngFor` expressions without the need to use `trackBy`.
* Returns an observable that emits an array of store objects, one for each element in `source`'s state. The emitted arrays will have references to the exact store objects included in the previous emission when possible, making them performant to use in `*ngFor` expressions without the need to use `trackBy`.
*
* ```ts
* @Component({
* standalone: true,
* template: `
* <hero
* *ngFor="let heroStore of heroStores$ | async"
* [heroStore]="heroStore"
* ></hero>
* @for (heroStore of heroStores$ | async; track heroStore) {
* <app-hero [heroStore]="heroStore" />
* }
* `,
* imports: [HeroComponent, AsyncPipe],
* })
* class HeroListComponent {
* heroStores$: Observable<Array<Store<Hero>>>;
* @Input() private heroesStore: Store<Array<Hero>>;
* class HeroListComponent implements OnChanges {
* @Input() heroesStore!: Store<Hero[]>;
* protected heroStores$!: Observable<Array<Store<Hero>>>;
*
* ngOnChanges() {
* this.heroStores$ = spreadArrayStore(this.heroesStore);
* ngOnChanges(): void {
* this.heroStores$ = spreadArrayStore$(this.heroesStore);
* }
* }
* ```
Expand Down
11 changes: 10 additions & 1 deletion projects/integration/src/app/api-tests/signal-store.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import { PersistentStore, RootStore, Store } from '@s-libs/signal-store';
import {
PersistentStore,
RootStore,
Store,
spreadArrayStore,
} from '@s-libs/signal-store';

describe('signal-store', () => {
it('has PersistentStore', () => {
Expand All @@ -12,4 +17,8 @@ describe('signal-store', () => {
it('has Store', () => {
expect(Store).toBeDefined();
});

it('has spreadArrayStore()', () => {
expect(spreadArrayStore).toBeDefined();
});
});
1 change: 1 addition & 0 deletions projects/signal-store/src/lib/utils/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export { PersistentStore, PersistenceCodec } from './persistent-store';
export { spreadArrayStore } from './spread-array-store';
176 changes: 176 additions & 0 deletions projects/signal-store/src/lib/utils/spread-array-store.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
import { Component, effect, Input, OnChanges, Signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { ComponentContext, staticTest } from '@s-libs/ng-dev';
import { expectTypeOf } from 'expect-type';
import { RootStore, Store } from '../index';
import { spreadArrayStore } from './spread-array-store';

describe('spreadArrayStore()', () => {
it('has fancy typing', () => {
staticTest(() => {
const array = new RootStore([1]);
const arrayOrNull = array as Store<number[] | null>;
const arrayOrUndefined = array as Store<number[] | undefined>;
const arrayOrNil = array as Store<number[] | null | undefined>;

expectTypeOf(spreadArrayStore(array)).toEqualTypeOf<
Signal<Array<Store<number>>>
>();
expectTypeOf(spreadArrayStore(arrayOrNull)).toEqualTypeOf<
Signal<Array<Store<number>>>
>();
expectTypeOf(spreadArrayStore(arrayOrUndefined)).toEqualTypeOf<
Signal<Array<Store<number>>>
>();
expectTypeOf(spreadArrayStore(arrayOrNil)).toEqualTypeOf<
Signal<Array<Store<number>>>
>();
});
});

it('emits a separate store object for each element in the array', () => {
TestBed.runInInjectionContext(() => {
const store = new RootStore([1, 2]);
const subStores = spreadArrayStore(store);
let emitted!: Array<Store<number>>;

effect(() => {
emitted = subStores();
});
TestBed.flushEffects();
expect(emitted.length).toBe(2);
expect(emitted[0].state).toBe(1);
expect(emitted[1].state).toBe(2);

store.state = [3, 4, 5];
TestBed.flushEffects();
expect(emitted.length).toBe(3);
expect(emitted[0].state).toBe(3);
expect(emitted[1].state).toBe(4);
expect(emitted[2].state).toBe(5);

store.state = [6];
TestBed.flushEffects();
expect(emitted.length).toBe(1);
expect(emitted[0].state).toBe(6);

store.state = [];
TestBed.flushEffects();
expect(emitted.length).toBe(0);
});
});

it('only emits when the length of the array changes', () => {
TestBed.runInInjectionContext(() => {
const store = new RootStore([1, 2]);
const subStores = spreadArrayStore(store);
let emissions = 0;
effect(() => {
emissions++;
subStores();
});
TestBed.flushEffects();
expect(emissions).toBe(1);

store.state = [3, 4];
TestBed.flushEffects();
expect(emissions).toBe(1);

store.state = [5, 6, 7];
TestBed.flushEffects();
expect(emissions).toBe(2);
});
});

// this makes it nice for use in templates that use OnPush change detection
it('emits the same object reference for indexes that remain', () => {
TestBed.runInInjectionContext(() => {
const store = new RootStore([1, 2]);
const subStores = spreadArrayStore(store);
let lastEmit: Array<Store<number>>;
let previousEmit: Array<Store<number>>;
effect(() => {
previousEmit = lastEmit;
lastEmit = subStores();
});
TestBed.flushEffects();

store.state = [3, 4, 5];
TestBed.flushEffects();
expect(lastEmit![0]).toBe(previousEmit![0]);
expect(lastEmit![1]).toBe(previousEmit![1]);

store.state = [6];
TestBed.flushEffects();
expect(lastEmit![0]).toBe(previousEmit![0]);
});
});

it('treats null and undefined as empty arrays', () => {
TestBed.runInInjectionContext(() => {
interface State {
array?: number[] | null;
}

const store = new RootStore<State>({})('array');
const subStores = spreadArrayStore(store);
let emitted!: Array<Store<number>>;
effect(() => {
emitted = subStores();
});
TestBed.flushEffects();
expect(emitted.length).toBe(0);

store.state = [1];
TestBed.flushEffects();
expect(emitted.length).toBe(1);

store.state = null;
TestBed.flushEffects();
expect(emitted.length).toBe(0);
});
});

describe('documentation', () => {
it('is working', async () => {
interface Hero {
name: string;
}

@Component({
standalone: true,
selector: 'app-hero',
template: `{{ heroStore('name').state }}`,
})
class HeroComponent {
@Input() heroStore!: Store<Hero>;
}

// vvvv documentation below
@Component({
standalone: true,
template: `
@for (heroStore of heroStores(); track heroStore) {
<app-hero [heroStore]="heroStore" />
}
`,
imports: [HeroComponent],
})
class HeroListComponent implements OnChanges {
@Input() heroesStore!: Store<Hero[]>;
protected heroStores!: Signal<Array<Store<Hero>>>;

ngOnChanges(): void {
this.heroStores = spreadArrayStore(this.heroesStore);
}
}
// ^^^^ documentation above

const ctx = new ComponentContext(HeroListComponent);
ctx.assignInputs({ heroesStore: new RootStore([{ name: 'Alice' }]) });
ctx.run(() => {
expect(ctx.fixture.nativeElement.textContent.trim()).toBe('Alice');
});
});
});
});
Loading

0 comments on commit 7cc374b

Please sign in to comment.