Skip to content

Commit

Permalink
feat: add response update feature to query stack and enhance post han…
Browse files Browse the repository at this point in the history
…dling
  • Loading branch information
TomTomB committed Feb 4, 2025
1 parent a3672f0 commit 60598e8
Show file tree
Hide file tree
Showing 8 changed files with 128 additions and 10 deletions.
41 changes: 36 additions & 5 deletions apps/playground/src/app/app.component.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { JsonPipe } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
Expand Down Expand Up @@ -107,6 +108,7 @@ const queryGqlPost = createGqlQuery<GetGqlPostsQueryArgs>({

type Post = {
id: number;
userId: number;
title: string;
body: string;
};
Expand Down Expand Up @@ -154,8 +156,7 @@ type GetUserQueryArgs = {
<p>Error</p>
<pre>{{ myPostQuery1.error() | json }}</pre>
-->
<p>Response</p>
<pre>{{ myPost.response() | json }}</pre>
Expand All @@ -165,7 +166,14 @@ type GetUserQueryArgs = {
<p>Error</p>
<pre>{{ myPost.error() | json }}</pre>
-->
<button (click)="updateResponse()">Update response</button>
<button (click)="refreshToOriginal()">Refresh to original</button>
<br />
<br />
<br />
<!-- <p>Response</p>
<pre>{{ myUsers.response() | json }}</pre>
Expand Down Expand Up @@ -211,7 +219,7 @@ type GetUserQueryArgs = {
`,
changeDetection: ChangeDetectionStrategy.OnPush,
encapsulation: ViewEncapsulation.None,
imports: [],
imports: [JsonPipe],
})
export class DynCompComponent {
data = input.required<string>();
Expand All @@ -234,8 +242,23 @@ export class DynCompComponent {

plusOnePage = signal(1);
currentPostId = signal(5);
updateResponseData = signal(0);

myPost = getPost(E.withArgs(() => ({ pathParams: { postId: this.plusOnePage() } })));
myPost = getPost(
E.withArgs(() => ({ pathParams: { postId: this.plusOnePage() } })),
E.withResponseUpdate({
updater: () => {
const data = this.updateResponseData();

if (data % 2 === 0) {
return { title: 'Even', body: 'Even', id: data, userId: 1 };
}

return { title: 'Odd', body: 'Odd', id: data, userId: 1 };
},
}),
// E.withPolling({ interval: 5000 }),
);

posts = getPosts(E.withAutoRefresh({ onSignalChanges: [this.plusOnePage] }));

Expand Down Expand Up @@ -298,6 +321,14 @@ export class DynCompComponent {
addPostQuery() {
this.currentPostId.update((id) => id + 1);
}

updateResponse() {
this.updateResponseData.update((data) => data + 1);
}

refreshToOriginal() {
this.myPost.execute();
}
}

@Component({
Expand Down
8 changes: 8 additions & 0 deletions libs/query/src/lib/experimental/http/query-errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ export const enum QueryRuntimeErrorCode {

// Query Stack
QUERY_STACK_WITH_ARGS_USED = 500,
QUERY_STACK_WITH_RESPONSE_UPDATE_USED = 501,
}

export const queryFeatureUsedMultipleTimes = (type: QueryFeatureType) => {
Expand Down Expand Up @@ -200,3 +201,10 @@ export const queryStackWithArgsUsed = () => {
`withArgs() has been used in a query stack or a paged query stack. This is not supported.`,
);
};

export const queryStackWithResponseUpdateUsed = () => {
return new RuntimeError(
QueryRuntimeErrorCode.QUERY_STACK_WITH_RESPONSE_UPDATE_USED,
`withResponseUpdate() has been used in a query stack or a paged query stack. This is not supported.`,
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ export const queryExecute = <TArgs extends QueryArgs>(options: QueryExecuteOptio
});

state.lastTimeExecutedAt.set(Date.now());
state.subtle.request.set(request);
};

export type CleanupPreviousExecuteOptions<TArgs extends QueryArgs> = {
Expand Down
58 changes: 58 additions & 0 deletions libs/query/src/lib/experimental/http/query-features.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export const enum QueryFeatureType {
WithSuccessHandling = 'withSuccessHandling',
WithPolling = 'withPolling',
WithAutoRefresh = 'withAutoRefresh',
WithResponseUpdate = 'withResponseUpdate',
}

export type QueryFeatureContext<TArgs extends QueryArgs> = {
Expand Down Expand Up @@ -269,3 +270,60 @@ export const withAutoRefresh = <TArgs extends QueryArgs>(options: WithAutoRefres
},
});
};

export type WithResponseUpdateFeatureFnData<TArgs extends QueryArgs> = {
/** The current response of the query */
currentResponse: ResponseType<TArgs> | null;
};

export type WithResponseUpdateFeatureOptions<TArgs extends QueryArgs> = {
/**
* A function that will be called with the latest response
* If the function returns `null`, the response will not be updated.
* Otherwise, the response will be updated with the returned value.
* The function will be called in a reactive signal context.
* If the query get's executed after the response was updated, the response will be set the the fresh data received from the server.
*
* This feature is most useful in combination with web sockets where the data received from the socket might be more up-to-date than the data received previously.
*
* @example
* const matchEvents = mySocket.joinRoom('match-events');
*
* const myMatchQuery = getMatch(
* withArgs(() => ({ matchId: 1 })),
* withResponseUpdate(({ currentResponse }) => {
* const matchEvent = matchEvents();
*
* if (!matchEvent) return null;
*
* // Do some checks here. This is just a very simple example.
* // To apply partial updates, you can use the spread operator in combination with the current response.
*
* return matchEvent;
* })
* )
*/
updater: (data: WithResponseUpdateFeatureFnData<TArgs>) => ResponseType<TArgs> | null;
};

export const withResponseUpdate = <TArgs extends QueryArgs>(options: WithResponseUpdateFeatureOptions<TArgs>) => {
return createQueryFeature<TArgs>({
type: QueryFeatureType.WithResponseUpdate,
fn: (context) => {
queryEffect(
() => {
const currentResponse = untracked(() => context.state.response());
const response = options.updater({ currentResponse });

if (response === null) return;

untracked(() => {
context.state.response.set(response);
});
},
QUERY_EFFECT_ERROR_MESSAGE,
{ injector: context.deps.injector },
);
},
});
};
7 changes: 6 additions & 1 deletion libs/query/src/lib/experimental/http/query-stack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { HttpErrorResponse } from '@angular/common/http';
import { computed, effect, inject, Injector, runInInjectionContext, Signal, signal, untracked } from '@angular/core';
import { AnyQuery, RequestArgs, ResponseType } from './query';
import { AnyQueryCreator, QueryArgsOf, RunQueryCreator } from './query-creator';
import { queryStackWithArgsUsed } from './query-errors';
import { queryStackWithArgsUsed, queryStackWithResponseUpdateUsed } from './query-errors';
import { QueryFeature, QueryFeatureType, withArgs } from './query-features';

export type QueryStack<TQuery extends AnyQuery, TTransform> = {
Expand Down Expand Up @@ -155,11 +155,16 @@ export const createQueryStack = <
const lastQuery = signal<QueryType | null>(null);

const hasWithArgsFeature = features.some((f) => f.type == QueryFeatureType.WithArgs);
const hasWithOptimisticUpdateFeature = features.some((f) => f.type == QueryFeatureType.WithResponseUpdate);

if (hasWithArgsFeature) {
throw queryStackWithArgsUsed();
}

if (hasWithOptimisticUpdateFeature) {
throw queryStackWithResponseUpdateUsed();
}

effect(() => {
// Clear the stack if dependencies change
dependencies?.();
Expand Down
12 changes: 11 additions & 1 deletion libs/query/src/lib/experimental/http/query-state.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,24 @@
import { HttpErrorResponse, HttpEvent } from '@angular/common/http';
import { WritableSignal, signal } from '@angular/core';
import { HttpRequestLoadingState } from './http-request';
import { HttpRequest, HttpRequestLoadingState } from './http-request';
import { QueryArgs, RequestArgs, ResponseType } from './query';

// eslint-disable-next-line @typescript-eslint/no-empty-object-type
export type SetupQueryStateOptions = {};

export type QueryStateSubtle<TArgs extends QueryArgs> = {
request: WritableSignal<HttpRequest<TArgs> | null>;
};

export type QueryState<TArgs extends QueryArgs> = {
response: WritableSignal<ResponseType<TArgs> | null>;
args: WritableSignal<RequestArgs<TArgs> | null>;
latestHttpEvent: WritableSignal<HttpEvent<ResponseType<TArgs>> | null>;
loading: WritableSignal<HttpRequestLoadingState | null>;
error: WritableSignal<HttpErrorResponse | null>;
lastTimeExecutedAt: WritableSignal<number | null>;

subtle: QueryStateSubtle<TArgs>;
};

export const setupQueryState = <TArgs extends QueryArgs>(options: SetupQueryStateOptions) => {
Expand All @@ -22,6 +28,7 @@ export const setupQueryState = <TArgs extends QueryArgs>(options: SetupQueryStat
const error = signal<HttpErrorResponse | null>(null);
const loading = signal<HttpRequestLoadingState | null>(null);
const lastTimeExecutedAt = signal<number | null>(null);
const request = signal<HttpRequest<TArgs> | null>(null);

const state: QueryState<TArgs> = {
response,
Expand All @@ -30,6 +37,9 @@ export const setupQueryState = <TArgs extends QueryArgs>(options: SetupQueryStat
loading,
error,
lastTimeExecutedAt,
subtle: {
request,
},
};

return state;
Expand Down
4 changes: 3 additions & 1 deletion libs/query/src/lib/experimental/http/query-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { getActiveConsumer, setActiveConsumer } from '@angular/core/primitives/s
import { isSymfonyPagerfantaOutOfRangeError } from '../../symfony';
import { CreateGqlQueryOptions, isCreateGqlQueryOptions } from '../gql/gql-query';
import { GqlQueryMethod } from '../gql/gql-query-creator';
import { CreateQueryOptions, Query, QueryArgs, RequestArgs } from './query';
import { CreateQueryOptions, Query, QueryArgs, RequestArgs, ResponseType } from './query';
import { QueryMethod } from './query-creator';
import { QueryDependencies } from './query-dependencies';
import { queryFeatureUsedMultipleTimes, withArgsQueryFeatureMissingButRouteIsFunction } from './query-errors';
Expand Down Expand Up @@ -264,6 +264,7 @@ export const createQueryObject = <TArgs extends QueryArgs>(options: CreateQueryO
const { state, execute, deps } = options;

const destroy = () => deps.injector.destroy();
const setResponse = (response: ResponseType<TArgs>) => state.response.set(response);
const createSnapshot = createQuerySnapshotFn({ state, deps, execute });

const query: Query<TArgs> = {
Expand All @@ -279,6 +280,7 @@ export const createQueryObject = <TArgs extends QueryArgs>(options: CreateQueryO
reset: execute.reset,
subtle: {
destroy,
setResponse,
},
};

Expand Down
7 changes: 5 additions & 2 deletions libs/query/src/lib/experimental/http/query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,9 +68,12 @@ export type QuerySnapshot<TArgs extends QueryArgs> = QueryBase<TArgs> & {
export type AnyQuerySnapshot = QuerySnapshot<any>;
export type AnyQuery = Query<any>;

export type QuerySubtle = {
export type QuerySubtle<TArgs extends QueryArgs> = {
/** Destroys the query and cleans up all resources. The query should not be used after this method is called. */
destroy: () => void;

/** Manually sets the response of the query. This will not trigger a new execution of the query. */
setResponse: (response: ResponseType<TArgs>) => void;
};

export type Query<TArgs extends QueryArgs> = QueryBase<TArgs> & {
Expand All @@ -84,7 +87,7 @@ export type Query<TArgs extends QueryArgs> = QueryBase<TArgs> & {
reset: () => void;

/** Advanced query features. **WARNING!** Incorrectly using these features will likely **BREAK** your application. You have been warned! */
subtle: QuerySubtle;
subtle: QuerySubtle<TArgs>;
};

export const createQuery = <TArgs extends QueryArgs>(options: CreateQueryOptions<TArgs>) => {
Expand Down

0 comments on commit 60598e8

Please sign in to comment.