diff --git a/backend/utils/tw/edit_task.go b/backend/utils/tw/edit_task.go index cf185ff8..b7056670 100644 --- a/backend/utils/tw/edit_task.go +++ b/backend/utils/tw/edit_task.go @@ -51,22 +51,24 @@ func EditTaskInTaskwarrior(uuid, description, email, encryptionSecret, taskID st } // Handle tags + + output, err := utils.ExecCommandForOutputInDir(tempDir, "task", taskID, "export") + if err == nil { + var tasks []map[string]interface{} + if err := json.Unmarshal(output, &tasks); err == nil && len(tasks) > 0 { + if existingTags, ok := tasks[0]["tags"].([]interface{}); ok { + for _, tag := range existingTags { + if tagStr, ok := tag.(string); ok { + utils.ExecCommand("task", taskID, "modify", "-"+tagStr) + } + } + } + } + } + if len(tags) > 0 { for _, tag := range tags { - if strings.HasPrefix(tag, "+") { - // Add tag - tagValue := strings.TrimPrefix(tag, "+") - if err := utils.ExecCommand("task", taskID, "modify", "+"+tagValue); err != nil { - return fmt.Errorf("failed to add tag %s: %v", tagValue, err) - } - } else if strings.HasPrefix(tag, "-") { - // Remove tag - tagValue := strings.TrimPrefix(tag, "-") - if err := utils.ExecCommand("task", taskID, "modify", "-"+tagValue); err != nil { - return fmt.Errorf("failed to remove tag %s: %v", tagValue, err) - } - } else { - // Add tag without prefix + if tag != "" { if err := utils.ExecCommand("task", taskID, "modify", "+"+tag); err != nil { return fmt.Errorf("failed to add tag %s: %v", tag, err) } diff --git a/frontend/src/components/HomeComponents/Tasks/AddTaskDialog.tsx b/frontend/src/components/HomeComponents/Tasks/AddTaskDialog.tsx index 18ed7f7e..1fe5fe1c 100644 --- a/frontend/src/components/HomeComponents/Tasks/AddTaskDialog.tsx +++ b/frontend/src/components/HomeComponents/Tasks/AddTaskDialog.tsx @@ -23,6 +23,7 @@ import { SelectValue, } from '@/components/ui/select'; import { AddTaskDialogProps } from '@/components/utils/types'; +import { MultiSelect } from './MultiSelect'; import { format } from 'date-fns'; export const AddTaskdialog = ({ @@ -30,12 +31,11 @@ export const AddTaskdialog = ({ setIsOpen, newTask, setNewTask, - tagInput, - setTagInput, onSubmit, isCreatingNewProject, setIsCreatingNewProject, uniqueProjects = [], + uniqueTags = [], allTasks = [], }: AddTaskDialogProps) => { const [annotationInput, setAnnotationInput] = useState(''); @@ -102,20 +102,6 @@ export const AddTaskdialog = ({ }); }; - const handleAddTag = () => { - if (tagInput && !newTask.tags.includes(tagInput, 0)) { - setNewTask({ ...newTask, tags: [...newTask.tags, tagInput] }); - setTagInput(''); - } - }; - - const handleRemoveTag = (tagToRemove: string) => { - setNewTask({ - ...newTask, - tags: newTask.tags.filter((tag) => tag !== tagToRemove), - }); - }; - return ( @@ -417,40 +403,14 @@ export const AddTaskdialog = ({ Tags
- setTagInput(e.target.value)} - onKeyDown={(e) => e.key === 'Enter' && handleAddTag()} - required - className="col-span-6" + setNewTask({ ...newTask, tags })} + placeholder="Select or create tags" />
- -
- {newTask.tags.length > 0 && ( -
-
-
- {newTask.tags.map((tag, index) => ( - - {tag} - - - ))} -
-
- )} -
diff --git a/frontend/src/components/HomeComponents/Tasks/__tests__/AddTaskDialog.test.tsx b/frontend/src/components/HomeComponents/Tasks/__tests__/AddTaskDialog.test.tsx index 51d11ab6..b6435827 100644 --- a/frontend/src/components/HomeComponents/Tasks/__tests__/AddTaskDialog.test.tsx +++ b/frontend/src/components/HomeComponents/Tasks/__tests__/AddTaskDialog.test.tsx @@ -100,10 +100,9 @@ describe('AddTaskDialog Component', () => { depends: [], }, setNewTask: jest.fn(), - tagInput: '', - setTagInput: jest.fn(), onSubmit: jest.fn(), uniqueProjects: [], + uniqueTags: ['work', 'urgent', 'personal'], allTasks: [], isCreatingNewProject: false, setIsCreatingNewProject: jest.fn(), @@ -317,33 +316,28 @@ describe('AddTaskDialog Component', () => { }); describe('Tags', () => { - test('adds a tag when user types and presses Enter', () => { + test('displays TagMultiSelect component', () => { mockProps.isOpen = true; - mockProps.tagInput = 'urgent'; render(); - const tagsInput = screen.getByPlaceholderText(/add a tag/i); - - fireEvent.keyDown(tagsInput, { key: 'Enter', code: 'Enter' }); + expect(screen.getByText('Select or create tags')).toBeInTheDocument(); + }); - expect(mockProps.setNewTask).toHaveBeenCalledWith({ - ...mockProps.newTask, - tags: ['urgent'], - }); + test('shows selected tags count when tags are selected', () => { + mockProps.isOpen = true; + mockProps.newTask.tags = ['urgent', 'work']; + render(); - expect(mockProps.setTagInput).toHaveBeenCalledWith(''); + expect(screen.getByText('2 items selected')).toBeInTheDocument(); }); - test('does not add duplicate tags', () => { + test('displays selected tags as badges', () => { mockProps.isOpen = true; - mockProps.tagInput = 'urgent'; - mockProps.newTask.tags = ['urgent']; + mockProps.newTask.tags = ['urgent', 'work']; render(); - const tagsInput = screen.getByPlaceholderText(/add a tag/i); - fireEvent.keyDown(tagsInput, { key: 'Enter', code: 'Enter' }); - - expect(mockProps.setNewTask).not.toHaveBeenCalled(); + expect(screen.getByText('urgent')).toBeInTheDocument(); + expect(screen.getByText('work')).toBeInTheDocument(); }); test('removes a tag when user clicks the remove button', () => { @@ -352,7 +346,6 @@ describe('AddTaskDialog Component', () => { render(); const removeButtons = screen.getAllByText('✖'); - fireEvent.click(removeButtons[0]); expect(mockProps.setNewTask).toHaveBeenCalledWith({ @@ -361,34 +354,61 @@ describe('AddTaskDialog Component', () => { }); }); - test('displays tags as badges', () => { + test('opens dropdown when TagMultiSelect button is clicked', () => { mockProps.isOpen = true; - mockProps.newTask.tags = ['urgent', 'work']; render(); - expect(screen.getByText('urgent')).toBeInTheDocument(); + const tagButton = screen.getByText('Select or create tags'); + fireEvent.click(tagButton); + + expect( + screen.getByPlaceholderText('Search or create...') + ).toBeInTheDocument(); + }); + + test('shows available tags in dropdown', () => { + mockProps.isOpen = true; + render(); + + const tagButton = screen.getByText('Select or create tags'); + fireEvent.click(tagButton); + expect(screen.getByText('work')).toBeInTheDocument(); + expect(screen.getByText('urgent')).toBeInTheDocument(); + expect(screen.getByText('personal')).toBeInTheDocument(); }); - test('updates tagInput when user types in tag field', () => { + test('adds tag when selected from dropdown', () => { mockProps.isOpen = true; render(); - const tagsInput = screen.getByPlaceholderText(/add a tag/i); - fireEvent.change(tagsInput, { target: { value: 'new-tag' } }); + const tagButton = screen.getByText('Select or create tags'); + fireEvent.click(tagButton); - expect(mockProps.setTagInput).toHaveBeenCalledWith('new-tag'); + const workTag = screen.getByText('work'); + fireEvent.click(workTag); + + expect(mockProps.setNewTask).toHaveBeenCalledWith({ + ...mockProps.newTask, + tags: ['work'], + }); }); - test('does not add empty tag when tagInput is empty', () => { + test('creates new tag when typed and Enter pressed', () => { mockProps.isOpen = true; - mockProps.tagInput = ''; render(); - const tagsInput = screen.getByPlaceholderText(/add a tag/i); - fireEvent.keyDown(tagsInput, { key: 'Enter', code: 'Enter' }); + const tagButton = screen.getByText('Select or create tags'); + fireEvent.click(tagButton); - expect(mockProps.setNewTask).not.toHaveBeenCalled(); + const searchInput = screen.getByPlaceholderText('Search or create...'); + fireEvent.change(searchInput, { target: { value: 'newtag' } }); + fireEvent.keyDown(searchInput, { key: 'Enter' }); + + expect(mockProps.setNewTask).toHaveBeenCalledWith({ + ...mockProps.newTask, + tags: ['newtag'], + }); }); }); diff --git a/frontend/src/components/HomeComponents/Tasks/__tests__/MultiSelect.test.tsx b/frontend/src/components/HomeComponents/Tasks/__tests__/MultiSelect.test.tsx new file mode 100644 index 00000000..bbcee3c3 --- /dev/null +++ b/frontend/src/components/HomeComponents/Tasks/__tests__/MultiSelect.test.tsx @@ -0,0 +1,506 @@ +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { MultiSelect } from '../MultiSelect'; +import '@testing-library/jest-dom'; + +describe('MultiSelect Component', () => { + const mockProps = { + availableItems: ['work', 'urgent', 'personal', 'bug', 'feature'], + selectedItems: [], + onItemsChange: jest.fn(), + placeholder: 'Select or create items', + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('Rendering', () => { + test('renders with placeholder when no items selected', () => { + render(); + + expect(screen.getByText('Select or create items')).toBeInTheDocument(); + }); + + test('shows selected tag count when tags are selected', () => { + render(); + + expect(screen.getByText('2 items selected')).toBeInTheDocument(); + }); + + test('shows singular form for single tag', () => { + render(); + + expect(screen.getByText('1 item selected')).toBeInTheDocument(); + }); + + test('displays selected tags as badges', () => { + render(); + + expect(screen.getByText('work')).toBeInTheDocument(); + expect(screen.getByText('urgent')).toBeInTheDocument(); + }); + + test('applies custom className', () => { + const { container } = render( + + ); + + expect(container.firstChild).toHaveClass('custom-class'); + }); + + test('respects disabled prop', () => { + render(); + + const button = screen.getByRole('button'); + expect(button).toBeDisabled(); + }); + }); + + describe('Dropdown Behavior', () => { + test('opens dropdown on button click', () => { + render(); + + const button = screen.getByRole('button'); + fireEvent.click(button); + + expect( + screen.getByPlaceholderText('Search or create...') + ).toBeInTheDocument(); + }); + + test('closes dropdown on button click when open', () => { + render(); + + const button = screen.getByRole('button'); + fireEvent.click(button); + fireEvent.click(button); + + expect( + screen.queryByPlaceholderText('Search or create...') + ).not.toBeInTheDocument(); + }); + + test('closes dropdown on outside click', async () => { + render(); + + const button = screen.getByRole('button'); + fireEvent.click(button); + + expect( + screen.getByPlaceholderText('Search or create...') + ).toBeInTheDocument(); + + fireEvent.mouseDown(document.body); + + await waitFor(() => { + expect( + screen.queryByPlaceholderText('Search or create...') + ).not.toBeInTheDocument(); + }); + }); + + test('closes dropdown on escape key', () => { + render(); + + const button = screen.getByRole('button'); + fireEvent.click(button); + + const searchInput = screen.getByPlaceholderText('Search or create...'); + fireEvent.keyDown(searchInput, { key: 'Escape' }); + + expect( + screen.queryByPlaceholderText('Search or create...') + ).not.toBeInTheDocument(); + }); + + test('focuses search input when dropdown opens', () => { + render(); + + const button = screen.getByRole('button'); + fireEvent.click(button); + + const searchInput = screen.getByPlaceholderText('Search or create...'); + expect(searchInput).toHaveFocus(); + }); + }); + + describe('Tag Selection', () => { + test('selects existing tag from dropdown', () => { + render(); + + const button = screen.getByRole('button'); + fireEvent.click(button); + + const workTag = screen.getByText('work'); + fireEvent.click(workTag); + + expect(mockProps.onItemsChange).toHaveBeenCalledWith(['work']); + }); + + test('does not show already selected tags in dropdown', () => { + render(); + + const dropdownButton = screen.getByText('1 item selected'); + fireEvent.click(dropdownButton); + + const dropdownContainer = screen + .getByPlaceholderText('Search or create...') + .closest('.absolute'); + expect(dropdownContainer).not.toHaveTextContent('work'); + expect(screen.getByText('urgent')).toBeInTheDocument(); + }); + + test('prevents duplicate tag selection', () => { + const onItemsChange = jest.fn(); + render( + + ); + + const dropdownButton = screen.getByText('1 item selected'); + fireEvent.click(dropdownButton); + + // Try to create 'work' again by typing it + const searchInput = screen.getByPlaceholderText('Search or create...'); + fireEvent.change(searchInput, { target: { value: 'work' } }); + fireEvent.keyDown(searchInput, { key: 'Enter' }); + + // Should not call onItemsChange since 'work' is already selected + expect(onItemsChange).not.toHaveBeenCalled(); + }); + + test('removes selected tag when badge X clicked', () => { + render(); + + const removeButtons = screen.getAllByText('✖'); + fireEvent.click(removeButtons[0]); + + expect(mockProps.onItemsChange).toHaveBeenCalledWith(['urgent']); + }); + + test('does not remove tags when disabled', () => { + render( + + ); + + const removeButton = screen.getByText('✖'); + expect(removeButton).toBeDisabled(); + }); + }); + + describe('Search Functionality', () => { + test('filters available tags by search term', () => { + render(); + + const button = screen.getByRole('button'); + fireEvent.click(button); + + const searchInput = screen.getByPlaceholderText('Search or create...'); + fireEvent.change(searchInput, { target: { value: 'ur' } }); + + expect(screen.getByText('urgent')).toBeInTheDocument(); + expect(screen.queryByText('work')).not.toBeInTheDocument(); + expect(screen.queryByText('personal')).not.toBeInTheDocument(); + }); + + test('search is case insensitive', () => { + render(); + + const button = screen.getByRole('button'); + fireEvent.click(button); + + const searchInput = screen.getByPlaceholderText('Search or create...'); + fireEvent.change(searchInput, { target: { value: 'WORK' } }); + + expect(screen.getByText('work')).toBeInTheDocument(); + }); + + test('shows "No items found" when no matches', () => { + render(); + + const button = screen.getByRole('button'); + fireEvent.click(button); + + const searchInput = screen.getByPlaceholderText('Search or create...'); + fireEvent.change(searchInput, { target: { value: 'nonexistent' } }); + + // Should show create option instead of "No items found" + expect(screen.getByText('Create "nonexistent"')).toBeInTheDocument(); + }); + + test('clears search term when tag is selected', () => { + render(); + + const button = screen.getByRole('button'); + fireEvent.click(button); + + const searchInput = screen.getByPlaceholderText('Search or create...'); + fireEvent.change(searchInput, { target: { value: 'work' } }); + + const workTag = screen.getByText('work'); + fireEvent.click(workTag); + + expect((searchInput as HTMLInputElement).value).toBe(''); + }); + }); + + describe('New Tag Creation', () => { + test('shows "create new" option for non-existing search', () => { + render(); + + const button = screen.getByRole('button'); + fireEvent.click(button); + + const searchInput = screen.getByPlaceholderText('Search or create...'); + fireEvent.change(searchInput, { target: { value: 'newtag' } }); + + expect(screen.getByText('Create "newtag"')).toBeInTheDocument(); + }); + + test('does not show "create new" for existing tags', () => { + render(); + + const button = screen.getByRole('button'); + fireEvent.click(button); + + const searchInput = screen.getByPlaceholderText('Search or create...'); + fireEvent.change(searchInput, { target: { value: 'work' } }); + + expect(screen.queryByText('Create "work"')).not.toBeInTheDocument(); + }); + + test('does not show "create new" for already selected tags', () => { + render(); + + const dropdownButton = screen.getByText('1 item selected'); + fireEvent.click(dropdownButton); + + const searchInput = screen.getByPlaceholderText('Search or create...'); + fireEvent.change(searchInput, { target: { value: 'work' } }); + + expect(screen.queryByText('Create "work"')).not.toBeInTheDocument(); + }); + + test('creates new tag when "create new" clicked', () => { + render(); + + const button = screen.getByRole('button'); + fireEvent.click(button); + + const searchInput = screen.getByPlaceholderText('Search or create...'); + fireEvent.change(searchInput, { target: { value: 'newtag' } }); + + const createOption = screen.getByText('Create "newtag"'); + fireEvent.click(createOption); + + expect(mockProps.onItemsChange).toHaveBeenCalledWith(['newtag']); + }); + + test('trims whitespace when creating new tag', () => { + render(); + + const button = screen.getByRole('button'); + fireEvent.click(button); + + const searchInput = screen.getByPlaceholderText('Search or create...'); + fireEvent.change(searchInput, { target: { value: ' newtag ' } }); + + const createOption = screen.getByText('Create "newtag"'); + fireEvent.click(createOption); + + expect(mockProps.onItemsChange).toHaveBeenCalledWith(['newtag']); + }); + + test('does not create empty tag', () => { + render(); + + const button = screen.getByRole('button'); + fireEvent.click(button); + + const searchInput = screen.getByPlaceholderText('Search or create...'); + fireEvent.change(searchInput, { target: { value: ' ' } }); + + expect(screen.queryByText(/Create/)).not.toBeInTheDocument(); + }); + }); + + describe('Keyboard Navigation', () => { + test('selects first filtered tag on Enter key', () => { + render(); + + const button = screen.getByRole('button'); + fireEvent.click(button); + + const searchInput = screen.getByPlaceholderText('Search or create...'); + fireEvent.change(searchInput, { target: { value: 'ur' } }); + fireEvent.keyDown(searchInput, { key: 'Enter' }); + + expect(mockProps.onItemsChange).toHaveBeenCalledWith(['urgent']); + }); + + test('creates new tag on Enter when no existing matches', () => { + render(); + + const button = screen.getByRole('button'); + fireEvent.click(button); + + const searchInput = screen.getByPlaceholderText('Search or create...'); + fireEvent.change(searchInput, { target: { value: 'newtag' } }); + fireEvent.keyDown(searchInput, { key: 'Enter' }); + + expect(mockProps.onItemsChange).toHaveBeenCalledWith(['newtag']); + }); + + test('does nothing on Enter when search is empty', () => { + const onItemsChange = jest.fn(); + render(); + + const button = screen.getByRole('button'); + fireEvent.click(button); + + const searchInput = screen.getByPlaceholderText('Search or create...'); + // Don't type anything, just press Enter + fireEvent.keyDown(searchInput, { key: 'Enter' }); + + expect(onItemsChange).not.toHaveBeenCalled(); + }); + + test('closes dropdown and clears search on Escape', () => { + render(); + + const button = screen.getByRole('button'); + fireEvent.click(button); + + const searchInput = screen.getByPlaceholderText('Search or create...'); + fireEvent.change(searchInput, { target: { value: 'test' } }); + fireEvent.keyDown(searchInput, { key: 'Escape' }); + + expect( + screen.queryByPlaceholderText('Search or create...') + ).not.toBeInTheDocument(); + }); + }); + + describe('Props Validation', () => { + test('calls onItemsChange when tags change', () => { + const onItemsChange = jest.fn(); + render(); + + const button = screen.getByRole('button'); + fireEvent.click(button); + + const workTag = screen.getByText('work'); + fireEvent.click(workTag); + + expect(onItemsChange).toHaveBeenCalledWith(['work']); + }); + + test('uses custom placeholder', () => { + render(); + + expect(screen.getByText('Custom placeholder')).toBeInTheDocument(); + }); + + test('handles empty availableItems array', () => { + render(); + + const button = screen.getByRole('button'); + fireEvent.click(button); + + expect(screen.getByText('No items found')).toBeInTheDocument(); + }); + + test('handles empty selectedItems array', () => { + render(); + + expect(screen.getByText('Select or create items')).toBeInTheDocument(); + expect(screen.queryByText('✖')).not.toBeInTheDocument(); + }); + }); + + describe('Integration Scenarios', () => { + test('works with pre-selected tags and available tags', () => { + render( + + ); + + // Should show selected tag + expect(screen.getByText('work')).toBeInTheDocument(); + expect(screen.getByText('1 item selected')).toBeInTheDocument(); + + // Should not show selected tag in dropdown + const dropdownButton = screen.getByText('1 item selected'); + fireEvent.click(dropdownButton); + + expect(screen.getByText('urgent')).toBeInTheDocument(); + expect(screen.getByText('personal')).toBeInTheDocument(); + + const dropdownContainer = screen + .getByPlaceholderText('Search or create...') + .closest('.absolute'); + expect(dropdownContainer).not.toHaveTextContent('work'); + }); + + test('maintains search state during tag operations', () => { + render(); + + const button = screen.getByRole('button'); + fireEvent.click(button); + + const searchInput = screen.getByPlaceholderText('Search or create...'); + fireEvent.change(searchInput, { target: { value: 'ur' } }); + + // Select a tag + const urgentTag = screen.getByText('urgent'); + fireEvent.click(urgentTag); + + // Search should be cleared after selection + expect((searchInput as HTMLInputElement).value).toBe(''); + }); + + test('handles rapid tag selection and removal', () => { + const onItemsChange = jest.fn(); + render( + + ); + + // Remove existing tag + const removeButton = screen.getByText('✖'); + fireEvent.click(removeButton); + + expect(onItemsChange).toHaveBeenCalledWith([]); + + // After removing, the button text should change back to placeholder + // We need to re-render with the updated state to test the next part + onItemsChange.mockClear(); + + // Simulate the component re-rendering with empty selectedItems + render( + + ); + + const dropdownButton = screen.getByText('Select or create items'); + fireEvent.click(dropdownButton); + + const urgentTag = screen.getByText('urgent'); + fireEvent.click(urgentTag); + + expect(onItemsChange).toHaveBeenCalledWith(['urgent']); + }); + }); +}); diff --git a/frontend/src/components/HomeComponents/Tasks/__tests__/TaskDialog.test.tsx b/frontend/src/components/HomeComponents/Tasks/__tests__/TaskDialog.test.tsx index 709c3ae4..eb4ed66d 100644 --- a/frontend/src/components/HomeComponents/Tasks/__tests__/TaskDialog.test.tsx +++ b/frontend/src/components/HomeComponents/Tasks/__tests__/TaskDialog.test.tsx @@ -1,4 +1,4 @@ -import { render, screen, fireEvent } from '@testing-library/react'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; import { TaskDialog } from '../TaskDialog'; import { Task, EditTaskState } from '../../../utils/types'; @@ -88,6 +88,7 @@ describe('TaskDialog Component', () => { onUpdateState: jest.fn(), allTasks: mockAllTasks, uniqueProjects: [], + uniqueTags: ['work', 'urgent', 'personal'], isCreatingNewProject: false, setIsCreatingNewProject: jest.fn(), onSaveDescription: jest.fn(), @@ -346,11 +347,10 @@ describe('TaskDialog Component', () => { } }); - test('should add new tag on Enter key press', () => { + test('should display TagMultiSelect when editing', () => { const editingState = { ...mockEditState, isEditingTags: true, - editTagInput: 'newtag', editedTags: ['tag1', 'tag2'], }; @@ -358,33 +358,56 @@ describe('TaskDialog Component', () => { ); - const input = screen.getByPlaceholderText( - 'Add a tag (press enter to add)' + expect(screen.getByText('2 items selected')).toBeInTheDocument(); + }); + + test('should show available tags in dropdown when editing', async () => { + const editingState = { + ...mockEditState, + isEditingTags: true, + editedTags: [], + }; + + render( + ); - fireEvent.keyDown(input, { key: 'Enter' }); - expect(defaultProps.onUpdateState).toHaveBeenCalledWith({ - editedTags: ['tag1', 'tag2', 'newtag'], - editTagInput: '', + const dropdownButton = screen.getByRole('button', { + name: /select items/i, + }); + fireEvent.click(dropdownButton); + + await waitFor(() => { + expect(screen.getByText('work')).toBeInTheDocument(); + expect(screen.getByText('urgent')).toBeInTheDocument(); + expect(screen.getByText('personal')).toBeInTheDocument(); }); }); - test('should remove tag when X button is clicked', () => { + test('should update tags when TagMultiSelect changes', async () => { const editingState = { ...mockEditState, isEditingTags: true, - editedTags: ['tag1', 'tag2'], + editedTags: [], }; render( ); - const removeButtons = screen.getAllByText('✖'); - if (removeButtons.length > 0) { - fireEvent.click(removeButtons[0]); - expect(defaultProps.onUpdateState).toHaveBeenCalled(); - } + const dropdownButton = screen.getByRole('button', { + name: /select items/i, + }); + fireEvent.click(dropdownButton); + + await waitFor(() => { + const workTag = screen.getByText('work'); + fireEvent.click(workTag); + }); + + expect(defaultProps.onUpdateState).toHaveBeenCalledWith({ + editedTags: ['work'], + }); }); test('should save tags when check icon is clicked', () => { @@ -400,7 +423,7 @@ describe('TaskDialog Component', () => { const saveButton = screen .getAllByRole('button') - .find((btn) => btn.getAttribute('aria-label') === 'Save tags'); + .find((btn) => btn.querySelector('.text-green-500')); if (saveButton) { fireEvent.click(saveButton); @@ -409,6 +432,33 @@ describe('TaskDialog Component', () => { 'tag2', 'tag3', ]); + expect(defaultProps.onUpdateState).toHaveBeenCalledWith({ + isEditingTags: false, + }); + } + }); + + test('should cancel editing when X icon is clicked', () => { + const editingState = { + ...mockEditState, + isEditingTags: true, + editedTags: ['tag1', 'tag2', 'tag3'], + }; + + render( + + ); + + const cancelButton = screen + .getAllByRole('button') + .find((btn) => btn.querySelector('.text-red-500')); + + if (cancelButton) { + fireEvent.click(cancelButton); + expect(defaultProps.onUpdateState).toHaveBeenCalledWith({ + isEditingTags: false, + editedTags: mockTask.tags || [], + }); } }); }); diff --git a/frontend/src/components/HomeComponents/Tasks/__tests__/Tasks.test.tsx b/frontend/src/components/HomeComponents/Tasks/__tests__/Tasks.test.tsx index 36886824..e48b380d 100644 --- a/frontend/src/components/HomeComponents/Tasks/__tests__/Tasks.test.tsx +++ b/frontend/src/components/HomeComponents/Tasks/__tests__/Tasks.test.tsx @@ -336,8 +336,13 @@ describe('Tasks Component', () => { const pencilButton = within(tagsRow).getByRole('button'); fireEvent.click(pencilButton); + const tagSelectButton = await screen.findByRole('button', { + name: /select items/i, + }); + fireEvent.click(tagSelectButton); + const editInput = await screen.findByPlaceholderText( - 'Add a tag (press enter to add)' + 'Search or create...' ); fireEvent.change(editInput, { target: { value: 'newtag' } }); @@ -363,8 +368,13 @@ describe('Tasks Component', () => { const pencilButton = within(tagsRow).getByRole('button'); fireEvent.click(pencilButton); + const tagSelectButton = await screen.findByRole('button', { + name: /select items/i, + }); + fireEvent.click(tagSelectButton); + const editInput = await screen.findByPlaceholderText( - 'Add a tag (press enter to add)' + 'Search or create...' ); fireEvent.change(editInput, { target: { value: 'addedtag' } }); @@ -373,7 +383,7 @@ describe('Tasks Component', () => { expect(await screen.findByText('addedtag')).toBeInTheDocument(); const saveButton = await screen.findByRole('button', { - name: /save tags/i, + name: /save/i, }); fireEvent.click(saveButton); @@ -406,8 +416,13 @@ describe('Tasks Component', () => { const pencilButton = within(tagsRow).getByRole('button'); fireEvent.click(pencilButton); + const tagSelectButton = await screen.findByRole('button', { + name: /select items/i, + }); + fireEvent.click(tagSelectButton); + const editInput = await screen.findByPlaceholderText( - 'Add a tag (press enter to add)' + 'Search or create...' ); fireEvent.change(editInput, { target: { value: 'newtag' } }); @@ -422,10 +437,17 @@ describe('Tasks Component', () => { const removeButton = within(badgeContainer).getByText('✖'); fireEvent.click(removeButton); - expect(screen.queryByText('tag2')).not.toBeInTheDocument(); + await waitFor(() => { + const selectedTagsArea = screen + .getByText('newtag') + .closest('div')?.parentElement; + expect( + within(selectedTagsArea as HTMLElement).queryByText('tag1') + ).not.toBeInTheDocument(); + }); const saveButton = await screen.findByRole('button', { - name: /save tags/i, + name: /save/i, }); fireEvent.click(saveButton); @@ -439,7 +461,7 @@ describe('Tasks Component', () => { const callArg = hooks.editTaskOnBackend.mock.calls[0][0]; - expect(callArg.tags).toEqual(expect.arrayContaining(['newtag', 'tag1'])); + expect(callArg.tags).toEqual(expect.arrayContaining(['newtag'])); }); it('clicking checkbox does not open task detail dialog', async () => { @@ -1246,14 +1268,17 @@ describe('Tasks Component', () => { const editButton = within(tagsRow).getByLabelText('edit'); fireEvent.click(editButton); - const editInput = await screen.findByPlaceholderText( - 'Add a tag (press enter to add)' - ); + const tagSelectButton = await screen.findByRole('button', { + name: /select items/i, + }); + fireEvent.click(tagSelectButton); + + const editInput = await screen.findByPlaceholderText('Search or create...'); fireEvent.change(editInput, { target: { value: 'unsyncedtag' } }); fireEvent.keyDown(editInput, { key: 'Enter', code: 'Enter' }); - const saveButton = screen.getByLabelText('Save tags'); + const saveButton = screen.getByLabelText('Save items'); fireEvent.click(saveButton); await waitFor(() => { diff --git a/frontend/src/components/HomeComponents/Tasks/multi-select-utils.ts b/frontend/src/components/HomeComponents/Tasks/multi-select-utils.ts new file mode 100644 index 00000000..04ff843a --- /dev/null +++ b/frontend/src/components/HomeComponents/Tasks/multi-select-utils.ts @@ -0,0 +1,24 @@ +export const getFilteredItems = ( + availableItems: string[], + selectedItems: string[], + searchTerm: string +): string[] => { + return availableItems.filter( + (item) => + item.toLowerCase().includes(searchTerm.toLowerCase()) && + !selectedItems.includes(item) + ); +}; + +export const shouldShowCreateOption = ( + searchTerm: string, + availableItems: string[], + selectedItems: string[] +): boolean => { + const trimmed = searchTerm.trim(); + return ( + !!trimmed && + !availableItems.includes(trimmed) && + !selectedItems.includes(trimmed) + ); +}; diff --git a/frontend/src/components/utils/types.ts b/frontend/src/components/utils/types.ts index 54d4482b..1acc74b1 100644 --- a/frontend/src/components/utils/types.ts +++ b/frontend/src/components/utils/types.ts @@ -119,15 +119,26 @@ export interface AddTaskDialogProps { setIsOpen: (value: boolean) => void; newTask: TaskFormData; setNewTask: (task: TaskFormData) => void; - tagInput: string; - setTagInput: (value: string) => void; onSubmit: (task: TaskFormData) => void; isCreatingNewProject: boolean; setIsCreatingNewProject: (value: boolean) => void; uniqueProjects: string[]; + uniqueTags: string[]; allTasks?: Task[]; } +export interface MultiSelectProps { + availableItems: string[]; + selectedItems: string[]; + onItemsChange: (items: string[]) => void; + placeholder?: string; + disabled?: boolean; + className?: string; + showActions?: boolean; + onSave?: () => void; + onCancel?: () => void; +} + export interface EditTaskDialogProps { index: number; task: Task; @@ -141,6 +152,7 @@ export interface EditTaskDialogProps { onUpdateState: (updates: Partial) => void; allTasks: Task[]; uniqueProjects: string[]; + uniqueTags: string[]; isCreatingNewProject: boolean; setIsCreatingNewProject: (value: boolean) => void; onSaveDescription: (task: Task, description: string) => void;