diff --git a/.gitignore b/.gitignore index 3fb7830..aee3abe 100644 --- a/.gitignore +++ b/.gitignore @@ -37,3 +37,6 @@ test-results/* playwright-report/* playwright/.auth src/tests/a12n-server/* + +# Coverage reports +coverage/ diff --git a/src/api/apps/index.ts b/src/api/apps/index.ts index 67ff0a5..e94ee92 100644 --- a/src/api/apps/index.ts +++ b/src/api/apps/index.ts @@ -1,2 +1,3 @@ export { default as getAllApps } from './getAllApps'; export { default as useAllAppsQuery } from './useAllAppsQuery'; +export { default as useCreateAppQuery } from './useCreateAppQuery'; diff --git a/src/api/apps/useCreateAppQuery.test.tsx b/src/api/apps/useCreateAppQuery.test.tsx new file mode 100644 index 0000000..1e27558 --- /dev/null +++ b/src/api/apps/useCreateAppQuery.test.tsx @@ -0,0 +1,90 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { Mock, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import useCreateAppQuery from './useCreateAppQuery'; + +// Mock the queryClient invalidateQueries method +const mockQueryClient = { + invalidateQueries: vi.fn(), +}; + +// Mock the useQueryClient hook +vi.mock('@tanstack/react-query', async () => { + const actual = await vi.importActual('@tanstack/react-query'); + return { + ...actual, + useQueryClient: () => mockQueryClient, + useMutation: (options: any) => { + // Return a mock mutation object that calls the mutationFn when mutate is called + return { + mutate: (variables: any, callbacks?: any) => { + // Call the mutationFn + options + .mutationFn(variables) + .then(() => { + // Call onSuccess if provided + options.onSuccess(); + callbacks?.onSuccess?.(); + }) + .catch((error: any) => { + callbacks?.onError?.(error); + }); + }, + isPending: false, + }; + }, + }; +}); + +describe('useCreateAppQuery', () => { + let mockClient: { post: Mock }; + + beforeEach(() => { + mockClient = { + post: vi.fn().mockResolvedValue({ id: 'test-app-id', nickname: 'Test App' }), + }; + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('should call client.post with correct parameters', async () => { + const mutation = useCreateAppQuery(mockClient as any); + + await new Promise((resolve) => { + mutation.mutate( + { nickname: 'Test App' }, + { + onSuccess: () => resolve(), + }, + ); + }); + + expect(mockClient.post).toHaveBeenCalledTimes(1); + expect(mockClient.post).toHaveBeenCalledWith({ + suffix: '/app', + body: { + type: 'app', + nickname: 'Test App', + }, + }); + }); + + it('should invalidate apps query on success', async () => { + const mutation = useCreateAppQuery(mockClient as any); + + await new Promise((resolve) => { + mutation.mutate( + { nickname: 'Test App' }, + { + onSuccess: () => resolve(), + }, + ); + }); + + expect(mockQueryClient.invalidateQueries).toHaveBeenCalledWith({ + queryKey: ['apps'], + }); + }); +}); diff --git a/src/api/apps/useCreateAppQuery.ts b/src/api/apps/useCreateAppQuery.ts new file mode 100644 index 0000000..5478940 --- /dev/null +++ b/src/api/apps/useCreateAppQuery.ts @@ -0,0 +1,30 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { SERVER_ROUTES } from '../../routes'; +import { formatAPIPath } from '../../utils'; +import APICore from '../core'; +import { queryKeys } from '../query-keys'; + +function useCreateAppQuery(client: APICore) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationKey: queryKeys.posts.app, + mutationFn: async ({ nickname }: { nickname: string }) => { + const response = await client.post({ + suffix: formatAPIPath([SERVER_ROUTES.APPS]), + body: { + type: 'app', + nickname: nickname, + }, + }); + + return response; + }, + onSuccess: () => { + // Invalidate and refetch apps list after successful creation + queryClient.invalidateQueries({ queryKey: queryKeys.apps.all }); + }, + }); +} + +export default useCreateAppQuery; diff --git a/src/api/query-keys.ts b/src/api/query-keys.ts index 285ad01..afd8bfd 100644 --- a/src/api/query-keys.ts +++ b/src/api/query-keys.ts @@ -6,6 +6,7 @@ export const queryKeys = { posts: { all: ['posts'] as const, user: ['posts', 'user'] as const, + app: ['posts', 'app'] as const, detail: (id: string) => ['posts', id] as const, }, privileges: { diff --git a/src/components/Modal/CreateAppModal.test.tsx b/src/components/Modal/CreateAppModal.test.tsx new file mode 100644 index 0000000..1cf7d67 --- /dev/null +++ b/src/components/Modal/CreateAppModal.test.tsx @@ -0,0 +1,54 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { render, screen, fireEvent } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import CreateAppModal from '../../components/Modal/CreateAppModal'; + +// Mock the dependencies +vi.mock('../../api', () => ({ + useCreateAppQuery: () => ({ + mutate: vi.fn(), + isPending: false, + }), +})); + +vi.mock('../../hooks', () => ({ + useAxios: () => ({}), + useFormValidation: () => ({ + formState: { appName: '', appURL: '' }, + errors: {}, + handleInputChange: vi.fn(), + isFormValid: () => true, + }), +})); + +describe('CreateAppModal', () => { + const mockOnClose = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders the modal when open', () => { + render(); + + expect(screen.getByText('Create App')).toBeInTheDocument(); + expect(screen.getByText('Enter in details below to create a new app')).toBeInTheDocument(); + expect(screen.getByPlaceholderText('My App')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Create' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument(); + }); + + it('does not render when closed', () => { + render(); + + expect(screen.queryByText('Create App')).not.toBeInTheDocument(); + }); + + it('calls onClose when Cancel button is clicked', () => { + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })); + + expect(mockOnClose).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/components/Modal/CreateAppModal.tsx b/src/components/Modal/CreateAppModal.tsx index 0d66999..920427c 100644 --- a/src/components/Modal/CreateAppModal.tsx +++ b/src/components/Modal/CreateAppModal.tsx @@ -1,5 +1,6 @@ import { Box, Button, Flex, Text } from '@radix-ui/themes'; -import { isValid } from 'zod'; +import { useCreateAppQuery } from '../../api'; +import { useAxios } from '../../hooks'; import { Modal } from '.'; import { InputField } from '..'; import { useFormValidation } from '../../hooks'; @@ -8,6 +9,8 @@ import { CreateAppModalSchema } from '../../types/forms'; export default function CreateAppModal({ onClose, isOpen }: { onClose: () => void; isOpen: boolean }) { const title = 'Create App'; const description = 'Enter in details below to create a new app'; + const api = useAxios(); + const createAppMutation = useCreateAppQuery(api); const { formState, errors, handleInputChange, isFormValid } = useFormValidation({ schema: CreateAppModalSchema, @@ -17,9 +20,22 @@ export default function CreateAppModal({ onClose, isOpen }: { onClose: () => voi e.preventDefault(); if (!isFormValid()) return false; - // TODO: API call to a12n server here - - console.log('App Created:', { formState }); + // Map form appName to API nickname field + createAppMutation.mutate( + { + nickname: formState.appName, + }, + { + onSuccess: () => { + onClose(); + // Clear the form state by triggering a re-render + }, + onError: (error) => { + console.error('Error creating app:', error); + // Error handling is managed by the API layer (toast notifications) + }, + }, + ); }; return ( @@ -84,10 +100,10 @@ export default function CreateAppModal({ onClose, isOpen }: { onClose: () => voi color='orange' radius='large' onClick={handleCreateApp} - disabled={!isValid} + disabled={!isFormValid() || createAppMutation.isPending} className='flex-1 bg-orange-500 text-white h-10 rounded-lg' > - Create + {createAppMutation.isPending ? 'Creating...' : 'Create'} diff --git a/src/pages/AppList/AppList.tsx b/src/pages/AppList/AppList.tsx index f4bf85d..7397029 100644 --- a/src/pages/AppList/AppList.tsx +++ b/src/pages/AppList/AppList.tsx @@ -2,11 +2,13 @@ import { Badge } from '@radix-ui/themes'; import { useMemo, useState } from 'react'; import { useAllAppsQuery } from '../../api'; import { Table } from '../../components'; +import CreateAppModal from '../../components/Modal/CreateAppModal'; import UpdateAppModal from '../../components/Modal/UpdateAppModal'; import { Apps } from '../../utils/helpers/models'; /* eslint-disable @typescript-eslint/no-explicit-any */ const AppList = () => { const [isUpdateModalOpen, setIsUpdateModalOpen] = useState(false); + const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); const appColumnHeadings = useMemo( () => [ @@ -71,7 +73,7 @@ const AppList = () => { if (error) return
Error: {error.message}
; const handleAddApp = () => { - console.log('Add app'); + setIsCreateModalOpen(true); }; const handleDeleteApp = () => { @@ -95,6 +97,7 @@ const AppList = () => { onDoubleClick={handleDoubleClick} /> + setIsCreateModalOpen(false)} isOpen={isCreateModalOpen} /> setIsUpdateModalOpen(false)} isOpen={isUpdateModalOpen} /> ); diff --git a/src/types/api.ts b/src/types/api.ts index db91624..4709494 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -19,7 +19,7 @@ export type MutationRequestParams = { onError?: (error: AxiosError) => void; }; -export type APIRequestBody = CreateUserAPIRequest | UpdateUserAPIRequest | Record; +export type APIRequestBody = CreateUserAPIRequest | UpdateUserAPIRequest | CreateAppAPIRequest | Record; export type APIResponseToastMessages = { [key: number]: { @@ -44,3 +44,8 @@ export type UpdateUserAPIRequest = { type: string; active: boolean; }; + +export type CreateAppAPIRequest = { + type: 'app'; + nickname: string; +};