Fix Adding labels to match the way we have it in EditChore
This commit is contained in:
@@ -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 "{trimmedValue}"
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 || []}
|
||||
/>
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user