Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Bal 3521 (WIP DO NOT MERGE) #3015

Open
wants to merge 4 commits into
base: dev
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@
"react-json-view": "^1.21.3",
"react-phone-input-2": "^2.15.1",
"recharts": "^2.7.2",
"sonner": "^1.4.3",
"string-ts": "^1.2.0",
"tailwind-merge": "^1.10.0",
"zod": "^3.23.4"
Expand Down
4 changes: 4 additions & 0 deletions packages/ui/src/common/hooks/useHttp/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export * from './types';
export * from './useHttp';
export * from './utils/format-headers';
export * from './utils/request';
6 changes: 6 additions & 0 deletions packages/ui/src/common/hooks/useHttp/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export interface IHttpParams {
url: string;
resultPath: string;
headers?: Record<string, string>;
method?: 'POST' | 'PUT' | 'GET' | 'DELETE';
}
34 changes: 34 additions & 0 deletions packages/ui/src/common/hooks/useHttp/useHttp.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { AnyObject } from '@/common/types';
import get from 'lodash/get';
import { useCallback, useState } from 'react';
import { IHttpParams } from './types';
import { request } from './utils/request';

export const useHttp = (params: IHttpParams, metadata: AnyObject) => {
const [responseError, setResponseError] = useState<Error | null>(null);
const [isLoading, setIsLoading] = useState(false);

const runRequest = useCallback(
async (requestPayload?: any) => {
setIsLoading(true);
setResponseError(null);

try {
const response = await request(params, metadata, requestPayload);

return params.resultPath ? get(response, params.resultPath) : response;
} catch (error) {
setResponseError(error as Error);
} finally {
setIsLoading(false);
}
},
[params, metadata],
);

return {
isLoading,
error: responseError,
run: runRequest,
};
};
110 changes: 110 additions & 0 deletions packages/ui/src/common/hooks/useHttp/useHttp.unit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { renderHook } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { useHttp } from './useHttp';
import { request } from './utils/request';

vi.mock('./utils/request');

describe('useHttp', () => {
const mockParams = {
url: 'test-url',
resultPath: 'data.items',
method: 'GET' as const,
headers: {},
};

const mockMetadata = {
token: 'test-token',
};

const mockResponse = {
data: {
items: ['item1', 'item2'],
},
};

beforeEach(() => {
vi.clearAllMocks();
});

it('should return initial state', () => {
const { result } = renderHook(() => useHttp(mockParams, mockMetadata));

expect(result.current.isLoading).toBe(false);
expect(result.current.error).toBeNull();
expect(typeof result.current.run).toBe('function');
});

it('should handle successful request', async () => {
vi.mocked(request).mockResolvedValueOnce(mockResponse);

const { result } = renderHook(() => useHttp(mockParams, mockMetadata));

const response = await result.current.run();

expect(request).toHaveBeenCalledWith(mockParams, mockMetadata, undefined);
expect(response).toEqual(['item1', 'item2']);
expect(result.current.isLoading).toBe(false);
expect(result.current.error).toBeNull();
});

it('should handle request with payload', async () => {
vi.mocked(request).mockResolvedValueOnce(mockResponse);
const payload = { test: 'payload' };

const { result } = renderHook(() => useHttp(mockParams, mockMetadata));

await result.current.run(payload);

expect(request).toHaveBeenCalledWith(mockParams, mockMetadata, payload);
});

it('should handle request without resultPath', async () => {
const paramsWithoutPath = {
url: 'test-url',
resultPath: '',
method: 'GET' as const,
headers: {},
};
vi.mocked(request).mockResolvedValueOnce(mockResponse);

const { result } = renderHook(() => useHttp(paramsWithoutPath, mockMetadata));

const response = await result.current.run();

expect(response).toEqual(mockResponse);
});

it('should handle error', async () => {
const mockError = new Error('Test error');
vi.mocked(request).mockRejectedValueOnce(mockError);

const { result, rerender } = renderHook(() => useHttp(mockParams, mockMetadata));

await result.current.run();

rerender();

expect(result.current.error).toBe(mockError);
expect(result.current.isLoading).toBe(false);
});

it('should set loading state during request', async () => {
vi.mocked(request).mockImplementationOnce(
() =>
new Promise(resolve => {
setTimeout(() => resolve(mockResponse), 100);
}),
);

const { result, rerender } = renderHook(() => useHttp(mockParams, mockMetadata));

const promise = result.current.run();
rerender();
expect(result.current.isLoading).toBe(true);

await promise;
rerender();
expect(result.current.isLoading).toBe(false);
});
});
15 changes: 15 additions & 0 deletions packages/ui/src/common/hooks/useHttp/utils/format-headers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { formatString } from '@/components/organisms/Form/DynamicForm/utils/format-string';

