Skip to content

Commit

Permalink
feat: simplify types
Browse files Browse the repository at this point in the history
feat: only infer types of inputs and outputs

Release-As: 0.3.0
  • Loading branch information
k3nsei committed Aug 17, 2024
1 parent b44810d commit 25615c8
Show file tree
Hide file tree
Showing 11 changed files with 118 additions and 128 deletions.
20 changes: 20 additions & 0 deletions apps/demo/src/app/counter/counter-api.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { type Observable, of, throwError } from 'rxjs';
import { delay } from 'rxjs/operators';

export class CounterApiService {
public increaseBy$(amount: number, count: number): Observable<CounterApiResponse> {
if (amount > 0 && count >= 5) {
return throwError(() => new RangeError('Count is too high'));
}

if (amount < 0 && count <= 0) {
return throwError(() => new RangeError('Count is too low'));
}

return of({ count: count + amount }).pipe(delay(250));
}
}

export interface CounterApiResponse {
count: number;
}
4 changes: 2 additions & 2 deletions apps/demo/src/app/counter/counter.component.html
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
<output aria-label="Current count">{{ value() }}</output>

<button mat-fab type="button" aria-label="Increase" title="Increase" [disabled]="isBusy()" (click)="increase()">
<button mat-fab type="button" aria-label="Increase" title="Increase" [disabled]="isBusy()" (click)="increaseBy(1)">
<mat-icon fontIcon="exposure_plus_1" />
</button>

<button mat-fab type="button" aria-label="Decrease" title="Decrease" [disabled]="isBusy()" (click)="decrease()">
<button mat-fab type="button" aria-label="Decrease" title="Decrease" [disabled]="isBusy()" (click)="increaseBy(-1)">
<mat-icon fontIcon="exposure_neg_1" />
</button>
20 changes: 6 additions & 14 deletions apps/demo/src/app/counter/counter.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/c
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';

import { CounterApiService } from './counter-api.service';
import { CounterStore } from './counter.store';

