diff --git a/app/src/components/modals/workspace/CreateWorkspaceModal.test.tsx b/app/src/components/modals/workspace/CreateWorkspaceModal.test.tsx new file mode 100644 index 0000000..9243174 --- /dev/null +++ b/app/src/components/modals/workspace/CreateWorkspaceModal.test.tsx @@ -0,0 +1,728 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { + render as rtlRender, + screen, + fireEvent, + waitFor, +} from '@testing-library/react'; +import React from 'react'; +import { MantineProvider } from '@mantine/core'; +import CreateWorkspaceModal from './CreateWorkspaceModal'; +import { Theme, type Workspace } from '@/types/models'; +import { notifications } from '@mantine/notifications'; +import { useModalContext } from '../../../contexts/ModalContext'; +import { createWorkspace } from '@/api/workspace'; + +// Mock notifications +vi.mock('@mantine/notifications', () => ({ + notifications: { + show: vi.fn(), + }, +})); + +// Mock ModalContext +vi.mock('../../../contexts/ModalContext', () => ({ + useModalContext: vi.fn(), +})); + +// Mock workspace API +vi.mock('@/api/workspace', () => ({ + createWorkspace: vi.fn(), +})); + +// Helper wrapper component for testing +const TestWrapper = ({ children }: { children: React.ReactNode }) => ( + {children} +); + +// Custom render function +const render = (ui: React.ReactElement) => { + return rtlRender(ui, { wrapper: TestWrapper }); +}; + +describe('CreateWorkspaceModal', () => { + const mockOnWorkspaceCreated = vi.fn(); + const mockNotificationsShow = vi.mocked(notifications.show); + const mockUseModalContext = vi.mocked(useModalContext); + const mockCreateWorkspace = vi.mocked(createWorkspace); + + const mockSetCreateWorkspaceModalVisible = vi.fn(); + const mockModalContext = { + newFileModalVisible: false, + setNewFileModalVisible: vi.fn(), + deleteFileModalVisible: false, + setDeleteFileModalVisible: vi.fn(), + commitMessageModalVisible: false, + setCommitMessageModalVisible: vi.fn(), + settingsModalVisible: false, + setSettingsModalVisible: vi.fn(), + switchWorkspaceModalVisible: false, + setSwitchWorkspaceModalVisible: vi.fn(), + createWorkspaceModalVisible: true, + setCreateWorkspaceModalVisible: mockSetCreateWorkspaceModalVisible, + }; + + const mockWorkspace: Workspace = { + id: 1, + userId: 1, + name: 'test-workspace', + createdAt: '2024-01-01T00:00:00Z', + theme: Theme.Light, + autoSave: false, + showHiddenFiles: false, + gitEnabled: false, + gitUrl: '', + gitUser: '', + gitToken: '', + gitAutoCommit: false, + gitCommitMsgTemplate: '${action} ${filename}', + gitCommitName: '', + gitCommitEmail: '', + }; + + beforeEach(() => { + vi.clearAllMocks(); + mockCreateWorkspace.mockResolvedValue(mockWorkspace); + mockOnWorkspaceCreated.mockResolvedValue(undefined); + mockSetCreateWorkspaceModalVisible.mockClear(); + mockNotificationsShow.mockClear(); + + // Set up default modal context + mockUseModalContext.mockReturnValue(mockModalContext); + }); + + describe('Modal Visibility', () => { + it('renders modal when visible', () => { + render( + + ); + + expect(screen.getByText('Create New Workspace')).toBeInTheDocument(); + expect(screen.getByTestId('workspace-name-input')).toBeInTheDocument(); + expect( + screen.getByTestId('cancel-create-workspace-button') + ).toBeInTheDocument(); + expect( + screen.getByTestId('confirm-create-workspace-button') + ).toBeInTheDocument(); + }); + + it('does not render modal when not visible', () => { + const hiddenModalContext = { + ...mockModalContext, + createWorkspaceModalVisible: false, + }; + + mockUseModalContext.mockReturnValueOnce(hiddenModalContext); + + render( + + ); + + expect( + screen.queryByText('Create New Workspace') + ).not.toBeInTheDocument(); + }); + + it('calls setCreateWorkspaceModalVisible when modal is closed via cancel button', () => { + render( + + ); + + const cancelButton = screen.getByTestId('cancel-create-workspace-button'); + fireEvent.click(cancelButton); + + expect(mockSetCreateWorkspaceModalVisible).toHaveBeenCalledWith(false); + }); + }); + + describe('Form Interaction', () => { + it('updates workspace name input when typed', () => { + render( + + ); + + const nameInput = screen.getByTestId('workspace-name-input'); + fireEvent.change(nameInput, { target: { value: 'my-workspace' } }); + + expect((nameInput as HTMLInputElement).value).toBe('my-workspace'); + }); + + it('handles form submission with valid workspace name', async () => { + render( + + ); + + const nameInput = screen.getByTestId('workspace-name-input'); + const createButton = screen.getByTestId( + 'confirm-create-workspace-button' + ); + + fireEvent.change(nameInput, { target: { value: 'new-workspace' } }); + fireEvent.click(createButton); + + await waitFor(() => { + expect(mockCreateWorkspace).toHaveBeenCalledWith('new-workspace'); + }); + }); + + it('prevents submission with empty workspace name', async () => { + render( + + ); + + const createButton = screen.getByTestId( + 'confirm-create-workspace-button' + ); + fireEvent.click(createButton); + + await waitFor(() => { + expect(mockNotificationsShow).toHaveBeenCalledWith({ + title: 'Error', + message: 'Workspace name is required', + color: 'red', + }); + }); + + expect(mockCreateWorkspace).not.toHaveBeenCalled(); + }); + + it('prevents submission with whitespace-only workspace name', async () => { + render( + + ); + + const nameInput = screen.getByTestId('workspace-name-input'); + const createButton = screen.getByTestId( + 'confirm-create-workspace-button' + ); + + fireEvent.change(nameInput, { target: { value: ' ' } }); + fireEvent.click(createButton); + + await waitFor(() => { + expect(mockNotificationsShow).toHaveBeenCalledWith({ + title: 'Error', + message: 'Workspace name is required', + color: 'red', + }); + }); + + expect(mockCreateWorkspace).not.toHaveBeenCalled(); + }); + + it('closes modal and clears form after successful creation', async () => { + render( + + ); + + const nameInput = screen.getByTestId('workspace-name-input'); + const createButton = screen.getByTestId( + 'confirm-create-workspace-button' + ); + + fireEvent.change(nameInput, { target: { value: 'success-workspace' } }); + fireEvent.click(createButton); + + await waitFor(() => { + expect(mockCreateWorkspace).toHaveBeenCalledWith('success-workspace'); + }); + + await waitFor(() => { + expect(mockSetCreateWorkspaceModalVisible).toHaveBeenCalledWith(false); + }); + + await waitFor(() => { + expect((nameInput as HTMLInputElement).value).toBe(''); + }); + }); + }); + + describe('Workspace Name Validation', () => { + it('handles various workspace name formats', async () => { + const workspaceNames = [ + 'simple', + 'workspace-with-dashes', + 'workspace_with_underscores', + 'workspace with spaces', + 'workspace123', + 'Very Long Workspace Name Here', + ]; + + for (const name of workspaceNames) { + const { unmount } = render( + + ); + + const nameInput = screen.getByTestId('workspace-name-input'); + const createButton = screen.getByTestId( + 'confirm-create-workspace-button' + ); + + fireEvent.change(nameInput, { target: { value: name } }); + fireEvent.click(createButton); + + await waitFor(() => { + expect(mockCreateWorkspace).toHaveBeenCalledWith(name); + }); + + unmount(); + vi.clearAllMocks(); + mockCreateWorkspace.mockResolvedValue(mockWorkspace); + } + }); + + it('handles unicode characters in workspace names', async () => { + render( + + ); + + const nameInput = screen.getByTestId('workspace-name-input'); + const createButton = screen.getByTestId( + 'confirm-create-workspace-button' + ); + + const unicodeName = 'ワークスペース'; + fireEvent.change(nameInput, { target: { value: unicodeName } }); + fireEvent.click(createButton); + + await waitFor(() => { + expect(mockCreateWorkspace).toHaveBeenCalledWith(unicodeName); + }); + }); + + it('trims whitespace from workspace names', async () => { + render( + + ); + + const nameInput = screen.getByTestId('workspace-name-input'); + const createButton = screen.getByTestId( + 'confirm-create-workspace-button' + ); + + fireEvent.change(nameInput, { + target: { value: ' trimmed-workspace ' }, + }); + fireEvent.click(createButton); + + await waitFor(() => { + expect(mockCreateWorkspace).toHaveBeenCalledWith('trimmed-workspace'); + }); + }); + }); + + describe('Loading State', () => { + it('shows loading state on create button during creation', async () => { + // Make the API call hang to test loading state + mockCreateWorkspace.mockImplementation(() => new Promise(() => {})); + + render( + + ); + + const nameInput = screen.getByTestId('workspace-name-input'); + const createButton = screen.getByTestId( + 'confirm-create-workspace-button' + ); + + fireEvent.change(nameInput, { target: { value: 'loading-test' } }); + fireEvent.click(createButton); + + await waitFor(() => { + expect(createButton).toHaveAttribute('data-loading', 'true'); + }); + }); + + it('disables form elements during creation', async () => { + mockCreateWorkspace.mockImplementation(() => new Promise(() => {})); + + render( + + ); + + const nameInput = screen.getByTestId('workspace-name-input'); + const createButton = screen.getByTestId( + 'confirm-create-workspace-button' + ); + const cancelButton = screen.getByTestId('cancel-create-workspace-button'); + + fireEvent.change(nameInput, { target: { value: 'disabled-test' } }); + fireEvent.click(createButton); + + await waitFor(() => { + expect(nameInput).toBeDisabled(); + expect(createButton).toBeDisabled(); + expect(cancelButton).toBeDisabled(); + }); + }); + + it('handles normal state when not loading', () => { + render( + + ); + + const nameInput = screen.getByTestId('workspace-name-input'); + const createButton = screen.getByTestId( + 'confirm-create-workspace-button' + ); + const cancelButton = screen.getByTestId('cancel-create-workspace-button'); + + expect(nameInput).not.toBeDisabled(); + expect(createButton).not.toBeDisabled(); + expect(cancelButton).not.toBeDisabled(); + expect(createButton).not.toHaveAttribute('data-loading', 'true'); + }); + }); + + describe('Success Handling', () => { + it('shows success notification after workspace creation', async () => { + render( + + ); + + const nameInput = screen.getByTestId('workspace-name-input'); + const createButton = screen.getByTestId( + 'confirm-create-workspace-button' + ); + + fireEvent.change(nameInput, { target: { value: 'success-workspace' } }); + fireEvent.click(createButton); + + await waitFor(() => { + expect(mockNotificationsShow).toHaveBeenCalledWith({ + title: 'Success', + message: 'Workspace created successfully', + color: 'green', + }); + }); + }); + + it('calls onWorkspaceCreated callback when provided', async () => { + render( + + ); + + const nameInput = screen.getByTestId('workspace-name-input'); + const createButton = screen.getByTestId( + 'confirm-create-workspace-button' + ); + + fireEvent.change(nameInput, { target: { value: 'callback-test' } }); + fireEvent.click(createButton); + + await waitFor(() => { + expect(mockOnWorkspaceCreated).toHaveBeenCalledWith(mockWorkspace); + }); + }); + + it('works without onWorkspaceCreated callback', async () => { + render(); + + const nameInput = screen.getByTestId('workspace-name-input'); + const createButton = screen.getByTestId( + 'confirm-create-workspace-button' + ); + + fireEvent.change(nameInput, { target: { value: 'no-callback-test' } }); + fireEvent.click(createButton); + + await waitFor(() => { + expect(mockCreateWorkspace).toHaveBeenCalledWith('no-callback-test'); + }); + + await waitFor(() => { + expect(mockNotificationsShow).toHaveBeenCalledWith({ + title: 'Success', + message: 'Workspace created successfully', + color: 'green', + }); + }); + }); + }); + + describe('Error Handling', () => { + it('handles creation errors gracefully', async () => { + mockCreateWorkspace.mockRejectedValue(new Error('Creation failed')); + + render( + + ); + + const nameInput = screen.getByTestId('workspace-name-input'); + const createButton = screen.getByTestId( + 'confirm-create-workspace-button' + ); + + fireEvent.change(nameInput, { target: { value: 'error-workspace' } }); + fireEvent.click(createButton); + + await waitFor(() => { + expect(mockNotificationsShow).toHaveBeenCalledWith({ + title: 'Error', + message: 'Failed to create workspace', + color: 'red', + }); + }); + + // Modal should remain open when creation fails + expect(mockSetCreateWorkspaceModalVisible).not.toHaveBeenCalledWith( + false + ); + expect(screen.getByText('Create New Workspace')).toBeInTheDocument(); + }); + + it('handles network errors', async () => { + mockCreateWorkspace.mockRejectedValue(new Error('Network error')); + + render( + + ); + + const nameInput = screen.getByTestId('workspace-name-input'); + const createButton = screen.getByTestId( + 'confirm-create-workspace-button' + ); + + fireEvent.change(nameInput, { target: { value: 'network-error-test' } }); + fireEvent.click(createButton); + + await waitFor(() => { + expect(mockNotificationsShow).toHaveBeenCalledWith({ + title: 'Error', + message: 'Failed to create workspace', + color: 'red', + }); + }); + + // Should not crash the component + expect(screen.getByText('Create New Workspace')).toBeInTheDocument(); + }); + + it('retains form values when creation fails', async () => { + mockCreateWorkspace.mockRejectedValue(new Error('Creation failed')); + + render( + + ); + + const nameInput = screen.getByTestId('workspace-name-input'); + const createButton = screen.getByTestId( + 'confirm-create-workspace-button' + ); + + fireEvent.change(nameInput, { target: { value: 'persist-error' } }); + fireEvent.click(createButton); + + await waitFor(() => { + expect(mockCreateWorkspace).toHaveBeenCalledWith('persist-error'); + }); + + // Form should retain values when creation fails + expect((nameInput as HTMLInputElement).value).toBe('persist-error'); + }); + + it('resets loading state after error', async () => { + mockCreateWorkspace.mockRejectedValue(new Error('Creation failed')); + + render( + + ); + + const nameInput = screen.getByTestId('workspace-name-input'); + const createButton = screen.getByTestId( + 'confirm-create-workspace-button' + ); + + fireEvent.change(nameInput, { target: { value: 'loading-error' } }); + fireEvent.click(createButton); + + await waitFor(() => { + expect(mockCreateWorkspace).toHaveBeenCalled(); + }); + + await waitFor(() => { + expect(createButton).not.toHaveAttribute('data-loading', 'true'); + expect(nameInput).not.toBeDisabled(); + }); + }); + }); + + describe('Accessibility', () => { + it('has proper form labels and structure', () => { + render( + + ); + + const nameInput = screen.getByTestId('workspace-name-input'); + expect(nameInput).toBeInTheDocument(); + expect(nameInput.tagName).toBe('INPUT'); + expect(nameInput).toHaveAttribute('type', 'text'); + expect(nameInput).toHaveAccessibleName(); + }); + + it('has proper button roles', () => { + render( + + ); + + const buttons = screen.getAllByRole('button'); + expect(buttons.length).toBeGreaterThanOrEqual(2); + + const cancelButton = screen.getByRole('button', { name: /cancel/i }); + const createButton = screen.getByRole('button', { name: /create/i }); + + expect(cancelButton).toBeInTheDocument(); + expect(createButton).toBeInTheDocument(); + }); + + it('supports keyboard navigation', () => { + render( + + ); + + const nameInput = screen.getByTestId('workspace-name-input'); + + // Check that the input is focusable + expect(nameInput).not.toHaveAttribute('disabled'); + expect(nameInput).not.toHaveAttribute('readonly'); + + // Test keyboard input + fireEvent.change(nameInput, { target: { value: 'keyboard-test' } }); + expect((nameInput as HTMLInputElement).value).toBe('keyboard-test'); + }); + + it('has proper modal structure', () => { + render( + + ); + + expect(screen.getByText('Create New Workspace')).toBeInTheDocument(); + expect(screen.getByTestId('workspace-name-input')).toBeInTheDocument(); + }); + }); + + describe('Component Props', () => { + it('accepts and uses onWorkspaceCreated prop correctly', async () => { + const customCallback = vi.fn().mockResolvedValue(undefined); + + render(); + + const nameInput = screen.getByTestId('workspace-name-input'); + const createButton = screen.getByTestId( + 'confirm-create-workspace-button' + ); + + fireEvent.change(nameInput, { target: { value: 'custom-callback' } }); + fireEvent.click(createButton); + + await waitFor(() => { + expect(customCallback).toHaveBeenCalledWith(mockWorkspace); + }); + }); + + it('handles function props correctly', () => { + const testCallback = vi.fn(); + + expect(() => { + render(); + }).not.toThrow(); + + expect(screen.getByText('Create New Workspace')).toBeInTheDocument(); + }); + }); + + describe('User Interaction Flow', () => { + it('completes full workspace creation flow successfully', async () => { + render( + + ); + + // 1. Modal opens and shows form + expect(screen.getByText('Create New Workspace')).toBeInTheDocument(); + + // 2. User types workspace name + const nameInput = screen.getByTestId('workspace-name-input'); + fireEvent.change(nameInput, { target: { value: 'complete-flow-test' } }); + + // 3. User clicks create + const createButton = screen.getByTestId( + 'confirm-create-workspace-button' + ); + fireEvent.click(createButton); + + // 4. API is called + await waitFor(() => { + expect(mockCreateWorkspace).toHaveBeenCalledWith('complete-flow-test'); + }); + + // 5. Success notification is shown + await waitFor(() => { + expect(mockNotificationsShow).toHaveBeenCalledWith({ + title: 'Success', + message: 'Workspace created successfully', + color: 'green', + }); + }); + + // 6. Callback is called + await waitFor(() => { + expect(mockOnWorkspaceCreated).toHaveBeenCalledWith(mockWorkspace); + }); + + // 7. Modal closes and form clears + await waitFor(() => { + expect(mockSetCreateWorkspaceModalVisible).toHaveBeenCalledWith(false); + }); + + await waitFor(() => { + expect((nameInput as HTMLInputElement).value).toBe(''); + }); + }); + + it('allows user to cancel workspace creation', () => { + render( + + ); + + // User types name but then cancels + const nameInput = screen.getByTestId('workspace-name-input'); + fireEvent.change(nameInput, { target: { value: 'cancelled-workspace' } }); + + const cancelButton = screen.getByTestId('cancel-create-workspace-button'); + fireEvent.click(cancelButton); + + // Should close modal without calling API + expect(mockCreateWorkspace).not.toHaveBeenCalled(); + expect(mockSetCreateWorkspaceModalVisible).toHaveBeenCalledWith(false); + }); + + it('handles validation error flow', async () => { + render( + + ); + + // User tries to submit without entering name + const createButton = screen.getByTestId( + 'confirm-create-workspace-button' + ); + fireEvent.click(createButton); + + // Should show validation error + await waitFor(() => { + expect(mockNotificationsShow).toHaveBeenCalledWith({ + title: 'Error', + message: 'Workspace name is required', + color: 'red', + }); + }); + + // Should not call API or close modal + expect(mockCreateWorkspace).not.toHaveBeenCalled(); + expect(mockSetCreateWorkspaceModalVisible).not.toHaveBeenCalledWith( + false + ); + expect(screen.getByText('Create New Workspace')).toBeInTheDocument(); + }); + }); +}); diff --git a/app/src/components/modals/workspace/CreateWorkspaceModal.tsx b/app/src/components/modals/workspace/CreateWorkspaceModal.tsx index 7a6c90c..271a3fb 100644 --- a/app/src/components/modals/workspace/CreateWorkspaceModal.tsx +++ b/app/src/components/modals/workspace/CreateWorkspaceModal.tsx @@ -18,7 +18,8 @@ const CreateWorkspaceModal: React.FC = ({ useModalContext(); const handleSubmit = async (): Promise => { - if (!name.trim()) { + const trimmedName = name.trim(); + if (!trimmedName) { notifications.show({ title: 'Error', message: 'Workspace name is required', @@ -29,7 +30,7 @@ const CreateWorkspaceModal: React.FC = ({ setLoading(true); try { - const workspace = await createWorkspace(name); + const workspace = await createWorkspace(trimmedName); notifications.show({ title: 'Success', message: 'Workspace created successfully', @@ -61,6 +62,7 @@ const CreateWorkspaceModal: React.FC = ({ > = ({ Cancel