From 94d148d0ab1ccbcc5d79bdb824a0adaca0a79bb4 Mon Sep 17 00:00:00 2001 From: Alexandre Anicio Date: Wed, 6 Mar 2024 09:25:01 -0300 Subject: [PATCH 1/3] BA: jwt user management --- packages/authentication/CHANGELOG.md | 13 +++ packages/authentication/index.ts | 26 +----- .../modules/access/usePreAuth/index.ts | 4 +- packages/authentication/modules/user/index.ts | 2 + .../__tests__/fixtures/request.json | 16 ++++ .../useJWTUser/__tests__/useJWTUser.test.tsx | 91 +++++++++++++++++++ .../modules/user/useJWTUser/index.ts | 46 ++++++++++ .../modules/user/useJWTUser/types.ts | 11 +++ .../__tests__/fixtures/request.json | 10 ++ .../__tests__/useUpdateUser.test.tsx | 82 +++++++++++++++++ .../modules/user/useUpdateUser/index.ts | 46 ++++++++++ .../modules/user/useUpdateUser/types.ts | 12 +++ .../modules/user/useUser/index.ts | 7 +- packages/authentication/package.json | 2 +- packages/authentication/services/auth.ts | 4 +- packages/authentication/services/user.ts | 11 ++- packages/authentication/types/user.ts | 8 ++ packages/utils/CHANGELOG.md | 8 ++ .../__tests__/createAxiosInstance.test.ts | 48 ++++++++++ .../axios/createAxiosInstance/index.ts | 16 ++++ .../__tests__/filterDirtyValues.test.ts | 38 ++++++++ .../functions/form/filterDirtyValues/index.ts | 35 +++++++ .../__tests__/getInitialValues.test.ts | 38 ++++++++ .../functions/form/getInitialValues/index.ts | 32 +++++++ packages/utils/functions/form/index.ts | 2 + .../__tests__/refreshAccessToken.test.ts | 6 +- .../token/refreshAccessToken/index.ts | 6 +- packages/utils/package.json | 2 +- 28 files changed, 586 insertions(+), 36 deletions(-) create mode 100644 packages/authentication/modules/user/useJWTUser/__tests__/fixtures/request.json create mode 100644 packages/authentication/modules/user/useJWTUser/__tests__/useJWTUser.test.tsx create mode 100644 packages/authentication/modules/user/useJWTUser/index.ts create mode 100644 packages/authentication/modules/user/useJWTUser/types.ts create mode 100644 packages/authentication/modules/user/useUpdateUser/__tests__/fixtures/request.json create mode 100644 packages/authentication/modules/user/useUpdateUser/__tests__/useUpdateUser.test.tsx create mode 100644 packages/authentication/modules/user/useUpdateUser/index.ts create mode 100644 packages/authentication/modules/user/useUpdateUser/types.ts create mode 100644 packages/utils/functions/form/filterDirtyValues/__tests__/filterDirtyValues.test.ts create mode 100644 packages/utils/functions/form/filterDirtyValues/index.ts create mode 100644 packages/utils/functions/form/getInitialValues/__tests__/getInitialValues.test.ts create mode 100644 packages/utils/functions/form/getInitialValues/index.ts rename packages/utils/functions/{axios/createAxiosInstance => token/refreshAccessToken}/__tests__/refreshAccessToken.test.ts (94%) diff --git a/packages/authentication/CHANGELOG.md b/packages/authentication/CHANGELOG.md index df948e27..dcefa8f2 100644 --- a/packages/authentication/CHANGELOG.md +++ b/packages/authentication/CHANGELOG.md @@ -1,5 +1,18 @@ # @baseapp-frontend/authentication +## 2.1.0 + +### Minor Changes + +- Create `useJWTUser` and `useUpdateUser` for user management in the client side using JWT. +- Rename the constant `PRE_AUTH_API_KEY` to `AUTH_API_KEY`. +- Add `phoneNumber` field to the `IUser` interface. + +### Patch Changes + +- Updated dependencies + - @baseapp-frontend/utils@2.1.0 + ## 2.0.1 ### Patch Changes diff --git a/packages/authentication/index.ts b/packages/authentication/index.ts index 312d53d1..d7006e60 100644 --- a/packages/authentication/index.ts +++ b/packages/authentication/index.ts @@ -8,26 +8,6 @@ export * from './services/mfa' export { default as UserApi } from './services/user' export * from './services/user' -export type { - ILoginMfaRequest, - ILoginMfaResponse, - ILoginSimpleTokenResponse, - ILoginRequest, - LoginResponse, - IForgotPasswordRequest, - IResetPasswordRequest, - IRegisterRequest, - ICookieName, -} from './types/auth' -export type { - IMfaActivationResponse, - IMfaActiveMethodResponse, - IMfaConfigurationResponse, - IMfaConfirmationResponse, - IMfaDeactivateRequest, - IMfaRequest, - MfaActivationEmailRequest, - MfaActivationPhoneRequest, - MfaMethod, -} from './types/mfa' -export type { IUser } from './types/user' +export type * from './types/auth' +export type * from './types/mfa' +export type * from './types/user' diff --git a/packages/authentication/modules/access/usePreAuth/index.ts b/packages/authentication/modules/access/usePreAuth/index.ts index fe4041b7..87e0fe88 100644 --- a/packages/authentication/modules/access/usePreAuth/index.ts +++ b/packages/authentication/modules/access/usePreAuth/index.ts @@ -3,7 +3,7 @@ import { ACCESS_COOKIE_NAME, REFRESH_COOKIE_NAME, TokenTypes } from '@baseapp-fr import { useQuery, useQueryClient } from '@tanstack/react-query' import Cookies from 'js-cookie' -import AuthApi, { PRE_AUTH_API_KEY } from '../../../services/auth' +import AuthApi, { AUTH_API_KEY } from '../../../services/auth' import { USER_API_KEY } from '../../../services/user' import { isJWTResponse } from '../../../utils/login' import { useSimpleTokenUser } from '../../user' @@ -25,7 +25,7 @@ const usePreAuth = ({ useErrorBoundary: false, retry: false, ...queryOptions, // needs to be placed bellow all overridable options - queryKey: PRE_AUTH_API_KEY.preAuth(token, tokenType), + queryKey: AUTH_API_KEY.preAuth(token, tokenType), queryFn: () => ApiClass.preAuth({ token }, tokenType), onSuccess: (response) => { if (isJWTResponse(tokenType, response)) { diff --git a/packages/authentication/modules/user/index.ts b/packages/authentication/modules/user/index.ts index c811862f..12a402f5 100644 --- a/packages/authentication/modules/user/index.ts +++ b/packages/authentication/modules/user/index.ts @@ -1,5 +1,7 @@ export { default as getUser } from './getUser' export { default as useSimpleTokenUser } from './useSimpleTokenUser' +export { default as useUpdateUser } from './useUpdateUser' +export { default as useJWTUser } from './useJWTUser' export { default as useUser } from './useUser' export { default as withUser } from './withUser' diff --git a/packages/authentication/modules/user/useJWTUser/__tests__/fixtures/request.json b/packages/authentication/modules/user/useJWTUser/__tests__/fixtures/request.json new file mode 100644 index 00000000..051be313 --- /dev/null +++ b/packages/authentication/modules/user/useJWTUser/__tests__/fixtures/request.json @@ -0,0 +1,16 @@ +{ + "id": 1, + "email": "aa@tsl.io", + "is_email_verified": false, + "new_email": "", + "is_new_email_confirmed": false, + "referral_code": "", + "avatar": { + "full_size": "/media/user-avatars/5/6/1/544ca406ae11bac5c49759a2407fdcbe.png", + "small": "/media/user-avatars/5/6/1/544ca406ae11bac5c49759a2407fdcbe.png" + }, + "first_name": "aa", + "last_name": "Test", + "phone_number": "+14073535656", + "exp": 1601546400 +} diff --git a/packages/authentication/modules/user/useJWTUser/__tests__/useJWTUser.test.tsx b/packages/authentication/modules/user/useJWTUser/__tests__/useJWTUser.test.tsx new file mode 100644 index 00000000..ac0fb6b2 --- /dev/null +++ b/packages/authentication/modules/user/useJWTUser/__tests__/useJWTUser.test.tsx @@ -0,0 +1,91 @@ +import { + ComponentWithProviders, + CookiesGetByNameFn, + MockAdapter, + renderHook, + waitFor, +} from '@baseapp-frontend/test' +import { TokenTypes, axios } from '@baseapp-frontend/utils' + +import { UseQueryResult } from '@tanstack/react-query' +import Cookies from 'js-cookie' + +import { IUser } from '../../../../types/user' +import { IUseJWTUser } from '../types' +import request from './fixtures/request.json' + +interface IIUseJWTUser extends Omit, 'data'> { + user?: IUser +} + +describe('useJWTUser', () => { + // @ts-ignore TODO: (BA-1081) investigate AxiosRequestHeaders error + const axiosMock = new MockAdapter(axios) + + let useJWTUser: >(props?: IUseJWTUser) => IIUseJWTUser + + const decodeJWTMock = jest.fn() + + const useQueryClientMock = jest.fn() + jest.mock('@tanstack/react-query', () => ({ + ...jest.requireActual('@tanstack/react-query'), + useQueryClient: useQueryClientMock, + })) + + beforeAll(async () => { + process.env.NEXT_PUBLIC_TOKEN_TYPE = TokenTypes.jwt + useJWTUser = (await import('../index')).default as any + // freeze time to + jest.useFakeTimers().setSystemTime(new Date(2020, 9, 1, 7)) + }) + + const token = + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbl90eXBlIjoiYWNjZXNzIiwiZXhwIjoxNzA5NjcxNjgzLCJpYXQiOjE3MDk2NjA3MTIsImp0aSI6IjhmMjg3ZGNhODVjODRjOTVhOThkZThmN2NiZTllNTE5IiwidXNlcl9pZCI6MSwiaWQiOjEsImVtYWlsIjoiYWFAdHNsLmlvIiwiaXNfZW1haWxfdmVyaWZpZWQiOmZhbHNlLCJuZXdfZW1haWwiOiIiLCJpc19uZXdfZW1haWxfY29uZmlybWVkIjpmYWxzZSwicmVmZXJyYWxfY29kZSI6IiIsImF2YXRhciI6eyJmdWxsX3NpemUiOiIvbWVkaWEvdXNlci1hdmF0YXJzLzUvNi8xL3Jlc2l6ZWQvMTAyNC8xMDI0LzU0NGNhNDA2YWUxMWJhYzVjNDk3NTlhMjQwN2ZkY2JlLnBuZyIsInNtYWxsIjoiL21lZGlhL3VzZXItYXZhdGFycy81LzYvMS9yZXNpemVkLzY0LzY0LzU0NGNhNDA2YWUxMWJhYzVjNDk3NTlhMjQwN2ZkY2JlLnBuZyJ9LCJmaXJzdF9uYW1lIjoiYWEiLCJsYXN0X25hbWUiOiJUZXN0In0.zmTBh3Iz6iRGTiV84o7r4JMA3AU4Q4bVbN76ZUwm5Jg' + + it(`should call the user's endpoint if there is no initial data`, async () => { + ;(Cookies.get as CookiesGetByNameFn) = jest.fn(() => token) + decodeJWTMock.mockImplementation(() => undefined) + axiosMock.onGet('/users/me').reply(200, { ...request }) + + const { result } = renderHook(() => useJWTUser({ options: { placeholderData: undefined } }), { + wrapper: ComponentWithProviders, + }) + + expect(result.current.isLoading).toBe(true) + await waitFor(() => expect(result.current.isSuccess).toBeTruthy()) + + expect(result.current.user?.email).toBe(request.email) + }) + + it('should run custom onError and resetQueries on 401', async () => { + ;(Cookies.get as CookiesGetByNameFn) = jest.fn(() => token) + decodeJWTMock.mockImplementation(() => undefined) + const resetQueriesMock = jest.fn() + useQueryClientMock.mockImplementation(() => ({ resetQueries: resetQueriesMock })) + + let hasOnErrorRan = false + + axiosMock.onGet('/users/me').reply(401, { + error: 'Invalid token.', + }) + const { result } = renderHook( + () => + useJWTUser({ + options: { + initialData: undefined, + retry: false, + onError: () => { + hasOnErrorRan = true + }, + }, + }), + { + wrapper: ComponentWithProviders, + }, + ) + + await waitFor(() => expect(result.current.isLoading).toBe(false)) + await waitFor(() => expect(hasOnErrorRan).toBe(true)) + await waitFor(() => expect(resetQueriesMock).toHaveBeenCalled()) + }) +}) diff --git a/packages/authentication/modules/user/useJWTUser/index.ts b/packages/authentication/modules/user/useJWTUser/index.ts new file mode 100644 index 00000000..7faf903e --- /dev/null +++ b/packages/authentication/modules/user/useJWTUser/index.ts @@ -0,0 +1,46 @@ +import { ACCESS_COOKIE_NAME, decodeJWT } from '@baseapp-frontend/utils' + +import { useQuery, useQueryClient } from '@tanstack/react-query' +import Cookies from 'js-cookie' + +import UserApi, { USER_API_KEY } from '../../../services/user' +import { IUser } from '../../../types/user' +import { IUseJWTUser } from './types' + +/** + * Fetches the user data using the JWT token data as placeholder data. + * + * This makes user data available before fetching it from the server. + */ +const useJWTUser = >({ + options, + cookieName = ACCESS_COOKIE_NAME, + ApiClass = UserApi, +}: IUseJWTUser = {}) => { + const token = Cookies.get(cookieName) || '' + const placeholderData = decodeJWT(token) || undefined + const queryClient = useQueryClient() + + const { data: user, ...rest } = useQuery({ + queryFn: () => ApiClass.getUser(), + queryKey: USER_API_KEY.getUser(), + staleTime: Infinity, // makes cache never expire automatically + enabled: !!token, + useErrorBoundary: false, + placeholderData, + ...options, // needs to be placed bellow all overridable options + onError: (error: any) => { + if (error?.response?.status === 401) { + // we don't need to remove cookies here since this should be done by the interceptor + + // making sure to reset the cache + queryClient.resetQueries(USER_API_KEY.getUser()) + } + options?.onError?.(error) + }, + }) + + return { user, ...rest } +} + +export default useJWTUser diff --git a/packages/authentication/modules/user/useJWTUser/types.ts b/packages/authentication/modules/user/useJWTUser/types.ts new file mode 100644 index 00000000..bd066476 --- /dev/null +++ b/packages/authentication/modules/user/useJWTUser/types.ts @@ -0,0 +1,11 @@ +import { UseQueryOptions } from '@tanstack/react-query' + +import UserApi from '../../../services/user' +import { ICookieName } from '../../../types/auth' + +type ApiClass = Pick + +export interface IUseJWTUser extends ICookieName { + options?: UseQueryOptions + ApiClass?: ApiClass +} diff --git a/packages/authentication/modules/user/useUpdateUser/__tests__/fixtures/request.json b/packages/authentication/modules/user/useUpdateUser/__tests__/fixtures/request.json new file mode 100644 index 00000000..cb4b60d1 --- /dev/null +++ b/packages/authentication/modules/user/useUpdateUser/__tests__/fixtures/request.json @@ -0,0 +1,10 @@ +{ + "email": "janedoe@example.com", + "id": 123, + "firstName": "Jane", + "lastName": "Doe", + "isEmailVerified": true, + "isNewEmailConfirmed": false, + "newEmail": "", + "referralCode": "1234" +} diff --git a/packages/authentication/modules/user/useUpdateUser/__tests__/useUpdateUser.test.tsx b/packages/authentication/modules/user/useUpdateUser/__tests__/useUpdateUser.test.tsx new file mode 100644 index 00000000..b679b6d3 --- /dev/null +++ b/packages/authentication/modules/user/useUpdateUser/__tests__/useUpdateUser.test.tsx @@ -0,0 +1,82 @@ +import { + ComponentWithProviders, + CookiesGetByNameFn, + MockAdapter, + renderHook, + waitFor, +} from '@baseapp-frontend/test' +import { TokenTypes, axios } from '@baseapp-frontend/utils' + +import { UseMutationResult } from '@tanstack/react-query' +import Cookies from 'js-cookie' + +import { IUser } from '../../../../types/user' +import { UseUpdateUserOptions } from '../types' +import request from './fixtures/request.json' + +interface IIUseUpdateUser extends Omit, 'data'> { + user?: IUser +} + +// TODO: BA-1308: improve tests +describe('useUserUpdate', () => { + // @ts-ignore TODO: (BA-1081) investigate AxiosRequestHeaders error + const axiosMock = new MockAdapter(axios) + + let useUserUpdate: >( + props?: UseUpdateUserOptions, + ) => IIUseUpdateUser + + const decodeJWTMock = jest.fn() + const refreshAccessTokenMock = jest.fn() + jest.mock('@baseapp-frontend/utils', () => ({ + ...jest.requireActual('@baseapp-frontend/utils'), + decodeJWT: decodeJWTMock, + refreshAccessToken: refreshAccessTokenMock, + })) + + const useQueryClientMock = jest.fn() + jest.mock('@tanstack/react-query', () => ({ + ...jest.requireActual('@tanstack/react-query'), + useQueryClient: useQueryClientMock, + })) + + beforeAll(async () => { + process.env.NEXT_PUBLIC_TOKEN_TYPE = TokenTypes.jwt + useUserUpdate = (await import('../index')).default as any + // freeze time to + jest.useFakeTimers().setSystemTime(new Date(2020, 9, 1, 7)) + }) + + const token = + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbl90eXBlIjoiYWNjZXNzIiwiZXhwIjoxNzA5NjcxNjgzLCJpYXQiOjE3MDk2NjA3MTIsImp0aSI6IjhmMjg3ZGNhODVjODRjOTVhOThkZThmN2NiZTllNTE5IiwidXNlcl9pZCI6MSwiaWQiOjEsImVtYWlsIjoiYWFAdHNsLmlvIiwiaXNfZW1haWxfdmVyaWZpZWQiOmZhbHNlLCJuZXdfZW1haWwiOiIiLCJpc19uZXdfZW1haWxfY29uZmlybWVkIjpmYWxzZSwicmVmZXJyYWxfY29kZSI6IiIsImF2YXRhciI6eyJmdWxsX3NpemUiOiIvbWVkaWEvdXNlci1hdmF0YXJzLzUvNi8xL3Jlc2l6ZWQvMTAyNC8xMDI0LzU0NGNhNDA2YWUxMWJhYzVjNDk3NTlhMjQwN2ZkY2JlLnBuZyIsInNtYWxsIjoiL21lZGlhL3VzZXItYXZhdGFycy81LzYvMS9yZXNpemVkLzY0LzY0LzU0NGNhNDA2YWUxMWJhYzVjNDk3NTlhMjQwN2ZkY2JlLnBuZyJ9LCJmaXJzdF9uYW1lIjoiYWEiLCJsYXN0X25hbWUiOiJUZXN0In0.zmTBh3Iz6iRGTiV84o7r4JMA3AU4Q4bVbN76ZUwm5Jg' + + it('should run custom onSettled on 401', async () => { + ;(Cookies.get as CookiesGetByNameFn) = jest.fn(() => token) + decodeJWTMock.mockImplementation(() => undefined) + const invalidateQueriesMock = jest.fn() + useQueryClientMock.mockImplementation(() => ({ invalidateQueries: invalidateQueriesMock })) + + let hasOnSettledRan = false + + axiosMock.onPatch('/users/1').reply(201, request) + const { result } = renderHook( + () => + useUserUpdate({ + options: { + onSettled: () => { + hasOnSettledRan = true + }, + }, + }), + { + wrapper: ComponentWithProviders, + }, + ) + + await result.current.mutateAsync({ userId: 1, data: { firstName: 'BB' } }) + + await waitFor(() => expect(result.current.isLoading).toBe(false)) + await waitFor(() => expect(hasOnSettledRan).toBe(true)) + }) +}) diff --git a/packages/authentication/modules/user/useUpdateUser/index.ts b/packages/authentication/modules/user/useUpdateUser/index.ts new file mode 100644 index 00000000..862afbd6 --- /dev/null +++ b/packages/authentication/modules/user/useUpdateUser/index.ts @@ -0,0 +1,46 @@ +import { + ACCESS_COOKIE_NAME, + REFRESH_COOKIE_NAME, + TokenTypes, + refreshAccessToken, +} from '@baseapp-frontend/utils' + +import { useMutation, useQueryClient } from '@tanstack/react-query' + +import UserApi, { USER_API_KEY } from '../../../services/user' +import { IUser, UserUpdateParams } from '../../../types/user' +import { UseUpdateUserOptions } from './types' + +const useUpdateUser = >({ + options, + cookieName = ACCESS_COOKIE_NAME, + refreshCookieName = REFRESH_COOKIE_NAME, + ApiClass = UserApi, +}: UseUpdateUserOptions = {}) => { + const queryClient = useQueryClient() + + const mutation = useMutation( + (params: UserUpdateParams) => ApiClass.updateUser(params), + { + onSettled: async (data, error, variables, context) => { + queryClient.invalidateQueries(USER_API_KEY.getUser()) + try { + const tokenType = process.env.NEXT_PUBLIC_TOKEN_TYPE as TokenTypes + if (tokenType === TokenTypes.jwt) { + await refreshAccessToken(cookieName, refreshCookieName) + } + } catch (e) { + // silently fail + // eslint-disable-next-line no-console + console.error(e) + } + options?.onSettled?.(data, error, variables, context) + }, + ...options, + }, + ) + + return mutation +} + +export default useUpdateUser diff --git a/packages/authentication/modules/user/useUpdateUser/types.ts b/packages/authentication/modules/user/useUpdateUser/types.ts new file mode 100644 index 00000000..ce068ea1 --- /dev/null +++ b/packages/authentication/modules/user/useUpdateUser/types.ts @@ -0,0 +1,12 @@ +import { UseMutationOptions } from '@tanstack/react-query' + +import UserApi from '../../../services/user' +import { ICookieName } from '../../../types/auth' +import { IUser, UserUpdateParams } from '../../../types/user' + +type ApiClass = Pick + +export interface UseUpdateUserOptions> extends ICookieName { + options?: UseMutationOptions, { previousUser?: TUser }> + ApiClass?: ApiClass +} diff --git a/packages/authentication/modules/user/useUser/index.ts b/packages/authentication/modules/user/useUser/index.ts index cd7221af..43649c38 100644 --- a/packages/authentication/modules/user/useUser/index.ts +++ b/packages/authentication/modules/user/useUser/index.ts @@ -8,7 +8,12 @@ import { IUseUser } from './types' /** * @deprecated - * Prefer using the `getUser` function instead. + * On the server side: + * - Prefer using the `getUser` function. + * + * On the client side: + * - If you are using `JWT`, prefer using the `useJWTUser` hook. + * - If you are using `Simple Token`, prefer using the `useSimpleTokenUser` hook. */ const useUser = & Partial>({ cookieName = ACCESS_COOKIE_NAME, diff --git a/packages/authentication/package.json b/packages/authentication/package.json index 6822861c..dc7647f3 100644 --- a/packages/authentication/package.json +++ b/packages/authentication/package.json @@ -1,7 +1,7 @@ { "name": "@baseapp-frontend/authentication", "description": "Authentication modules.", - "version": "2.0.1", + "version": "2.1.0", "main": "./dist/index.ts", "module": "./dist/index.mjs", "scripts": { diff --git a/packages/authentication/services/auth.ts b/packages/authentication/services/auth.ts index e76443b2..d2a5fbfc 100644 --- a/packages/authentication/services/auth.ts +++ b/packages/authentication/services/auth.ts @@ -40,8 +40,8 @@ export default class AuthApi { } } -export const PRE_AUTH_API_KEY = { +export const AUTH_API_KEY = { default: ['auth'], preAuth: (token: string, tokenType: TokenTypes) => - [...PRE_AUTH_API_KEY.default, 'preAuth', token, tokenType] as const, + [...AUTH_API_KEY.default, 'preAuth', token, tokenType] as const, } diff --git a/packages/authentication/services/user.ts b/packages/authentication/services/user.ts index b99c1013..f3318b40 100644 --- a/packages/authentication/services/user.ts +++ b/packages/authentication/services/user.ts @@ -1,11 +1,18 @@ -import { axios } from '@baseapp-frontend/utils' +import { axios, axiosForFiles } from '@baseapp-frontend/utils' -import { IUser } from '../types/user' +import { IUser, UserUpdateParams } from '../types/user' export default class UserApi { static getUser>(): Promise { return axios.get(`/users/me`) } + + static updateUser>({ + userId, + data, + }: UserUpdateParams): Promise { + return axiosForFiles.patch(`/users/${userId}`, data) + } } export const USER_API_KEY = { diff --git a/packages/authentication/types/user.ts b/packages/authentication/types/user.ts index 27fc3269..3696f1a7 100644 --- a/packages/authentication/types/user.ts +++ b/packages/authentication/types/user.ts @@ -11,4 +11,12 @@ export interface IUser { } firstName: string lastName: string + phoneNumber: string +} + +export interface UserUpdateParams> { + userId: TUser['id'] + data: Partial> & { + avatar?: File | string + } } diff --git a/packages/utils/CHANGELOG.md b/packages/utils/CHANGELOG.md index 8071bb18..9517b09b 100644 --- a/packages/utils/CHANGELOG.md +++ b/packages/utils/CHANGELOG.md @@ -1,5 +1,13 @@ # @baseapp-frontend/utils +## 2.1.0 + +### Minor Changes + +- Append data in a `FormData` instance when using an axios instance created by `createAxiosInstance` setting `file` and `useFormData` options to `true`. This will be the default behavior when using `axiosForFiles`. +- Create `filterDirtyValues` and `getInitialValues` util functions. +- Add default parameters to the `refreshAccessToken` function. + ## 2.0.0 ### Major Changes diff --git a/packages/utils/functions/axios/createAxiosInstance/__tests__/createAxiosInstance.test.ts b/packages/utils/functions/axios/createAxiosInstance/__tests__/createAxiosInstance.test.ts index ad4b914a..c26f4a19 100644 --- a/packages/utils/functions/axios/createAxiosInstance/__tests__/createAxiosInstance.test.ts +++ b/packages/utils/functions/axios/createAxiosInstance/__tests__/createAxiosInstance.test.ts @@ -113,4 +113,52 @@ describe('createAxiosInstance', () => { eject(requestInterceptorId) expect(eject).toBeCalledWith(requestInterceptorId) }) + + it('should transform request.data to FormData when file is true and useFormData is true', () => { + const has = jest + .fn() + .mockImplementation( + (key) => key === 'some_file' || key === 'some_object' || key === 'simple_key', + ) + + // @ts-ignore + global.FormData = class CustomFormData { + entries = jest.fn() + + append = jest.fn() + + has = has + } + + const testFile = new File(['test'], 'test.txt', { type: 'text/plain' }) + const requestData = { + someFile: testFile, + someObject: { nestedKey: 'nestedValue' }, + simpleKey: 'simpleValue', + } + const mockRequest = { + data: requestData, + headers: {}, + } + + const { + axios: { + interceptors: { + request: { use }, + }, + }, + } = createAxiosInstance({ file: true, useFormData: true }) + const [[interceptorFn]] = (use as jest.Mock).mock.calls + const request = mockRequest + + interceptorFn(request) + + expect(request.data instanceof FormData).toBeTruthy() + // @ts-ignore + expect(request.data.has('some_file')).toBeTruthy() + // @ts-ignore + expect(request.data.has('some_object')).toBeTruthy() + // @ts-ignore + expect(request.data.has('simple_key')).toBeTruthy() + }) }) diff --git a/packages/utils/functions/axios/createAxiosInstance/index.ts b/packages/utils/functions/axios/createAxiosInstance/index.ts index e94780ac..5d7b5fa9 100644 --- a/packages/utils/functions/axios/createAxiosInstance/index.ts +++ b/packages/utils/functions/axios/createAxiosInstance/index.ts @@ -18,6 +18,7 @@ export const createAxiosInstance = ({ refreshCookieName = REFRESH_COOKIE_NAME, servicesWithoutToken = SERVICES_WITHOUT_TOKEN, tokenType: instanceTokenType = TokenTypes.jwt, + useFormData = true, } = {}) => { const instance = _axios.create({ baseURL: process.env.NEXT_PUBLIC_API_BASE_URL, @@ -63,6 +64,21 @@ export const createAxiosInstance = ({ if (request.data && !file) { request.data = JSON.stringify(humps.decamelizeKeys(request.data)) } + if (request.data && file && useFormData) { + const formData = new FormData() + Object.entries(request.data).forEach(([key, value]) => { + const decamelizedKey = humps.decamelize(key) + if (!value) return + if (value instanceof File) { + formData.append(decamelizedKey, value) + } else if (typeof value === 'object') { + formData.append(decamelizedKey, JSON.stringify(value)) + } else { + formData.append(decamelizedKey, value?.toString()) + } + }) + request.data = formData + } if (request.params) { request.params = humps.decamelizeKeys(request.params) diff --git a/packages/utils/functions/form/filterDirtyValues/__tests__/filterDirtyValues.test.ts b/packages/utils/functions/form/filterDirtyValues/__tests__/filterDirtyValues.test.ts new file mode 100644 index 00000000..3d91d598 --- /dev/null +++ b/packages/utils/functions/form/filterDirtyValues/__tests__/filterDirtyValues.test.ts @@ -0,0 +1,38 @@ +import { filterDirtyValues } from '..' + +describe('filterDirtyValues', () => { + it('should return all values when all fields are marked as dirty', () => { + const values = { name: 'John Doe', age: 20, email: 'johndoe@email.com' } + const dirtyFields = { name: true, age: true, email: true } + const result = filterDirtyValues({ values, dirtyFields }) + expect(result).toEqual(values) + }) + + it('should return an empty object when no fields are marked as dirty', () => { + const values = { name: 'John Doe', age: 20, email: 'johndoe@email.com' } + const dirtyFields = { name: false, age: false, email: false } + const result = filterDirtyValues({ values, dirtyFields }) + expect(result).toEqual({}) + }) + + it('should only return dirty fields', () => { + const values = { name: 'John Doe', age: 20, email: 'johndoe@email.com' } + const dirtyFields = { name: true, age: false, email: false } + const result = filterDirtyValues({ values, dirtyFields }) + expect(result).toEqual({ name: 'John Doe' }) + }) + + it('should ignore dirty fields not present in values', () => { + const values = { name: 'John Doe', age: 20 } + const dirtyFields = { name: true, email: true } + const result = filterDirtyValues({ values, dirtyFields }) + expect(result).toEqual({ name: 'John Doe' }) + }) + + it('should correctly handle dirty fields object with non-boolean properties', () => { + const values = { name: 'John Doe', age: 20, email: 'johndoe@email.com' } + const dirtyFields = { name: true, age: 'changed', email: true } + const result = filterDirtyValues({ values, dirtyFields }) + expect(result).toEqual({ name: 'John Doe', age: 20, email: 'johndoe@email.com' }) + }) +}) diff --git a/packages/utils/functions/form/filterDirtyValues/index.ts b/packages/utils/functions/form/filterDirtyValues/index.ts new file mode 100644 index 00000000..eb4617e8 --- /dev/null +++ b/packages/utils/functions/form/filterDirtyValues/index.ts @@ -0,0 +1,35 @@ +import keys from 'lodash/keys' +import pick from 'lodash/pick' +import { FieldValues } from 'react-hook-form' + +/** + * Filter dirty values from a form, based on the current values and the dirty fields. + * + * @example + * const values = { + * name: 'John Doe', + * age: 20, + * email: 'johndoe@email.com' + * } + * + * const dirtyFields = { + * name: true, + * age: false, + * email: true + * } + * + * const dirtyValues = filterDirtyValues(values, dirtyFields) + * console.log(dirtyValues) // { name: 'John Doe', email: 'johndoe@email.com' } + */ +export const filterDirtyValues = ({ + values, + dirtyFields, +}: { + values: T + dirtyFields: Partial> +}) => { + const dirtyKeys = keys(dirtyFields).filter((key) => dirtyFields[key]) + const filteredValues = pick(values, dirtyKeys) + + return filteredValues +} diff --git a/packages/utils/functions/form/getInitialValues/__tests__/getInitialValues.test.ts b/packages/utils/functions/form/getInitialValues/__tests__/getInitialValues.test.ts new file mode 100644 index 00000000..0fb493ca --- /dev/null +++ b/packages/utils/functions/form/getInitialValues/__tests__/getInitialValues.test.ts @@ -0,0 +1,38 @@ +import { getInitialValues } from '..' + +describe('getInitialValues', () => { + it('should pick properties from current that exist in initial when current is truthy', () => { + const current = { a: 1, b: 2 } + const initial = { a: 0 } + const result = getInitialValues({ current, initial }) + expect(result).toEqual({ a: 1 }) + }) + + it('should return an empty object when current is truthy but has no overlapping keys with initial', () => { + const current = { c: 3, d: 4 } + const initial = { a: 0, b: 1 } + const result = getInitialValues({ current, initial }) + expect(result).toEqual({}) + }) + + it('should return initial when current is falsy', () => { + const current = undefined + const initial = { a: 0, b: 1 } + const result = getInitialValues({ current, initial }) + expect(result).toBe(initial) + }) + + it('should work with complex objects as current and initial', () => { + const current = { a: { nested: true }, b: 2 } + const initial = { a: { nested: false } } + const result = getInitialValues({ current, initial }) + expect(result).toEqual({ a: { nested: true } }) + }) + + it('should ignore additional properties in current not present in initial', () => { + const current = { a: 1, b: 2, extra: 'ignored' } + const initial = { a: 0 } + const result = getInitialValues({ current, initial }) + expect(result).toEqual({ a: 1 }) + }) +}) diff --git a/packages/utils/functions/form/getInitialValues/index.ts b/packages/utils/functions/form/getInitialValues/index.ts new file mode 100644 index 00000000..b53c94b5 --- /dev/null +++ b/packages/utils/functions/form/getInitialValues/index.ts @@ -0,0 +1,32 @@ +import keys from 'lodash/keys' +import pick from 'lodash/pick' + +/** + * Get initial values for a form, based on the current values and the initial values. + * + * @example + * const current = { + * name: 'John Doe', + * age: 20, + * email: 'johndoe@email.com' + * } + * + * const initial = { + * name: '', + * age: 0, + * } + * + * const initialValues = getInitialValues({current, initial}) + * console.log(initialValues) // { name: 'John Doe', age: 20 } + * + */ +export const getInitialValues = ({ + current, + initial, +}: { + current?: Current + initial: Initial +}) => { + if (current) return pick(current, keys(initial)) + return initial +} diff --git a/packages/utils/functions/form/index.ts b/packages/utils/functions/form/index.ts index 10a5d7d7..762bfcaa 100644 --- a/packages/utils/functions/form/index.ts +++ b/packages/utils/functions/form/index.ts @@ -1,4 +1,6 @@ export { default as withController } from './withController' export type * from './withController/types' +export * from './filterDirtyValues' +export * from './getInitialValues' export * from './setFormApiErrors' diff --git a/packages/utils/functions/axios/createAxiosInstance/__tests__/refreshAccessToken.test.ts b/packages/utils/functions/token/refreshAccessToken/__tests__/refreshAccessToken.test.ts similarity index 94% rename from packages/utils/functions/axios/createAxiosInstance/__tests__/refreshAccessToken.test.ts rename to packages/utils/functions/token/refreshAccessToken/__tests__/refreshAccessToken.test.ts index 8a5482db..c6e15c72 100644 --- a/packages/utils/functions/axios/createAxiosInstance/__tests__/refreshAccessToken.test.ts +++ b/packages/utils/functions/token/refreshAccessToken/__tests__/refreshAccessToken.test.ts @@ -2,11 +2,11 @@ import { MockAdapter } from '@baseapp-frontend/test' import Cookies from 'js-cookie' +import { simpleAxios as refreshTokenAxios } from '..' import { ACCESS_COOKIE_NAME, REFRESH_COOKIE_NAME } from '../../../../constants/cookie' import { CookieType } from '../../../../types/cookie' -import { simpleAxios as refreshTokenAxios } from '../../../token' -import { isUserTokenValid } from '../../../token/isUserTokenValid' -import { axios } from '../index' +import { axios } from '../../../axios' +import { isUserTokenValid } from '../../isUserTokenValid' jest.mock('js-cookie') jest.mock('../../../token/decodeJWT') diff --git a/packages/utils/functions/token/refreshAccessToken/index.ts b/packages/utils/functions/token/refreshAccessToken/index.ts index 19f68d59..a503be4d 100644 --- a/packages/utils/functions/token/refreshAccessToken/index.ts +++ b/packages/utils/functions/token/refreshAccessToken/index.ts @@ -1,6 +1,7 @@ import _axios from 'axios' import Cookies from 'js-cookie' +import { ACCESS_COOKIE_NAME, REFRESH_COOKIE_NAME } from '../../../constants/cookie' import { IJWTResponse } from '../../../types/jwt' const REFRESH_TOKEN_URL = '/auth/refresh' @@ -10,7 +11,10 @@ export const simpleAxios = _axios.create({ baseURL: process.env.NEXT_PUBLIC_API_BASE_URL, }) -export const refreshAccessToken = async (cookieName: string, refreshCookieName: string) => { +export const refreshAccessToken = async ( + cookieName = ACCESS_COOKIE_NAME, + refreshCookieName = REFRESH_COOKIE_NAME, +) => { const refreshToken = Cookies.get(refreshCookieName) if (!refreshToken) { diff --git a/packages/utils/package.json b/packages/utils/package.json index 29c93342..7e9feb0a 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -1,7 +1,7 @@ { "name": "@baseapp-frontend/utils", "description": "Util functions, constants and types.", - "version": "2.0.0", + "version": "2.1.0", "main": "./dist/index.ts", "module": "./dist/index.mjs", "scripts": { From be03a2a0b5b53471cc3bdacf1387579b727afa23 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pedro=20Tib=C3=BArcio?= Date: Thu, 7 Mar 2024 08:07:05 -0500 Subject: [PATCH 2/3] feat: add file to base64 converter util and change updateUser to use axios --- packages/authentication/services/user.ts | 4 ++-- packages/core/tsconfig.json | 4 ++-- packages/tsconfig/core.json | 4 ++-- packages/utils/CHANGELOG.md | 2 +- .../utils/functions/axios/createAxiosInstance/index.ts | 5 ++++- packages/utils/functions/file/index.ts | 1 + packages/utils/functions/file/toBase64/index.ts | 10 ++++++++++ packages/utils/index.ts | 1 + 8 files changed, 23 insertions(+), 8 deletions(-) create mode 100644 packages/utils/functions/file/index.ts create mode 100644 packages/utils/functions/file/toBase64/index.ts diff --git a/packages/authentication/services/user.ts b/packages/authentication/services/user.ts index f3318b40..2714b332 100644 --- a/packages/authentication/services/user.ts +++ b/packages/authentication/services/user.ts @@ -1,4 +1,4 @@ -import { axios, axiosForFiles } from '@baseapp-frontend/utils' +import { axios } from '@baseapp-frontend/utils' import { IUser, UserUpdateParams } from '../types/user' @@ -11,7 +11,7 @@ export default class UserApi { userId, data, }: UserUpdateParams): Promise { - return axiosForFiles.patch(`/users/${userId}`, data) + return axios.patch(`/users/${userId}`, data) } } diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json index 22de3be4..e59d04fe 100644 --- a/packages/core/tsconfig.json +++ b/packages/core/tsconfig.json @@ -1,7 +1,7 @@ { "compilerOptions": { - "target": "es5", - "lib": ["dom", "dom.iterable", "esnext"], + "target": "ES6", + "lib": ["dom", "dom.iterable", "esnext", "es2015"], "allowJs": true, "skipLibCheck": true, "strict": true, diff --git a/packages/tsconfig/core.json b/packages/tsconfig/core.json index 29274b89..e80d5164 100644 --- a/packages/tsconfig/core.json +++ b/packages/tsconfig/core.json @@ -1,7 +1,7 @@ { "compilerOptions": { - "target": "es5", - "lib": ["dom", "dom.iterable", "esnext"], + "target": "ES6", + "lib": ["dom", "dom.iterable", "esnext", "es2015"], "allowJs": true, "skipLibCheck": true, "strict": true, diff --git a/packages/utils/CHANGELOG.md b/packages/utils/CHANGELOG.md index 9517b09b..722ca820 100644 --- a/packages/utils/CHANGELOG.md +++ b/packages/utils/CHANGELOG.md @@ -5,7 +5,7 @@ ### Minor Changes - Append data in a `FormData` instance when using an axios instance created by `createAxiosInstance` setting `file` and `useFormData` options to `true`. This will be the default behavior when using `axiosForFiles`. -- Create `filterDirtyValues` and `getInitialValues` util functions. +- Create `filterDirtyValues`, `getInitialValues` and `toBase64` util functions. - Add default parameters to the `refreshAccessToken` function. ## 2.0.0 diff --git a/packages/utils/functions/axios/createAxiosInstance/index.ts b/packages/utils/functions/axios/createAxiosInstance/index.ts index 5d7b5fa9..063c6b2b 100644 --- a/packages/utils/functions/axios/createAxiosInstance/index.ts +++ b/packages/utils/functions/axios/createAxiosInstance/index.ts @@ -95,7 +95,10 @@ export const createAxiosInstance = ({ return returnData && response.data ? response.data : response }, async (error) => { - if (error.response.data && error.response.headers?.['content-type'] === 'application/json') { + if ( + error.response?.data && + error.response?.headers?.['content-type'] === 'application/json' + ) { const newError = { response: { data: {} } } newError.response.data = humps.camelizeKeys(error.response.data) } diff --git a/packages/utils/functions/file/index.ts b/packages/utils/functions/file/index.ts new file mode 100644 index 00000000..42b74178 --- /dev/null +++ b/packages/utils/functions/file/index.ts @@ -0,0 +1 @@ +export * from './toBase64' \ No newline at end of file diff --git a/packages/utils/functions/file/toBase64/index.ts b/packages/utils/functions/file/toBase64/index.ts new file mode 100644 index 00000000..abf14939 --- /dev/null +++ b/packages/utils/functions/file/toBase64/index.ts @@ -0,0 +1,10 @@ +/** + * Converts a File from a form field to a Base64 string. + */ +export const toBase64 = (file: Blob | File) => + new Promise((resolve, reject) => { + const reader = new FileReader() + reader.readAsDataURL(file) + reader.onload = () => resolve(reader.result as string) + reader.onerror = reject + }) diff --git a/packages/utils/index.ts b/packages/utils/index.ts index 6f4ffc27..42af24e0 100644 --- a/packages/utils/index.ts +++ b/packages/utils/index.ts @@ -9,6 +9,7 @@ export * from './constants/zod' export * from './functions/api' export * from './functions/axios' export * from './functions/events' +export * from './functions/file' export * from './functions/form' export * from './functions/string' export * from './functions/token' From 261bc0af24075a6f5dcbe912e981bf98eb4c1a9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pedro=20Tib=C3=BArcio?= Date: Fri, 8 Mar 2024 12:53:14 -0500 Subject: [PATCH 3/3] chore: versioning --- packages/tsconfig/CHANGELOG.md | 6 ++++++ packages/tsconfig/package.json | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/tsconfig/CHANGELOG.md b/packages/tsconfig/CHANGELOG.md index 4de59ef4..489d859a 100644 --- a/packages/tsconfig/CHANGELOG.md +++ b/packages/tsconfig/CHANGELOG.md @@ -1,5 +1,11 @@ # @baseapp-frontend/tsconfig +## 1.1.5 + +### Patch Changes + +- Upgrade to ES6 + ## 1.1.4 ### Patch Changes diff --git a/packages/tsconfig/package.json b/packages/tsconfig/package.json index ca45d3cf..7c1a2ac7 100644 --- a/packages/tsconfig/package.json +++ b/packages/tsconfig/package.json @@ -1,7 +1,7 @@ { "name": "@baseapp-frontend/tsconfig", "description": "Reusable typescript configs.", - "version": "1.1.4", + "version": "1.1.5", "main": "index.js", "files": [ "base.json",