diff --git a/src/App.tsx b/src/App.tsx index 76ff57d..d34fb63 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,7 +1,7 @@ import '@radix-ui/themes/styles.css'; import { Route, BrowserRouter as Router, Routes } from 'react-router-dom'; import { ToastContainer } from 'react-toastify'; -import { AppList, GroupList, NotFoundPage, OAuthTriggerPage, UserList } from './pages'; +import { AppList, GroupList, NotFoundPage, OAuthTriggerPage, UserList, UserEdit } from './pages'; import Loading from './pages/Loading'; import './theme.css'; @@ -51,6 +51,14 @@ function App() { } /> + + + + } + /> { + let mockClient: { get: Mock }; + const userId = 'test-user-id'; + + beforeEach(() => { + mockClient = { + get: vi.fn(), + }; + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('should call client.get with the correct suffix', async () => { + const queryObj = useGetUserQuery(mockClient as any, userId); + + await queryObj.queryFn!({ + queryKey: ['users', userId], + signal: new AbortController().signal, + meta: undefined, + client: {} as QueryClient, + }); + + expect(mockClient.get).toHaveBeenCalledTimes(1); + expect(mockClient.get).toHaveBeenCalledWith( + expect.objectContaining({ + suffix: expect.stringContaining(`/user/${userId}`), + }), + ); + }); +}); diff --git a/src/api/users/getUser.ts b/src/api/users/getUser.ts new file mode 100644 index 0000000..c6ab5bc --- /dev/null +++ b/src/api/users/getUser.ts @@ -0,0 +1,22 @@ +import { queryOptions } from '@tanstack/react-query'; +import { SERVER_ROUTES } from '../../routes'; +import { formatAPIPath } from '../../utils'; +import APICore from '../core'; +import { queryKeys } from '../query-keys'; +import { User } from '../../types/models'; + +function useGetUserQuery(client: APICore, userId: string) { + return queryOptions({ + enabled: true, + queryKey: queryKeys.users.detail(userId), + queryFn: async () => + (await client.get({ + suffix: formatAPIPath([SERVER_ROUTES.USERS, userId]), + })) as User, + throwOnError: true, + staleTime: 1000 * 60 * 5, // 5 minutes + gcTime: 1000 * 60 * 3600, // 1 hour + }); +} + +export default useGetUserQuery; diff --git a/src/api/users/index.ts b/src/api/users/index.ts index fff7f49..d041d83 100644 --- a/src/api/users/index.ts +++ b/src/api/users/index.ts @@ -3,3 +3,5 @@ export { default as getVerifiedUsers } from './getVerifiedUsers'; export { default as useAllUsersQuery } from './useAllUsersQuery'; export { default as useCreateUserQuery } from './useCreateUserQuery'; export { default as useUpdateUserQuery } from './useUpdateUserQuery'; +export { default as getUser } from './getUser'; +export { default as useGetUserQuery } from './useGetUserQuery'; diff --git a/src/api/users/useGetUserQuery.ts b/src/api/users/useGetUserQuery.ts new file mode 100644 index 0000000..f930277 --- /dev/null +++ b/src/api/users/useGetUserQuery.ts @@ -0,0 +1,20 @@ +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { useAxios } from '../../hooks'; +import { getUser } from '.'; + +function useGetUserQuery(userId: string) { + const api = useAxios(); + const options = getUser(api, userId); + + const { data, isLoading, error, refetch, isRefetching } = useQuery(options); + + const queryClient = useQueryClient(); + + const prefetchUser = async () => { + await queryClient.prefetchQuery(options); + }; + + return { data, isLoading, error, refetch, isRefetching, prefetchUser }; +} + +export default useGetUserQuery; diff --git a/src/api/users/useUpdateUserQuery.ts b/src/api/users/useUpdateUserQuery.ts index cacf654..01a33aa 100644 --- a/src/api/users/useUpdateUserQuery.ts +++ b/src/api/users/useUpdateUserQuery.ts @@ -1,6 +1,6 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; import { SERVER_ROUTES } from '../../routes'; -import { ResourceType } from '../../types'; +import { ResourceType, UpdateUserAPIRequest } from '../../types'; import { formatAPIPath } from '../../utils'; import APICore from '../core'; import { queryKeys } from '../query-keys'; @@ -10,14 +10,10 @@ function useUpdateUserQuery(client: APICore) { return useMutation({ mutationKey: queryKeys.puts.user, - mutationFn: async ({ nickname, id, active }: { nickname: string; id: string; active: boolean }) => { + mutationFn: async ({ id, userData }: { id: string; userData: UpdateUserAPIRequest }) => { return await client.put({ suffix: formatAPIPath([SERVER_ROUTES.USERS, id]), - body: { - nickname: nickname, - type: ResourceType.USER, - active, - }, + body: userData, }); }, onSuccess: () => { diff --git a/src/components/Modal/UpdateUserModal.tsx b/src/components/Modal/UpdateUserModal.tsx deleted file mode 100644 index b43e941..0000000 --- a/src/components/Modal/UpdateUserModal.tsx +++ /dev/null @@ -1,126 +0,0 @@ -import { Box, Button, Checkbox, Flex, Text } from '@radix-ui/themes'; -import { isValid } from 'zod'; -import { Modal } from '.'; -import { InputField } from '..'; -import { useUpdateUserQuery } from '../../api/users'; -import { useAxios, useFormValidation } from '../../hooks'; -import { UpdateUserModalSchema, UserUpdateInitialValues } from '../../types/forms'; - -type UpdateUserModalProps = { - isOpen: boolean; - onClose: () => void; - initialValues: UserUpdateInitialValues; -}; - -export default function UpdateUserModal({ isOpen, onClose, initialValues }: UpdateUserModalProps) { - const title = 'Update User'; - const description = 'Enter in credentials below to update an existing user'; - - const { formState, errors, handleInputChange, handleCheckboxChange, isFormValid } = useFormValidation({ - schema: UpdateUserModalSchema, - initialValues, - }); - - const api = useAxios(); - const mutation = useUpdateUserQuery(api); - - const handleUpdateUser = async (e: React.FormEvent) => { - e.preventDefault(); - if (!isFormValid()) return false; - - const { id } = initialValues; - const { nickname, active } = formState; - - console.log('User Updated:', { formState }); - - try { - const data = await mutation.mutateAsync({ nickname, id, active }); - console.log('User Updated Successfully', data); - onClose(); - return true; - } catch (error) { - console.error('Error creating user:', error); - return false; - } - }; - - return ( - -
- - - - User Name* - - - - - - - - - handleCheckboxChange('active', !!checked)} - /> - Set user to be active? - - - - - - - - - -
-
- ); -} diff --git a/src/components/Modal/index.ts b/src/components/Modal/index.ts index 0b50433..29de733 100644 --- a/src/components/Modal/index.ts +++ b/src/components/Modal/index.ts @@ -3,4 +3,3 @@ export { default as CreateUserModal } from './CreateUserModal'; export { default as Modal } from './Modal'; export { default as PasswordGeneratedModal } from './PasswordGeneratedModal'; export { default as UpdateAppModal } from './UpdateAppModal'; -export { default as UpdateUserModal } from './UpdateUserModal'; diff --git a/src/components/Sidebar/Sidebar.tsx b/src/components/Sidebar/Sidebar.tsx index d663fb7..0e3511b 100644 --- a/src/components/Sidebar/Sidebar.tsx +++ b/src/components/Sidebar/Sidebar.tsx @@ -32,7 +32,6 @@ type SidebarProps = { const Sidebar = ({ version, profileOptions = [], serverStats, authenticatedUser }: SidebarProps) => { const location = useLocation(); const { setTokens } = useOAuth(); - console.log(authenticatedUser); const isAdmin = authenticatedUser?.privileges['*'].includes('admin'); const navItems = useMemo( () => [ diff --git a/src/components/TableList/TableList.tsx b/src/components/TableList/TableList.tsx index 6be10ec..d1332ba 100644 --- a/src/components/TableList/TableList.tsx +++ b/src/components/TableList/TableList.tsx @@ -4,9 +4,9 @@ import { Button, Card, Flex, Text, Theme } from '@radix-ui/themes'; import { GridOptions, RowDoubleClickedEvent, themeQuartz } from 'ag-grid-community'; import { AgGridReact } from 'ag-grid-react'; import { useRef, useState } from 'react'; -import { UserUpdateInitialValues } from '../../types'; import { Users } from '../../utils/helpers/models'; -import { CreateUserModal, PasswordGeneratedModal, UpdateUserModal } from '../Modal'; +import { CreateUserModal, PasswordGeneratedModal } from '../Modal'; +import { CLIENT_ROUTES } from '../../routes'; /** * @deprecated Do NOT add more functionality to this @@ -24,17 +24,9 @@ type TableListProps = { }; const TableList = ({ columnDefs, data, itemName, onDelete }: TableListProps) => { - const initialUserUpdateValues: UserUpdateInitialValues = { - nickname: '', - id: '', - active: false, - }; - const gridRef = useRef(null); const [selectedCount, setSelectedCount] = useState(0); const [isCreateUserModalOpen, setCreateUserModalOpen] = useState(false); - const [isUpdateUserModalOpen, setUpdateUserModalOpen] = useState(false); - const [selectedUserData, setSelectedUserData] = useState(initialUserUpdateValues); const [password, setPassword] = useState(''); const [showPasswordModal, setShowPasswordModal] = useState(false); @@ -53,8 +45,9 @@ const TableList = ({ columnDefs, data, itemName, onDelete }: TableListProps) => const handleRowDoubleClick = (event: RowDoubleClickedEvent) => { const rowData = event.data; - setSelectedUserData({ id: Users.parseUserID(rowData), ...rowData }); - setUpdateUserModalOpen(true); + // setSelectedUserData({ id: Users.parseUserID(rowData), ...rowData }); + // setUpdateUserModalOpen(true); + window.location.href = CLIENT_ROUTES.USER_EDIT.replace(':id', Users.parseUserID(rowData)); }; const downloadCSV = () => { @@ -130,13 +123,6 @@ const TableList = ({ columnDefs, data, itemName, onDelete }: TableListProps) => onRowDoubleClicked={handleRowDoubleClick} /> - {isUpdateUserModalOpen && itemName === 'user' && ( - setUpdateUserModalOpen(false)} - /> - )} diff --git a/src/hooks/useFormValidation.ts b/src/hooks/useFormValidation.ts index 5dcb77f..879ded7 100644 --- a/src/hooks/useFormValidation.ts +++ b/src/hooks/useFormValidation.ts @@ -2,6 +2,9 @@ import { useState } from 'react'; import { z, infer as ZodInfer, ZodObject } from 'zod'; import { useToast } from '.'; +type ErrorShape = + T extends Array ? Array> : T extends object ? { [K in keyof T]?: ErrorShape } : string; + const useFormValidation = >({ schema, initialValues, @@ -25,12 +28,42 @@ const useFormValidation = >({ ); return { ...schemaDefaults, ...initialValues }; }); - const [errors, setErrors] = useState, string>>>({}); + const [errors, setErrors] = useState>>>({}); + + function setNestedValue(obj: any, path: string, value: any) { + const keys = path.split('.'); + let temp = obj; + for (let i = 0; i < keys.length - 1; i++) { + const key = keys[i]; + const nextKey = keys[i + 1]; + const isArrayIndex = !isNaN(Number(nextKey)); + if (isArrayIndex) { + if (!Array.isArray(temp[key])) temp[key] = []; + } else { + if (!temp[key]) temp[key] = {}; + } + temp = temp[key]; + } + const lastKey = keys[keys.length - 1]; + if (!isNaN(Number(lastKey))) { + temp[Number(lastKey)] = value; + } else { + temp[lastKey] = value; + } + } const handleInputChange: React.ChangeEventHandler = (e) => { const { name, value } = e.target; - setFormState((prev) => ({ ...prev, [name]: value })); - setErrors((prev) => ({ ...prev, [name]: '' })); + setFormState((prev) => { + const updated = { ...prev }; + setNestedValue(updated, name, value); + return updated; + }); + setErrors((prev) => { + const updated = { ...prev }; + setNestedValue(updated, name, ''); + return updated; + }); }; const handleCheckboxChange = (name: string, checked: boolean) => { @@ -45,17 +78,20 @@ const useFormValidation = >({ return true; } catch (err) { if (err instanceof z.ZodError) { + const newErrors: any = {}; + err.errors.forEach((zodErr) => { + const path = zodErr.path.join('.'); + setNestedValue(newErrors, path, zodErr.message); + }); + setErrors(newErrors); const firstError = err.errors[0]; - setErrors({ [firstError.path[0] as keyof ZodInfer]: firstError.message } as Partial< - Record, string> - >); toast.error({ title: 'Invalid Form Submission.', description: firstError.message }); } return false; } }; - return { formState, errors, handleInputChange, handleCheckboxChange, isFormValid }; + return { formState, setFormState, errors, handleInputChange, handleCheckboxChange, isFormValid }; }; export default useFormValidation; diff --git a/src/pages/UserEdit/UserEdit.test.tsx b/src/pages/UserEdit/UserEdit.test.tsx new file mode 100644 index 0000000..9d5fb8e --- /dev/null +++ b/src/pages/UserEdit/UserEdit.test.tsx @@ -0,0 +1,100 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import '@testing-library/jest-dom'; +import { cleanup, render, screen } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import UserEdit from './UserEdit'; +import { useGetUserQuery } from '../../api'; +import { ResourceType } from '../../types'; + +vi.mock('../../api', async () => { + const actual = await vi.importActual('../../api'); + return { + ...actual, + useGetUserQuery: vi.fn(), + useUpdateUserQuery: vi.fn(() => ({ mutateAsync: vi.fn() })), + }; +}); +const createQueryClient = () => + new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + +describe('UserEdit', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + cleanup(); + }); + + const mockUserData = { + nickname: 'Clark Kent', + address: { + streetAddress: ['123 Main St'], + locality: 'Metropolis', + postalCode: '12345', + region: 'NY', + country: 'US', + }, + active: true, + createdAt: '2023-01-01T00:00:00.000Z', + modifiedAt: '2023-01-02T00:00:00.000Z', + type: ResourceType.USER, + privileges: {}, + hasPassword: true, + }; + + it('renders loading state initially', async () => { + vi.mocked(useGetUserQuery).mockImplementation(() => ({ + data: undefined, + isLoading: true, + error: null, + refetch: vi.fn(), + isRefetching: false, + prefetchUser: vi.fn(), + })); + render( + + + , + ); + expect(await screen.findByText('Loading...')).toBeInTheDocument(); + }); + + it('renders user data when available', async () => { + vi.mocked(useGetUserQuery).mockImplementation(() => ({ + data: mockUserData, + isLoading: false, + error: null, + refetch: vi.fn(), + isRefetching: false, + prefetchUser: vi.fn(), + })); + render( + + + , + ); + expect(await screen.findByText('Clark Kent')).toBeInTheDocument(); + expect(await screen.findByText('View and edit user details below')).toBeInTheDocument(); + }); + + it('renders error state', async () => { + vi.mocked(useGetUserQuery).mockImplementation(() => ({ + data: undefined, + isLoading: false, + error: new Error('Failed to fetch user'), + refetch: vi.fn(), + isRefetching: false, + prefetchUser: vi.fn(), + })); + render( + + + , + ); + expect(await screen.findByText('Error: Failed to fetch user')).toBeInTheDocument(); + }); +}); diff --git a/src/pages/UserEdit/UserEdit.tsx b/src/pages/UserEdit/UserEdit.tsx new file mode 100644 index 0000000..1cb33dd --- /dev/null +++ b/src/pages/UserEdit/UserEdit.tsx @@ -0,0 +1,424 @@ +import { Box, Heading, Text, Separator, Button, Flex, Checkbox, Grid } from '@radix-ui/themes'; +import { isValid } from 'zod'; +import { InputField } from '../../components'; +import { useUpdateUserQuery, useGetUserQuery } from '../../api'; +import { useEffect } from 'react'; +import { useParams } from 'react-router-dom'; +import { useAxios, useFormValidation } from '../../hooks'; +import { UpdateUserModalSchema } from '../../types'; +import { useQueryClient } from '@tanstack/react-query'; +import { queryKeys } from '../../api/query-keys'; +import { CLIENT_ROUTES } from '../../routes'; + +const UserEdit = () => { + const { id } = useParams<{ id: string }>(); + const api = useAxios(); + + const { data, isLoading, error } = useGetUserQuery(id as string); + const mutation = useUpdateUserQuery(api); + const queryClient = useQueryClient(); + + const { formState, setFormState, errors, handleInputChange, handleCheckboxChange, isFormValid } = useFormValidation( + { + schema: UpdateUserModalSchema, + }, + ); + + useEffect(() => { + if (data) { + queryClient.invalidateQueries({ queryKey: queryKeys.users.detail(id as string) }); + setFormState({ + nickname: data.nickname || '', + active: !!data.active, + name: data.name || undefined, + locale: data.locale || undefined, + givenName: data.givenName || undefined, + middleName: data.middleName || undefined, + familyName: data.familyName || undefined, + birthdate: data.birthdate || undefined, + address: data.address || undefined, + zoneinfo: data.zoneinfo || undefined, + }); + } + }, [data, isLoading]); + + if (error) { + return
Error: {error.message}
; + } + + if (isLoading) { + return
Loading...
; + } + + const handleUpdateUser = async (e: React.FormEvent) => { + e.preventDefault(); + if (!isFormValid()) return false; + try { + const result = await mutation.mutateAsync({ id: id as string, userData: formState }); + console.log('User Updated Successfully', data); + return true; + } catch (error) { + console.error('Error updating user:', error); + return false; + } + }; + + // Add this function to your component + + return ( +
+ + {data?.nickname} + View and edit user details below + + + + + + + + + User Name* + + + + + + + + Given Name + + + + + + + Family Name + + + + + + + Language + + + + + + + Birthdate + + + + + + + Zone Info + + + + + + + Address + + + + + Street Address + + + + + + + City + + + + + + + Postal Code + + + + + + + Country + + + + + + + Region + + + + + + + + + + handleCheckboxChange('active', !!checked)} + /> + Set user to be active? + + + + + + + + + +
+ ); +}; + +export default UserEdit; diff --git a/src/pages/index.ts b/src/pages/index.ts index 5a4349c..827ec11 100644 --- a/src/pages/index.ts +++ b/src/pages/index.ts @@ -6,3 +6,4 @@ export { default as NotFoundPage } from './NotFoundPage'; export { default as OAuthTriggerPage } from './OAuthTriggerPage'; export { default as PrivilegeList } from './PrivilegeList/PrivilegeList'; export { default as UserList } from './UserList/UserList'; +export { default as UserEdit } from './UserEdit/UserEdit'; diff --git a/src/routes.ts b/src/routes.ts index f904490..549ed6f 100644 --- a/src/routes.ts +++ b/src/routes.ts @@ -4,6 +4,7 @@ export const CLIENT_ROUTES = { AUTH_REDIRECT: '/auth/redirect', USERS_TABLE: '/users/table', USERS_SANDBOX: '/users/sandbox', + USER_EDIT: '/users/:id/edit', GROUPS_TABLE: '/groups/table', GROUPS_SANDBOX: '/groups/sandbox', APPS_TABLE: '/apps/table', diff --git a/src/types/api.ts b/src/types/api.ts index db91624..d9fd4ca 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -41,6 +41,19 @@ export type CreateUserAPIRequest = { export type UpdateUserAPIRequest = { nickname: string; - type: string; active: boolean; + name?: string; + locale?: string; + givenName?: string; + middleName?: string; + familyName?: string; + birthdate?: string; + address?: { + streetAddress: (string | undefined)[]; + locality: string; + postalCode: string; + region: string; + country: string; + }; + zoneinfo?: string; }; diff --git a/src/types/forms.ts b/src/types/forms.ts index 48d1feb..4ab7608 100644 --- a/src/types/forms.ts +++ b/src/types/forms.ts @@ -10,6 +10,32 @@ export const CreateUserModalSchema = z.object({ export const UpdateUserModalSchema = z.object({ nickname: z.string().min(1, { message: 'User Name is required' }), active: z.boolean(), + name: z.string().optional(), + locale: z + .string() + .optional() + .refine((val) => !val || /^([a-zA-Z]{2,3})-([a-zA-Z]{2,4})$/.test(val), { + message: 'Locale must be in format en-US, where language is 2 or 3 letters and country is 2 to 4 letters', + }), + givenName: z.string().optional(), + middleName: z.string().optional(), + familyName: z.string().optional(), + birthdate: z + .string() + .optional() + .refine((val) => !val || (/^\d{4}-\d{2}-\d{2}$/.test(val) && !isNaN(Date.parse(val))), { + message: 'Birthdate must be a valid date in YYYY-MM-DD format', + }), + address: z + .object({ + streetAddress: z.array(z.string().optional()), + locality: z.string(), + postalCode: z.string(), + region: z.string(), + country: z.string().regex(/^[a-zA-Z]{2}$/, { message: 'Country must be a 2-letter country code' }), + }) + .optional(), + zoneinfo: z.string().optional(), }); export type UserUpdateInitialValues = { diff --git a/src/types/models.ts b/src/types/models.ts index 2430a3e..da869d6 100644 --- a/src/types/models.ts +++ b/src/types/models.ts @@ -40,6 +40,20 @@ export type User = BaseResource & { hasPassword: boolean; password?: string; verifiedAt?: string; + name?: string; + locale?: string; + givenName?: string; + middleName?: string; + familyName?: string; + birthdate?: string; + address?: { + streetAddress: (string | undefined)[]; + locality: string; + postalCode: string; + region: string; + country: string; + }; + zoneinfo?: string; }; export type Model = App | Template | Group | User;