export const formatHeaders = (
headers: Record<string, string>,
metadata: Record<string, string> = {},
) => {
const formattedHeaders: Record<string, string> = {};

Object.entries(headers).forEach(([key, value]) => {
const formattedValue = formatString(value, metadata);
formattedHeaders[key] = formattedValue;
});

return formattedHeaders;
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { formatString } from '@/components/organisms/Form/DynamicForm/utils/format-string';
import { describe, expect, it, vi } from 'vitest';
import { formatHeaders } from './format-headers';

vi.mock('@/components/organisms/Form/DynamicForm/utils/format-string', () => ({
formatString: vi.fn(),
}));

const mockedFormatString = vi.mocked(formatString);

describe('formatHeaders', () => {
it('should format headers with metadata', () => {
const headers = {
Authorization: 'Bearer {token}',

Check failure

Code scanning / CodeQL

Hard-coded credentials Critical

The hard-coded value "Bearer {token}" is used as
authorization header
.
'Content-Type': 'application/json',
};

const metadata = {
token: 'abc123',
};

mockedFormatString.mockReturnValueOnce('Bearer abc123').mockReturnValueOnce('application/json');

const result = formatHeaders(headers, metadata);

expect(result).toEqual({
Authorization: 'Bearer abc123',

Check failure

Code scanning / CodeQL

Hard-coded credentials Critical

The hard-coded value "Bearer abc123" is used as
authorization header
.
'Content-Type': 'application/json',
});

expect(mockedFormatString).toHaveBeenCalledTimes(2);
expect(mockedFormatString).toHaveBeenCalledWith('Bearer {token}', metadata);
expect(mockedFormatString).toHaveBeenCalledWith('application/json', metadata);
});

it('should handle empty headers', () => {
const result = formatHeaders({});

expect(result).toEqual({});
expect(mockedFormatString).not.toHaveBeenCalled();
});

it('should use empty metadata object if not provided', () => {
const headers = {
'X-Custom': 'test',
};

mockedFormatString.mockReturnValueOnce('test');

const result = formatHeaders(headers);

expect(result).toEqual({
'X-Custom': 'test',
});

expect(mockedFormatString).toHaveBeenCalledWith('test', {});
});
});
29 changes: 29 additions & 0 deletions packages/ui/src/common/hooks/useHttp/utils/request.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { AnyObject } from '@/common/types';
import { formatString } from '@/components/organisms/Form/DynamicForm/utils/format-string';
import axios from 'axios';
import { IHttpParams } from '../types';
import { formatHeaders } from './format-headers';

export type TReuqestParams = Omit<IHttpParams, 'resultPath'>;

export const request = async (request: TReuqestParams, metadata: AnyObject = {}, data?: any) => {
const { url, headers = {}, method } = request;

const formattedUrl = formatString(url, metadata);
const formattedHeaders = formatHeaders(headers, metadata);

try {
const response = await axios({
url: formattedUrl,
method,
headers: formattedHeaders,
data,
});

return response.data;
} catch (error) {
console.error('Failed to perform request.', error);

throw error;
}
};
92 changes: 92 additions & 0 deletions packages/ui/src/common/hooks/useHttp/utils/request.unit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { formatString } from '@/components/organisms/Form/DynamicForm/utils/format-string';
import axios from 'axios';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { formatHeaders } from './format-headers';
import { request } from './request';

vi.mock('axios');
vi.mock('@/components/organisms/Form/DynamicForm/utils/format-string');
vi.mock('./format-headers');

describe('request', () => {
const mockAxios = vi.mocked(axios);
const mockFormatString = vi.mocked(formatString);
const mockFormatHeaders = vi.mocked(formatHeaders);

beforeEach(() => {
vi.clearAllMocks();
});

it('should make a request with formatted url and headers', async () => {
const requestParams = {
url: 'http://api.example.com/{path}',
method: 'GET',
headers: {
Authorization: 'Bearer {token}',

Check failure

Code scanning / CodeQL

Hard-coded credentials Critical

The hard-coded value "Bearer {token}" is used as
authorization header
.
},
} as const;
const metadata = {
path: 'test',
token: '12345',
};
const mockResponse = { data: { result: 'success' } };

mockFormatString.mockReturnValue('http://api.example.com/test');
mockFormatHeaders.mockReturnValue({ Authorization: 'Bearer 12345' });
mockAxios.mockResolvedValue(mockResponse);

const result = await request(requestParams, metadata);

expect(mockFormatString).toHaveBeenCalledWith('http://api.example.com/{path}', metadata);
expect(mockFormatHeaders).toHaveBeenCalledWith({ Authorization: 'Bearer {token}' }, metadata);

Check failure

Code scanning / CodeQL

Hard-coded credentials Critical

The hard-coded value "Bearer {token}" is used as
authorization header
.
expect(mockAxios).toHaveBeenCalledWith({
url: 'http://api.example.com/test',
method: 'GET',
headers: { Authorization: 'Bearer 12345' },
data: undefined,
});
expect(result).toEqual({ result: 'success' });
});

it('should make a request with data when provided', async () => {
const requestParams = {
url: 'http://api.example.com/test',
method: 'POST',
headers: {},
} as const;
const data = { foo: 'bar' };
const mockResponse = { data: { result: 'success' } };

mockFormatString.mockReturnValue('http://api.example.com/test');
mockFormatHeaders.mockReturnValue({});
mockAxios.mockResolvedValue(mockResponse);

const result = await request(requestParams, {}, data);

expect(mockAxios).toHaveBeenCalledWith({
url: 'http://api.example.com/test',
method: 'POST',
headers: {},
data: { foo: 'bar' },
});
expect(result).toEqual({ result: 'success' });
});

it('should throw and log error when request fails', async () => {
const requestParams = {
url: 'http://api.example.com/test',
method: 'GET',
headers: {},
} as const;
const error = new Error('Request failed');

mockFormatString.mockReturnValue('http://api.example.com/test');
mockFormatHeaders.mockReturnValue({});
mockAxios.mockRejectedValue(error);

const consoleSpy = vi.spyOn(console, 'error');

await expect(request(requestParams, {})).rejects.toThrow('Request failed');
expect(consoleSpy).toHaveBeenCalledWith('Failed to perform request.', error);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ const schema: Array<IFormElement<any, any>> = [
uploadSettings: {
url: 'http://localhost:3000/upload',
resultPath: 'filename',
method: 'POST',
},
},
},
Expand All @@ -28,6 +29,7 @@ const schema: Array<IFormElement<any, any>> = [
uploadSettings: {
url: 'http://localhost:3000/upload-protected',
resultPath: 'filename',
method: 'POST',
headers: {
Authorization: '{token}',
},
Expand All @@ -45,6 +47,7 @@ const schema: Array<IFormElement<any, any>> = [
uploadSettings: {
url: 'http://localhost:3000/upload',
resultPath: 'filename',
method: 'POST',
},
template: {
id: 'document-1',
Expand All @@ -63,6 +66,7 @@ const schema: Array<IFormElement<any, any>> = [
uploadSettings: {
url: 'http://localhost:3000/upload',
resultPath: 'filename',
method: 'POST',
},
template: {
id: 'document-2',
Expand Down
Loading