diff --git a/src/views/TestView/AutocompleteDropdown.jsx b/src/views/TestView/AutocompleteDropdown.jsx index afe96b3..685800e 100644 --- a/src/views/TestView/AutocompleteDropdown.jsx +++ b/src/views/TestView/AutocompleteDropdown.jsx @@ -1,4 +1,5 @@ // AutocompleteDropdown.jsx +import { Add } from '@mui/icons-material' import { Divider, Menu, MenuItem } from '@mui/joy' import React, { useEffect } from 'react' @@ -8,6 +9,7 @@ const AutocompleteDropdown = ({ selectedIndex, onSelectSuggestion, onMouseEnterSuggestion, // Added for hover selection + onCreateSuggestion, // Called when the "Create new" row is chosen parentRefer, // Ref to the dropdown element }) => { // Scroll selected item into view @@ -26,9 +28,28 @@ const AutocompleteDropdown = ({ return null // Don't render if no suggestions } + const filteredOptions = suggestions.options.filter(option => { + if (typeof option === 'string') { + return option.toLowerCase().includes(currentValue.toLowerCase()) + } + return option[suggestions.display] + .toLowerCase() + .includes(currentValue.toLowerCase()) + }) + + const trimmedValue = currentValue.trim() + const hasExactMatch = filteredOptions.some(option => { + const optionText = suggestions.display + ? option[suggestions.display] + : option + return optionText.toLowerCase() === trimmedValue.toLowerCase() + }) + const showCreateOption = + suggestions.creatable && trimmedValue.length > 0 && !hasExactMatch + return ( {}} @@ -42,45 +63,45 @@ const AutocompleteDropdown = ({ zIndex: 1300, }} > - {suggestions?.options - .filter(option => { - if (typeof option === 'string') { - return option.toLowerCase().includes(currentValue.toLowerCase()) - } - return option[suggestions.display] - .toLowerCase() - .includes(currentValue.toLowerCase()) - }) - .map((option, index) => ( + {filteredOptions.map((option, index) => ( + onSelectSuggestion(option)} + onMouseEnter={() => onMouseEnterSuggestion(index)} // Update selected index on hover + className={selectedIndex === index ? 'selected' : ''} // Add class for selected item + sx={{ + cursor: 'pointer', + backgroundColor: selectedIndex === index ? 'gray.800' : 'inherit', + }} + > + {suggestions.display ? option[suggestions.display] : option} + + ))} + {showCreateOption && ( + <> + {filteredOptions.length > 0 && } onSelectSuggestion(option)} - onMouseEnter={() => onMouseEnterSuggestion(index)} // Update selected index on hover - className={selectedIndex === index ? 'selected' : ''} // Add class for selected item + selected={selectedIndex === filteredOptions.length} + onClick={() => onCreateSuggestion(trimmedValue)} + onMouseEnter={() => onMouseEnterSuggestion(filteredOptions.length)} + className={ + selectedIndex === filteredOptions.length ? 'selected' : '' + } sx={{ cursor: 'pointer', - backgroundColor: selectedIndex === index ? 'gray.800' : 'inherit', + backgroundColor: + selectedIndex === filteredOptions.length + ? 'gray.800' + : 'inherit', + gap: 0.5, }} > - {suggestions.display ? option[suggestions.display] : option} + + Create "{trimmedValue}" - ))} - - {/* - { - onSelectSuggestion(currentValue) - }} - sx={{ - cursor: 'pointer', - backgroundColor: 'gray.800', - }} - > - - Add new Label - */} + + )} ) } diff --git a/src/views/components/AddTaskModal.jsx b/src/views/components/AddTaskModal.jsx index 30ed6f5..bc2daa7 100644 --- a/src/views/components/AddTaskModal.jsx +++ b/src/views/components/AddTaskModal.jsx @@ -3,10 +3,12 @@ import { Box, Button, Typography } from '@mui/joy' import { useMediaQuery } from '@mui/material' import * as chrono from 'chrono-node' import moment from 'moment' +import { useQueryClient } from '@tanstack/react-query' import { useCallback, useEffect, useRef, useState } from 'react' import { useResponsiveModal } from '../../hooks/useResponsiveModal' import { useCreateChore } from '../../queries/ChoreQueries' import { useCircleMembers, useUserProfile } from '../../queries/UserQueries' +import { CreateLabel } from '../../utils/Fetcher' import { isPlusAccount } from '../../utils/Helpers' import { generateUUID } from '../../utils/UUID' import { useLabels } from '../Labels/LabelQueries' @@ -24,7 +26,7 @@ import SmartTaskTitleInput from './SmartTaskTitleInput' import KeyboardShortcutHint from '../../components/common/KeyboardShortcutHint' import { useDocumentScanner } from '../../hooks/useDocumentScanner' import { localAIService } from '../../service/LocalAIService' -import { TASK_COLOR } from '../../utils/Colors' +import LABEL_COLORS, { TASK_COLOR } from '../../utils/Colors' import AdvancedOptionsSection, { AdvancedOptionsTrigger, } from './AdvancedOptionsSection' @@ -66,9 +68,22 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose }) => { useCircleMembers() const { isLoading: isProjectsLoading } = useProjects() const createChoreMutation = useCreateChore() + const queryClient = useQueryClient() const { data: userProfile } = useUserProfile() + const handleCreateLabel = useCallback( + name => { + const color = + LABEL_COLORS[1 + Math.floor(Math.random() * (LABEL_COLORS.length - 1))] + .value + CreateLabel({ name, color }) + .then(() => queryClient.invalidateQueries(['labels'])) + .catch(error => console.error('Error creating label:', error)) + }, + [queryClient], + ) + // Get initial project from localStorage (current active project) const getInitialProject = () => { const saved = localStorage.getItem('selectedProject') @@ -378,11 +393,8 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose }) => { if (priority.result) setPriority(parseInt(priority.result, 10)) if (pointsParsed.result) setPoints(pointsParsed.result) if (labels.result) { - // parseLabels returns array of label objects, extract their IDs - const labelIds = labels.result - .filter(label => label.id) // Only labels with IDs (existing labels) - .map(label => label.id) - setLabelsV2(labelIds) + // parseLabels only returns #mentions matched to an existing label + setLabelsV2(labels.result) } if (assigneesResult.isAnyone) { @@ -856,6 +868,8 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose }) => { value: 'id', display: 'name', options: userLabels ? userLabels : [], + creatable: true, + onCreate: handleCreateLabel, }, '!': { value: 'id', @@ -954,8 +968,12 @@ const TaskInput = ({ onChoreUpdate, isModalOpen, onClose }) => { /> label.id)} + onChange={ids => + setLabelsV2( + (userLabels || []).filter(label => ids.includes(label.id)), + ) + } onClear={() => setLabelsV2([])} labels={userLabels || []} /> diff --git a/src/views/components/CustomParsers.js b/src/views/components/CustomParsers.js index df9a5d1..317be0e 100644 --- a/src/views/components/CustomParsers.js +++ b/src/views/components/CustomParsers.js @@ -97,64 +97,70 @@ export const parsePriority = inputSentence => { } } export const parseLabels = (inputSentence, userLabels) => { - let sentence = inputSentence.toLowerCase() const currentLabels = [] - const newLabels = [] + const matchedRanges = [] const allHighlights = [] - // Find all #label patterns in the sentence - // Use [\p{L}\p{N}_]+ to support Unicode letters (including umlauts) and numbers - const labelPattern = /#([\p{L}\p{N}_]+)/giu - const matches = [...inputSentence.matchAll(labelPattern)] + // Labels can contain spaces (e.g. "New Label"), so a plain word-boundary + // regex can't capture them — check each '#' against real label names + // instead, longest name first so "New Label" wins over a label named "New". + const sortedLabels = [...userLabels].sort( + (a, b) => b.name.length - a.name.length, + ) + const isWordChar = ch => ch !== undefined && /[\p{L}\p{N}_]/u.test(ch) - for (const match of matches) { - const labelName = match[1] - const fullMatch = match[0] - const startIndex = match.index + const hashPattern = /#/g + let hashMatch + while ((hashMatch = hashPattern.exec(inputSentence)) !== null) { + const startIndex = hashMatch.index + const rest = inputSentence.slice(startIndex + 1) - // Check if this label already exists - const existingLabel = userLabels.find( - label => label.name.toLowerCase() === labelName.toLowerCase(), - ) + const existingLabel = sortedLabels.find(label => { + const name = label.name + if (rest.toLowerCase().slice(0, name.length) !== name.toLowerCase()) { + return false + } + // Require a non-word boundary right after the name so "New" doesn't + // match inside a longer typed word like "Newer". + return !isWordChar(rest[name.length]) + }) - if (existingLabel) { - currentLabels.push(existingLabel) - } else { - // Create a new label object for new labels - newLabels.push({ - name: labelName, - color: '#3b82f6', // Default blue color - isNew: true, - }) - } + // Unmatched #mentions (no existing label, not created) are left as plain + // text — only #mentions resolving to a real label are extracted/cleaned. + if (!existingLabel) continue - allHighlights.push({ - text: fullMatch, + const fullMatch = `#${existingLabel.name}` + currentLabels.push(existingLabel) + matchedRanges.push({ start: startIndex, end: startIndex + fullMatch.length, }) - - // Remove the label from the sentence - sentence = sentence.replace(fullMatch.toLowerCase(), '') + allHighlights.push({ + text: inputSentence.slice(startIndex, startIndex + fullMatch.length), + start: startIndex, + end: startIndex + fullMatch.length, + }) + hashPattern.lastIndex = startIndex + fullMatch.length } - const allLabels = [...currentLabels, ...newLabels] + if (currentLabels.length > 0) { + let cleanedSentence = inputSentence + for (const range of matchedRanges + .slice() + .sort((a, b) => b.start - a.start)) { + cleanedSentence = + cleanedSentence.slice(0, range.start) + cleanedSentence.slice(range.end) + } - if (allLabels.length > 0) { return { - result: allLabels, - newLabels: newLabels, + result: currentLabels, highlight: allHighlights, - cleanedSentence: inputSentence - .replace(labelPattern, '') - .replace(/\s+/g, ' ') - .trim(), + cleanedSentence: cleanedSentence.replace(/\s+/g, ' ').trim(), } } return { result: null, - newLabels: [], cleanedSentence: inputSentence, } } diff --git a/src/views/components/SmartTaskTitleInput.jsx b/src/views/components/SmartTaskTitleInput.jsx index 39fa034..9558b5f 100644 --- a/src/views/components/SmartTaskTitleInput.jsx +++ b/src/views/components/SmartTaskTitleInput.jsx @@ -111,46 +111,71 @@ const SmartTaskTitleInput = ({ setCursorPosition(e.target.selectionStart) } + const selectSuggestionText = suggestionValue => { + const newValue = `${value.slice(0, cursorPosition - lastWord.length)}${suggestionValue} ${value.slice(cursorPosition)}` + onChange(newValue) + titleInputRef.current.value = newValue + + setShowSuggestions(false) + + const newCursorPosition = + cursorPosition - lastWord.length + suggestionValue.length + 1 + titleInputRef.current.setSelectionRange( + newCursorPosition, + newCursorPosition, + ) + } + const handleTextareaKeyDown = e => { if (showSuggestions) { - const currentSuggestions = suggestions[suggestionTrigger].options.filter( - option => { - if (typeof option === 'string') { - return option.toLowerCase().includes(lastWord.toLowerCase()) - } - return option[suggestions[suggestionTrigger].display] - .toLowerCase() - .includes(lastWord.toLowerCase()) - }, - ) + const activeSuggestions = suggestions[suggestionTrigger] + const currentSuggestions = activeSuggestions.options.filter(option => { + if (typeof option === 'string') { + return option.toLowerCase().includes(lastWord.toLowerCase()) + } + return option[activeSuggestions.display] + .toLowerCase() + .includes(lastWord.toLowerCase()) + }) + + const trimmedWord = lastWord.trim() + const hasExactMatch = currentSuggestions.some(option => { + const optionText = activeSuggestions.display + ? option[activeSuggestions.display] + : option + return optionText.toLowerCase() === trimmedWord.toLowerCase() + }) + const showCreateOption = + activeSuggestions.creatable && trimmedWord.length > 0 && !hasExactMatch + const optionCount = currentSuggestions.length + (showCreateOption ? 1 : 0) if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { e.preventDefault() + if (optionCount === 0) return const newIndex = e.key === 'ArrowDown' - ? (selectedSuggestionIndex + 1) % currentSuggestions.length - : (selectedSuggestionIndex - 1 + currentSuggestions.length) % - currentSuggestions.length + ? (selectedSuggestionIndex + 1) % optionCount + : (selectedSuggestionIndex - 1 + optionCount) % optionCount setSelectedSuggestionIndex(newIndex) } else if (e.key === 'Enter' || e.key === 'Tab') { - e.preventDefault() + if ( + showCreateOption && + selectedSuggestionIndex === currentSuggestions.length + ) { + e.preventDefault() + selectSuggestionText(trimmedWord) + activeSuggestions.onCreate?.(trimmedWord) + return + } + const selectedSuggestion = currentSuggestions[selectedSuggestionIndex] - const suggestionValue = suggestions[suggestionTrigger].display - ? selectedSuggestion[suggestions[suggestionTrigger].display] + const suggestionValue = activeSuggestions.display + ? selectedSuggestion?.[activeSuggestions.display] : selectedSuggestion if (suggestionValue) { - const newValue = `${value.slice(0, cursorPosition - lastWord.length)}${suggestionValue} ${value.slice(cursorPosition)}` - onChange(newValue) - titleInputRef.current.value = newValue - - setShowSuggestions(false) - - const newCursorPosition = cursorPosition + suggestionValue.length + 1 - titleInputRef.current.setSelectionRange( - newCursorPosition, - newCursorPosition, - ) + e.preventDefault() + selectSuggestionText(suggestionValue) } } else if (e.key === 'Escape') { e.preventDefault() @@ -349,6 +374,10 @@ const SmartTaskTitleInput = ({ ) setShowSuggestions(false) }} + onCreateSuggestion={name => { + selectSuggestionText(name) + suggestions[suggestionTrigger].onCreate?.(name) + }} parentRefer={dropdownRef} /> )}