Fix Adding labels to match the way we have it in EditChore

This commit is contained in:
Mo Tarbin
2026-07-27 23:55:27 -04:00
parent b145bb422b
commit 9044193aa3
4 changed files with 181 additions and 107 deletions

View File

@@ -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 (
<Menu
open={Boolean(suggestions?.options?.length)}
open={Boolean(suggestions?.options?.length) || showCreateOption}
ref={dropdownMenuRef}
anchorEl={parentRefer.current}
onClose={() => {}}
@@ -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) => (
<MenuItem
key={suggestions.display ? option[suggestions.value] : option}
selected={selectedIndex === index}
onClick={() => 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}
</MenuItem>
))}
{showCreateOption && (
<>
{filteredOptions.length > 0 && <Divider orientation='horizontal' />}
<MenuItem
key={suggestions.display ? option[suggestions.value] : option}
selected={selectedIndex === index}
onClick={() => 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}
<Add fontSize='small' />
Create &quot;{trimmedValue}&quot;
</MenuItem>
))}
<Divider orientation='horizontal' />
{/*
<MenuItem
selected={selectedIndex === suggestions?.options?.length}
onClick={() => {
onSelectSuggestion(currentValue)
}}
sx={{
cursor: 'pointer',
backgroundColor: 'gray.800',
}}
>
<Add />
Add new Label
</MenuItem> */}
</>
)}
</Menu>
)
}

View File

@@ -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 }) => {
/>
<LabelsPickerField
emptyDisplay={pickerEmptyDisplay}
values={labelsV2 || []}
onChange={setLabelsV2}
values={(labelsV2 || []).map(label => label.id)}
onChange={ids =>
setLabelsV2(
(userLabels || []).filter(label => ids.includes(label.id)),
)
}
onClear={() => setLabelsV2([])}
labels={userLabels || []}
/>

View File

@@ -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,
}
}

View File

@@ -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}
/>
)}