diff --git a/package.json b/package.json index 7a673cd67cf..c648c2d0876 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "prepare": "husky" }, "devDependencies": { + "@types/react-color": "^2", "@typescript-eslint/eslint-plugin": "^8.1.0", "@typescript-eslint/parser": "^8.1.0", "confusing-browser-globals": "^1.0.11", @@ -80,5 +81,9 @@ "browserslist": [ "electron 24.0", "defaults" - ] + ], + "dependencies": { + "react-color": "^2.19.3", + "react-contenteditable": "^3.3.7" + } } diff --git a/packages/desktop-client/e2e/transactions.test.js-snapshots/Transactions-filters-transactions-by-category-1-chromium-linux.png b/packages/desktop-client/e2e/transactions.test.js-snapshots/Transactions-filters-transactions-by-category-1-chromium-linux.png index 4a682c1f0ed..a099f6a117a 100644 Binary files a/packages/desktop-client/e2e/transactions.test.js-snapshots/Transactions-filters-transactions-by-category-1-chromium-linux.png and b/packages/desktop-client/e2e/transactions.test.js-snapshots/Transactions-filters-transactions-by-category-1-chromium-linux.png differ diff --git a/packages/desktop-client/e2e/transactions.test.js-snapshots/Transactions-filters-transactions-by-category-2-chromium-linux.png b/packages/desktop-client/e2e/transactions.test.js-snapshots/Transactions-filters-transactions-by-category-2-chromium-linux.png index f98e9d0f63e..1b27a5bcb63 100644 Binary files a/packages/desktop-client/e2e/transactions.test.js-snapshots/Transactions-filters-transactions-by-category-2-chromium-linux.png and b/packages/desktop-client/e2e/transactions.test.js-snapshots/Transactions-filters-transactions-by-category-2-chromium-linux.png differ diff --git a/packages/desktop-client/e2e/transactions.test.js-snapshots/Transactions-filters-transactions-by-category-3-chromium-linux.png b/packages/desktop-client/e2e/transactions.test.js-snapshots/Transactions-filters-transactions-by-category-3-chromium-linux.png index 6e51c3f9faf..8fc889f31be 100644 Binary files a/packages/desktop-client/e2e/transactions.test.js-snapshots/Transactions-filters-transactions-by-category-3-chromium-linux.png and b/packages/desktop-client/e2e/transactions.test.js-snapshots/Transactions-filters-transactions-by-category-3-chromium-linux.png differ diff --git a/packages/desktop-client/package.json b/packages/desktop-client/package.json index 57a8c952bbc..d5517867bad 100644 --- a/packages/desktop-client/package.json +++ b/packages/desktop-client/package.json @@ -23,6 +23,7 @@ "@types/lodash": "^4", "@types/promise-retry": "^1.1.6", "@types/react": "^18.2.0", + "@types/react-color": "^2", "@types/react-dom": "^18.2.1", "@types/react-grid-layout": "^1", "@types/react-modal": "^3.16.0", @@ -97,5 +98,8 @@ "test": "vitest", "e2e": "npx playwright test --browser=chromium", "vrt": "cross-env VRT=true npx playwright test --browser=chromium" + }, + "dependencies": { + "react-color": "^2.19.3" } } diff --git a/packages/desktop-client/src/components/accounts/Account.jsx b/packages/desktop-client/src/components/accounts/Account.jsx index 01bcc5abd85..b946bcbabae 100644 --- a/packages/desktop-client/src/components/accounts/Account.jsx +++ b/packages/desktop-client/src/components/accounts/Account.jsx @@ -1113,10 +1113,10 @@ class AccountInternal extends PureComponent { ); }; - onConditionsOpChange = (value, conditions) => { + onConditionsOpChange = value => { this.setState({ filterConditionsOp: value }); this.setState({ filterId: { ...this.state.filterId, status: 'changed' } }); - this.applyFilters([...conditions]); + this.applyFilters([...this.state.filterConditions]); if (this.state.search !== '') { this.onSearch(this.state.search); } diff --git a/packages/desktop-client/src/components/autocomplete/TagAutocomplete.tsx b/packages/desktop-client/src/components/autocomplete/TagAutocomplete.tsx new file mode 100644 index 00000000000..629028e15f3 --- /dev/null +++ b/packages/desktop-client/src/components/autocomplete/TagAutocomplete.tsx @@ -0,0 +1,385 @@ +import { type RefObject, useEffect, useRef, useState } from 'react'; +import { TwitterPicker } from 'react-color'; +import { useDispatch } from 'react-redux'; + +import { updateTags } from 'loot-core/client/actions'; +import { getNormalisedString } from 'loot-core/shared/normalisation'; +import { extractAllTags, TAGCOLORS } from 'loot-core/shared/tag'; + +import { useTags } from '../../hooks/useTags'; +import { styles, theme } from '../../style'; +import { Button } from '../common/Button2'; +import { Popover } from '../common/Popover'; +import { Text } from '../common/Text'; +import { View } from '../common/View'; + +type Tag = { + id: string; + tag: string; + color: string; + textColor: string; + hoverColor: string; +}; + +type TagAutocompleteProps = { + onMenuSelect: (item: Tag) => void; + hint: string; + clickedOnIt: () => void; + keyPressed: string | null; + onKeyHandled: () => void; + element: HTMLElement | null; + value: string | null; +}; + +function TagAutocomplete({ + onMenuSelect, + hint, + clickedOnIt, + keyPressed, + onKeyHandled, + element, + value, +}: TagAutocompleteProps) { + const tags = useTags(); + const [suggestions, setSuggestions] = useState([]); + const [selectedIndex, setSelectedIndex] = useState(0); + + useEffect(() => { + if (tags && tags.length > 0) { + let filteredTags = tags; + if (hint && hint.length > 0) { + filteredTags = tags.filter(tag => + getNormalisedString(tag.tag).includes(getNormalisedString(hint)), + ); + } + setSuggestions( + filteredTags.map(tag => ({ + ...tag, + name: tag.tag, + })), + ); + setSelectedIndex(0); + + if (filteredTags.length === 0) { + clickedOnIt(); + } + } + }, [tags, hint, setSelectedIndex]); + + useEffect(() => { + const minIndex = 0; + + if (keyPressed) { + if (keyPressed === 'ArrowRight') { + setSelectedIndex( + prevIndex => (prevIndex + 1) % Math.min(suggestions.length, 10), + ); + } else if (keyPressed === 'ArrowLeft') { + setSelectedIndex(prevIndex => + prevIndex === minIndex + ? Math.min(suggestions.length, 10) - 1 + : prevIndex - 1, + ); + } else if (keyPressed === 'Tab' || keyPressed === 'Enter') { + onMenuSelect(suggestions[selectedIndex]); + } + if (onKeyHandled) { + onKeyHandled(); + } + } + }, [ + keyPressed, + suggestions, + selectedIndex, + onMenuSelect, + onKeyHandled, + hint, + ]); + + return ( + + ); +} + +type TagListProps = { + items: Tag[]; + onMenuSelect: (item: Tag) => void; + tags: Tag[]; + clickedOnIt: () => void; + selectedIndex: number; + // hint: string; + element: HTMLElement | null; + value: string | null; +}; + +function TagList({ + items, + onMenuSelect, + tags, + clickedOnIt, + selectedIndex, + // hint, + element, + value, +}: TagListProps) { + const [width, setWidth] = useState(0); + const [showColors, setShowColors] = useState(false); + const [selectedItem, setSelectedItem] = useState(null); + const [uncommitedTags, setUncommitedTags] = useState([]); + const colorRef = useRef(null); + const dispatch = useDispatch(); + + useEffect(() => { + if (value && tags) { + let list = extractAllTags(value); + list = list + .filter(item => item.length > 1) + .filter(item => !tags.some(tag => tag.tag === item)); + setUncommitedTags(list); + } + }, [value, tags]); + + useEffect(() => { + if (!element) return; + + const handleResize = () => { + if (element) { + setWidth(element.offsetWidth); + } + }; + + handleResize(); + + window.addEventListener('resize', handleResize); + + const resizeObserver = new ResizeObserver(() => { + if (element) { + setWidth(element.offsetWidth); + } + }); + + if (element) { + resizeObserver.observe(element); + } + + return () => { + window.removeEventListener('resize', handleResize); + + if (resizeObserver && element) { + resizeObserver.unobserve(element); + resizeObserver.disconnect(); + } + }; + }, [element]); + + return ( + + {/* {items.length === 0 && ( + + + Tags will be saved when saving the transaction. + + + )} */} + {items.length > 0 && ( + + {items.map((item, index) => ( + { + colorRef.current = e.target; + setSelectedItem(item); + setShowColors(true); + e.preventDefault(); + }} + > + + + ))} + + )} + {/* {uncommitedTags.length > 0 && ( + <> + + Tags that will be created when saving + + + {uncommitedTags.map((item, index) => ( + + + {item} + + + ))} + + + )} */} + {showColors && selectedItem && ( + setShowColors(isOpen)} + placement="bottom start" + offset={10} + // style={{ marginLeft: pickerPosition.left }} + > + { + selectedItem.color = newColor.hex; + dispatch(updateTags(selectedItem)); + }} + onChangeComplete={() => { + setShowColors(false); + }} + /> + + )} + + ); +} + +type TagPopoverProps = { + triggerRef: RefObject; + isOpen: boolean; + hint: string; + onMenuSelect: (item: Tag) => void; + keyPressed: string | null; + onKeyHandled: () => void; + onClose: () => void; + value: string | null; +}; + +export function TagPopover({ + triggerRef, + isOpen, + hint, + onMenuSelect, + keyPressed, + onKeyHandled, + onClose, + value, +}: TagPopoverProps) { + const [showPopOver, setShowPopOver] = useState(isOpen); + + useEffect(() => { + setShowPopOver(isOpen); + }, [isOpen]); + + return ( + setShowPopOver(isOpen)} + placement="bottom start" + > + { + setShowPopOver(false); + onClose(); + }} + keyPressed={keyPressed} + onKeyHandled={onKeyHandled} + onMenuSelect={onMenuSelect} + element={triggerRef?.current} + /> + + ); +} diff --git a/packages/desktop-client/src/components/common/InputWithContent.tsx b/packages/desktop-client/src/components/common/InputWithContent.tsx index d7cb8b93a96..ee43cfcedc7 100644 --- a/packages/desktop-client/src/components/common/InputWithContent.tsx +++ b/packages/desktop-client/src/components/common/InputWithContent.tsx @@ -1,8 +1,15 @@ -import { useState, type ComponentProps, type ReactNode } from 'react'; +import { + type ElementType, + useState, + type ComponentProps, + type ReactNode, + type FocusEvent, +} from 'react'; import { type CSSProperties, theme } from '../../style'; import { Input, defaultInputStyle } from './Input'; +import { InputWithTags } from './InputWithTags'; import { View } from './View'; type InputWithContentProps = ComponentProps & { @@ -12,7 +19,9 @@ type InputWithContentProps = ComponentProps & { focusStyle?: CSSProperties; style?: CSSProperties; getStyle?: (focused: boolean) => CSSProperties; + inputWithTags?: boolean; }; + export function InputWithContent({ leftContent, rightContent, @@ -20,9 +29,11 @@ export function InputWithContent({ focusStyle, style, getStyle, + inputWithTags, ...props }: InputWithContentProps) { const [focused, setFocused] = useState(props.focused ?? false); + const InputType: ElementType = inputWithTags ? InputWithTags : Input; return ( {leftContent} - { + onFocus={(e: FocusEvent) => { setFocused(true); props.onFocus?.(e); }} - onBlur={e => { + onBlur={(e: FocusEvent) => { setFocused(false); props.onBlur?.(e); }} diff --git a/packages/desktop-client/src/components/common/InputWithTags.tsx b/packages/desktop-client/src/components/common/InputWithTags.tsx new file mode 100644 index 00000000000..996b201daa0 --- /dev/null +++ b/packages/desktop-client/src/components/common/InputWithTags.tsx @@ -0,0 +1,130 @@ +import React, { + type InputHTMLAttributes, + type KeyboardEvent, + type Ref, + useEffect, + useRef, +} from 'react'; + +import { css } from 'glamor'; + +import { useMergedRefs } from '../../hooks/useMergedRefs'; +import { useTagPopover } from '../../hooks/useTagPopover'; +import { type CSSProperties, styles, theme } from '../../style'; +import { TagPopover } from '../autocomplete/TagAutocomplete'; + +import { defaultInputStyle } from './Input'; + +type InputWithTagsProps = InputHTMLAttributes & { + style?: CSSProperties; + inputRef?: Ref; + onEnter?: (event: KeyboardEvent) => void; + onEscape?: (event: KeyboardEvent) => void; + onChangeValue?: (newValue: string) => void; + onUpdate?: (newValue: string) => void; + focused?: boolean; +}; + +export function InputWithTags({ + style, + inputRef, + onEnter, + onEscape, + defaultValue, + value = defaultValue, + onChangeValue, + onUpdate, + focused, + className, + ...nativeProps +}: InputWithTagsProps) { + const ref = useRef(null); + const mergedRef = useMergedRefs(ref, inputRef); + + const { + content, + setContent, + hint, + showAutocomplete, + setShowAutocomplete, + keyPressed, + setKeyPressed, + handleKeyUp, + handleKeyDown, + handleMenuSelect, + } = useTagPopover(value?.toString() || '', ref); + + useEffect(() => { + setContent(value?.toString() || ''); + }, [value, setContent]); + + const onChangeValueRef = useRef(onChangeValue); + + useEffect(() => { + onChangeValueRef.current = onChangeValue; + }, [onChangeValue]); + + useEffect(() => { + if (content) { + onChangeValueRef.current?.(content); + } + }, [content]); + + return ( + <> + { + nativeProps.onKeyDown?.(e); + + if (e.key === 'Enter' && onEnter) { + onEnter(e); + } + + if (e.key === 'Escape' && onEscape) { + onEscape(e); + } + + handleKeyDown?.(e); + }} + onKeyUp={handleKeyUp} + onBlur={e => { + onUpdate?.(content); + nativeProps.onBlur?.(e); + }} + onChange={e => { + setContent(e.target.value); + onChangeValue?.(e.target.value); + nativeProps.onChange?.(e); + }} + /> + setKeyPressed(null)} + onClose={() => setShowAutocomplete(false)} + /> + + ); +} diff --git a/packages/desktop-client/src/components/common/Search.tsx b/packages/desktop-client/src/components/common/Search.tsx index 1cf98e8bf05..6bf3a90c2cb 100644 --- a/packages/desktop-client/src/components/common/Search.tsx +++ b/packages/desktop-client/src/components/common/Search.tsx @@ -26,6 +26,7 @@ export function Search({ return ( & { innerRef?: Ref; className?: string; children?: ReactNode; style?: CSSProperties; + textWithTags?: boolean; +}; + +const ProcessText = (text: string, tags: TagEntity[]): ReactNode => { + const [tagColors, setTagColors] = useState>(new Map()); + const [tagTextColors, setTagTextColors] = useState(new Map()); + + useEffect(() => { + const map = new Map(); + const mapTextColor = new Map(); + + const extractedTags = extractAllTags(text); + + extractedTags.forEach(tag => { + const filteredTags = tags.filter(t => t.tag === tag); + if (filteredTags.length > 0) { + map.set(tag, filteredTags[0].color ?? theme.noteTagBackground); + mapTextColor.set(tag, filteredTags[0].textColor ?? theme.noteTagText); + } else { + map.set(tag, theme.noteTagBackground); + mapTextColor.set(tag, theme.noteTagText); + } + }); + + setTagColors(map); + setTagTextColors(mapTextColor); + }, [tags, text]); + + const words = text.split(' '); + + return ( + <> + {words.map((word, i, arr) => { + const separator = arr.length - 1 === i ? '' : ' '; + if (word.includes('#') && word.length > 1) { + let lastEmptyTag = -1; + // Treat tags in a single word as separate tags. + // #tag1#tag2 => (#tag1)(#tag2) + // not-a-tag#tag2#tag3 => not-a-tag(#tag2)(#tag3) + return word.split('#').map((tag, ti) => { + if (ti === 0) { + return tag; + } + + if (!tag) { + lastEmptyTag = ti; + return '#'; + } + + if (lastEmptyTag === ti - 1) { + return `${tag} `; + } + lastEmptyTag = -1; + + const validTag = `#${tag}`; + + return ( + + + {validTag} + + {separator} + + ); + }); + } + return `${word}${separator}`; + })} + + ); }; export const Text = forwardRef((props, ref) => { - const { className = '', style, innerRef, ...restProps } = props; + const { + className = '', + style, + innerRef, + children, + textWithTags, + ...restProps + } = props; + + if (textWithTags) { + return ( + + {children} + + ); + } + + return ( + + {children} + + ); +}); + +const TextWithTags = forwardRef((props, ref) => { + const { + className = '', + style, + innerRef, + children, + textWithTags, + ...restProps + } = props; + + const tags = useTags(); + return ( + > + {typeof children === 'string' ? ProcessText(children, tags) : children} + ); }); Text.displayName = 'Text'; +TextWithTags.displayName = 'TextWithTags'; diff --git a/packages/desktop-client/src/components/filters/FilterExpression.tsx b/packages/desktop-client/src/components/filters/FilterExpression.tsx index d649fd09c3f..34df7694a76 100644 --- a/packages/desktop-client/src/components/filters/FilterExpression.tsx +++ b/packages/desktop-client/src/components/filters/FilterExpression.tsx @@ -79,7 +79,8 @@ export function FilterExpression({ valueIsRaw={ op === 'contains' || op === 'matches' || - op === 'doesNotContain' + op === 'doesNotContain' || + op === 'hasTags' } /> diff --git a/packages/desktop-client/src/components/filters/FiltersMenu.jsx b/packages/desktop-client/src/components/filters/FiltersMenu.jsx index 61720dff6dd..e7579335c1e 100644 --- a/packages/desktop-client/src/components/filters/FiltersMenu.jsx +++ b/packages/desktop-client/src/components/filters/FiltersMenu.jsx @@ -192,7 +192,6 @@ function ConfigureField({ )} -
{type !== 'boolean' && ( void; filterId: SavedFilter; savedFilters: TransactionFilterEntity[]; - onConditionsOpChange: () => void; + onConditionsOpChange: (value: 'and' | 'or') => void; }) { return ( diff --git a/packages/desktop-client/src/components/filters/updateFilterReducer.ts b/packages/desktop-client/src/components/filters/updateFilterReducer.ts index 5cb3727367e..4f6286f8383 100644 --- a/packages/desktop-client/src/components/filters/updateFilterReducer.ts +++ b/packages/desktop-client/src/components/filters/updateFilterReducer.ts @@ -18,7 +18,8 @@ export function updateFilterReducer( action.op === 'matches' || action.op === 'is' || action.op === 'doesNotContain' || - action.op === 'isNot') + action.op === 'isNot' || + action.op === 'hasTags') ) { // Clear out the value if switching between contains or // is/oneof for the id or string type diff --git a/packages/desktop-client/src/components/modals/EditRule.jsx b/packages/desktop-client/src/components/modals/EditRule.jsx index b896846fcfe..c409c77f51a 100644 --- a/packages/desktop-client/src/components/modals/EditRule.jsx +++ b/packages/desktop-client/src/components/modals/EditRule.jsx @@ -94,7 +94,7 @@ export function FieldSelect({ fields, style, value, onChange }) { bare options={fields} value={value} - onChange={value => onChange('field', value)} + onChange={onChange} buttonStyle={{ color: theme.pageTextPositive }} /> @@ -116,7 +116,7 @@ export function OpSelect({ // TODO: Add matches op support for payees, accounts, categories. .filter(op => type === 'id' - ? !['contains', 'matches', 'doesNotContain'].includes(op) + ? !['contains', 'matches', 'doesNotContain', 'hasTags'].includes(op) : true, ) .map(op => [op, formatOp(op, type)]); @@ -260,7 +260,11 @@ function ConditionEditor({ return ( - + onChange('field', value)} + /> {valueEditor} @@ -373,7 +377,7 @@ function ActionEditor({ action, editorStyle, onChange, onDelete, onAdd }) { onChange('field', value)} /> @@ -838,7 +842,7 @@ export function EditRule({ defaultRule, onSave: originalOnSave }) { setStage(stage); } - function onChangeConditionsOp(name, value) { + function onChangeConditionsOp(value) { setConditionsOp(value); } diff --git a/packages/desktop-client/src/components/reports/reports/CustomReport.tsx b/packages/desktop-client/src/components/reports/reports/CustomReport.tsx index c0ed8630347..df09f6eed27 100644 --- a/packages/desktop-client/src/components/reports/reports/CustomReport.tsx +++ b/packages/desktop-client/src/components/reports/reports/CustomReport.tsx @@ -155,7 +155,7 @@ export function CustomReport() { !!conditions.find( ({ field, op }) => field === 'category' && - ['contains', 'doesNotContain', 'matches'].includes(op), + ['contains', 'doesNotContain', 'matches', 'hasTags'].includes(op), ) || conditions.filter(({ field }) => field === 'category').length >= 2; const setSelectedCategories = (newCategories: CategoryEntity[]) => { diff --git a/packages/desktop-client/src/components/rules/Value.tsx b/packages/desktop-client/src/components/rules/Value.tsx index 3d26c5a0b44..ae8d59e9b55 100644 --- a/packages/desktop-client/src/components/rules/Value.tsx +++ b/packages/desktop-client/src/components/rules/Value.tsx @@ -44,6 +44,8 @@ export function Value({ ...style, }; + const textWithTags = field === 'notes'; + const data = dataProp || (field === 'payee' @@ -116,7 +118,11 @@ export function Value({ } else if (value.length === 1) { return ( - [{formatValue(value[0])}] + [ + + {formatValue(value[0])} + + ] ); } @@ -130,7 +136,11 @@ export function Value({ [ {displayed.map((v, i) => { - const text = {formatValue(v)}; + const text = ( + + {formatValue(v)} + + ); let spacing; if (inline) { spacing = i !== 0 ? ' ' : ''; @@ -171,11 +181,20 @@ export function Value({ const { num1, num2 } = value; return ( - {formatValue(num1)} and{' '} - {formatValue(num2)} + + {formatValue(num1)} + {' '} + and{' '} + + {formatValue(num2)} + ); } else { - return {formatValue(value)}; + return ( + + {formatValue(value)} + + ); } } diff --git a/packages/desktop-client/src/components/table.tsx b/packages/desktop-client/src/components/table.tsx index c62d6111b69..78c29b4c2d6 100644 --- a/packages/desktop-client/src/components/table.tsx +++ b/packages/desktop-client/src/components/table.tsx @@ -24,11 +24,13 @@ import { useProperFocus, } from '../hooks/useProperFocus'; import { useSelectedItems } from '../hooks/useSelected'; +import { useTagPopover } from '../hooks/useTagPopover'; import { AnimatedLoading } from '../icons/AnimatedLoading'; import { SvgDelete, SvgExpandArrow } from '../icons/v0'; import { SvgCheckmark } from '../icons/v1'; import { type CSSProperties, styles, theme } from '../style'; +import { TagPopover } from './autocomplete/TagAutocomplete'; import { Button } from './common/Button'; import { Input } from './common/Input'; import { Menu, type MenuItem } from './common/Menu'; @@ -340,7 +342,11 @@ function InputValue({ } } - function onKeyDown(e) { + function onKeyDown(e: KeyboardEvent) { + if (props.onKeyDown) { + props.onKeyDown(e); + } + // Only enter and tab to escape (which allows the user to move // around) if (e.key !== 'Enter' && e.key !== 'Tab') { @@ -351,7 +357,7 @@ function InputValue({ if (value !== defaultValue) { setValue(defaultValue); } - } else if (shouldSaveFromKey(e)) { + } else if (!e.isDefaultPrevented() && shouldSaveFromKey(e)) { onUpdate?.(value); } } @@ -415,6 +421,55 @@ export function InputCell({ ); } +export function InputCellWithTags({ + inputProps, + onUpdate, + onBlur, + textAlign, + ...props +}: InputCellProps) { + const edit = useRef(); + const { + content, + setContent, + hint, + showAutocomplete, + setShowAutocomplete, + keyPressed, + setKeyPressed, + handleKeyUp, + handleKeyDown, + handleMenuSelect, + } = useTagPopover(props.value, edit); + + return ( + <> + + setKeyPressed(null)} + onClose={() => setShowAutocomplete(false)} + /> + + ); +} + function shouldSaveFromKey(e) { switch (e.key) { case 'Tab': @@ -1340,6 +1395,17 @@ export function useTableNavigator( } } + function closest(element: Element | null, selector: string): Element | null { + let el: Element | null = element; + + while (el && el.nodeType === 1) { + if (el.matches(selector)) return el; + el = el.parentElement || (el.parentNode as Element); + } + + return null; + } + function getNavigatorProps(userProps) { return { ...userProps, @@ -1402,8 +1468,9 @@ export function useTableNavigator( // input, and it will be refocused when the modal closes. const prevNumModals = modalStackLength.current; const numModals = store.getState().modals.modalStack.length; - + const parentWithAttr = closest(e.relatedTarget, '[data-keep-editing]'); if ( + !parentWithAttr && document.hasFocus() && (e.relatedTarget == null || !containerRef.current.contains(e.relatedTarget) || diff --git a/packages/desktop-client/src/components/transactions/TransactionList.jsx b/packages/desktop-client/src/components/transactions/TransactionList.jsx index 9b3eb7895fb..ad43582ebbf 100644 --- a/packages/desktop-client/src/components/transactions/TransactionList.jsx +++ b/packages/desktop-client/src/components/transactions/TransactionList.jsx @@ -1,9 +1,7 @@ import React, { useRef, useCallback, useLayoutEffect } from 'react'; import { useDispatch } from 'react-redux'; -import escapeRegExp from 'lodash/escapeRegExp'; - -import { pushModal } from 'loot-core/client/actions'; +import { getTags, pushModal } from 'loot-core/client/actions'; import { send } from 'loot-core/src/platform/client/fetch'; import { splitTransaction, @@ -103,6 +101,7 @@ export function TransactionList({ await saveDiff({ added: newTransactions }); onRefetch(); + dispatch(getTags()); }, []); const onSave = useCallback(async transaction => { @@ -120,6 +119,7 @@ export function TransactionList({ onChange(changes.newTransaction, changes.data); saveDiffAndApply(changes.diff, changes, onChange); } + dispatch(getTags()); } }, []); @@ -191,8 +191,8 @@ export function TransactionList({ const onNotesTagClick = useCallback(tag => { onApplyFilter({ field: 'notes', - op: 'matches', - value: `(^|\\s|\\w)${escapeRegExp(tag)}($|\\s|#)`, + op: 'hasTags', + value: tag, type: 'string', }); }); diff --git a/packages/desktop-client/src/components/transactions/TransactionsTable.jsx b/packages/desktop-client/src/components/transactions/TransactionsTable.jsx index 20ba4a19446..56c6696d416 100644 --- a/packages/desktop-client/src/components/transactions/TransactionsTable.jsx +++ b/packages/desktop-client/src/components/transactions/TransactionsTable.jsx @@ -10,6 +10,7 @@ import React, { useLayoutEffect, useEffect, } from 'react'; +import { TwitterPicker } from 'react-color'; import { useHotkeys } from 'react-hotkeys-hook'; import { useDispatch } from 'react-redux'; @@ -19,7 +20,7 @@ import { isValid as isDateValid, } from 'date-fns'; -import { pushModal } from 'loot-core/client/actions'; +import { pushModal, updateTags } from 'loot-core/client/actions'; import { useCachedSchedules } from 'loot-core/src/client/data-hooks/schedules'; import { getAccountsById, @@ -46,10 +47,12 @@ import { titleFirst, } from 'loot-core/src/shared/util'; +import { TAGCOLORS } from '../../../../loot-core/src/shared/tag'; import { useMergedRefs } from '../../hooks/useMergedRefs'; import { usePrevious } from '../../hooks/usePrevious'; import { useSelectedDispatch, useSelectedItems } from '../../hooks/useSelected'; import { useSplitsExpanded } from '../../hooks/useSplitsExpanded'; +import { useTags } from '../../hooks/useTags'; import { SvgLeftArrow2, SvgRightArrow2, SvgSplit } from '../../icons/v0'; import { SvgArrowDown, SvgArrowUp, SvgCheveronDown } from '../../icons/v1'; import { @@ -57,7 +60,7 @@ import { SvgCalendar, SvgHyperlink2, } from '../../icons/v2'; -import { styles, theme } from '../../style'; +import { styles, theme, useTheme } from '../../style'; import { AccountAutocomplete } from '../autocomplete/AccountAutocomplete'; import { CategoryAutocomplete } from '../autocomplete/CategoryAutocomplete'; import { PayeeAutocomplete } from '../autocomplete/PayeeAutocomplete'; @@ -81,6 +84,7 @@ import { useTableNavigator, Table, UnexposedCellContent, + InputCellWithTags, } from '../table'; function getDisplayValue(obj, name) { @@ -1256,7 +1260,7 @@ const Transaction = memo(function Transaction({ /> ))()} - notesTagFormatter(value, onNotesTagClick)} + onEdit={onEdit} + onNotesTagClick={onNotesTagClick} onExpose={name => !isPreview && onEdit(id, name)} inputProps={{ - value: notes || '', onUpdate: onUpdate.bind(null, 'notes'), }} /> @@ -2495,8 +2499,89 @@ export const TransactionTable = forwardRef((props, ref) => { TransactionTable.displayName = 'TransactionTable'; -function notesTagFormatter(notes, onNotesTagClick) { +function NotesCell({ + onEdit, + onNotesTagClick, + inputProps, + onUpdate, + onBlur, + textAlign, + ...props +}) { + const [showColors, setShowColors] = useState(false); + const triggerRef = useRef(null); + const [selectedItem, setSelectedItem] = useState(null); + const [pickerPosition, setPickerPosition] = useState({ top: 0, left: 0 }); + const dispatch = useDispatch(); + const [theme] = useTheme(); + + const handleContextMenu = (e, item) => { + e.preventDefault(); + e.stopPropagation(); + setSelectedItem(item); + + const rect = e.currentTarget.getBoundingClientRect(); + setPickerPosition({ + top: rect.bottom, // Position the picker right below the selected item + left: + e.currentTarget.getBoundingClientRect().left - + e.currentTarget.parentElement.getBoundingClientRect().left, // Align the picker with the left side of the selected item + }); + + setShowColors(true); + }; + + return ( + <> +
+ + NotesTagFormatter(value, onNotesTagClick, (e, item) => + handleContextMenu(e, item), + ) + } + > + {() => ( + + )} + + {showColors && selectedItem && ( + setShowColors(isOpen)} + placement="bottom start" + offset={10} + style={{ marginLeft: pickerPosition.left }} + > + { + selectedItem.color = newColor.hex; + dispatch(updateTags(selectedItem)); + }} + onChangeComplete={() => { + setShowColors(false); + onEdit(null); + }} + /> + + )} + + ); +} +function NotesTagFormatter(notes, onNotesTagClick, onContextMenu) { + const tags = useTags(); const words = notes.split(' '); + return ( <> {words.map((word, i, arr) => { @@ -2522,21 +2607,43 @@ function notesTagFormatter(notes, onNotesTagClick) { lastEmptyTag = -1; const validTag = `#${tag}`; + const filteredTags = tags.filter(t => t.tag === validTag); + return ( - + { + if (onContextMenu) { + onContextMenu(e, filteredTags[0]); + } + }} + >