Browse Source

[ui-core] add useSaveData hook for api calls (#3864)

* [ui-core] add useSaveData hook for api calls

* removes the url prefix from options
Ram Prasad Agarwal 1 year ago
parent
commit
59fad964a9

+ 201 - 0
desktop/core/src/desktop/js/utils/hooks/useSaveData.test.tsx

@@ -0,0 +1,201 @@
+// Licensed to Cloudera, Inc. under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  Cloudera, Inc. licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+import { renderHook, act, waitFor } from '@testing-library/react';
+import useSaveData from './useSaveData';
+import { post } from '../../api/utils';
+
+jest.mock('../../api/utils', () => ({
+  post: jest.fn()
+}));
+
+const mockPost = post as jest.MockedFunction<typeof post>;
+const mockUrlPrefix = 'https://api.example.com';
+const mockEndpoint = '/save-endpoint';
+const mockUrl = `${mockUrlPrefix}${mockEndpoint}`;
+const mockData = { id: 1, product: 'Hue' };
+const mockBody = { id: 1 };
+
+describe('useSaveData', () => {
+  beforeAll(() => {
+    jest.clearAllMocks();
+  });
+
+  beforeEach(() => {
+    mockPost.mockResolvedValue(mockData);
+  });
+
+  afterEach(() => {
+    jest.clearAllMocks();
+  });
+
+  it('should save data with body successfully', async () => {
+    const { result } = renderHook(() => useSaveData(mockUrl));
+
+    expect(result.current.data).toBeUndefined();
+    expect(result.current.error).toBeUndefined();
+    expect(result.current.loading).toBe(false);
+
+    act(() => {
+      result.current.save(mockBody);
+    });
+    expect(result.current.loading).toBe(true);
+
+    await waitFor(() => {
+      expect(mockPost).toHaveBeenCalledTimes(1);
+      expect(mockPost).toHaveBeenCalledWith(mockUrl, mockBody, expect.any(Object));
+      expect(result.current.data).toEqual(mockData);
+      expect(result.current.error).toBeUndefined();
+      expect(result.current.loading).toBe(false);
+    });
+  });
+
+  it('should handle save errors', async () => {
+    const mockError = new Error('Save error');
+    mockPost.mockRejectedValue(mockError);
+
+    const { result } = renderHook(() => useSaveData(mockUrl));
+
+    expect(result.current.data).toBeUndefined();
+    expect(result.current.error).toBeUndefined();
+    expect(result.current.loading).toBe(false);
+
+    act(() => {
+      result.current.save(mockBody);
+    });
+    expect(result.current.loading).toBe(true);
+
+    await waitFor(() => {
+      expect(mockPost).toHaveBeenCalledWith(mockUrl, mockBody, expect.any(Object));
+      expect(result.current.data).toBeUndefined();
+      expect(result.current.error).toEqual(mockError);
+      expect(result.current.loading).toBe(false);
+    });
+  });
+
+  it('should respect the skip option', () => {
+    const { result } = renderHook(() => useSaveData(mockUrl, { skip: true }));
+
+    act(() => {
+      result.current.save(mockBody);
+    });
+
+    expect(result.current.data).toBeUndefined();
+    expect(result.current.error).toBeUndefined();
+    expect(result.current.loading).toBe(false);
+    expect(mockPost).not.toHaveBeenCalled();
+  });
+
+  it('should update options correctly', async () => {
+    const { result, rerender } = renderHook((props: { url: string }) => useSaveData(props.url), {
+      initialProps: { url: mockUrl }
+    });
+
+    expect(result.current.data).toBeUndefined();
+    expect(result.current.error).toBeUndefined();
+    expect(result.current.loading).toBe(false);
+
+    act(() => {
+      result.current.save(mockBody);
+    });
+    expect(result.current.loading).toBe(true);
+
+    await waitFor(() => {
+      expect(mockPost).toHaveBeenCalledWith(mockUrl, mockBody, expect.any(Object));
+      expect(result.current.data).toEqual(mockData);
+      expect(result.current.error).toBeUndefined();
+      expect(result.current.loading).toBe(false);
+    });
+
+    const newBody = { id: 2 };
+    const newMockData = { ...mockData, id: 2 };
+    mockPost.mockResolvedValueOnce(newMockData);
+
+    rerender({ url: mockUrl });
+
+    act(() => {
+      result.current.save(newBody);
+    });
+    expect(result.current.loading).toBe(true);
+
+    await waitFor(() => {
+      expect(mockPost).toHaveBeenCalledWith(mockUrl, newBody, expect.any(Object));
+      expect(result.current.data).toEqual(newMockData);
+      expect(result.current.error).toBeUndefined();
+      expect(result.current.loading).toBe(false);
+    });
+  });
+
+  it('should call onSuccess callback', async () => {
+    const mockOnSuccess = jest.fn();
+    const mockOnError = jest.fn();
+    const { result } = renderHook(() =>
+      useSaveData(mockUrl, {
+        onSuccess: mockOnSuccess,
+        onError: mockOnError
+      })
+    );
+
+    expect(result.current.data).toBeUndefined();
+    expect(result.current.error).toBeUndefined();
+    expect(result.current.loading).toBe(false);
+
+    act(() => {
+      result.current.save(mockBody);
+    });
+    expect(result.current.loading).toBe(true);
+
+    await waitFor(() => {
+      expect(mockPost).toHaveBeenCalledWith(mockUrl, mockBody, expect.any(Object));
+      expect(result.current.data).toEqual(mockData);
+      expect(result.current.error).toBeUndefined();
+      expect(result.current.loading).toBe(false);
+      expect(mockOnSuccess).toHaveBeenCalledWith(mockData);
+      expect(mockOnError).not.toHaveBeenCalled();
+    });
+  });
+
+  it('should call onError callback', async () => {
+    const mockError = new Error('Save error');
+    mockPost.mockRejectedValue(mockError);
+
+    const mockOnSuccess = jest.fn();
+    const mockOnError = jest.fn();
+    const { result } = renderHook(() =>
+      useSaveData(mockUrl, {
+        onSuccess: mockOnSuccess,
+        onError: mockOnError
+      })
+    );
+
+    expect(result.current.data).toBeUndefined();
+    expect(result.current.error).toBeUndefined();
+    expect(result.current.loading).toBe(false);
+
+    act(() => {
+      result.current.save(mockBody);
+    });
+    expect(result.current.loading).toBe(true);
+
+    await waitFor(() => {
+      expect(mockPost).toHaveBeenCalledWith(mockUrl, mockBody, expect.any(Object));
+      expect(result.current.data).toBeUndefined();
+      expect(result.current.error).toEqual(mockError);
+      expect(result.current.loading).toBe(false);
+      expect(mockOnSuccess).not.toHaveBeenCalled();
+      expect(mockOnError).toHaveBeenCalledWith(mockError);
+    });
+  });
+});

+ 88 - 0
desktop/core/src/desktop/js/utils/hooks/useSaveData.ts

@@ -0,0 +1,88 @@
+// Licensed to Cloudera, Inc. under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  Cloudera, Inc. licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+import { useCallback, useEffect, useMemo, useState } from 'react';
+import { ApiFetchOptions, post } from '../../api/utils';
+
+export interface Options<T> {
+  postOptions?: ApiFetchOptions<T>;
+  skip?: boolean;
+  onSuccess?: (data: T) => void;
+  onError?: (error: Error) => void;
+}
+
+interface UseSaveData<T, U> {
+  data?: T;
+  loading: boolean;
+  error?: Error;
+  save: (body: U) => void;
+}
+
+const useSaveData = <T, U = unknown>(url?: string, options?: Options<T>): UseSaveData<T, U> => {
+  const [localOptions, setLocalOptions] = useState<Options<T> | undefined>(options);
+  const [data, setData] = useState<T | undefined>();
+  const [loading, setLoading] = useState<boolean>(false);
+  const [error, setError] = useState<Error | undefined>();
+
+  const postOptionsDefault: ApiFetchOptions<T> = {
+    silenceErrors: false,
+    ignoreSuccessErrors: true
+  };
+
+  const postOptions = useMemo(
+    () => ({ ...postOptionsDefault, ...localOptions?.postOptions }),
+    [localOptions]
+  );
+
+  const saveData = useCallback(
+    async (body: U) => {
+      // Avoid Posting data if the skip option is true
+      // or if the URL is not provided
+      if (options?.skip || !url) {
+        return;
+      }
+      setLoading(true);
+      setError(undefined);
+
+      try {
+        const response = await post<T, U>(url, body, postOptions);
+        setData(response);
+        if (localOptions?.onSuccess) {
+          localOptions.onSuccess(response);
+        }
+      } catch (error) {
+        setError(error);
+        if (localOptions?.onError) {
+          localOptions.onError(error);
+        }
+      } finally {
+        setLoading(false);
+      }
+    },
+    [url, localOptions, postOptions]
+  );
+
+  useEffect(() => {
+    // set new options if they are different (deep comparison)
+    if (JSON.stringify(options) !== JSON.stringify(localOptions)) {
+      setLocalOptions(options);
+    }
+  }, [options]);
+
+  return { data, loading, error, save: saveData };
+};
+
+export default useSaveData;