Skip to content

Commit

Permalink
feat(computed-from): added computed-from
Browse files Browse the repository at this point in the history
closes #2
  • Loading branch information
eneajaho committed Sep 10, 2023
1 parent 3bc0960 commit d78f2e1
Show file tree
Hide file tree
Showing 8 changed files with 2,942 additions and 2,648 deletions.
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"
}
}
158 changes: 158 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,158 @@
import { signal } from '@angular/core';
import { TestBed, fakeAsync, tick } from '@angular/core/testing';
import {
BehaviorSubject,
Subject,
delay,
filter,
map,
of,
pipe,
startWith,
switchMap,
} from 'rxjs';
import { computedFrom } from './computed-from';

describe('computedFrom', () => {
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]);
});
});

// TODO: fix this test
xit('by using async operators', fakeAsync(() => {
TestBed.runInInjectionContext(() => {
const valueS = signal(1);
const valueO = new BehaviorSubject(1);

const s = computedFrom(
[valueS, valueO],
pipe(
map(([s, o]) => [s + 1, o + 1]),
switchMap((v) => {
// return value after 2 seconds
return of(v).pipe(delay(2000));
})
)
);

console.log(s());
expect(s()).toEqual([1, 1]);

tick(2000);

console.log(s());

tick(2000);

console.log(s());

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

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

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

// tick(2000);

// expect(s()).toEqual([2, 2]);
});
}));
});
});
81 changes: 81 additions & 0 deletions libs/ngxtension/computed-from/src/computed-from.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { isSignal, Signal, untracked } from '@angular/core';
import { toObservable, toSignal } from '@angular/core/rxjs-interop';
import {
combineLatest,
distinctUntilChanged,
from,
isObservable,
ObservableInput,
of,
OperatorFunction,
take,
} from 'rxjs';

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

// TODO(enea): Add comments for everything below.

/**
* 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>
): Signal<Output>;

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

export function computedFrom(
sources: any,
operator?: OperatorFunction<any, any>
): Signal<any> {
let { normalizedSources, initialValues } = Object.entries(sources).reduce(
(acc, [keyOrIndex, source]) => {
if (isSignal(source)) {
acc.normalizedSources[keyOrIndex] = toObservable(source);
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 });
}
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 @@ -47,7 +47,9 @@
"libs/ngxtension/resize/**/*.ts",
"libs/ngxtension/resize/**/*.html",
"libs/ngxtension/create-injection-token/**/*.ts",
"libs/ngxtension/create-injection-token/**/*.html"
"libs/ngxtension/create-injection-token/**/*.html",
"libs/ngxtension/computed-from/**/*.ts",
"libs/ngxtension/computed-from/**/*.html"
]
}
},
Expand Down
Loading

0 comments on commit d78f2e1

Please sign in to comment.