Skip to content

Commit

Permalink
feat: added injector to computedFrom
Browse files Browse the repository at this point in the history
  • Loading branch information
eneajaho committed Sep 11, 2023
1 parent 9d7a8ff commit 9f97b5b
Show file tree
Hide file tree
Showing 9 changed files with 429 additions and 54 deletions.
2 changes: 1 addition & 1 deletion libs/ngxtension/assert-injector/src/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1 @@
export const greeting = 'Hello World!';
export * from './assert-injector';
3 changes: 3 additions & 0 deletions libs/ngxtension/computed-from/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# ngxtension/computed-from

Secondary entry point of `ngxtension`. It can be used by importing from `ngxtension/computed-from`.
5 changes: 5 additions & 0 deletions libs/ngxtension/computed-from/ng-package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"lib": {
"entryFile": "src/index.ts"
}
}
228 changes: 228 additions & 0 deletions libs/ngxtension/computed-from/src/computed-from.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
import {
Component,
inject,
Injector,
Input,
OnInit,
Signal,
signal,
} from '@angular/core';
import {
ComponentFixture,
fakeAsync,
TestBed,
tick,
} from '@angular/core/testing';
import {
BehaviorSubject,
delay,
filter,
map,
of,
pipe,
startWith,
Subject,
switchMap,
} from 'rxjs';
import { computedFrom } from './computed-from';

describe(computedFrom.name, () => {
describe('works with signals', () => {
it('value inside array', () => {
TestBed.runInInjectionContext(() => {
const value = signal(1);
const s = computedFrom([value]);
expect(s()).toEqual([1]);
});
});
it('value inside object', () => {
TestBed.runInInjectionContext(() => {
const value = signal(1);
const s = computedFrom({ value });
expect(s()).toEqual({ value: 1 });
});
});
});
describe('works with observables', () => {
it('with initial value', () => {
TestBed.runInInjectionContext(() => {
const value = new BehaviorSubject(1);
const s = computedFrom([value]);
expect(s()).toEqual([1]);
});
});
it('without initial value', () => {
TestBed.runInInjectionContext(() => {
const value = new Subject<number>();
const s = computedFrom([value.pipe(startWith(1))]);
expect(s()).toEqual([1]);
});
});
it('value inside array', () => {
TestBed.runInInjectionContext(() => {
const value = new BehaviorSubject(1);
const s = computedFrom([value]);
expect(s()).toEqual([1]);
});
});
it('value inside object', () => {
TestBed.runInInjectionContext(() => {
const value = new BehaviorSubject(1);
const s = computedFrom({ value });
expect(s()).toEqual({ value: 1 });
});
});
});
describe('works with observables and signals', () => {
it('value inside array', () => {
TestBed.runInInjectionContext(() => {
const valueS = signal(1);
const valueO = new BehaviorSubject(1);
const s = computedFrom([valueS, valueO]);
expect(s()).toEqual([1, 1]);
});
});
it('value inside object', () => {
TestBed.runInInjectionContext(() => {
const valueS = signal(1);
const valueO = new BehaviorSubject(1);
const s = computedFrom({ valueS, valueO });
expect(s()).toEqual({ valueS: 1, valueO: 1 });
});
});
});
describe('works with observables, signals and rxjs operators', () => {
it('by using rxjs operators directly', () => {
TestBed.runInInjectionContext(() => {
const valueS = signal(1);
const valueO = new BehaviorSubject(1);

const s = computedFrom(
[valueS, valueO],
map(([s, o]) => [s + 1, o + 1])
);

expect(s()).toEqual([2, 2]);
});
});
it('by using pipe operator', () => {
TestBed.runInInjectionContext(() => {
const valueS = signal(1);
const valueO = new BehaviorSubject(1);

const s = computedFrom(
[valueS, valueO],
pipe(
map(([s, o]) => [s + 1, o + 1]),
filter(([s, o]) => s === 2 && o === 2)
)
);

expect(s()).toEqual([2, 2]);

valueS.set(2);
valueO.next(2);

expect(s()).toEqual([2, 2]);
});
});

describe('by using async operators', () => {
@Component({ standalone: true, template: '{{s()}}' })
class TestComponent {
valueS = signal(1);
valueO = new BehaviorSubject(1);

s = computedFrom(
[this.valueS, this.valueO],
pipe(
map(([s, o]) => [s + 1, o + 1]),
switchMap((v) => of(v).pipe(delay(1000)))
)
);
}

let component: TestComponent;
let fixture: ComponentFixture<TestComponent>;

beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [TestComponent],
}).compileComponents();
fixture = TestBed.createComponent(TestComponent);
component = fixture.componentInstance;
});

it('should handle async stuff', fakeAsync(() => {
fixture.detectChanges(); // initial change detection to trigger effect scheduler
expect(component.s()).toEqual([1, 1]); // initial value is 1,1 because of delay in switchMap
expect(fixture.nativeElement.textContent).toEqual('1,1');

fixture.detectChanges(); // trigger effect scheduler (for the moment)
tick(1000); // wait 1s for switchMap delay

expect(component.s()).toEqual([2, 2]);
fixture.detectChanges(); // trigger effect scheduler again
expect(fixture.nativeElement.textContent).toEqual('2,2');

component.valueS.set(3);
component.valueO.next(3);

// by running CD we are triggering the effect scheduler
// if we comment this line, the effect scheduler will not be triggered
// and the signal value will not be updated after 1s
// PLAY WITH IT BY COMMENTING THIS LINE
fixture.detectChanges();

expect(component.s()).toEqual([2, 2]); // value is still 2,2 because of delay in switchMap
expect(fixture.nativeElement.textContent).toEqual('2,2');

tick(1000); // wait 1s for switchMap delay

expect(component.s()).toEqual([4, 4]); // value is now 4,4. But we need to run CD to update the view
// view is not updated because CD has not been run
expect(fixture.nativeElement.textContent).toEqual('2,2');

fixture.detectChanges(); // trigger change detection to update the view
expect(fixture.nativeElement.textContent).toEqual('4,4');
}));
});