@Component({
Expand All @@ -11,26 +12,17 @@ import { CounterStore } from './counter.store';
templateUrl: './counter.component.html',
styleUrl: './counter.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
providers: [CounterStore],
providers: [CounterApiService, CounterStore],
imports: [MatButtonModule, MatIconModule],
})
export class CounterComponent {
private readonly store = inject(CounterStore);

protected readonly value = this.store.count;
protected readonly value = computed(() => this.store.count());

public readonly isBusy = computed(() => {
const increase = this.store.increaseMutation.isPending();
const decrease = this.store.decreaseMutation.isPending();
protected readonly isBusy = computed(() => this.store.counterMutation.isPending());

return increase || decrease;
});

public increase(): void {
this.store.increaseMutation.mutate(1);
}

public decrease(): void {
this.store.decreaseMutation.mutate(1);
protected increaseBy(amount: number): void {
this.store.counterMutation.mutate(amount);
}
}
60 changes: 9 additions & 51 deletions apps/demo/src/app/counter/counter.store.ts
Original file line number Diff line number Diff line change
@@ -1,33 +1,26 @@
import { DestroyRef, inject } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';

import { MatSnackBar } from '@angular/material/snack-bar';

import { patchState, signalStore, withState } from '@ngrx/signals';
import { withMutation } from '@ngx-signal-store-query/core';
import { lastValueFrom } from 'rxjs';

import { CounterApiService } from './counter-api.service';

export const CounterStore = signalStore(
withState({ count: 0 }),
withMutation('increase', (store) => () => {
withMutation('counter', (store) => () => {
const destroyRef = inject(DestroyRef);
const snackBar = inject(MatSnackBar);

let timer: ReturnType<typeof setTimeout> | null = null;

destroyRef.onDestroy(() => timer != null && clearTimeout(timer));
const api = inject(CounterApiService);

return {
mutationFn(amount: number): Promise<CounterResponse> {
const count = store.count();

return new Promise((resolve, reject) => {
if (count >= 5) {
return reject(new RangeError('Count is too high'));
}

timer = setTimeout(() => resolve({ count: count + amount }), 250);
});
mutationFn(amount: number) {
return lastValueFrom(api.increaseBy$(amount, store.count()).pipe(takeUntilDestroyed(destroyRef)));
},
onSuccess({ count }: CounterResponse): void {
onSuccess({ count }): void {
return patchState(store, { count });
},
onError(error: Error): void {
Expand All @@ -38,39 +31,4 @@ export const CounterStore = signalStore(
},
};
}),
withMutation('decrease', (store) => () => {
const destroyRef = inject(DestroyRef);
const snackBar = inject(MatSnackBar);

let timer: ReturnType<typeof setTimeout> | null = null;

destroyRef.onDestroy(() => timer != null && clearTimeout(timer));

return {
mutationFn: (amount: number): Promise<CounterResponse> => {
const count = store.count();

return new Promise((resolve, reject) => {
if (count <= 0) {
return reject(new RangeError('Count is too low'));
}

timer = setTimeout(() => resolve({ count: count - amount }), 250);
});
},
onSuccess: ({ count }: CounterResponse): void => {
return patchState(store, { count });
},
onError: (error: Error): void => {
snackBar.open(error.message, '', {
panelClass: 'popover-error',
duration: 5000,
});
},
};
}),
);

interface CounterResponse {
count: number;
}
4 changes: 2 additions & 2 deletions apps/demo/src/app/gh-repos/github-repos.component.html
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
@if (isFetching() || isLoading()) {
@if (isBusy()) {
<mat-progress-bar class="busy-indicator" mode="indeterminate" />
}

<mat-list class="list">
<div mat-subheader>{{ organization() | titlecase }} Repositories</div>

@if (isFetching() || isLoading()) {
@if (isBusy()) {
<ng-container *ngTemplateOutlet="skeleton" />
<ng-container *ngTemplateOutlet="skeleton" />
<ng-container *ngTemplateOutlet="skeleton" />
Expand Down
37 changes: 31 additions & 6 deletions apps/demo/src/app/gh-repos/github-repos.component.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
import { NgTemplateOutlet, TitleCasePipe } from '@angular/common';
import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core';
import { isPlatformBrowser, NgTemplateOutlet, TitleCasePipe } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
computed,
DestroyRef,
inject,
type OnInit,
PLATFORM_ID,
} from '@angular/core';

import { MatIconModule } from '@angular/material/icon';
import { MatListModule } from '@angular/material/list';
Expand All @@ -19,14 +27,31 @@ import { GithubStore } from './github.store';
providers: [GithubApiService, GithubStore],
imports: [NgTemplateOutlet, TitleCasePipe, MatIconModule, MatListModule, MatProgressBarModule, SkeletonTextComponent],
})
export class GithubReposComponent {
export class GithubReposComponent implements OnInit {
private readonly destroyRef = inject(DestroyRef);

private readonly platformId = inject(PLATFORM_ID);

private readonly store = inject(GithubStore);

protected readonly organization = this.store.organization;
protected readonly organization = computed(() => this.store.organization());

protected readonly isFetching = this.store.githubQuery.isFetching;
protected readonly isBusy = computed(() => {
const isFetching = this.store.githubQuery.isFetching();
const isLoading = this.store.githubQuery.isLoading();

protected readonly isLoading = this.store.githubQuery.isLoading;
return isFetching || isLoading;
});

protected readonly data = computed(() => this.store.githubQuery.data() ?? []);

public ngOnInit(): void {
if (!isPlatformBrowser(this.platformId)) {
return;
}

const timer = setTimeout(() => this.store.changeOrganization('angular'), this.store.delay() + 3000);

this.destroyRef.onDestroy(() => clearTimeout(timer));
}
}
45 changes: 18 additions & 27 deletions apps/demo/src/app/gh-repos/github.store.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { isPlatformBrowser } from '@angular/common';
import { type HttpErrorResponse } from '@angular/common/http';
import { DestroyRef, inject, PLATFORM_ID } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';

import { MatSnackBar } from '@angular/material/snack-bar';

import { patchState, signalStore, withHooks, withState } from '@ngrx/signals';
import { patchState, signalStore, withMethods, withState } from '@ngrx/signals';
import { withQuery } from '@ngx-signal-store-query/core';
import { lastValueFrom } from 'rxjs';

Expand All @@ -19,7 +20,13 @@ export const GithubStore = signalStore(
delay: isPlatformBrowser(platformId) ? 2000 : 0,
};
}),
withMethods((store) => ({
changeOrganization(organization: string) {
return patchState(store, { organization });
},
})),
withQuery('github', (store) => {
const destroyRef = inject(DestroyRef);
const snackBar = inject(MatSnackBar);
const api = inject(GithubApiService);

Expand All @@ -31,34 +38,18 @@ export const GithubStore = signalStore(
enabled: !!organization,
queryKey: ['github', 'orgs', { organization }, 'repos'],
queryFn: () =>
lastValueFrom(api.fetchOrganizationRepositoryList$(organization, delay)).catch(
(error: HttpErrorResponse | Error) => {
snackBar.open(error.message, '', {
panelClass: 'popover-error',
duration: 5000,
});

return [];
},
),
lastValueFrom(
api.fetchOrganizationRepositoryList$(organization, delay).pipe(takeUntilDestroyed(destroyRef)),
).catch((error: HttpErrorResponse | Error) => {
snackBar.open(error.message, '', {
panelClass: 'popover-error',
duration: 5000,
});

return [];
}),
staleTime: 5 * 60 * 1000,
};
};
}),
withHooks((store) => {
const destroyRef = inject(DestroyRef);
const platformId = inject(PLATFORM_ID);

return {
onInit() {
if (!isPlatformBrowser(platformId)) {
return;
}

const timer = setTimeout(() => patchState(store, { organization: 'angular' }), store.delay() + 3000);

destroyRef.onDestroy(() => clearTimeout(timer));
},
};
}),
);
2 changes: 1 addition & 1 deletion libs/ngx-signal-store-query/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@ngx-signal-store-query/core",
"version": "0.2.0",
"version": "0.3.0",
"description": "Signal Store feature that bridges with Angular Query",
"keywords": [
"Angular",
Expand Down
19 changes: 7 additions & 12 deletions libs/ngx-signal-store-query/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,32 +7,27 @@ import {
import {
type CreateMutationOptions,
type CreateMutationResult,
type CreateQueryOptions,
type CreateQueryResult,
type injectQuery,
} from '@tanstack/angular-query-experimental';
import { type QueryClient } from '@tanstack/query-core';

import type { QueryClient } from '@tanstack/query-core';
export type QueryStore<Input extends SignalStoreFeatureResult> = Prettify<
StateSignals<Input['state']> & Input['computed'] & Input['methods'] & WritableStateSource<Prettify<Input['state']>>
>;

export type CreateQueryFn<
TDataFn = unknown,
TData = TDataFn,
TError = Error,
TData = TDataFn,
Input extends SignalStoreFeatureResult = SignalStoreFeatureResult,
> = (store: QueryStore<Input>) => QueryFactory<TDataFn, TData, TError>;
> = (store: QueryStore<Input>) => (client: QueryClient) => CreateQueryOptions<TDataFn, TError, TData>;

export type QueryProp<Name extends string> = `${Uncapitalize<Name>}Query`;

export type QueryMethod<TData = unknown, TError = Error> = (() => CreateQueryResult<TData, TError>) &
CreateQueryResult<TData, TError>;

export type QueryStore<Input extends SignalStoreFeatureResult> = Prettify<
StateSignals<Input['state']> & Input['computed'] & Input['methods'] & WritableStateSource<Prettify<Input['state']>>
>;

export type QueryFactory<TDataFn = unknown, TData = TDataFn, TError = Error> = Parameters<
typeof injectQuery<TDataFn, TError, TData>
>[0];

export type CreateMutationFn<
TData = unknown,
TError = Error,
Expand Down
20 changes: 13 additions & 7 deletions libs/ngx-signal-store-query/src/with-mutation.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import { inject, Injector } from '@angular/core';

import {
type EmptyFeatureResult,
signalStoreFeature,
Expand All @@ -21,23 +19,31 @@ export const withMutation = <
Input extends SignalStoreFeatureResult = SignalStoreFeatureResult,
>(
name: Name,
createMutationFn: CreateMutationFn<TData, TError, TVariables, TContext, Input>,
createMutationFn: CreateMutationFn<TData, TError, TVariables, TContext, NoInfer<Input>>,
): SignalStoreFeature<
Input,
EmptyFeatureResult & { methods: Record<MutationProp<Name>, MutationMethod<TData, TError, TVariables, TContext>> }
EmptyFeatureResult & {
methods: Record<
MutationProp<NoInfer<Name>>,
MutationMethod<NoInfer<TData>, NoInfer<TError>, NoInfer<TVariables>, NoInfer<TContext>>
>;
}
> => {
const prop: MutationProp<Name> = `${lowerFirst(name)}Mutation`;
const prop: MutationProp<NoInfer<Name>> = `${lowerFirst(name)}Mutation`;

return signalStoreFeature(
withMethods((store) => {
const mutation = injectMutation(createMutationFn(store as QueryStore<Input>), inject(Injector));
const mutation = injectMutation(createMutationFn(store as QueryStore<NoInfer<Input>>));

return {
[prop]: new Proxy(() => mutation, {
get: (_, prop) => Reflect.get(mutation, prop),
has: (_, prop) => Reflect.has(mutation, prop),
}),
} as Record<MutationProp<Name>, MutationMethod<TData, TError, TVariables, TContext>>;
} as Record<
MutationProp<NoInfer<Name>>,
MutationMethod<NoInfer<TData>, NoInfer<TError>, NoInfer<TVariables>, NoInfer<TContext>>
>;
}),
);
};
Loading

0 comments on commit 25615c8

Please sign in to comment.