Эх сурвалжийг харах

[ui-core]: feat: add useLoadData hook for API calls (#3819)

* [ui-core]: feat: add useFetch hook for API calls

* add the license information

* fix lint

* lint fix

* add library

* pin the library version

* revert extra changes

* revert package-lock json

* fix: rename hook

* fix the function name

* typecaste error object
Ram Prasad Agarwal 1 жил өмнө
parent
commit
8c7a36429b

+ 182 - 0
desktop/core/src/desktop/js/utils/hooks/useLoadData.test.tsx

@@ -0,0 +1,182 @@
+// 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 useLoadData from './useLoadData';
+import { get } from '../../api/utils';
+
+// Mock the `get` function
+jest.mock('../../api/utils', () => ({
+  get: jest.fn()
+}));
+
+const mockGet = get as jest.MockedFunction<typeof get>;
+const mockUrlPrefix = 'https://api.example.com';
+const mockEndpoint = '/endpoint';
+const mockUrl = `${mockUrlPrefix}${mockEndpoint}`;
+const mockData = { id: 1, product: 'Hue' };
+const mockOptions = {
+  params: { id: 1 }
+};
+
+describe('useLoadData', () => {
+  beforeAll(() => {
+    jest.clearAllMocks();
+  });
+
+  beforeEach(() => {
+    mockGet.mockResolvedValue(mockData);
+  });
+
+  afterEach(() => {
+    jest.clearAllMocks();
+  });
+
+  it('should fetch data successfully', async () => {
+    const { result } = renderHook(() => useLoadData(mockUrl));
+    expect(result.current.data).toBeUndefined();
+    expect(result.current.error).toBeUndefined();
+    expect(result.current.loading).toBe(true);
+
+    await waitFor(() => {
+      expect(mockGet).toHaveBeenCalledWith(mockUrl, undefined, expect.any(Object));
+      expect(result.current.data).toEqual(mockData);
+      expect(result.current.error).toBeUndefined();
+      expect(result.current.loading).toBe(false);
+    });
+  });
+
+  it('should fetch data with params successfully', async () => {
+    const { result } = renderHook(() => useLoadData(mockUrl, mockOptions));
+
+    expect(result.current.data).toBeUndefined();
+    expect(result.current.error).toBeUndefined();
+    expect(result.current.loading).toBe(true);
+
+    waitFor(() => {
+      expect(mockGet).toHaveBeenCalledWith(mockUrl, mockOptions.params, expect.any(Object));
+      expect(result.current.data).toEqual(mockData);
+      expect(result.current.error).toBeUndefined();
+      expect(result.current.loading).toBe(false);
+    });
+  });
+
+  it('should handle fetch errors', async () => {
+    const mockError = new Error('Fetch error');
+    mockGet.mockRejectedValue(mockError);
+
+    const { result } = renderHook(() => useLoadData(mockUrl));
+
+    expect(result.current.data).toBeUndefined();
+    expect(result.current.error).toBeUndefined();
+    expect(result.current.loading).toBe(true);
+
+    waitFor(() => {
+      expect(mockGet).toHaveBeenCalledWith(mockUrl, undefined, 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(() => useLoadData(mockUrl, { skip: true }));
+
+    expect(result.current.data).toBeUndefined();
+    expect(result.current.error).toBeUndefined();
+    expect(result.current.loading).toBe(false);
+    expect(mockGet).not.toHaveBeenCalled();
+  });
+
+  it('should call refetch function', async () => {
+    const { result } = renderHook(() => useLoadData(mockUrl));
+
+    expect(result.current.data).toBeUndefined();
+    expect(result.current.error).toBeUndefined();
+    expect(result.current.loading).toBe(true);
+
+    waitFor(() => {
+      expect(mockGet).toHaveBeenCalledWith(mockUrl, undefined, expect.any(Object));
+      expect(result.current.data).toEqual(mockData);
+      expect(result.current.error).toBeUndefined();
+      expect(result.current.loading).toBe(false);
+    });
+
+    mockGet.mockResolvedValueOnce({ ...mockData, product: 'Hue 2' });
+
+    act(() => {
+      result.current.reloadData();
+    });
+
+    waitFor(() => {
+      expect(mockGet).toHaveBeenCalledTimes(2);
+      expect(mockGet).toHaveBeenCalledWith(mockUrl, undefined, expect.any(Object));
+      expect(result.current.data).toEqual({ ...mockData, product: 'Hue 2' });
+      expect(result.current.error).toBeUndefined();
+      expect(result.current.loading).toBe(false);
+    });
+  });
+
+  it('should handle URL prefix correctly', async () => {
+    const { result } = renderHook(() => useLoadData(mockEndpoint, { urlPrefix: mockUrlPrefix }));
+
+    expect(result.current.data).toBeUndefined();
+    expect(result.current.error).toBeUndefined();
+    expect(result.current.loading).toBe(true);
+
+    waitFor(() => {
+      expect(mockGet).toHaveBeenCalledWith(mockUrl, undefined, expect.any(Object));
+      expect(result.current.data).toEqual(mockData);
+      expect(result.current.error).toBeUndefined();
+      expect(result.current.loading).toBe(false);
+    });
+  });
+
+  it('should update options correctly', async () => {
+    const { result, rerender } = renderHook(
+      (props: { url: string; options }) => useLoadData(props.url, props.options),
+      {
+        initialProps: { url: mockUrl, options: mockOptions }
+      }
+    );
+
+    expect(result.current.data).toBeUndefined();
+    expect(result.current.error).toBeUndefined();
+    expect(result.current.loading).toBe(true);
+
+    waitFor(() => {
+      expect(mockGet).toHaveBeenCalledWith(mockUrl, mockOptions.params, expect.any(Object));
+      expect(result.current.data).toEqual(mockData);
+      expect(result.current.error).toBeUndefined();
+      expect(result.current.loading).toBe(false);
+    });
+
+    const newOptions = {
+      params: { id: 2 }
+    };
+    const newMockData = { ...mockData, id: 2 };
+    mockGet.mockResolvedValueOnce(newMockData);
+
+    rerender({ url: mockUrl, options: newOptions });
+
+    waitFor(() => {
+      expect(mockGet).toHaveBeenCalledWith(mockUrl, newOptions.params, expect.any(Object));
+      expect(result.current.data).toEqual(newMockData);
+      expect(result.current.error).toBeUndefined();
+      expect(result.current.loading).toBe(false);
+    });
+  });
+});

+ 87 - 0
desktop/core/src/desktop/js/utils/hooks/useLoadData.ts

@@ -0,0 +1,87 @@
+// 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, get } from '../../api/utils';
+
+export type IOptions<T, U> = {
+  urlPrefix?: string;
+  params?: U;
+  fetchOptions?: ApiFetchOptions<T>;
+  skip?: boolean;
+};
+
+type IUseLoadData<T> = {
+  data?: T;
+  loading: boolean;
+  error?: Error;
+  reloadData: () => void;
+};
+
+const useLoadData = <T, U = unknown>(url?: string, options?: IOptions<T, U>): IUseLoadData<T> => {
+  const [localOptions, setLocalOptions] = useState<IOptions<T, U> | undefined>(options);
+  const [data, setData] = useState<T | undefined>();
+  const [loading, setLoading] = useState<boolean>(false);
+  const [error, setError] = useState<Error | undefined>();
+
+  const fetchOptionsDefault: ApiFetchOptions<T> = {
+    silenceErrors: false,
+    ignoreSuccessErrors: true
+  };
+
+  const fetchOptions = useMemo(
+    () => ({ ...fetchOptionsDefault, ...localOptions?.fetchOptions }),
+    [localOptions]
+  );
+
+  const loadData = useCallback(
+    async (isForced: boolean = false) => {
+      // Avoid fetching data if the skip option is true
+      // or if the URL is not provided
+      if ((options?.skip && !isForced) || !url) {
+        return;
+      }
+      setLoading(true);
+      setError(undefined);
+
+      try {
+        const fetchUrl = localOptions?.urlPrefix ? `${localOptions.urlPrefix}${url}` : url;
+        const response = await get<T, U>(fetchUrl, localOptions?.params, fetchOptions);
+        setData(response);
+      } catch (error) {
+        setError(error instanceof Error ? error : new Error(error));
+      } finally {
+        setLoading(false);
+      }
+    },
+    [url, localOptions, fetchOptions]
+  );
+
+  useEffect(() => {
+    loadData();
+  }, [loadData]);
+
+  useEffect(() => {
+    // set new options if they are different (deep comparison)
+    if (JSON.stringify(options) !== JSON.stringify(localOptions)) {
+      setLocalOptions(options);
+    }
+  }, [options]);
+
+  return { data, loading, error, reloadData: () => loadData(true) };
+};
+
+export default useLoadData;