From df9c50561340da274833eb58274ea2fa601564fc Mon Sep 17 00:00:00 2001 From: Adrien Crivelli Date: Wed, 29 Jul 2026 15:56:06 +0900 Subject: [PATCH 1/5] BREAKING: `modelService.delete()` does not `refetchObservableQueries()` #12584 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The most common use case for `modelService.delete()` is from a page detail, and after deletion the user is redirected to the page listing. So there is no need to `refetchObservableQueries()` unconditionally. # Other use cases For other use cases we can reload specific queries like those examples. ## Dialog From a dialog on top of the listing page: ```ts this.itemService .delete([id], { // Wait till we refresh the list under the dialog before closing the dialog, to avoid re-clicking on the just deleted item refetchQueries: [itemsQuery], awaitRefetchQueries: true, }) .subscribe({ next: () => { this.alertService.info(`Supprimé`); this.dialogRef.close(); } }); ``` ## Inline deletion in listing From a listing page where deletion is directly accessible on the row: ```ts this.itemService.delete([id], { refetchQueries: [itemsQuery], }).subscribe({ next: () => { this.alertService.info(`Supprimé`); }, error: () => this.deleting.delete(id), }); ``` ## Bulk deleting Bulk deleting via `NaturalAbstractList.bulkDelete()` still automatically refetch the listing of items, but no other queries anymore. --- .../src/lib/classes/abstract-detail.ts | 2 +- .../natural/src/lib/classes/abstract-list.ts | 18 ++++++---- .../modules/relations/relations.component.ts | 2 +- .../services/abstract-model.service.spec.ts | 36 ------------------- .../lib/services/abstract-model.service.ts | 26 ++++++++------ 5 files changed, 28 insertions(+), 56 deletions(-) diff --git a/projects/natural/src/lib/classes/abstract-detail.ts b/projects/natural/src/lib/classes/abstract-detail.ts index 8a65f83a..21db6c7c 100644 --- a/projects/natural/src/lib/classes/abstract-detail.ts +++ b/projects/natural/src/lib/classes/abstract-detail.ts @@ -50,7 +50,7 @@ export class NaturalAbstractDetail< any, any, any, - unknown, + any, any >, ExtraResolve extends Literal = Record, diff --git a/projects/natural/src/lib/classes/abstract-list.ts b/projects/natural/src/lib/classes/abstract-list.ts index 757032c9..0014f9fc 100644 --- a/projects/natural/src/lib/classes/abstract-list.ts +++ b/projects/natural/src/lib/classes/abstract-list.ts @@ -569,7 +569,7 @@ export class NaturalAbstractList< } /** - * Delete multiple items at once + * Delete multiple items at once, and then refresh the list of items automatically */ protected bulkDelete(): Observable { const subject = new Subject(); @@ -581,12 +581,16 @@ export class NaturalAbstractList< // never call this method. const selection = this.selection.selected as {id: string}[]; - this.service.delete(selection).subscribe(() => { - this.selection.clear(); - this.alertService.info($localize`Supprimé`); - subject.next(); - subject.complete(); - }); + this.service + .delete(selection, { + refetchQueries: this.service.allQuery ? [this.service.allQuery] : [], + }) + .subscribe(() => { + this.selection.clear(); + this.alertService.info($localize`Supprimé`); + subject.next(); + subject.complete(); + }); } }); diff --git a/projects/natural/src/lib/modules/relations/relations.component.ts b/projects/natural/src/lib/modules/relations/relations.component.ts index 0c081677..4a83447b 100644 --- a/projects/natural/src/lib/modules/relations/relations.component.ts +++ b/projects/natural/src/lib/modules/relations/relations.component.ts @@ -94,7 +94,7 @@ export class NaturalRelationsComponent< any, unknown, any, - unknown, + any, any >, > diff --git a/projects/natural/src/lib/services/abstract-model.service.spec.ts b/projects/natural/src/lib/services/abstract-model.service.spec.ts index ef26162a..2cac7c7a 100644 --- a/projects/natural/src/lib/services/abstract-model.service.spec.ts +++ b/projects/natural/src/lib/services/abstract-model.service.spec.ts @@ -8,7 +8,6 @@ import {type Literal} from '../types/types'; import {NullService} from '../testing/null.service'; import {Apollo} from 'apollo-angular'; import {takeWhile} from 'rxjs/operators'; -import {type ObservableQuery} from '@apollo/client'; const observableError = 'Cannot use Observable as variables. Instead you should use .subscribe() to call the method with a real value'; @@ -27,41 +26,6 @@ describe('NaturalAbstractModelService', () => { service = TestBed.inject(PostService); }); - it('should be delay deleted resolving', fakeAsync(() => { - const apollo = TestBed.inject(Apollo); - - let resolveMyPromise: (value: ObservableQuery.Result[]) => void; - apollo.client.refetchObservableQueries = () => - new Promise[]>(resolve => { - resolveMyPromise = resolve; - }); - - let resolved = false; - let completed = false; - service.delete([{id: '123'}]).subscribe({ - next: () => { - resolved = true; - }, - complete: () => { - completed = true; - }, - }); - - expect(resolved).toBeFalse(); - expect(completed).toBeFalse(); - - tick(2000); - - expect(resolved).toBeFalse(); - expect(completed).toBeFalse(); - - resolveMyPromise!([]); - tick(2000); - - expect(resolved).toBeTrue(); - expect(completed).toBeTrue(); - })); - it('should be created', () => { expect(service).toBeTruthy(); }); diff --git a/projects/natural/src/lib/services/abstract-model.service.ts b/projects/natural/src/lib/services/abstract-model.service.ts index 48f38963..6f4d9187 100644 --- a/projects/natural/src/lib/services/abstract-model.service.ts +++ b/projects/natural/src/lib/services/abstract-model.service.ts @@ -10,7 +10,7 @@ import { import {type DocumentNode} from 'graphql'; import {merge, pick} from 'es-toolkit'; import {defaults} from 'es-toolkit/compat'; -import {catchError, combineLatest, EMPTY, first, from, Observable, of, type OperatorFunction} from 'rxjs'; +import {catchError, combineLatest, EMPTY, first, Observable, of, type OperatorFunction} from 'rxjs'; import {debounceTime, filter, map, shareReplay, startWith, switchMap, takeWhile, tap} from 'rxjs/operators'; import {NaturalQueryVariablesManager, type QueryVariables} from '../classes/query-variable-manager'; import {type Literal} from '../types/types'; @@ -32,6 +32,10 @@ export type FormControls = Record; export type WithId = {id: string} & T; +export type MutateOptionsWithoutVariables = Vdelete extends never + ? never + : Omit, 'mutation' | 'variables'>; + export abstract class NaturalAbstractModelService< Tone, Vone extends {id: string}, @@ -68,7 +72,7 @@ export abstract class NaturalAbstractModelService< public constructor( protected readonly name: string, protected readonly oneQuery: DocumentNode | null, - protected readonly allQuery: DocumentNode | null, + public readonly allQuery: DocumentNode | null, protected readonly createMutation: DocumentNode | null, protected readonly updateMutation: DocumentNode | null, protected readonly deleteMutation: DocumentNode | null, @@ -430,7 +434,13 @@ export abstract class NaturalAbstractModelService< /** * Delete objects and then refetch the list of objects */ - public delete(objects: {id: string}[]): Observable { + public delete( + objects: {id: string}[], + options: MutateOptionsWithoutVariables = {} as MutateOptionsWithoutVariables< + Tdelete, + Vdelete + >, + ): Observable { this.throwIfObservable(objects); this.throwIfNotQuery(this.deleteMutation); @@ -449,17 +459,11 @@ export abstract class NaturalAbstractModelService< return this.apollo .mutate({ + ...options, mutation: this.deleteMutation, variables: variables, }) - .pipe( - // Delay the observable until Apollo refetch is completed - switchMap(result => { - const mappedResult = this.mapDelete(result); - - return from(this.apollo.client.refetchObservableQueries()).pipe(map(() => mappedResult)); - }), - ); + .pipe(map(result => this.mapDelete(result))); } /** From 737fa14f7e6c89d2fb77c3b9bdaa1bd0df7028c3 Mon Sep 17 00:00:00 2001 From: Adrien Crivelli Date: Thu, 30 Jul 2026 14:46:51 +0900 Subject: [PATCH 2/5] BREAKING: `modelService.create()` does not `refetchObservableQueries()` #12584 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The most common use case for `modelService.create()` is from a page detail for creation, and after the creation the user is redirected to the page detail for edition. So there is no need to `refetchObservableQueries()` unconditionally. # Other use cases For other use cases we can reload specific queries like those examples. ## Dialog From a dialog on top of the listing page: ```ts this.itemService .create({...}, { // Refresh the list under the dialog refetchQueries: [itemsQuery] }) .subscribe(newItem => { this.alertService.info('Créé'); this.dialogRef.close(newItem); }); ``` ## Inline creation in listing From a listing page where row creation is inline, there is nothing to do, because the row already exists before we even mutate the server. --- .../src/lib/classes/abstract-detail.ts | 2 +- .../modules/relations/relations.component.ts | 2 +- .../lib/services/abstract-model.service.ts | 37 ++++++++++--------- 3 files changed, 22 insertions(+), 19 deletions(-) diff --git a/projects/natural/src/lib/classes/abstract-detail.ts b/projects/natural/src/lib/classes/abstract-detail.ts index 21db6c7c..8a65f83a 100644 --- a/projects/natural/src/lib/classes/abstract-detail.ts +++ b/projects/natural/src/lib/classes/abstract-detail.ts @@ -50,7 +50,7 @@ export class NaturalAbstractDetail< any, any, any, - any, + unknown, any >, ExtraResolve extends Literal = Record, diff --git a/projects/natural/src/lib/modules/relations/relations.component.ts b/projects/natural/src/lib/modules/relations/relations.component.ts index 4a83447b..0c081677 100644 --- a/projects/natural/src/lib/modules/relations/relations.component.ts +++ b/projects/natural/src/lib/modules/relations/relations.component.ts @@ -94,7 +94,7 @@ export class NaturalRelationsComponent< any, unknown, any, - any, + unknown, any >, > diff --git a/projects/natural/src/lib/services/abstract-model.service.ts b/projects/natural/src/lib/services/abstract-model.service.ts index 6f4d9187..8a315b41 100644 --- a/projects/natural/src/lib/services/abstract-model.service.ts +++ b/projects/natural/src/lib/services/abstract-model.service.ts @@ -1,5 +1,11 @@ import {Apollo, gql, onlyCompleteData} from 'apollo-angular'; -import {type ApolloLink, NetworkStatus, type ObservableQuery, type WatchQueryFetchPolicy} from '@apollo/client'; +import { + type ApolloLink, + NetworkStatus, + type ObservableQuery, + type OperationVariables, + type WatchQueryFetchPolicy, +} from '@apollo/client'; import { type AbstractControl, type AsyncValidatorFn, @@ -32,9 +38,10 @@ export type FormControls = Record; export type WithId = {id: string} & T; -export type MutateOptionsWithoutVariables = Vdelete extends never - ? never - : Omit, 'mutation' | 'variables'>; +export type MutateOptionsWithoutVariables = Omit< + Apollo.MutateOptions, + 'mutation' | 'variables' +>; export abstract class NaturalAbstractModelService< Tone, @@ -366,9 +373,12 @@ export abstract class NaturalAbstractModelService< } /** - * Create an object in DB and then refetch the list of objects + * Create an object in DB */ - public create(object: Vcreate['input']): Observable { + public create( + object: Vcreate['input'], + options: MutateOptionsWithoutVariables = {}, + ): Observable { this.throwIfObservable(object); this.throwIfNotQuery(this.createMutation); @@ -379,15 +389,11 @@ export abstract class NaturalAbstractModelService< return this.apollo .mutate({ + ...(options as MutateOptionsWithoutVariables), mutation: this.createMutation, variables: variables, }) - .pipe( - map(result => { - this.apollo.client.refetchObservableQueries(); - return this.mapCreation(result); - }), - ); + .pipe(map(result => this.mapCreation(result))); } /** @@ -436,10 +442,7 @@ export abstract class NaturalAbstractModelService< */ public delete( objects: {id: string}[], - options: MutateOptionsWithoutVariables = {} as MutateOptionsWithoutVariables< - Tdelete, - Vdelete - >, + options: MutateOptionsWithoutVariables = {}, ): Observable { this.throwIfObservable(objects); this.throwIfNotQuery(this.deleteMutation); @@ -459,7 +462,7 @@ export abstract class NaturalAbstractModelService< return this.apollo .mutate({ - ...options, + ...(options as MutateOptionsWithoutVariables), mutation: this.deleteMutation, variables: variables, }) From 8b7b49445c64523a7573e0f7f6b59522960ef1a3 Mon Sep 17 00:00:00 2001 From: Adrien Crivelli Date: Mon, 3 Aug 2026 17:03:11 +0900 Subject: [PATCH 3/5] BREAKING: modelService `update()`, `updateNow()` and `createOrUpdate()` do not `refetchObservableQueries()` #12584 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The most common use case for `modelService.updateNow()`, and variants, is from a page detail for edition. The update mutation itself already fetches the very few fields that are server computed and should be refetched. So there is no need to `refetchObservableQueries()` unconditionally. # Other use cases For other use cases we can reload specific queries like those examples. ## Dialog From a dialog on top of the listing page: ```ts this.itemService .updateNow({...}, { // Wait till we refresh the list under the dialog before closing the dialog, to avoid re-clicking on the just updated item refetchQueries: [itemsQuery], awaitRefetchQueries: true }) .subscribe(newItem => { this.alertService.info('Mis à jour'); this.dialogRef.close(newItem); }); ``` ## Inline update in listing From a listing page where row update is inline, there is nothing to do, because the row already is up to date before we even mutate the server. ## Bulk updating Bulk updating is extremely rare in our projects, and should be treated on a case by base basis. --- .../services/abstract-model.service.spec.ts | 10 +++---- .../lib/services/abstract-model.service.ts | 29 +++++++------------ 2 files changed, 15 insertions(+), 24 deletions(-) diff --git a/projects/natural/src/lib/services/abstract-model.service.spec.ts b/projects/natural/src/lib/services/abstract-model.service.spec.ts index 2cac7c7a..f822beb5 100644 --- a/projects/natural/src/lib/services/abstract-model.service.spec.ts +++ b/projects/natural/src/lib/services/abstract-model.service.spec.ts @@ -197,7 +197,7 @@ describe('NaturalAbstractModelService', () => { }; let creationResult: any; - const creation = service.createOrUpdate(input, true); + const creation = service.createOrUpdate(input); creation.subscribe(v => (creationResult = v)); // After create, should be usual object after creation @@ -207,11 +207,11 @@ describe('NaturalAbstractModelService', () => { // Create or update again let updateResult: any; - const update = service.createOrUpdate(creationResult, true); + const update = service.createOrUpdate(creationResult); update.subscribe(v => (updateResult = v)); // should show created + updated objects merged - tick(); + tick(5000); expect('updateDate' in updateResult).toBeTrue(); flush(); @@ -227,11 +227,11 @@ describe('NaturalAbstractModelService', () => { let repeatedResult: any = null; // Create, should be cached - const creation = service.createOrUpdate(input, true); + const creation = service.createOrUpdate(input); creation.subscribe(res => (result = res)); // Repeated create should wait for the first creation, then update the object - const repeatedCreation = service.createOrUpdate(input, true); + const repeatedCreation = service.createOrUpdate(input); repeatedCreation.subscribe(res => (repeatedResult = res)); tick(5000); diff --git a/projects/natural/src/lib/services/abstract-model.service.ts b/projects/natural/src/lib/services/abstract-model.service.ts index 8a315b41..c9c8cbb3 100644 --- a/projects/natural/src/lib/services/abstract-model.service.ts +++ b/projects/natural/src/lib/services/abstract-model.service.ts @@ -324,13 +324,10 @@ export abstract class NaturalAbstractModelService< * This functions allow to quickly create or update objects. * * Manages a "creation is pending" status, and update when creation is ready. - * Uses regular update/updateNow and create methods. - * Used mainly when editing multiple objects in same controller (like in editable arrays) + * Uses regular `create()` (with immediate effect) and `update()` (with debounced effect) methods. + * Used mainly when editing multiple objects in the same controller, like in editable arrays. */ - public createOrUpdate( - object: Vcreate['input'] | WithId, - now = false, - ): Observable { + public createOrUpdate(object: Vcreate['input'] | WithId): Observable { this.throwIfObservable(object); this.throwIfNotQuery(this.createMutation); this.throwIfNotQuery(this.updateMutation); @@ -350,12 +347,7 @@ export abstract class NaturalAbstractModelService< // If object has Id, just save it if ('id' in object && object.id) { - if (now) { - // used mainly for tests, because lodash debounced used in update() does not work fine with fakeAsync and tick() - return this.updateNow(object as WithId); - } else { - return this.update(object as WithId); - } + return this.update(object as WithId); } // If object was not saving, and has no ID, create it @@ -412,7 +404,10 @@ export abstract class NaturalAbstractModelService< /** * Update an object immediately when subscribing */ - public updateNow(object: WithId): Observable { + public updateNow( + object: WithId, + options: MutateOptionsWithoutVariables = {}, + ): Observable { this.throwIfObservable(object); this.throwIfNotQuery(this.updateMutation); @@ -426,15 +421,11 @@ export abstract class NaturalAbstractModelService< return this.apollo .mutate({ + ...(options as MutateOptionsWithoutVariables), mutation: this.updateMutation, variables: variables, }) - .pipe( - map(result => { - this.apollo.client.refetchObservableQueries(); - return this.mapUpdate(result); - }), - ); + .pipe(map(result => this.mapUpdate(result))); } /** From 4be128f981b98bcdae48e35d2f52f670659c8c1a Mon Sep 17 00:00:00 2001 From: Adrien Crivelli Date: Mon, 3 Aug 2026 18:13:12 +0900 Subject: [PATCH 4/5] Update comment according to recent changes #12584 --- projects/natural/src/lib/services/abstract-model.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/projects/natural/src/lib/services/abstract-model.service.ts b/projects/natural/src/lib/services/abstract-model.service.ts index c9c8cbb3..7f377373 100644 --- a/projects/natural/src/lib/services/abstract-model.service.ts +++ b/projects/natural/src/lib/services/abstract-model.service.ts @@ -429,7 +429,7 @@ export abstract class NaturalAbstractModelService< } /** - * Delete objects and then refetch the list of objects + * Delete objects */ public delete( objects: {id: string}[], From 1c752c900745debc286bc2f63e642d463e67e639 Mon Sep 17 00:00:00 2001 From: Adrien Crivelli Date: Mon, 3 Aug 2026 18:16:30 +0900 Subject: [PATCH 5/5] Bulk delete wait until after refetch #12584 Like it used to do before the recent changes --- projects/natural/src/lib/classes/abstract-list.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/projects/natural/src/lib/classes/abstract-list.ts b/projects/natural/src/lib/classes/abstract-list.ts index 0014f9fc..1dbb7eb1 100644 --- a/projects/natural/src/lib/classes/abstract-list.ts +++ b/projects/natural/src/lib/classes/abstract-list.ts @@ -584,6 +584,7 @@ export class NaturalAbstractList< this.service .delete(selection, { refetchQueries: this.service.allQuery ? [this.service.allQuery] : [], + awaitRefetchQueries: true, }) .subscribe(() => { this.selection.clear();