@centreon/ui 25.1.3 → 25.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@centreon/ui",
3
- "version": "25.1.3",
3
+ "version": "25.1.4",
4
4
  "description": "Centreon UI Components",
5
5
  "scripts": {
6
6
  "update:deps": "pnpx npm-check-updates -i --format group",
@@ -3,15 +3,20 @@ import { useEffect } from 'react';
3
3
  import {
4
4
  UseMutationOptions,
5
5
  UseMutationResult,
6
- useMutation
6
+ useMutation,
7
+ useQueryClient
7
8
  } from '@tanstack/react-query';
8
- import { includes, omit } from 'ramda';
9
+ import { equals, includes, omit, type } from 'ramda';
9
10
  import { JsonDecoder } from 'ts.data.json';
10
11
 
11
12
  import useSnackbar from '../../Snackbar/useSnackbar';
12
13
  import { useDeepCompare } from '../../utils';
13
14
  import { CatchErrorProps, ResponseError, customFetch } from '../customFetch';
14
15
  import { errorLog } from '../logger';
16
+ import {
17
+ OptimisticListing,
18
+ useOptimisticMutation
19
+ } from './useOptimisticMutation';
15
20
 
16
21
  export enum Method {
17
22
  DELETE = 'DELETE',
@@ -46,6 +51,7 @@ export type UseMutationQueryProps<T, TMeta> = {
46
51
  variables: Variables<TMeta, T>,
47
52
  context: unknown
48
53
  ) => unknown;
54
+ optimisticListing?: OptimisticListing;
49
55
  } & Omit<
50
56
  UseMutationOptions<{ _meta?: TMeta; payload: T }>,
51
57
  'mutationFn' | 'onError' | 'onMutate' | 'onSuccess' | 'mutateAsync' | 'mutate'
@@ -79,10 +85,15 @@ const useMutationQuery = <T extends object, TMeta>({
79
85
  onError,
80
86
  onSuccess,
81
87
  onSettled,
82
- baseEndpoint
88
+ baseEndpoint,
89
+ optimisticListing
83
90
  }: UseMutationQueryProps<T, TMeta>): UseMutationQueryState<T, TMeta> => {
84
91
  const { showErrorMessage } = useSnackbar();
85
92
 
93
+ const queryClient = useQueryClient();
94
+ const { getListingQueryKey, getOptimisticMutationItems, getPreviousListing } =
95
+ useOptimisticMutation({ optimisticListing });
96
+
86
97
  const queryData = useMutation<
87
98
  T | ResponseError,
88
99
  ResponseError,
@@ -100,7 +111,7 @@ const useMutationQuery = <T extends object, TMeta>({
100
111
  defaultFailureMessage,
101
112
  endpoint: getEndpoint(_meta as TMeta),
102
113
  headers: new Headers({
103
- 'Content-Type': 'application/x-www-form-urlencoded',
114
+ 'Content-Type': 'application/json',
104
115
  ...fetchHeaders
105
116
  }),
106
117
  isMutation: true,
@@ -108,11 +119,57 @@ const useMutationQuery = <T extends object, TMeta>({
108
119
  payload
109
120
  });
110
121
  },
111
- onError,
112
- onMutate,
122
+ onError: (error, variables, context) => {
123
+ if (optimisticListing?.enabled) {
124
+ const listingQueryKey = getListingQueryKey();
125
+ queryClient.setQueriesData(
126
+ { queryKey: listingQueryKey },
127
+ context.previousListing
128
+ );
129
+ }
130
+
131
+ onError?.(error, variables, context);
132
+ },
133
+ onMutate: optimisticListing?.enabled
134
+ ? ({ payload, _meta }) => {
135
+ const listingQueryKey = getListingQueryKey();
136
+ const newListing = getOptimisticMutationItems({
137
+ method,
138
+ payload,
139
+ _meta
140
+ });
141
+ const previousListing = getPreviousListing();
142
+
143
+ queryClient.setQueriesData({ queryKey: listingQueryKey }, newListing);
144
+
145
+ return { previousListing };
146
+ }
147
+ : onMutate,
113
148
  onSettled,
114
149
  onSuccess: (data, variables, context) => {
150
+ if (optimisticListing?.enabled) {
151
+ const isQueryKeyArray = equals(
152
+ type(optimisticListing.queryKey),
153
+ 'Array'
154
+ );
155
+ const listingQueryKey = isQueryKeyArray
156
+ ? optimisticListing?.queryKey
157
+ : [optimisticListing?.queryKey];
158
+
159
+ queryClient.invalidateQueries({
160
+ queryKey: listingQueryKey
161
+ });
162
+ }
163
+
115
164
  if (data?.isError) {
165
+ if (optimisticListing?.enabled) {
166
+ const listingQueryKey = getListingQueryKey();
167
+ queryClient.setQueriesData(
168
+ { queryKey: listingQueryKey },
169
+ context.previousListing
170
+ );
171
+ }
172
+
116
173
  onError?.(data, variables, context);
117
174
 
118
175
  return;
@@ -0,0 +1,111 @@
1
+ import useMutationQuery, { Method } from '.';
2
+ import SnackbarProvider from '../../Snackbar/SnackbarProvider';
3
+ import TestQueryProvider from '../TestQueryProvider';
4
+
5
+ // biome-ignore lint/suspicious/noImplicitAnyLet: <explanation>
6
+ let spyMutation;
7
+
8
+ const TestComponent = (props) => {
9
+ const mutation = useMutationQuery({
10
+ ...props,
11
+ getEndpoint: () => '/endpoint'
12
+ });
13
+
14
+ spyMutation = mutation;
15
+
16
+ return (
17
+ <button
18
+ type="button"
19
+ onClick={() =>
20
+ mutation.mutateAsync({ payload: { a: 'a', b: 2, c: ['arr', 'ay'] } })
21
+ }
22
+ >
23
+ Send
24
+ </button>
25
+ );
26
+ };
27
+
28
+ const initialize = ({ mutationProps, isError = false }) => {
29
+ cy.interceptAPIRequest({
30
+ alias: 'mutateEndpoint',
31
+ path: './api/latest/endpoint',
32
+ statusCode: isError ? 400 : 204,
33
+ method: mutationProps.method,
34
+ response: isError
35
+ ? {
36
+ message: 'custom error message'
37
+ }
38
+ : undefined
39
+ });
40
+
41
+ cy.mount({
42
+ Component: (
43
+ <SnackbarProvider>
44
+ <TestQueryProvider>
45
+ <TestComponent {...mutationProps} />
46
+ </TestQueryProvider>
47
+ </SnackbarProvider>
48
+ )
49
+ }).then(() => {
50
+ cy.spy(spyMutation, 'mutateAsync').as('mutateAsync');
51
+ });
52
+ };
53
+
54
+ describe('useMutationQuery', () => {
55
+ it('sends data to an endpoint', () => {
56
+ initialize({
57
+ mutationProps: {
58
+ getEndpoint: () => '/endpoint',
59
+ method: Method.POST
60
+ }
61
+ });
62
+
63
+ cy.get('button').click();
64
+
65
+ cy.waitForRequest('@mutateEndpoint').then(({ request }) => {
66
+ expect(request.method).to.equal('POST');
67
+ expect(request.body).to.deep.equal({ a: 'a', b: 2, c: ['arr', 'ay'] });
68
+ expect(request.headers.get('content-type')).to.equal('application/json');
69
+ });
70
+ cy.get('@mutateAsync').should('be.calledWith', {
71
+ payload: {
72
+ a: 'a',
73
+ b: 2,
74
+ c: ['arr', 'ay']
75
+ }
76
+ });
77
+ });
78
+
79
+ it("shows an error from the API via the Snackbar and inside the browser's console when posting data to an endpoint", () => {
80
+ initialize({
81
+ mutationProps: {
82
+ getEndpoint: () => '/endpoint',
83
+ method: Method.POST
84
+ },
85
+ isError: true
86
+ });
87
+
88
+ cy.get('button').click();
89
+
90
+ cy.get('@mutateAsync').should('be.called');
91
+
92
+ cy.contains('custom error message').should('be.visible');
93
+ });
94
+
95
+ it('does not show any message via the Snackbar when the httpCodesBypassErrorSnackbar is passed when posting data to an API', () => {
96
+ initialize({
97
+ mutationProps: {
98
+ getEndpoint: () => '/endpoint',
99
+ method: Method.POST,
100
+ httpCodesBypassErrorSnackbar: [400]
101
+ },
102
+ isError: true
103
+ });
104
+
105
+ cy.get('button').click();
106
+
107
+ cy.get('@mutateAsync').should('be.called');
108
+
109
+ cy.contains('custom error message').should('not.exist');
110
+ });
111
+ });
@@ -0,0 +1,114 @@
1
+ import { useQueryClient } from '@tanstack/react-query';
2
+ import { append, equals, last, remove, type, update } from 'ramda';
3
+ import { Method } from '.';
4
+
5
+ interface GetOptimisticMutationListingProps<T, TMeta> {
6
+ method: Method;
7
+ payload: T;
8
+ _meta: TMeta;
9
+ }
10
+
11
+ export interface OptimisticListing {
12
+ enabled: boolean;
13
+ queryKey: string | Array<string>;
14
+ total: number;
15
+ limit: number;
16
+ }
17
+
18
+ interface UseOptimisticMutationProps {
19
+ optimisticListing?: OptimisticListing;
20
+ }
21
+
22
+ interface UseOptimisticMutationState<T, TMeta> {
23
+ getOptimisticMutationItems: (
24
+ props: GetOptimisticMutationListingProps<T, TMeta>
25
+ ) => object;
26
+ getListingQueryKey: () => Array<string>;
27
+ getPreviousListing: () => unknown;
28
+ }
29
+
30
+ export const useOptimisticMutation = <T, TMeta>({
31
+ optimisticListing
32
+ }: UseOptimisticMutationProps): UseOptimisticMutationState<T, TMeta> => {
33
+ const queryClient = useQueryClient();
34
+
35
+ const getListingQueryKey = (): Array<string> => {
36
+ const isQueryKeyArray = equals(type(optimisticListing?.queryKey), 'Array');
37
+ const listingQueryKey = isQueryKeyArray
38
+ ? optimisticListing?.queryKey
39
+ : [optimisticListing?.queryKey];
40
+
41
+ return listingQueryKey;
42
+ };
43
+
44
+ const getPreviousListing = (): unknown => {
45
+ const listingQueryKey = getListingQueryKey();
46
+
47
+ const items = last(
48
+ queryClient.getQueriesData({
49
+ queryKey: listingQueryKey
50
+ })
51
+ )?.[1];
52
+
53
+ return items;
54
+ };
55
+
56
+ const getOptimisticMutationItems = ({
57
+ method,
58
+ payload,
59
+ _meta
60
+ }: GetOptimisticMutationListingProps<T, TMeta>): object => {
61
+ const listingQueryKey = getListingQueryKey();
62
+
63
+ const hasOnlyOnePage =
64
+ (optimisticListing?.total || 0) <= (optimisticListing?.limit || 0);
65
+ const isFormDataPayload = equals(type(payload), 'FormData');
66
+
67
+ const items = last(
68
+ queryClient.getQueriesData({
69
+ queryKey: listingQueryKey
70
+ })
71
+ )?.[1];
72
+
73
+ if (equals(Method.POST, method) && !isFormDataPayload && hasOnlyOnePage) {
74
+ const newItems = append(payload, items.result);
75
+
76
+ return { ...items, result: newItems };
77
+ }
78
+
79
+ if (equals(Method.DELETE, method) && hasOnlyOnePage) {
80
+ const itemIndex = items.result.findIndex(({ id }) =>
81
+ equals(id, _meta.id)
82
+ );
83
+ const newItems = remove(itemIndex, 1, items.result);
84
+
85
+ return { ...items, result: newItems };
86
+ }
87
+
88
+ if (
89
+ (equals(Method.PUT, method) ||
90
+ equals(Method.PATCH, method) ||
91
+ (equals(Method.POST, method) && isFormDataPayload)) &&
92
+ hasOnlyOnePage
93
+ ) {
94
+ const itemIndex = items.result.findIndex(({ id }) =>
95
+ equals(id, _meta.id)
96
+ );
97
+ const item = items.result.find(({ id }) => equals(id, _meta.id));
98
+ const updatedItem = equals(Method.PUT, method)
99
+ ? payload
100
+ : {
101
+ ...item,
102
+ ...(isFormDataPayload
103
+ ? Object.fromEntries(payload.entries())
104
+ : payload)
105
+ };
106
+ const newItems = update(itemIndex, updatedItem, items.result);
107
+
108
+ return { ...items, result: newItems };
109
+ }
110
+
111
+ return items;
112
+ };
113
+ return { getOptimisticMutationItems, getListingQueryKey, getPreviousListing };
114
+ };
@@ -1,118 +0,0 @@
1
- import { RenderHookResult, renderHook, waitFor } from '@testing-library/react';
2
- import fetchMock from 'jest-fetch-mock';
3
-
4
- import TestQueryProvider from '../TestQueryProvider';
5
-
6
- import useMutationQuery, {
7
- Method,
8
- UseMutationQueryProps,
9
- UseMutationQueryState
10
- } from '.';
11
-
12
- const mockedShowErrorMessage = jest.fn();
13
-
14
- interface User {
15
- email: string;
16
- name: string;
17
- }
18
-
19
- const user: User = {
20
- email: 'john@doe.com',
21
- name: 'John Doe'
22
- };
23
-
24
- jest.mock('../../Snackbar/useSnackbar', () => ({
25
- __esModule: true,
26
- default: jest
27
- .fn()
28
- .mockImplementation(() => ({ showErrorMessage: mockedShowErrorMessage }))
29
- }));
30
-
31
- const renderMutationQuery = <T extends object>(
32
- params: UseMutationQueryProps<T>
33
- ): RenderHookResult<UseMutationQueryState<T>, unknown> =>
34
- renderHook(() => useMutationQuery<T>(params), {
35
- wrapper: TestQueryProvider
36
- }) as RenderHookResult<UseMutationQueryState<T>, unknown>;
37
-
38
- describe('useFetchQuery', () => {
39
- beforeEach(() => {
40
- mockedShowErrorMessage.mockReset();
41
- fetchMock.resetMocks();
42
- });
43
-
44
- it('posts data to an endpoint', async () => {
45
- fetchMock.once(JSON.stringify({}));
46
- const { result } = renderMutationQuery<User>({
47
- getEndpoint: () => '/endpoint',
48
- method: Method.POST
49
- });
50
-
51
- result.current.mutate({ payload: user });
52
-
53
- await waitFor(() => {
54
- expect(result.current?.isError).toEqual(false);
55
- });
56
- });
57
-
58
- it("shows an error from the API via the Snackbar and inside the browser's console when posting data to an endpoint", async () => {
59
- fetchMock.once(JSON.stringify({ code: 2, message: 'custom message' }), {
60
- status: 400
61
- });
62
- const { result } = renderMutationQuery<User>({
63
- getEndpoint: () => '/endpoint',
64
- method: Method.POST
65
- });
66
-
67
- result.current.mutate({ payload: user });
68
-
69
- await waitFor(() => {
70
- expect(result.current?.isError).toEqual(true);
71
- });
72
-
73
- await waitFor(() => {
74
- expect(mockedShowErrorMessage).toHaveBeenCalledWith('custom message');
75
- });
76
- });
77
-
78
- it('shows a default failure message via the Snackbar as fallback when posting data to an API', async () => {
79
- fetchMock.once(JSON.stringify({}), {
80
- status: 400
81
- });
82
-
83
- const { result } = renderMutationQuery<User>({
84
- getEndpoint: () => '/endpoint',
85
- method: Method.POST
86
- });
87
-
88
- result.current.mutate({ payload: user });
89
-
90
- await waitFor(() => {
91
- expect(result.current?.isError).toEqual(true);
92
- });
93
-
94
- await waitFor(() => {
95
- expect(mockedShowErrorMessage).toHaveBeenCalledWith(
96
- 'Something went wrong'
97
- );
98
- });
99
- });
100
-
101
- it('does not show any message via the Snackbar when the httpCodesBypassErrorSnackbar is passed when posting data to an API', async () => {
102
- fetchMock.once(JSON.stringify({}), {
103
- status: 400
104
- });
105
-
106
- const { result } = renderMutationQuery<User>({
107
- getEndpoint: () => '/endpoint',
108
- httpCodesBypassErrorSnackbar: [400],
109
- method: Method.POST
110
- });
111
-
112
- result.current.mutate({ payload: user });
113
-
114
- await waitFor(() => {
115
- expect(mockedShowErrorMessage).not.toHaveBeenCalled();
116
- });
117
- });
118
- });