浏览代码

[ui-core] add put and path methods in api utils and useSaveData hook (#4199)

Ram Prasad Agarwal 3 月之前
父节点
当前提交
fcde6da3b4

+ 31 - 6
desktop/core/src/desktop/js/api/utils.ts

@@ -42,6 +42,12 @@ export interface DefaultApiResponse {
   content?: string;
 }
 
+export enum HttpMethod {
+  POST = 'post',
+  PUT = 'put',
+  PATCH = 'patch'
+}
+
 export interface ApiFetchOptions<T, E = AxiosError<DefaultApiResponse>> extends AxiosRequestConfig {
   silenceErrors?: boolean;
   ignoreSuccessErrors?: boolean;
@@ -193,7 +199,9 @@ const getCancelToken = (): { cancelToken: CancelToken; cancel: () => void } => {
   return { cancelToken: cancelTokenSource.token, cancel: cancelTokenSource.cancel };
 };
 
-export const post = <T, U = unknown, E = AxiosError>(
+// Shared HTTP method for post, put, patch requests
+export const sendApiRequest = <T, U = unknown, E = AxiosError>(
+  method: HttpMethod,
   url: string,
   data?: U,
   options?: ApiFetchOptions<T, E>
@@ -204,11 +212,10 @@ export const post = <T, U = unknown, E = AxiosError>(
 
     const encodeData = options?.qsEncodeData == undefined || options?.qsEncodeData;
 
-    axiosInstance
-      .post<T & DefaultApiResponse>(url, encodeData ? qs.stringify(data) : data, {
-        cancelToken,
-        ...options
-      })
+    axiosInstance[method]<T & DefaultApiResponse>(url, encodeData ? qs.stringify(data) : data, {
+      cancelToken,
+      ...options
+    })
       .then(response => {
         handleResponse(response, resolve, reject, options);
       })
@@ -233,6 +240,24 @@ export const post = <T, U = unknown, E = AxiosError>(
     });
   });
 
+export const post = <T, U = unknown, E = AxiosError>(
+  url: string,
+  data?: U,
+  options?: ApiFetchOptions<T, E>
+): CancellablePromise<T> => sendApiRequest(HttpMethod.POST, url, data, options);
+
+export const put = <T, U = unknown, E = AxiosError>(
+  url: string,
+  data?: U,
+  options?: ApiFetchOptions<T, E>
+): CancellablePromise<T> => sendApiRequest(HttpMethod.PUT, url, data, options);
+
+export const patch = <T, U = unknown, E = AxiosError>(
+  url: string,
+  data?: U,
+  options?: ApiFetchOptions<T, E>
+): CancellablePromise<T> => sendApiRequest(HttpMethod.PATCH, url, data, options);
+
 export const get = <T, U = unknown, E = AxiosError<DefaultApiResponse>>(
   url: string,
   data?: U,

+ 8 - 4
desktop/core/src/desktop/js/apps/storageBrowser/StorageFilePage/StorageFilePage.test.tsx

@@ -32,10 +32,14 @@ jest.mock('../../../utils/huePubSub', () => ({
   publish: jest.fn()
 }));
 
-const mockSave = jest.fn();
-jest.mock('../../../api/utils', () => ({
-  post: () => mockSave()
-}));
+const mockSendApiRequest = jest.fn();
+jest.mock('../../../api/utils', () => {
+  const original = jest.requireActual('../../../api/utils');
+  return {
+    ...original,
+    sendApiRequest: () => mockSendApiRequest()
+  };
+});
 
 const mockLoadData = jest.fn().mockReturnValue({
   contents: 'Initial file content'

+ 154 - 22
desktop/core/src/desktop/js/utils/hooks/useSaveData/useSaveData.test.tsx

@@ -16,23 +16,35 @@
 
 import { renderHook, act, waitFor } from '@testing-library/react';
 import useSaveData from './useSaveData';
-import { post } from '../../../api/utils';
-
-jest.mock('../../../api/utils', () => ({
-  post: jest.fn()
-}));
+import { HttpMethod, sendApiRequest } from '../../../api/utils';
+
+jest.mock('../../../api/utils', () => {
+  const original = jest.requireActual('../../../api/utils');
+  return {
+    ...original,
+    post: jest.fn(),
+    put: jest.fn(),
+    patch: jest.fn(),
+    sendApiRequest: jest.fn()
+  };
+});
 
-const mockPost = post as jest.MockedFunction<typeof post>;
+const mockSendApiRequest = sendApiRequest as jest.MockedFunction<typeof sendApiRequest>;
 const mockUrlPrefix = 'https://api.example.com';
 const mockEndpoint = '/save-endpoint';
 const mockUrl = `${mockUrlPrefix}${mockEndpoint}`;
 const mockData = { id: 1, product: 'Hue' };
 const mockBody = { id: 1 };
+const mockRequestOptions = {
+  ignoreSuccessErrors: true,
+  qsEncodeData: false,
+  silenceErrors: true
+};
 
 describe('useSaveData', () => {
   beforeEach(() => {
     jest.clearAllMocks();
-    mockPost.mockResolvedValue(mockData);
+    mockSendApiRequest.mockResolvedValue(mockData);
   });
 
   it('should save data successfully and update state', async () => {
@@ -49,8 +61,13 @@ describe('useSaveData', () => {
     expect(result.current.loading).toBe(true);
 
     await waitFor(() => {
-      expect(mockPost).toHaveBeenCalledTimes(1);
-      expect(mockPost).toHaveBeenCalledWith(mockUrl, mockBody, expect.any(Object));
+      expect(mockSendApiRequest).toHaveBeenCalledTimes(1);
+      expect(mockSendApiRequest).toHaveBeenCalledWith(
+        HttpMethod.POST,
+        mockUrl,
+        mockBody,
+        mockRequestOptions
+      );
       expect(result.current.data).toEqual(mockData);
       expect(result.current.error).toBeUndefined();
       expect(result.current.loading).toBe(false);
@@ -59,7 +76,7 @@ describe('useSaveData', () => {
 
   it('should handle errors and update error state', async () => {
     const mockError = new Error('Save error');
-    mockPost.mockRejectedValue(mockError);
+    mockSendApiRequest.mockRejectedValue(mockError);
 
     const { result } = renderHook(() => useSaveData(mockUrl));
 
@@ -74,7 +91,12 @@ describe('useSaveData', () => {
     expect(result.current.loading).toBe(true);
 
     await waitFor(() => {
-      expect(mockPost).toHaveBeenCalledWith(mockUrl, mockBody, expect.any(Object));
+      expect(mockSendApiRequest).toHaveBeenCalledWith(
+        HttpMethod.POST,
+        mockUrl,
+        mockBody,
+        mockRequestOptions
+      );
       expect(result.current.data).toBeUndefined();
       expect(result.current.error).toEqual(mockError);
       expect(result.current.loading).toBe(false);
@@ -91,7 +113,7 @@ describe('useSaveData', () => {
     expect(result.current.data).toBeUndefined();
     expect(result.current.error).toBeUndefined();
     expect(result.current.loading).toBe(false);
-    expect(mockPost).not.toHaveBeenCalled();
+    expect(mockSendApiRequest).not.toHaveBeenCalled();
   });
 
   it('should update options when props change', async () => {
@@ -110,7 +132,12 @@ describe('useSaveData', () => {
     expect(result.current.loading).toBe(true);
 
     await waitFor(() => {
-      expect(mockPost).toHaveBeenCalledWith(mockUrl, mockBody, expect.any(Object));
+      expect(mockSendApiRequest).toHaveBeenCalledWith(
+        HttpMethod.POST,
+        mockUrl,
+        mockBody,
+        mockRequestOptions
+      );
       expect(result.current.data).toEqual(mockData);
       expect(result.current.error).toBeUndefined();
       expect(result.current.loading).toBe(false);
@@ -118,7 +145,7 @@ describe('useSaveData', () => {
 
     const newBody = { id: 2 };
     const newMockData = { ...mockData, id: 2 };
-    mockPost.mockResolvedValueOnce(newMockData);
+    mockSendApiRequest.mockResolvedValueOnce(newMockData);
 
     rerender({ url: mockUrl });
 
@@ -129,7 +156,12 @@ describe('useSaveData', () => {
     expect(result.current.loading).toBe(true);
 
     await waitFor(() => {
-      expect(mockPost).toHaveBeenCalledWith(mockUrl, newBody, expect.any(Object));
+      expect(mockSendApiRequest).toHaveBeenCalledWith(
+        HttpMethod.POST,
+        mockUrl,
+        newBody,
+        mockRequestOptions
+      );
       expect(result.current.data).toEqual(newMockData);
       expect(result.current.error).toBeUndefined();
       expect(result.current.loading).toBe(false);
@@ -157,7 +189,12 @@ describe('useSaveData', () => {
     expect(result.current.loading).toBe(true);
 
     await waitFor(() => {
-      expect(mockPost).toHaveBeenCalledWith(mockUrl, mockBody, expect.any(Object));
+      expect(mockSendApiRequest).toHaveBeenCalledWith(
+        HttpMethod.POST,
+        mockUrl,
+        mockBody,
+        mockRequestOptions
+      );
       expect(result.current.data).toEqual(mockData);
       expect(result.current.error).toBeUndefined();
       expect(result.current.loading).toBe(false);
@@ -168,7 +205,7 @@ describe('useSaveData', () => {
 
   it('should call onError callback when provided', async () => {
     const mockError = new Error('Save error');
-    mockPost.mockRejectedValue(mockError);
+    mockSendApiRequest.mockRejectedValue(mockError);
 
     const mockOnSuccess = jest.fn();
     const mockOnError = jest.fn();
@@ -190,7 +227,12 @@ describe('useSaveData', () => {
     expect(result.current.loading).toBe(true);
 
     await waitFor(() => {
-      expect(mockPost).toHaveBeenCalledWith(mockUrl, mockBody, expect.any(Object));
+      expect(mockSendApiRequest).toHaveBeenCalledWith(
+        HttpMethod.POST,
+        mockUrl,
+        mockBody,
+        mockRequestOptions
+      );
       expect(result.current.data).toBeUndefined();
       expect(result.current.error).toEqual(mockError);
       expect(result.current.loading).toBe(false);
@@ -207,7 +249,8 @@ describe('useSaveData', () => {
     });
 
     await waitFor(() => {
-      expect(mockPost).toHaveBeenCalledWith(
+      expect(mockSendApiRequest).toHaveBeenCalledWith(
+        HttpMethod.POST,
         mockUrl,
         'hue data',
         expect.objectContaining({ qsEncodeData: true })
@@ -228,7 +271,8 @@ describe('useSaveData', () => {
     });
 
     await waitFor(() => {
-      expect(mockPost).toHaveBeenCalledWith(
+      expect(mockSendApiRequest).toHaveBeenCalledWith(
+        HttpMethod.POST,
         mockUrl,
         payload,
         expect.objectContaining({ qsEncodeData: false })
@@ -249,7 +293,8 @@ describe('useSaveData', () => {
     });
 
     await waitFor(() => {
-      expect(mockPost).toHaveBeenCalledWith(
+      expect(mockSendApiRequest).toHaveBeenCalledWith(
+        HttpMethod.POST,
         mockUrl,
         payload,
         expect.objectContaining({ qsEncodeData: false })
@@ -274,7 +319,8 @@ describe('useSaveData', () => {
     });
 
     await waitFor(() => {
-      expect(mockPost).toHaveBeenCalledWith(
+      expect(mockSendApiRequest).toHaveBeenCalledWith(
+        HttpMethod.POST,
         mockUrl,
         payload,
         expect.objectContaining({ qsEncodeData: true })
@@ -284,4 +330,90 @@ describe('useSaveData', () => {
       expect(result.current.loading).toBe(false);
     });
   });
+
+  it('should use PUT method when specified in options', async () => {
+    mockSendApiRequest.mockResolvedValue(mockData);
+
+    const { result } = renderHook(() => useSaveData(mockUrl, { method: HttpMethod.PUT }));
+
+    act(() => {
+      result.current.save(mockBody);
+    });
+
+    await waitFor(() => {
+      expect(mockSendApiRequest).toHaveBeenCalledTimes(1);
+      expect(mockSendApiRequest).toHaveBeenCalledWith(
+        HttpMethod.PUT,
+        mockUrl,
+        mockBody,
+        mockRequestOptions
+      );
+      expect(result.current.data).toEqual(mockData);
+      expect(result.current.loading).toBe(false);
+    });
+  });
+
+  it('should use PATCH method when specified in saveOptions', async () => {
+    mockSendApiRequest.mockResolvedValue(mockData);
+
+    const { result } = renderHook(() => useSaveData(mockUrl));
+
+    act(() => {
+      result.current.save(mockBody, { method: HttpMethod.PATCH });
+    });
+
+    await waitFor(() => {
+      expect(mockSendApiRequest).toHaveBeenCalledTimes(1);
+      expect(mockSendApiRequest).toHaveBeenCalledWith(
+        HttpMethod.PATCH,
+        mockUrl,
+        mockBody,
+        mockRequestOptions
+      );
+      expect(result.current.data).toEqual(mockData);
+      expect(result.current.loading).toBe(false);
+    });
+  });
+
+  it('should prioritize saveOptions method over options method', async () => {
+    mockSendApiRequest.mockResolvedValue(mockData);
+
+    const { result } = renderHook(() => useSaveData(mockUrl, { method: HttpMethod.PUT }));
+
+    act(() => {
+      result.current.save(mockBody, { method: HttpMethod.PATCH });
+    });
+
+    await waitFor(() => {
+      expect(mockSendApiRequest).toHaveBeenCalledTimes(1);
+      expect(mockSendApiRequest).toHaveBeenCalledWith(
+        HttpMethod.PATCH,
+        mockUrl,
+        mockBody,
+        mockRequestOptions
+      );
+      expect(result.current.data).toEqual(mockData);
+      expect(result.current.loading).toBe(false);
+    });
+  });
+
+  it('should default to POST when no method is specified', async () => {
+    const { result } = renderHook(() => useSaveData(mockUrl));
+
+    act(() => {
+      result.current.save(mockBody);
+    });
+
+    await waitFor(() => {
+      expect(mockSendApiRequest).toHaveBeenCalledTimes(1);
+      expect(mockSendApiRequest).toHaveBeenCalledWith(
+        HttpMethod.POST,
+        mockUrl,
+        mockBody,
+        mockRequestOptions
+      );
+      expect(result.current.data).toEqual(mockData);
+      expect(result.current.loading).toBe(false);
+    });
+  });
 });

+ 13 - 9
desktop/core/src/desktop/js/utils/hooks/useSaveData/useSaveData.ts

@@ -16,11 +16,12 @@
 
 import { useCallback, useEffect, useState } from 'react';
 import { AxiosError } from 'axios';
-import { ApiFetchOptions, post } from '../../../api/utils';
+import { ApiFetchOptions, HttpMethod, sendApiRequest } from '../../../api/utils';
 import { isJSON } from '../../jsonUtils';
 
 interface saveOptions<T, E> {
   url?: string;
+  method?: HttpMethod;
   onSuccess?: (data: T) => void;
   onError?: (error: AxiosError) => void;
   postOptions?: ApiFetchOptions<T, E>;
@@ -46,14 +47,14 @@ const useSaveData = <T, U = unknown, E = string>(
   const [loading, setLoading] = useState<boolean>(false);
   const [error, setError] = useState<E | undefined>();
 
-  const postOptionsDefault: ApiFetchOptions<T, E> = {
+  const requestOptionsDefault: ApiFetchOptions<T, E> = {
     silenceErrors: true,
     ignoreSuccessErrors: true
   };
 
   const saveData = useCallback(
     async (body: U, saveOptions?: saveOptions<T, E>) => {
-      // Avoid Posting data if the skip option is true
+      // Avoid sending data if the skip option is true
       // or if the URL is not provided
       const apiUrl = saveOptions?.url ?? url;
       if (options?.skip || !apiUrl) {
@@ -62,15 +63,18 @@ const useSaveData = <T, U = unknown, E = string>(
       setLoading(true);
       setError(undefined);
 
-      const postOptions = {
-        ...postOptionsDefault,
+      const requestOptions = {
+        ...requestOptionsDefault,
         qsEncodeData: body instanceof FormData || isJSON(body) ? false : true,
         ...localOptions?.postOptions,
         ...saveOptions?.postOptions
       };
 
+      const method = saveOptions?.method ?? localOptions?.method ?? HttpMethod.POST;
+
       try {
-        const response = await post<T, U, E>(apiUrl, body, postOptions);
+        const response = await sendApiRequest(method, apiUrl, body, requestOptions);
+
         setData(response);
         if (saveOptions?.onSuccess) {
           saveOptions.onSuccess(response);
@@ -79,12 +83,12 @@ const useSaveData = <T, U = unknown, E = string>(
           localOptions.onSuccess(response);
         }
       } catch (error) {
-        setError(error);
+        setError(error as E);
         if (saveOptions?.onError) {
-          saveOptions.onError(error);
+          saveOptions.onError(error as AxiosError);
         }
         if (localOptions?.onError) {
-          localOptions.onError(error);
+          localOptions.onError(error as AxiosError);
         }
       } finally {
         setLoading(false);