diff --git a/package.json b/package.json index e9e516e4..3dcd90ce 100644 --- a/package.json +++ b/package.json @@ -32,10 +32,10 @@ "@angular/material": "^21.2.14", "@angular/platform-browser": "^21.2.17", "@angular/router": "^21.2.17", - "@apollo/client": "^3.13.7", + "@apollo/client": "^4.2.7", "@ecodev/natural-layout": "^2.0.2", "@graphql-tools/mock": "^9.0.25", - "apollo-angular": "^11.0.0", + "apollo-angular": "^14.1.0", "es-toolkit": "^1.39.10", "extract-files": "^13.0.0", "graphql": "^16.11.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7079a1da..a10d2edb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -36,8 +36,8 @@ importers: specifier: ^21.2.17 version: 21.2.17(@angular/common@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1))(@angular/platform-browser@21.2.17(@angular/common@21.2.17(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1)))(rxjs@7.8.2) '@apollo/client': - specifier: ^3.13.7 - version: 3.14.0(graphql@16.12.0) + specifier: ^4.2.7 + version: 4.2.7(graphql@16.12.0)(rxjs@7.8.2) '@ecodev/natural-layout': specifier: ^2.0.2 version: 2.0.5 @@ -45,8 +45,8 @@ importers: specifier: ^9.0.25 version: 9.1.5(graphql@16.12.0) apollo-angular: - specifier: ^11.0.0 - version: 11.0.0(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1))(@apollo/client@3.14.0(graphql@16.12.0))(graphql@16.12.0)(rxjs@7.8.2) + specifier: ^14.1.0 + version: 14.1.0(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1))(@apollo/client@4.2.7(graphql@16.12.0)(rxjs@7.8.2))(graphql@16.12.0)(rxjs@7.8.2) es-toolkit: specifier: ^1.39.10 version: 1.44.0 @@ -466,13 +466,14 @@ packages: '@angular/platform-browser': 21.2.17 rxjs: ^6.5.3 || ^7.4.0 - '@apollo/client@3.14.0': - resolution: {integrity: sha512-0YQKKRIxiMlIou+SekQqdCo0ZTHxOcES+K8vKB53cIDpwABNR0P0yRzPgsbgcj3zRJniD93S/ontsnZsCLZrxQ==} + '@apollo/client@4.2.7': + resolution: {integrity: sha512-Z129zR77VP0oWWIXPpHgwbtwhCVBzkw/FhiiymbqwlUniKb5z2KBeuQkpNIz3QfuO7ezHiaxsJ0PPjYBJTSHtg==} peerDependencies: - graphql: ^15.0.0 || ^16.0.0 + graphql: ^16.0.0 || ^17.0.0 graphql-ws: ^5.5.5 || ^6.0.3 - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || >=19.0.0-rc - react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || >=19.0.0-rc + react: ^17.0.0 || ^18.0.0 || >=19.0.0-rc + react-dom: ^17.0.0 || ^18.0.0 || >=19.0.0-rc + rxjs: ^7.3.0 subscriptions-transport-ws: ^0.9.0 || ^0.11.0 peerDependenciesMeta: graphql-ws: @@ -2117,14 +2118,14 @@ packages: resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} engines: {node: '>= 8'} - apollo-angular@11.0.0: - resolution: {integrity: sha512-8esMQ7Y/avjodz5G0vMlPy58KHrMetzx/I6Xp1Zn1AnYhBbbv7cEc25fZrdMWKwi0c061Fux3HWfu8iCk1OpDg==} + apollo-angular@14.1.0: + resolution: {integrity: sha512-rE6gcY9pSsI6FuG0tNhoVGxVJ82qN6vQAtG0t2yfujWTJRW1YD6aZe85D5LyyADM1oXGYySuYQcTtWBqAAQ6XQ==} engines: {node: '>=16'} peerDependencies: - '@angular/core': ^18.0.0 || ^19.0.0 || ^20.0.0 - '@apollo/client': ^3.13.1 + '@angular/core': ^20.0.0 || ^21.0.0 || ^22.0.0 + '@apollo/client': ^4.0.1 graphql: ^16.0.0 - rxjs: ^6.0.0 || ^7.0.0 + rxjs: ^7.8.0 argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} @@ -2741,11 +2742,11 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} - graphql-tag@2.12.6: - resolution: {integrity: sha512-FdSNcu2QQcWnM2VNvSCCDCVS5PpPqpzgFT8+GXzqJuoDd0CBncxCY278u4mhRO7tMgo2JjgJA5aZ+nWSQ/Z+xg==} + graphql-tag@2.12.7: + resolution: {integrity: sha512-xnE/NFzy+0eIesvAsREJZ284zTl/wYuBAvpsFSDhRGRdRHdnE90M21Q3xAWyYInb0J756c6x0pIQ62+vtvOs1Q==} engines: {node: '>=10'} peerDependencies: - graphql: ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 + graphql: ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 graphql@16.12.0: resolution: {integrity: sha512-DKKrynuQRne0PNpEbzuEdHlYOMksHSUI8Zc9Unei5gTsMNA2/vMpoMz/yKba50pejK56qj98qM0SjYxAKi13gQ==} @@ -2767,9 +2768,6 @@ packages: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} - hoist-non-react-statics@3.3.2: - resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==} - hono@4.12.3: resolution: {integrity: sha512-SFsVSjp8sj5UumXOOFlkZOG6XS9SJDKw0TbwFeV+AJ8xlST8kxK5Z/5EYa111UY8732lK2S/xB653ceuaoGwpg==} engines: {node: '>=16.9.0'} @@ -3075,10 +3073,6 @@ packages: resolution: {integrity: sha512-1somDdy9sChrr9/f4UlzhdaGfDR2c/SaD2a4T7qEkG4jTS57/B3qmnjLYePwQ8cqWnUHZI0iAKxMBpCZICiZ2g==} engines: {node: '>=8.0'} - loose-envify@1.4.0: - resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} - hasBin: true - lru-cache@11.2.5: resolution: {integrity: sha512-vFrFJkWtJvJnD5hg+hJvVE8Lh/TcMzKnTgCWmtBipwI5yLX/iX+5UB2tfuyODF5E7k9xEzMdYgGqaSb1c0c5Yw==} engines: {node: 20 || >=22} @@ -3496,9 +3490,6 @@ packages: resolution: {integrity: sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==} engines: {node: '>=10'} - prop-types@15.8.1: - resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} - prosemirror-commands@1.7.1: resolution: {integrity: sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w==} @@ -3575,9 +3566,6 @@ packages: resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} engines: {node: '>= 0.10'} - react-is@16.13.1: - resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} - readdirp@3.6.0: resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} engines: {node: '>=8.10.0'} @@ -3593,17 +3581,6 @@ packages: reflect-metadata@0.2.2: resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==} - rehackt@0.1.0: - resolution: {integrity: sha512-7kRDOuLHB87D/JESKxQoRwv4DzbIdwkAGQ7p6QKGdVlY1IZheUnVhlk/4UZlNUVxdAXpyxikE3URsG067ybVzw==} - peerDependencies: - '@types/react': '*' - react: '*' - peerDependenciesMeta: - '@types/react': - optional: true - react: - optional: true - require-directory@2.1.1: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} @@ -3846,10 +3823,6 @@ packages: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} - symbol-observable@4.0.0: - resolution: {integrity: sha512-b19dMThMV4HVFynSAM1++gBHAbk2Tc/osgLIBZMKsyqh34jb2e8Os7T6ZW/Bt3pJFdBTd2JwAnAAEQV7rSNvcQ==} - engines: {node: '>=0.10'} - tagged-tag@1.0.0: resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==} engines: {node: '>=20'} @@ -3885,10 +3858,6 @@ packages: peerDependencies: typescript: '>=4.8.4' - ts-invariant@0.10.3: - resolution: {integrity: sha512-uivwYcQaxAucv1CzRp2n/QdYPo4ILf9VXgH19zEIjFx2EJufV16P0JtJVpYHy89DItG6Kwj2oIUjrcK5au+4tQ==} - engines: {node: '>=8'} - tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} @@ -4119,12 +4088,6 @@ packages: resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==} engines: {node: '>=18'} - zen-observable-ts@1.2.5: - resolution: {integrity: sha512-QZWQekv6iB72Naeake9hS1KxHlotfRpe+WGNbNx5/ta+R3DNjVO2bswf63gXlWDcs+EMd7XY8HfVQyP1X6T4Zg==} - - zen-observable@0.8.15: - resolution: {integrity: sha512-PQ2PC7R9rslx84ndNBZB/Dkv8V8fZEpk83RLgXtYd0fwUgEjseMn1Dgajh2x6S8QbZAFa9p2qVCEuYZNgve0dQ==} - zod-to-json-schema@3.25.1: resolution: {integrity: sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==} peerDependencies: @@ -4511,24 +4474,17 @@ snapshots: rxjs: 7.8.2 tslib: 2.8.1 - '@apollo/client@3.14.0(graphql@16.12.0)': + '@apollo/client@4.2.7(graphql@16.12.0)(rxjs@7.8.2)': dependencies: '@graphql-typed-document-node/core': 3.2.0(graphql@16.12.0) '@wry/caches': 1.0.1 '@wry/equality': 0.5.7 '@wry/trie': 0.5.0 graphql: 16.12.0 - graphql-tag: 2.12.6(graphql@16.12.0) - hoist-non-react-statics: 3.3.2 + graphql-tag: 2.12.7(graphql@16.12.0) optimism: 0.18.1 - prop-types: 15.8.1 - rehackt: 0.1.0 - symbol-observable: 4.0.0 - ts-invariant: 0.10.3 + rxjs: 7.8.2 tslib: 2.8.1 - zen-observable-ts: 1.2.5 - transitivePeerDependencies: - - '@types/react' '@babel/code-frame@7.29.0': dependencies: @@ -5869,10 +5825,10 @@ snapshots: normalize-path: 3.0.0 picomatch: 2.3.2 - apollo-angular@11.0.0(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1))(@apollo/client@3.14.0(graphql@16.12.0))(graphql@16.12.0)(rxjs@7.8.2): + apollo-angular@14.1.0(@angular/core@21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1))(@apollo/client@4.2.7(graphql@16.12.0)(rxjs@7.8.2))(graphql@16.12.0)(rxjs@7.8.2): dependencies: '@angular/core': 21.2.17(@angular/compiler@21.2.17)(rxjs@7.8.2)(zone.js@0.15.1) - '@apollo/client': 3.14.0(graphql@16.12.0) + '@apollo/client': 4.2.7(graphql@16.12.0)(rxjs@7.8.2) graphql: 16.12.0 rxjs: 7.8.2 tslib: 2.8.1 @@ -6584,7 +6540,7 @@ snapshots: graceful-fs@4.2.11: {} - graphql-tag@2.12.6(graphql@16.12.0): + graphql-tag@2.12.7(graphql@16.12.0): dependencies: graphql: 16.12.0 tslib: 2.8.1 @@ -6603,10 +6559,6 @@ snapshots: dependencies: function-bind: 1.1.2 - hoist-non-react-statics@3.3.2: - dependencies: - react-is: 16.13.1 - hono@4.12.3: {} hosted-git-info@9.0.2: @@ -6967,10 +6919,6 @@ snapshots: transitivePeerDependencies: - supports-color - loose-envify@1.4.0: - dependencies: - js-tokens: 4.0.0 - lru-cache@11.2.5: {} lru-cache@5.1.1: @@ -7422,12 +7370,6 @@ snapshots: err-code: 2.0.3 retry: 0.12.0 - prop-types@15.8.1: - dependencies: - loose-envify: 1.4.0 - object-assign: 4.1.1 - react-is: 16.13.1 - prosemirror-commands@1.7.1: dependencies: prosemirror-model: 1.25.4 @@ -7543,8 +7485,6 @@ snapshots: iconv-lite: 0.7.2 unpipe: 1.0.0 - react-is@16.13.1: {} - readdirp@3.6.0: dependencies: picomatch: 2.3.2 @@ -7555,8 +7495,6 @@ snapshots: reflect-metadata@0.2.2: {} - rehackt@0.1.0: {} - require-directory@2.1.1: {} require-from-string@2.0.2: {} @@ -7909,8 +7847,6 @@ snapshots: dependencies: has-flag: 4.0.0 - symbol-observable@4.0.0: {} - tagged-tag@1.0.0: {} tar@7.5.7: @@ -7943,10 +7879,6 @@ snapshots: dependencies: typescript: 5.9.3 - ts-invariant@0.10.3: - dependencies: - tslib: 2.8.1 - tslib@2.8.1: {} tuf-js@4.1.0: @@ -8129,12 +8061,6 @@ snapshots: yoctocolors@2.1.2: {} - zen-observable-ts@1.2.5: - dependencies: - zen-observable: 0.8.15 - - zen-observable@0.8.15: {} - zod-to-json-schema@3.25.1(zod@4.3.6): dependencies: zod: 4.3.6 diff --git a/projects/natural/package.json b/projects/natural/package.json index c6ed8eba..4eeb6162 100644 --- a/projects/natural/package.json +++ b/projects/natural/package.json @@ -28,7 +28,7 @@ "@angular/material": "^21.1.0", "@angular/platform-browser": "^21.1.0", "@angular/router": "^21.1.0", - "apollo-angular": "^11.0.0", + "apollo-angular": "^14.1.0", "es-toolkit": "^1.39.7", "rxjs": "^7.8.2" } diff --git a/projects/natural/src/lib/classes/apollo-utils.spec.ts b/projects/natural/src/lib/classes/apollo-utils.spec.ts index b8d19543..97c18efe 100644 --- a/projects/natural/src/lib/classes/apollo-utils.spec.ts +++ b/projects/natural/src/lib/classes/apollo-utils.spec.ts @@ -1,6 +1,5 @@ import {hasFilesAndProcessDate, isMutation, naturalExtractFiles} from './apollo-utils'; -import {OperationDefinitionNode, SchemaDefinitionNode} from 'graphql/language/ast'; -import {Kind} from 'graphql/language/kinds'; +import {Kind, OperationDefinitionNode, SchemaDefinitionNode} from 'graphql'; describe('hasFilesAndProcessDate', () => { // Use a pattern because tests may be executed in different time zones diff --git a/projects/natural/src/lib/classes/apollo-utils.ts b/projects/natural/src/lib/classes/apollo-utils.ts index 7ae454fc..f9943a28 100644 --- a/projects/natural/src/lib/classes/apollo-utils.ts +++ b/projects/natural/src/lib/classes/apollo-utils.ts @@ -1,13 +1,13 @@ -import {ApolloLink, DocumentNode} from '@apollo/client/core'; +import {HttpBatchLink, HttpLink} from 'apollo-angular/http'; +import {ApolloLink, DocumentNode} from '@apollo/client'; import {formatIsoDateTime, isFile} from './utility'; -import {HttpBatchLink, HttpLink, Options} from 'apollo-angular/http'; // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-expect-error import extractFiles from 'extract-files/extractFiles.mjs'; // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-expect-error import isExtractableFile from 'extract-files/isExtractableFile.mjs'; -import {Kind, OperationTypeNode} from 'graphql/language'; +import {Kind, OperationTypeNode} from 'graphql'; /** * Detect if the given variables have a file to be uploaded or not, and @@ -47,14 +47,19 @@ export function isMutation(query: DocumentNode): boolean { ); } -export const naturalExtractFiles: NonNullable = body => extractFiles(body, isExtractableFile); +export const naturalExtractFiles: NonNullable = body => + extractFiles(body, isExtractableFile); /** * Create an Apollo link that supports batched queries and file uploads. * * File uploads and mutations are never batched. */ -export function createHttpLink(httpLink: HttpLink, httpBatchLink: HttpBatchLink, options: Options): ApolloLink { +export function createHttpLink( + httpLink: HttpLink, + httpBatchLink: HttpBatchLink, + options: HttpLink.Options, +): ApolloLink { // If the query has no file, batch it, otherwise upload only that query return ApolloLink.split( operation => hasFilesAndProcessDate(operation.variables) || isMutation(operation.query), diff --git a/projects/natural/src/lib/classes/create-error-handler.spec.ts b/projects/natural/src/lib/classes/create-error-handler.spec.ts new file mode 100644 index 00000000..ddb48417 --- /dev/null +++ b/projects/natural/src/lib/classes/create-error-handler.spec.ts @@ -0,0 +1,201 @@ +import {createErrorHandler} from './create-error-handler'; +import {ErrorLink} from '@apollo/client/link/error'; +import {FormattedExecutionResult, GraphQLError, GraphQLFormattedError} from 'graphql'; +import {ErrorService, NaturalAlertService, NetworkActivityService} from '@ecodev/natural'; +import {CombinedGraphQLErrors, ServerError} from '@apollo/client'; + +function combinedGraphQLErrors(errors: readonly GraphQLFormattedError[]): CombinedGraphQLErrors { + return new CombinedGraphQLErrors( + { + data: undefined, + extensions: undefined, + } as Partial, + errors, + ); +} + +function errorResponse(error: ErrorLink.ErrorHandlerOptions['error']): ErrorLink.ErrorHandlerOptions { + return { + error, + forward: undefined as any, + operation: undefined as any, + }; +} + +const graphQLQueryError = combinedGraphQLErrors([new GraphQLError('Cannot query field ...')]); + +const objectNotFoundError = combinedGraphQLErrors([ + new GraphQLError('Entity not found for class `Application\\Model\\User` and ID `123123`.', { + extensions: { + objectNotFound: true, + }, + }), +]); + +const invalidTokenError = combinedGraphQLErrors([ + new GraphQLError('Le lien que vous avez suivi ...', { + extensions: { + showSnack: true, + }, + }), +]); + +const invalidEmailError = combinedGraphQLErrors([ + new GraphQLError('Variable "$input" got invalid value "asd@asd_asd.com" ...', { + extensions: { + showSnack: true, + }, + }), +]); + +const networkError = new Error('Http failure response for /graphql: 0 Unknown Error'); + +// As returned by `\Ecodev\Felix\FatalErrorHandler` +const networkErrorPhpMaxExecutionTime = serverError(500, 'Maximum execution time of 30 seconds exceeded'); + +function serverError(status: number, message: string): ServerError { + return new ServerError('fake error for testing', { + response: {status: status} as Response, + bodyText: message + ? JSON.stringify({ + message, + }) + : '', + }); +} +// As returned by `\GraphQL\Upload\UploadMiddleware` +const networkErrorPhpPostMaxSize = serverError( + 413, + 'The server `post_max_size` is configured to accept 5 MiB, but received 99 MiB"', +); + +const networkErrorGeneric500 = serverError(500, ''); + +const internalServerError = combinedGraphQLErrors([ + { + message: 'Internal server error', + locations: [ + { + line: 2, + column: 3, + }, + ], + path: ['tableCharts'], + // The typing says `extensions` must always exist. However, it is incorrect, because in + // real life runtime it is sometimes missing. This might be something wrong in `@apollo/client`, + // but I could not find the root cause. + } as unknown as GraphQLError, +]); + +describe('createErrorHandler', () => { + let errorHandler: ErrorLink.ErrorHandler; + let networkActivityServiceSpy: jasmine.SpyObj; + let errorServiceSpy: jasmine.SpyObj; + let alertServiceSpy: jasmine.SpyObj; + + beforeEach(() => { + networkActivityServiceSpy = jasmine.createSpyObj('NetworkActivityService', [ + 'addErrors', + ]); + errorServiceSpy = jasmine.createSpyObj('ErrorService', ['redirectError']); + alertServiceSpy = jasmine.createSpyObj('NaturalAlertService', ['error']); + + errorHandler = createErrorHandler(networkActivityServiceSpy, errorServiceSpy, alertServiceSpy); + }); + + it('graphql query error are redirected to full page', () => { + errorHandler(errorResponse(graphQLQueryError)); + + expect(networkActivityServiceSpy.addErrors).toHaveBeenCalledOnceWith(graphQLQueryError.errors); + expect(errorServiceSpy.redirectError).toHaveBeenCalledOnceWith(graphQLQueryError.errors[0]); + expect(alertServiceSpy.error).not.toHaveBeenCalled(); + }); + + it('object not found are redirected to full page', () => { + errorHandler(errorResponse(objectNotFoundError)); + + expect(networkActivityServiceSpy.addErrors).toHaveBeenCalledOnceWith(objectNotFoundError.errors); + expect(errorServiceSpy.redirectError).toHaveBeenCalledOnceWith(objectNotFoundError.errors[0]); + expect(alertServiceSpy.error).not.toHaveBeenCalled(); + }); + + it('invalid token error show snack bar', () => { + errorHandler(errorResponse(invalidTokenError)); + + expect(networkActivityServiceSpy.addErrors).toHaveBeenCalledOnceWith(invalidTokenError.errors); + expect(errorServiceSpy.redirectError).not.toHaveBeenCalled(); + expect(alertServiceSpy.error).toHaveBeenCalledOnceWith('Le lien que vous avez suivi ...', 5000); + }); + + it('invalid email error show snack bar', () => { + errorHandler(errorResponse(invalidEmailError)); + + expect(networkActivityServiceSpy.addErrors).toHaveBeenCalledOnceWith(invalidEmailError.errors); + expect(errorServiceSpy.redirectError).not.toHaveBeenCalled(); + expect(alertServiceSpy.error).toHaveBeenCalledOnceWith( + 'Variable "$input" got invalid value "asd@asd_asd.com" ...', + 5000, + ); + }); + + it('network error show snack bar', () => { + errorHandler(errorResponse(networkError)); + + expect(networkActivityServiceSpy.addErrors).not.toHaveBeenCalled(); + expect(errorServiceSpy.redirectError).not.toHaveBeenCalled(); + expect(alertServiceSpy.error).toHaveBeenCalledOnceWith('Une erreur est survenue sur le réseau'); + }); + + it('internal server error are redirected to full page', () => { + errorHandler(errorResponse(internalServerError)); + + expect(networkActivityServiceSpy.addErrors).toHaveBeenCalledOnceWith(internalServerError.errors); + expect(errorServiceSpy.redirectError).toHaveBeenCalledOnceWith(internalServerError.errors[0]); + expect(alertServiceSpy.error).not.toHaveBeenCalled(); + }); + + it('network error for PHP `max_execution_time` show snack bar', () => { + errorHandler(errorResponse(networkErrorPhpMaxExecutionTime)); + + expect(networkActivityServiceSpy.addErrors.calls.allArgs()).toEqual([ + [ + [ + { + message: 'Maximum execution time of 30 seconds exceeded', + extensions: {showSnack: true}, + }, + ], + ], + ]); + expect(errorServiceSpy.redirectError).not.toHaveBeenCalled(); + expect(alertServiceSpy.error).toHaveBeenCalledOnceWith('Maximum execution time of 30 seconds exceeded', 5000); + }); + + it('network error for PHP `post_max_size` show snack bar', () => { + errorHandler(errorResponse(networkErrorPhpPostMaxSize)); + + expect(networkActivityServiceSpy.addErrors.calls.allArgs()).toEqual([ + [ + [ + { + message: 'The server `post_max_size` is configured to accept 5 MiB, but received 99 MiB"', + extensions: {showSnack: true}, + }, + ], + ], + ]); + expect(errorServiceSpy.redirectError).not.toHaveBeenCalled(); + expect(alertServiceSpy.error).toHaveBeenCalledOnceWith( + 'The server `post_max_size` is configured to accept 5 MiB, but received 99 MiB"', + 5000, + ); + }); + + it('network error with a 500 code, similar to `max_execution_time`, show snack bar', () => { + errorHandler(errorResponse(networkErrorGeneric500)); + + expect(networkActivityServiceSpy.addErrors).not.toHaveBeenCalled(); + expect(errorServiceSpy.redirectError).not.toHaveBeenCalled(); + expect(alertServiceSpy.error).toHaveBeenCalledOnceWith('Une erreur est survenue sur le réseau'); + }); +}); diff --git a/projects/natural/src/lib/classes/create-error-handler.ts b/projects/natural/src/lib/classes/create-error-handler.ts new file mode 100644 index 00000000..847b225f --- /dev/null +++ b/projects/natural/src/lib/classes/create-error-handler.ts @@ -0,0 +1,87 @@ +import {CombinedGraphQLErrors, ErrorLike, ServerError} from '@apollo/client'; +import {ErrorLink} from '@apollo/client/link/error'; +import type {ErrorService} from '../services/error.service'; +import type {NetworkActivityService} from './network-activity.service'; +import type {NaturalAlertService} from '../modules/alert/alert.service'; +import {FormattedExecutionResult} from 'graphql'; + +/** + * Create an Apollo link to show alert in case of error, and message if network is down + */ +export function createErrorLink( + networkActivityService: NetworkActivityService, + errorService: ErrorService, + alertService: NaturalAlertService, +): ErrorLink { + return new ErrorLink(createErrorHandler(networkActivityService, errorService, alertService)); +} + +export function createErrorHandler( + networkActivityService: NetworkActivityService, + errorService: ErrorService, + alertService: NaturalAlertService, +): ErrorLink.ErrorHandler { + return options => { + const error = serverErrorToUserFriendlyError(options.error); + + // Show GraphQL responses with errors to end-users + if (CombinedGraphQLErrors.is(error)) { + error.errors.forEach(error => { + if ('extensions' in error && error.extensions?.showSnack) { + // Show whatever server prepared for end-user, with a bit more time to read + alertService.error(error.message, 5000); + } else { + // Show full page error if graphql error (such as API incompatibilities) + errorService.redirectError(error); + } + }); + + networkActivityService.addErrors(error.errors); + } else { + alertService.error($localize`Une erreur est survenue sur le réseau`); + } + }; +} + +/** + * Maybe transform the server error into a user visible, user friendly, error, but only if it is: + * + * - an 413 error from `graphql-upload` about `post_max_size` + * - a 500 error about max_execution_time + */ +function serverErrorToUserFriendlyError(error: ErrorLike): ErrorLike | CombinedGraphQLErrors { + if (!ServerError.is(error) || ![413, 500].includes(error.statusCode)) { + return error; + } + + let json: unknown; + try { + json = JSON.parse(error.bodyText) as unknown; + } catch (e) { + return error; + } + + // If we are sure it's a JSON error in our custom format of `{message: "my error message"}` + if ( + json && + typeof json === 'object' && + 'message' in json && + Object.keys(json).length === 1 && + typeof json.message === 'string' + ) { + return new CombinedGraphQLErrors( + { + data: undefined, + extensions: undefined, + } as Partial, + [ + { + message: json.message, + extensions: {showSnack: true}, + }, + ], + ); + } + + return error; +} diff --git a/projects/natural/src/lib/classes/rxjs.ts b/projects/natural/src/lib/classes/rxjs.ts index 92a39b60..7375f63b 100644 --- a/projects/natural/src/lib/classes/rxjs.ts +++ b/projects/natural/src/lib/classes/rxjs.ts @@ -1,5 +1,7 @@ -import {map, MonoTypeOperatorFunction, Observable, take, takeUntil, tap, timer} from 'rxjs'; +import {Apollo} from 'apollo-angular'; import {DestroyRef} from '@angular/core'; +import {ApolloClient} from '@apollo/client'; +import {filter, map, MonoTypeOperatorFunction, Observable, OperatorFunction, take, takeUntil, tap, timer} from 'rxjs'; import {takeUntilDestroyed} from '@angular/core/rxjs-interop'; /** @@ -57,3 +59,32 @@ export function debug(debugName: string): MonoTypeOperatorFunction { complete: () => console.log('COMPLETE', debugName), }); } + +/** + * Filter emitted results to only receive results that are successful (`result.data !== undefined`). + * + * This is a small wrapper around rxjs `filter()` for convenience only. + * + * This should be entirely deleted once we adopt Apollo Client 4.2 modern signatures that provide the same convenience but through typing inference only. + * + * See https://github.com/the-guild-org/apollo-angular/issues/2429 + * + * Usage: + * + * ```ts + * apollo + * .query({ + * query: myQuery, + * }) + * .pipe(ignoreErrors()) + * .subscribe(result => { + * // Do something with complete result + * }); + * ``` + */ +export function ignoreErrors(): OperatorFunction< + Apollo.QueryResult, + ApolloClient.QueryResultMap['none'] +> { + return filter((result): result is ApolloClient.QueryResultMap['none'] => result.data !== undefined); +} 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 9662403d..470a8d6e 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,7 @@ import {Literal} from '../types/types'; import {NullService} from '../testing/null.service'; import {Apollo} from 'apollo-angular'; import {takeWhile} from 'rxjs/operators'; -import {ApolloQueryResult} from '@apollo/client/core/types'; +import {ObservableQuery} from '@apollo/client'; const observableError = 'Cannot use Observable as variables. Instead you should use .subscribe() to call the method with a real value'; @@ -30,9 +30,9 @@ describe('NaturalAbstractModelService', () => { it('should be delay deleted resolving', fakeAsync(() => { const apollo = TestBed.inject(Apollo); - let resolveMyPromise: (value: ApolloQueryResult[]) => void; - apollo.client.reFetchObservableQueries = () => - new Promise[]>(resolve => { + let resolveMyPromise: (value: ObservableQuery.Result[]) => void; + apollo.client.refetchObservableQueries = () => + new Promise[]>(resolve => { resolveMyPromise = resolve; }); @@ -66,7 +66,7 @@ describe('NaturalAbstractModelService', () => { expect(service).toBeTruthy(); }); - it('should resolve to model and optional enums', fakeAsync(() => { + it('should resolve to model', fakeAsync(() => { expectAnythingAndComplete(id => service.resolve(id), '123'); })); @@ -318,7 +318,7 @@ describe('NaturalAbstractModelService', () => { expect(service).toBeTruthy(); }); - it('should throw instead of resolve to model and optional enums', () => { + it('should throw instead of resolve to model', () => { expect(() => service.resolve('123')).toThrowError(notConfiguredError); }); @@ -486,8 +486,8 @@ function expectAnythingAndCompleteWithQVM( return result; } -describe('NaturalAbstractModelService with failing Apollo should still keep watchAll observable alive', () => { - it('should resolve to model and optional enums', fakeAsync(() => { +describe('NaturalAbstractModelService with failing Apollo', () => { + it('should still keep watchAll observable alive', fakeAsync(() => { let count = 0; let actual: any; let completed = false; @@ -501,7 +501,10 @@ describe('NaturalAbstractModelService with failing Apollo should still keep watc const obs = count === 2 ? throwError(() => new Error('mock XHR failure')) - : of({data: {posts: count}}); + : of({ + dataState: 'complete', + data: {posts: count}, + }); return { valueChanges: obs, diff --git a/projects/natural/src/lib/services/abstract-model.service.ts b/projects/natural/src/lib/services/abstract-model.service.ts index d4645e92..4d66152b 100644 --- a/projects/natural/src/lib/services/abstract-model.service.ts +++ b/projects/natural/src/lib/services/abstract-model.service.ts @@ -1,5 +1,5 @@ -import {Apollo, gql, MutationResult} from 'apollo-angular'; -import {FetchResult, NetworkStatus, WatchQueryFetchPolicy} from '@apollo/client/core'; +import {Apollo, gql, onlyCompleteData} from 'apollo-angular'; +import {ApolloLink, NetworkStatus, ObservableQuery, WatchQueryFetchPolicy} from '@apollo/client'; import {AbstractControl, AsyncValidatorFn, UntypedFormControl, UntypedFormGroup, ValidatorFn} from '@angular/forms'; import {DocumentNode} from 'graphql'; import {merge, pick} from 'es-toolkit'; @@ -11,7 +11,6 @@ import {Literal} from '../types/types'; import {makePlural, relationsToIds, upperCaseFirstLetter} from '../classes/utility'; import {PaginatedData} from '../classes/data-source'; import {NaturalDebounceService} from './debounce.service'; -import {ApolloQueryResult} from '@apollo/client/core/types'; import {deepClone} from '../modules/search/classes/utils'; import {inject} from '@angular/core'; @@ -202,7 +201,10 @@ export abstract class NaturalAbstractModelService< return this.prepareOneQuery(id, fetchPolicy).pipe(map(result => (result.data as Literal)[this.name])); } - private prepareOneQuery(id: string, fetchPolicy: WatchQueryFetchPolicy): Observable> { + private prepareOneQuery( + id: string, + fetchPolicy: WatchQueryFetchPolicy, + ): Observable> { this.throwIfObservable(id); this.throwIfNotQuery(this.oneQuery); @@ -215,9 +217,10 @@ export abstract class NaturalAbstractModelService< variables: variables, fetchPolicy: fetchPolicy, nextFetchPolicy: 'cache-only', + notifyOnNetworkStatusChange: false, }).valueChanges; }), - filter(result => !!result.data), + onlyCompleteData(), ); } @@ -239,7 +242,7 @@ export abstract class NaturalAbstractModelService< const manager = new NaturalQueryVariablesManager(queryVariablesManager); manager.merge('partial-variables', partialVariables); - return this.apollo.query({ + return this.apollo.query({ query: this.allQuery, variables: manager.variables.value, fetchPolicy: 'network-only', @@ -285,14 +288,15 @@ export abstract class NaturalAbstractModelService< this.throwIfNotQuery(this.allQuery); return this.apollo - .watchQuery({ + .watchQuery({ query: this.allQuery, variables: manager.variables.value, fetchPolicy: fetchPolicy, + notifyOnNetworkStatusChange: false, }) .valueChanges.pipe( catchError(() => EMPTY), - filter(r => !!r.data), + onlyCompleteData(), this.mapAll(), ); }), @@ -370,7 +374,7 @@ export abstract class NaturalAbstractModelService< }) .pipe( map(result => { - this.apollo.client.reFetchObservableQueries(); + this.apollo.client.refetchObservableQueries(); return this.mapCreation(result); }), ); @@ -411,7 +415,7 @@ export abstract class NaturalAbstractModelService< }) .pipe( map(result => { - this.apollo.client.reFetchObservableQueries(); + this.apollo.client.refetchObservableQueries(); return this.mapUpdate(result); }), ); @@ -447,7 +451,7 @@ export abstract class NaturalAbstractModelService< switchMap(result => { const mappedResult = this.mapDelete(result); - return from(this.apollo.client.reFetchObservableQueries()).pipe(map(() => mappedResult)); + return from(this.apollo.client.refetchObservableQueries()).pipe(map(() => mappedResult)); }), ); } @@ -505,7 +509,7 @@ export abstract class NaturalAbstractModelService< const queryName = 'Count' + upperCaseFirstLetter(this.plural); const filterType = upperCaseFirstLetter(this.name) + 'Filter'; - const query = gql` + const query = gql<{count: {length: number}}, Literal>` query ${queryName} ($filter: ${filterType}) { count: ${this.plural} (filter: $filter, pagination: {pageSize: 0, pageIndex: 0}) { length @@ -518,13 +522,13 @@ export abstract class NaturalAbstractModelService< const manager = new NaturalQueryVariablesManager(queryVariablesManager); manager.merge('partial-variables', partialVariables); - return this.apollo.query<{count: {length: number}}, Vall>({ + return this.apollo.query({ query: query, variables: manager.variables.value, fetchPolicy: 'network-only', }); }), - map(result => result.data.count.length), + map(result => result.data!.count.length), ); } @@ -557,14 +561,14 @@ export abstract class NaturalAbstractModelService< /** * This is used to extract only the array of fetched objects out of the entire fetched data */ - protected mapAll(): OperatorFunction, Tall> { - return map(result => (result.data as any)[this.plural]); // See https://github.com/apollographql/apollo-client/issues/5662 + protected mapAll(): OperatorFunction, Tall> { + return map(result => (result as any).data[this.plural]); // See https://github.com/apollographql/apollo-client/issues/5662 } /** * This is used to extract only the created object out of the entire fetched data */ - protected mapCreation(result: MutationResult): Tcreate { + protected mapCreation(result: Apollo.MutateResult): Tcreate { const name = this.createName ?? 'create' + upperCaseFirstLetter(this.name); return (result.data as any)[name]; // See https://github.com/apollographql/apollo-client/issues/5662 } @@ -572,7 +576,7 @@ export abstract class NaturalAbstractModelService< /** * This is used to extract only the updated object out of the entire fetched data */ - protected mapUpdate(result: MutationResult): Tupdate { + protected mapUpdate(result: Apollo.MutateResult): Tupdate { const name = this.updateName ?? 'update' + upperCaseFirstLetter(this.name); return (result.data as any)[name]; // See https://github.com/apollographql/apollo-client/issues/5662 } @@ -580,7 +584,7 @@ export abstract class NaturalAbstractModelService< /** * This is used to extract only flag when deleting an object */ - protected mapDelete(result: MutationResult): Tdelete { + protected mapDelete(result: Apollo.MutateResult): Tdelete { const name = this.deleteName ?? 'delete' + upperCaseFirstLetter(this.plural); return (result.data as any)[name]; // See https://github.com/apollographql/apollo-client/issues/5662 } diff --git a/projects/natural/src/lib/services/enum.service.ts b/projects/natural/src/lib/services/enum.service.ts index 054b433d..67dd24ce 100644 --- a/projects/natural/src/lib/services/enum.service.ts +++ b/projects/natural/src/lib/services/enum.service.ts @@ -52,7 +52,7 @@ export class NaturalEnumService { .pipe( map(result => { const values: IEnum[] = []; - if (result.data.__type?.enumValues) { + if (result.data?.__type?.enumValues) { for (const enumValue of result.data.__type.enumValues) { values.push({ value: enumValue.name, diff --git a/projects/natural/src/lib/services/error.service.spec.ts b/projects/natural/src/lib/services/error.service.spec.ts new file mode 100644 index 00000000..f954b2bc --- /dev/null +++ b/projects/natural/src/lib/services/error.service.spec.ts @@ -0,0 +1,100 @@ +import {TestBed} from '@angular/core/testing'; +import {ErrorService} from './error.service'; +import {provideRouter, Router} from '@angular/router'; +import {NaturalAvatarComponent} from '@ecodev/natural'; +import {of, throwError} from 'rxjs'; + +describe('ErrorService', () => { + let service: ErrorService; + let router: Router; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + provideRouter([ + { + path: 'error', + component: NaturalAvatarComponent, // Fake component + }, + ]), + ], + }); + + service = TestBed.inject(ErrorService); + router = TestBed.inject(Router); + spyOn(router, 'navigateByUrl').and.returnValue(Promise.resolve(true)); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + describe('redirectError', () => { + it('should navigate to the error page and store the error', () => { + const error = new Error('test error'); + service.redirectError(error); + + expect(service.getLastError()).toBe(error); + expect(router.navigateByUrl).toHaveBeenCalledOnceWith('/error', {skipLocationChange: true}); + }); + + it('should store the current href when not on the error page', () => { + service.redirectError(new Error('test error')); + + expect(service.getLastErrorHref()).not.toBeNull(); + }); + }); + + describe('redirectIfError', () => { + it('should redirect and rethrow when the observable errors', () => { + const error = new Error('test error'); + let thrownError: unknown; + + service.redirectIfError(throwError(() => error)).subscribe({ + error: e => (thrownError = e), + }); + + expect(thrownError).toBe(error); + expect(service.getLastError()).toBe(error); + expect(router.navigateByUrl).toHaveBeenCalledOnceWith('/error', {skipLocationChange: true}); + }); + + it('should pass through values when the observable succeeds', done => { + service.redirectIfError(of(42)).subscribe({ + next: value => { + expect(value).toBe(42); + done(); + }, + error: done.fail, + }); + }); + }); + + describe('redirectIfDenied', () => { + it('should redirect when the observable emits false', () => { + let emittedValue: boolean | undefined; + + service.redirectIfDenied(of(false)).subscribe({ + next: value => (emittedValue = value), + error: fail, + }); + + expect(emittedValue).toBeFalse(); + expect(service.getLastError()).toBeTruthy(); + expect(router.navigateByUrl).toHaveBeenCalledOnceWith('/error', {skipLocationChange: true}); + }); + + it('should pass through when the observable emits true', () => { + let emittedValue: boolean | undefined; + + service.redirectIfDenied(of(true)).subscribe({ + next: value => (emittedValue = value), + error: fail, + }); + + expect(emittedValue).toBeTrue(); + expect(service.getLastError()).toBeNull(); + expect(router.navigateByUrl).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/projects/natural/src/lib/services/error.service.ts b/projects/natural/src/lib/services/error.service.ts new file mode 100644 index 00000000..fdd86390 --- /dev/null +++ b/projects/natural/src/lib/services/error.service.ts @@ -0,0 +1,67 @@ +import {DOCUMENT, inject, Injectable} from '@angular/core'; +import {Router} from '@angular/router'; +import {Observable, throwError} from 'rxjs'; +import {catchError, map} from 'rxjs/operators'; +import {GraphQLFormattedError} from 'graphql'; + +/** + * Service for storing the last error and redirecting to error page conveniently + */ +@Injectable({ + providedIn: 'root', +}) +export class ErrorService { + private readonly document = inject(DOCUMENT); + private readonly router = inject(Router); + + private lastError: Error | GraphQLFormattedError | null = null; + + private lastErrorHref: string | null = null; + + /** + * Redirect to error page and display given error + */ + public redirectError(error: Error | GraphQLFormattedError): void { + // Don't count error page as a location to have an error + const errorLocation = this.document.defaultView?.window.location; + if (errorLocation && errorLocation.pathname !== '/error') { + this.lastErrorHref = errorLocation.href; + } + this.lastError = error; + + this.router.navigateByUrl('/error', {skipLocationChange: true}); + } + + public getLastError(): Error | GraphQLFormattedError | null { + return this.lastError; + } + + public getLastErrorHref(): string | null { + return this.lastErrorHref; + } + + /** + * Redirect to error page if the observable fails + */ + public redirectIfError(observable: Observable): Observable { + return observable.pipe( + catchError(error => { + this.redirectError(error); + + return throwError(() => error); + }), + ); + } + + public redirectIfDenied(observable: Observable): Observable { + return observable.pipe( + map(allowed => { + if (!allowed) { + this.redirectError(new Error($localize`Accès refusé`)); + } + + return allowed; + }), + ); + } +} diff --git a/projects/natural/src/lib/services/link-mutation.service.ts b/projects/natural/src/lib/services/link-mutation.service.ts index 7c3b48f5..c6c58158 100644 --- a/projects/natural/src/lib/services/link-mutation.service.ts +++ b/projects/natural/src/lib/services/link-mutation.service.ts @@ -1,5 +1,5 @@ import {Apollo, gql} from 'apollo-angular'; -import {FetchResult} from '@apollo/client/core'; +import {ApolloLink} from '@apollo/client'; import {inject, Injectable} from '@angular/core'; import {clone} from 'es-toolkit'; import {forkJoin, Observable, of} from 'rxjs'; @@ -83,7 +83,7 @@ export class NaturalLinkMutationService { obj2: LinkableObject, otherName: string | null = null, variables: Literal = {}, - ): Observable> { + ): Observable> { // clone prevents to affect the original reference const clonedVariables = clone(variables); @@ -100,7 +100,7 @@ export class NaturalLinkMutationService { objects: LinkableObject[], otherName: string | null = null, variables: Literal = {}, - ): Observable[]> { + ): Observable[]> { return forkJoin(objects.map(obj2 => this.link(obj1, obj2, otherName, variables))); } @@ -111,7 +111,7 @@ export class NaturalLinkMutationService { obj1: LinkableObject, obj2: LinkableObject, otherName: string | null = null, - ): Observable> { + ): Observable> { return this.getMutation('unlink', obj1, obj2, otherName).pipe(switchMap(mutation => this.execute(mutation))); } @@ -137,7 +137,7 @@ export class NaturalLinkMutationService { }) .pipe( map(({data}) => { - if (data.__type?.fields) { + if (data?.__type?.fields) { this.allMutations = data.__type.fields .filter(v => /^(link|unlink)/.exec(v.name)) .map(v => { @@ -188,14 +188,14 @@ export class NaturalLinkMutationService { /** * Execute mutation */ - private execute(mutation: string): Observable> { + private execute(mutation: string): Observable> { return this.apollo .mutate<{id: string}>({ mutation: gql(mutation), }) .pipe( map(r => { - this.apollo.client.reFetchObservableQueries(); + this.apollo.client.refetchObservableQueries(); return r; }), ); diff --git a/projects/natural/src/lib/testing/mock-apollo.provider.ts b/projects/natural/src/lib/testing/mock-apollo.provider.ts index 554fa8bf..27e2d93a 100644 --- a/projects/natural/src/lib/testing/mock-apollo.provider.ts +++ b/projects/natural/src/lib/testing/mock-apollo.provider.ts @@ -1,9 +1,10 @@ import {Apollo, gql} from 'apollo-angular'; -import {ApolloClient, InMemoryCache} from '@apollo/client/core'; +import {ApolloClient, InMemoryCache} from '@apollo/client'; import {SchemaLink} from '@apollo/client/link/schema'; import {inject, Injectable, NgZone, Provider} from '@angular/core'; import {buildSchema} from 'graphql'; import {addMocksToSchema, IMocks} from '@graphql-tools/mock'; +import {apolloDefaultOptions} from '../../../../../src/app/shared/config/apollo-options.provider'; export type Blog = { id: string; @@ -155,7 +156,7 @@ const typeDefs = ` * This will create a fake ApolloClient who can responds to queries * against our real schema with random values */ -function createMockClient(): ApolloClient { +function createMockClient(): ApolloClient { // Configure hardcoded mocked values on a type basis. // That means all data will look be very similar, but at least // tests are robust and won't change if/when random generators @@ -176,6 +177,7 @@ function createMockClient(): ApolloClient { return new ApolloClient({ cache: apolloCache, link: new SchemaLink({schema: schemaWithMocks}), + defaultOptions: apolloDefaultOptions, }); } diff --git a/projects/natural/src/public-api.ts b/projects/natural/src/public-api.ts index 2f32fa61..5fcaf51f 100644 --- a/projects/natural/src/public-api.ts +++ b/projects/natural/src/public-api.ts @@ -9,9 +9,10 @@ export * from './lib/classes/abstract-editable-list'; export * from './lib/classes/abstract-list'; export * from './lib/classes/abstract-navigable-list'; export {createHttpLink, hasFilesAndProcessDate} from './lib/classes/apollo-utils'; +export {createErrorLink} from './lib/classes/create-error-handler'; export * from './lib/classes/data-source'; export * from './lib/classes/query-variable-manager'; -export {cancellableTimeout, debug} from './lib/classes/rxjs'; +export {cancellableTimeout, debug, ignoreErrors} from './lib/classes/rxjs'; export { copyToClipboard, formatIsoDate, @@ -35,6 +36,7 @@ export {NaturalPersistenceService, NATURAL_PERSISTENCE_VALIDATOR} from './lib/se export {provideThemes, ColorScheme, NaturalThemeService} from './lib/services/theme.service'; export type {PersistenceValidator} from './lib/services/persistence.service'; export * from './lib/services/swiss-parsing-date-adapter.service'; +export {ErrorService} from './lib/services/error.service'; export type { ExtractResolve, diff --git a/projects/natural/tsconfig.spec.json b/projects/natural/tsconfig.spec.json index 95b83f72..1f51e179 100644 --- a/projects/natural/tsconfig.spec.json +++ b/projects/natural/tsconfig.spec.json @@ -4,5 +4,5 @@ "outDir": "../../out-tsc/spec", "types": ["jasmine"] }, - "include": ["./src/**/*.ts"] + "include": ["./src/**/*.ts", "../../src/app/shared/config/apollo.d.ts"] } diff --git a/src/app/shared/config/apollo-options.provider.ts b/src/app/shared/config/apollo-options.provider.ts new file mode 100644 index 00000000..0d05f907 --- /dev/null +++ b/src/app/shared/config/apollo-options.provider.ts @@ -0,0 +1,25 @@ +import {ApolloClient, ApolloLink, InMemoryCache} from '@apollo/client'; + +export const apolloDefaultOptions: ApolloClient.Options['defaultOptions'] = { + query: { + fetchPolicy: 'network-only', + errorPolicy: 'none', + }, + watchQuery: { + fetchPolicy: 'cache-and-network', + errorPolicy: 'none', + returnPartialData: false, + notifyOnNetworkStatusChange: false, + }, + mutate: { + errorPolicy: 'none', + }, +}; + +export function apolloOptionsFactory(): ApolloClient.Options { + return { + link: ApolloLink.empty(), + cache: new InMemoryCache(), + defaultOptions: apolloDefaultOptions, + }; +} diff --git a/src/app/shared/config/apollo.d.ts b/src/app/shared/config/apollo.d.ts new file mode 100644 index 00000000..ca4df275 --- /dev/null +++ b/src/app/shared/config/apollo.d.ts @@ -0,0 +1,26 @@ +/* eslint-disable @typescript-eslint/consistent-type-definitions */ +import '@apollo/client'; + +declare module '@apollo/client' { + namespace ApolloClient { + namespace DeclareDefaultOptions { + // Affects client.query() + interface Query { + fetchPolicy: 'network-only'; + errorPolicy: 'none'; + } + + // Affects client.watchQuery() + interface WatchQuery { + fetchPolicy: 'cache-and-network'; + errorPolicy: 'none'; + returnPartialData: false; + } + + // Affects client.mutate() + interface Mutate { + errorPolicy: 'none'; + } + } + } +} diff --git a/src/app/shared/services/any-link-mutation.service.ts b/src/app/shared/services/any-link-mutation.service.ts index 2d9efc5b..a60d27a3 100644 --- a/src/app/shared/services/any-link-mutation.service.ts +++ b/src/app/shared/services/any-link-mutation.service.ts @@ -1,5 +1,5 @@ import {Injectable} from '@angular/core'; -import {FetchResult} from '@apollo/client/core'; +import {ApolloLink} from '@apollo/client'; import {debug, LinkableObject} from '@ecodev/natural'; import {delay, Observable, of} from 'rxjs'; @@ -7,15 +7,15 @@ import {delay, Observable, of} from 'rxjs'; providedIn: 'root', }) export class AnyLinkMutationService { - public link(obj1: LinkableObject): Observable> { + public link(obj1: LinkableObject): Observable> { return of({data: obj1}).pipe(debug('Mock NaturalLinkMutationService.link()'), delay(500)); } - public linkMany(obj1: LinkableObject): Observable[]> { + public linkMany(obj1: LinkableObject): Observable[]> { return of([{data: obj1}]).pipe(debug('Mock NaturalLinkMutationService.linkMany()'), delay(500)); } - public unlink(obj1: LinkableObject): Observable> { + public unlink(obj1: LinkableObject): Observable> { return of({data: obj1}).pipe(debug('Mock NaturalLinkMutationService.unlink()'), delay(500)); } } diff --git a/src/main.ts b/src/main.ts index 366c0743..6d547c23 100644 --- a/src/main.ts +++ b/src/main.ts @@ -21,7 +21,7 @@ import {DemoLoggerExtra} from './app/demo.error-handler'; import {AnyLinkMutationService} from './app/shared/services/any-link-mutation.service'; import {environment} from './environments/environment'; import {provideApollo} from 'apollo-angular'; -import {InMemoryCache} from '@apollo/client/core'; +import {apolloOptionsFactory} from './app/shared/config/apollo-options.provider'; if (environment.production) { enableProdMode(); @@ -31,7 +31,7 @@ bootstrapApplication(AppComponent, { providers: [ provideZoneChangeDetection({eventCoalescing: true}), provideNativeDateAdapter(), - provideApollo(() => ({cache: new InMemoryCache()})), + provideApollo(apolloOptionsFactory), naturalProviders, provideIcons({ natural: {