describe('works in ngOnInit by passing an Injector', () => {
@Component({ standalone: true, template: '' })
class InInitComponent implements OnInit {
@Input() inputValue = 1;
valueS = signal(1);
injector = inject(Injector);
data!: Signal<number>;

ngOnInit() {
this.data = computedFrom(
[this.valueS],
map(([s]) => s + this.inputValue),
this.injector
);
}
}

let component: InInitComponent;

beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [InInitComponent],
}).compileComponents();
const fixture = TestBed.createComponent(InInitComponent);
component = fixture.componentInstance;
});

it('should not throw an error', () => {
component.inputValue = 2;
component.ngOnInit();
expect(component.data()).toBe(3);
component.inputValue = 3;
component.ngOnInit();
expect(component.data()).toBe(4);
});
});
});
});
85 changes: 85 additions & 0 deletions libs/ngxtension/computed-from/src/computed-from.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { Injector, isSignal, Signal, untracked } from '@angular/core';
import { toObservable, toSignal } from '@angular/core/rxjs-interop';
import { assertInjector } from 'ngxtension/assert-injector';
import {
combineLatest,
distinctUntilChanged,
from,
isObservable,
ObservableInput,
of,
OperatorFunction,
take,
} from 'rxjs';

export type ObservableSignalInput<T> = ObservableInput<T> | Signal<T>;

/**
* So that we can have `fn([Observable<A>, Signal<B>]): Observable<[A, B]>`
*/
type ObservableSignalInputTuple<T> = {
[K in keyof T]: ObservableSignalInput<T[K]>;
};

export function computedFrom<Input extends readonly unknown[], Output = Input>(
sources: readonly [...ObservableSignalInputTuple<Input>],
operator?: OperatorFunction<Input, Output>,
injector?: Injector
): Signal<Output>;

export function computedFrom<Input extends object, Output = Input>(
sources: ObservableSignalInputTuple<Input>,
operator?: OperatorFunction<Input, Output>,
injector?: Injector
): Signal<Output>;

export function computedFrom(
sources: any,
operator?: OperatorFunction<any, any>,
injector?: Injector
): Signal<any> {
injector = assertInjector(computedFrom, injector);

let { normalizedSources, initialValues } = Object.entries(sources).reduce(
(acc, [keyOrIndex, source]) => {
if (isSignal(source)) {
acc.normalizedSources[keyOrIndex] = toObservable(source, { injector });
acc.initialValues[keyOrIndex] = untracked(source);
} else if (isObservable(source)) {
acc.normalizedSources[keyOrIndex] = source.pipe(distinctUntilChanged());
source.pipe(take(1)).subscribe((attemptedSyncValue) => {
if (acc.initialValues[keyOrIndex] !== null) {
acc.initialValues[keyOrIndex] = attemptedSyncValue;
}
});
acc.initialValues[keyOrIndex] ??= null;
} else {
acc.normalizedSources[keyOrIndex] = from(source as any).pipe(
distinctUntilChanged()
);
acc.initialValues[keyOrIndex] = null;
}

return acc;
},
{
normalizedSources: Array.isArray(sources) ? [] : {},
initialValues: Array.isArray(sources) ? [] : {},
} as {
normalizedSources: any;
initialValues: any;
}
);

normalizedSources = combineLatest(normalizedSources);
if (operator) {
normalizedSources = normalizedSources.pipe(operator);
operator(of(initialValues))
.pipe(take(1))
.subscribe((newInitialValues) => {
initialValues = newInitialValues;
});
}

return toSignal(normalizedSources, { initialValue: initialValues, injector });
}
1 change: 1 addition & 0 deletions libs/ngxtension/computed-from/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './computed-from';
4 changes: 3 additions & 1 deletion libs/ngxtension/project.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,9 @@
"libs/ngxtension/assert-injector/**/*.ts",
"libs/ngxtension/assert-injector/**/*.html",
"libs/ngxtension/repeat/**/*.ts",
"libs/ngxtension/repeat/**/*.html"
"libs/ngxtension/repeat/**/*.html",
"libs/ngxtension/computed-from/**/*.ts",
"libs/ngxtension/computed-from/**/*.html"
]
}
},
Expand Down
Loading

0 comments on commit 9f97b5b

Please sign in to comment.