From 6e0d705dd0263b998a56a93701517d7d05c380f5 Mon Sep 17 00:00:00 2001 From: Leandro Menezes Date: Wed, 12 Jun 2024 12:12:46 -0300 Subject: [PATCH 01/39] proof of concept of wysiwyg for input tags --- package.json | 5 +- .../src/components/common/Input.tsx | 60 ++++++++++++++----- .../src/components/common/Text.tsx | 30 +++++++++- .../components/filters/FilterExpression.tsx | 4 +- .../src/components/filters/FiltersMenu.jsx | 3 +- .../components/filters/updateFilterReducer.ts | 3 +- .../src/components/modals/EditRule.jsx | 7 ++- .../loot-core/src/server/accounts/rules.ts | 20 ++++++- .../src/server/accounts/transaction-rules.ts | 10 ++++ packages/loot-core/src/shared/rules.ts | 22 ++++++- packages/loot-core/src/types/models/rule.d.ts | 3 +- yarn.lock | 15 ++++- 12 files changed, 152 insertions(+), 30 deletions(-) diff --git a/package.json b/package.json index 0a12853c8c9..04e71f44d7a 100644 --- a/package.json +++ b/package.json @@ -69,5 +69,8 @@ "browserslist": [ "electron 24.0", "defaults" - ] + ], + "dependencies": { + "react-contenteditable": "^3.3.7" + } } diff --git a/packages/desktop-client/src/components/common/Input.tsx b/packages/desktop-client/src/components/common/Input.tsx index 317c23a7616..b79918e7667 100644 --- a/packages/desktop-client/src/components/common/Input.tsx +++ b/packages/desktop-client/src/components/common/Input.tsx @@ -2,13 +2,14 @@ import React, { type InputHTMLAttributes, type KeyboardEvent, type Ref, - useRef, + useEffect, + useState, + useCallback, } from 'react'; +import ContentEditable from 'react-contenteditable'; import { css } from 'glamor'; -import { useMergedRefs } from '../../hooks/useMergedRefs'; -import { useProperFocus } from '../../hooks/useProperFocus'; import { type CSSProperties, styles, theme } from '../../style'; export const defaultInputStyle = { @@ -21,14 +22,15 @@ export const defaultInputStyle = { border: '1px solid ' + theme.formInputBorder, }; -type InputProps = InputHTMLAttributes & { +type InputProps = InputHTMLAttributes & { style?: CSSProperties; - inputRef?: Ref; - onEnter?: (event: KeyboardEvent) => void; - onEscape?: (event: KeyboardEvent) => void; + inputRef?: Ref; + onEnter?: (event: KeyboardEvent) => void; + onEscape?: (event: KeyboardEvent) => void; onChangeValue?: (newValue: string) => void; onUpdate?: (newValue: string) => void; focused?: boolean; + value?: string; }; export function Input({ @@ -39,16 +41,43 @@ export function Input({ onChangeValue, onUpdate, focused, + value = '', ...nativeProps }: InputProps) { - const ref = useRef(null); - useProperFocus(ref, focused); + const [content, setContent] = useState(value); - const mergedRef = useMergedRefs(ref, inputRef); + const onContentChange = useCallback( + evt => { + let updatedContent = evt.currentTarget.innerText; + //just a hack to make it work without mega refactory. should use evt.currentTarget.innerText where needed + evt.currentTarget.value = updatedContent; + + updatedContent = generateTags(updatedContent); + + setContent(updatedContent); + onChangeValue?.(evt.currentTarget.innerText); + }, + [onChangeValue], + ); + + const generateTags = text => { + return text.replace(/(#\w+)(?=\s|$)/g, (match, p1) => { + return `${p1}`; + }); + }; + + useEffect(() => { + if (value !== '') { + setContent(generateTags(value)); + } + },[value]); return ( - { - onUpdate?.(e.target.value); + onUpdate?.(e.target.innerText); + onContentChange(e); nativeProps.onBlur?.(e); }} onChange={e => { - onChangeValue?.(e.target.value); - nativeProps.onChange?.(e); + onContentChange(e); }} + html={content} /> ); } diff --git a/packages/desktop-client/src/components/common/Text.tsx b/packages/desktop-client/src/components/common/Text.tsx index 71d5be4c00e..f882033a57d 100644 --- a/packages/desktop-client/src/components/common/Text.tsx +++ b/packages/desktop-client/src/components/common/Text.tsx @@ -16,14 +16,40 @@ type TextProps = HTMLProps & { style?: CSSProperties; }; +const processText = (text: string): ReactNode => { + const tagRegex = /(#\w+)(?=\s|$)/g; + + const processedText = text.split(tagRegex).map((part, index) => { + if (tagRegex.test(part)) { + return ( + + {part} + + ); + } + return part; + }); + + return processedText; +}; + export const Text = forwardRef((props, ref) => { - const { className = '', style, innerRef, ...restProps } = props; + const { className = '', style, innerRef, children, ...restProps } = props; return ( + > + {typeof children === 'string' ? processText(children) : children} + ); }); diff --git a/packages/desktop-client/src/components/filters/FilterExpression.tsx b/packages/desktop-client/src/components/filters/FilterExpression.tsx index e72a657cb22..d76ed685187 100644 --- a/packages/desktop-client/src/components/filters/FilterExpression.tsx +++ b/packages/desktop-client/src/components/filters/FilterExpression.tsx @@ -76,7 +76,9 @@ export function FilterExpression({ value={value} field={field} inline={true} - valueIsRaw={op === 'contains' || op === 'doesNotContain'} + valueIsRaw={ + op === 'contains' || op === 'doesNotContain' || op === 'tags' + } /> )} diff --git a/packages/desktop-client/src/components/filters/FiltersMenu.jsx b/packages/desktop-client/src/components/filters/FiltersMenu.jsx index b8b79edaee9..c78fecf67b8 100644 --- a/packages/desktop-client/src/components/filters/FiltersMenu.jsx +++ b/packages/desktop-client/src/components/filters/FiltersMenu.jsx @@ -200,7 +200,8 @@ function ConfigureField({ field={field} subfield={subfield} type={ - type === 'id' && (op === 'contains' || op === 'doesNotContain') + type === 'id' && + (op === 'contains' || op === 'doesNotContain' || op === 'tags') ? 'string' : type } diff --git a/packages/desktop-client/src/components/filters/updateFilterReducer.ts b/packages/desktop-client/src/components/filters/updateFilterReducer.ts index 8bbca3ce850..a38c545534e 100644 --- a/packages/desktop-client/src/components/filters/updateFilterReducer.ts +++ b/packages/desktop-client/src/components/filters/updateFilterReducer.ts @@ -14,7 +14,8 @@ export function updateFilterReducer( (action.op === 'contains' || action.op === 'is' || action.op === 'doesNotContain' || - action.op === 'isNot') + action.op === 'isNot' || + action.op === 'tags') ) { // 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 6387eaa11c2..c542329205f 100644 --- a/packages/desktop-client/src/components/modals/EditRule.jsx +++ b/packages/desktop-client/src/components/modals/EditRule.jsx @@ -106,7 +106,9 @@ export function OpSelect({ // We don't support the `contains` operator for the id type for // rules yet if (type === 'id') { - ops = ops.filter(op => op !== 'contains' && op !== 'doesNotContain'); + ops = ops.filter( + op => (op !== 'contains' && op !== 'doesNotContain') || op !== 'tags', + ); line = ops.length / 2; } if (type === 'string') { @@ -548,8 +550,7 @@ function ConditionsList({ // clear the value if ( cond.op !== 'oneOf' && - cond.op !== 'notOneOf' && - (op === 'oneOf' || op === 'notOneOf') + cond.op !== 'notOneOf'(op === 'oneOf' || op === 'notOneOf') ) { return newInput( makeValue(cond.value != null ? [cond.value] : [], { diff --git a/packages/loot-core/src/server/accounts/rules.ts b/packages/loot-core/src/server/accounts/rules.ts index d164c53091f..c0d103fd086 100644 --- a/packages/loot-core/src/server/accounts/rules.ts +++ b/packages/loot-core/src/server/accounts/rules.ts @@ -138,7 +138,15 @@ const CONDITION_TYPES = { }, }, string: { - ops: ['is', 'contains', 'oneOf', 'isNot', 'doesNotContain', 'notOneOf'], + ops: [ + 'is', + 'contains', + 'oneOf', + 'isNot', + 'doesNotContain', + 'notOneOf', + 'tags', + ], nullable: true, parse(op, value, fieldName) { if (op === 'oneOf' || op === 'notOneOf') { @@ -152,7 +160,7 @@ const CONDITION_TYPES = { return value.filter(Boolean).map(val => val.toLowerCase()); } - if (op === 'contains' || op === 'doesNotContain') { + if (op === 'contains' || op === 'doesNotContain' || op === 'tags') { assert( typeof value === 'string' && value.length > 0, 'no-empty-string', @@ -362,6 +370,13 @@ export class Condition { return false; } return this.value.indexOf(fieldValue) !== -1; + + case 'tags': + if (fieldValue === null) { + return false; + } + return fieldValue.indexOf(this.value) !== -1; + case 'notOneOf': if (fieldValue === null) { return false; @@ -804,6 +819,7 @@ const OP_SCORES: Record = { isNot: 10, oneOf: 9, notOneOf: 9, + tags: 9, isapprox: 5, isbetween: 5, gt: 1, diff --git a/packages/loot-core/src/server/accounts/transaction-rules.ts b/packages/loot-core/src/server/accounts/transaction-rules.ts index 3119cdd69c0..60218852831 100644 --- a/packages/loot-core/src/server/accounts/transaction-rules.ts +++ b/packages/loot-core/src/server/accounts/transaction-rules.ts @@ -467,6 +467,16 @@ export function conditionsToAQL(conditions, { recurDateBounds = 100 } = {}) { return { id: null }; } return { $or: values.map(v => apply(field, '$eq', v)) }; + + case 'tags': + const tagValues = value + .split(/(\s+)/) + .filter(tag => tag.startsWith('#')); + + return { + $and: tagValues.map(v => apply(field, '$like', '%' + v + '%')), + }; + case 'notOneOf': const notValues = value; if (notValues.length === 0) { diff --git a/packages/loot-core/src/shared/rules.ts b/packages/loot-core/src/shared/rules.ts index c54b8723a3a..0b917b03013 100644 --- a/packages/loot-core/src/shared/rules.ts +++ b/packages/loot-core/src/shared/rules.ts @@ -9,7 +9,15 @@ export const TYPE_INFO = { nullable: false, }, id: { - ops: ['is', 'contains', 'oneOf', 'isNot', 'doesNotContain', 'notOneOf'], + ops: [ + 'is', + 'contains', + 'oneOf', + 'isNot', + 'doesNotContain', + 'notOneOf', + 'tags', + ], nullable: true, }, saved: { @@ -17,7 +25,15 @@ export const TYPE_INFO = { nullable: false, }, string: { - ops: ['is', 'contains', 'oneOf', 'isNot', 'doesNotContain', 'notOneOf'], + ops: [ + 'is', + 'contains', + 'oneOf', + 'isNot', + 'doesNotContain', + 'notOneOf', + 'tags', + ], nullable: true, }, number: { @@ -91,6 +107,8 @@ export function friendlyOp(op, type?) { return 'is between'; case 'contains': return 'contains'; + case 'tags': + return 'has tags'; case 'doesNotContain': return 'does not contain'; case 'gt': diff --git a/packages/loot-core/src/types/models/rule.d.ts b/packages/loot-core/src/types/models/rule.d.ts index 026aef47a25..18804d14139 100644 --- a/packages/loot-core/src/types/models/rule.d.ts +++ b/packages/loot-core/src/types/models/rule.d.ts @@ -24,7 +24,8 @@ export type RuleConditionOp = | 'lt' | 'lte' | 'contains' - | 'doesNotContain'; + | 'doesNotContain' + | 'tags'; export interface RuleConditionEntity { field?: string; diff --git a/yarn.lock b/yarn.lock index b8b91f59e6f..3b39f19e34c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6296,6 +6296,7 @@ __metadata: node-jq: "npm:^4.0.1" npm-run-all: "npm:^4.1.5" prettier: "npm:3.2.4" + react-contenteditable: "npm:^3.3.7" source-map-support: "npm:^0.5.21" typescript: "npm:^5.0.2" typescript-strict-plugin: "npm:^2.2.2-beta.2" @@ -15183,7 +15184,7 @@ __metadata: languageName: node linkType: hard -"prop-types@npm:^15.0.0, prop-types@npm:^15.5.10, prop-types@npm:^15.6.2, prop-types@npm:^15.7.2, prop-types@npm:^15.8.1": +"prop-types@npm:^15.0.0, prop-types@npm:^15.5.10, prop-types@npm:^15.6.2, prop-types@npm:^15.7.1, prop-types@npm:^15.7.2, prop-types@npm:^15.8.1": version: 15.8.1 resolution: "prop-types@npm:15.8.1" dependencies: @@ -15382,6 +15383,18 @@ __metadata: languageName: node linkType: hard +"react-contenteditable@npm:^3.3.7": + version: 3.3.7 + resolution: "react-contenteditable@npm:3.3.7" + dependencies: + fast-deep-equal: "npm:^3.1.3" + prop-types: "npm:^15.7.1" + peerDependencies: + react: ">=16.3" + checksum: f34b77cdd57f44eab0fe48a1f56ed957b2f901ed990f7d96e46eaf00b18c58445fb3a1fccf997d4950562cbd7a9dd505dfe6cb9c1b1c8027f12d15d73d87ebfb + languageName: node + linkType: hard + "react-dnd-html5-backend@npm:^16.0.1": version: 16.0.1 resolution: "react-dnd-html5-backend@npm:16.0.1" From 158a77f05e4456e003d5cc7fb4d6bdbe5e4e586d Mon Sep 17 00:00:00 2001 From: Leandro Menezes Date: Fri, 16 Aug 2024 09:52:11 -0300 Subject: [PATCH 02/39] more features --- package.json | 2 + packages/desktop-client/package.json | 4 + .../autocomplete/TagAutocomplete.tsx | 138 ++++++++ .../src/components/common/Input.tsx | 62 +--- .../components/common/InputWithContent.tsx | 6 +- .../src/components/common/InputWithTags.tsx | 303 ++++++++++++++++++ .../src/components/common/Search.tsx | 1 + .../src/components/common/Text.tsx | 118 ++++++- .../src/components/modals/EditRule.jsx | 2 +- .../src/components/rules/Value.tsx | 10 +- .../desktop-client/src/components/table.tsx | 137 +++++++- .../transactions/TransactionList.jsx | 8 +- .../transactions/TransactionsTable.jsx | 155 ++++++++- .../src/components/util/GenericInput.jsx | 5 +- packages/desktop-client/src/hooks/useTags.ts | 21 ++ .../migrations/1723562367412_tags.sql | 12 + packages/loot-core/package.json | 2 + .../loot-core/src/client/actions/queries.ts | 18 ++ packages/loot-core/src/client/constants.ts | 1 + .../loot-core/src/client/reducers/queries.ts | 8 + .../src/client/state-types/queries.d.ts | 10 +- .../src/server/accounts/transaction-rules.ts | 11 +- packages/loot-core/src/server/db/index.ts | 62 ++++ packages/loot-core/src/server/main.ts | 9 + packages/loot-core/src/shared/rules.ts | 2 +- packages/loot-core/src/shared/tag.ts | 80 +++++ packages/loot-core/src/types/models/tag.d.ts | 12 + .../loot-core/src/types/server-handlers.d.ts | 5 + yarn.lock | 148 ++++++++- 29 files changed, 1254 insertions(+), 98 deletions(-) create mode 100644 packages/desktop-client/src/components/autocomplete/TagAutocomplete.tsx create mode 100644 packages/desktop-client/src/components/common/InputWithTags.tsx create mode 100644 packages/desktop-client/src/hooks/useTags.ts create mode 100644 packages/loot-core/migrations/1723562367412_tags.sql create mode 100644 packages/loot-core/src/shared/tag.ts create mode 100644 packages/loot-core/src/types/models/tag.d.ts diff --git a/package.json b/package.json index f050d473b8b..937c399b323 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "prepare": "husky" }, "devDependencies": { + "@types/react-color": "^2", "cross-env": "^7.0.3", "eslint": "^8.37.0", "eslint-config-prettier": "^9.1.0", @@ -77,6 +78,7 @@ "defaults" ], "dependencies": { + "react-color": "^2.19.3", "react-contenteditable": "^3.3.7" } } diff --git a/packages/desktop-client/package.json b/packages/desktop-client/package.json index d7a7de8623b..3732ad7bda6 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-modal": "^3.16.0", "@types/react-redux": "^7.1.25", @@ -89,5 +90,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/autocomplete/TagAutocomplete.tsx b/packages/desktop-client/src/components/autocomplete/TagAutocomplete.tsx new file mode 100644 index 00000000000..9914bb5047d --- /dev/null +++ b/packages/desktop-client/src/components/autocomplete/TagAutocomplete.tsx @@ -0,0 +1,138 @@ +import { useTags } from '../../hooks/useTags'; +import { getNormalisedString } from 'loot-core/shared/normalisation'; +import { View } from '../common/View'; +import { useEffect, useRef, useState } from 'react'; +import { Button } from '../common/Button2'; +import { theme } from '../../style'; +import { CompactPicker, SketchPicker } from 'react-color'; +import { useDispatch } from 'react-redux'; +import { updateTags } from 'loot-core/client/actions'; + +export function TagAutocomplete({ onMenuSelect, hint, clickedOnIt }) { + const tags = useTags(); + const [suggestions, setSuggestions] = useState([]); + + 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 => { + return { + ...tag, + name: tag.tag, + }; + }), + ); + } + }, [tags, hint]); + + return ( + + ); +} + +function TagList({ items, onMenuSelect, tags, clickedOnIt }) { + 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 handleContextMenu = (e, item) => { + e.preventDefault(); + e.stopPropagation(); + setSelectedItem(item); + + // Calculate position of the clicked item + const rect = e.currentTarget.getBoundingClientRect(); + setPickerPosition({ + top: rect.bottom, // Position the picker right below the selected item + left: rect.left, // Align the picker with the left side of the selected item + }); + + setShowColors(true); + }; + + return ( + + {items.length === 0 && ( + + {tags.length === 0 && ( + No tags found. Tags will be added automatically + )} + {tags.length > 0 && No tags found with this terms} + + )} + {items.map(item => ( + handleContextMenu(e, item)} + > + + + ))} + {showColors && selectedItem && ( + + { + selectedItem.color = newColor.hex; + dispatch(updateTags(selectedItem)); + }} + onChangeComplete={color => { + setShowColors(false); + }} + /> + + )} + + ); +} diff --git a/packages/desktop-client/src/components/common/Input.tsx b/packages/desktop-client/src/components/common/Input.tsx index b79918e7667..4fb622690fd 100644 --- a/packages/desktop-client/src/components/common/Input.tsx +++ b/packages/desktop-client/src/components/common/Input.tsx @@ -2,14 +2,13 @@ import React, { type InputHTMLAttributes, type KeyboardEvent, type Ref, - useEffect, - useState, - useCallback, + useRef, } from 'react'; -import ContentEditable from 'react-contenteditable'; import { css } from 'glamor'; +import { useMergedRefs } from '../../hooks/useMergedRefs'; +import { useProperFocus } from '../../hooks/useProperFocus'; import { type CSSProperties, styles, theme } from '../../style'; export const defaultInputStyle = { @@ -22,15 +21,14 @@ export const defaultInputStyle = { border: '1px solid ' + theme.formInputBorder, }; -type InputProps = InputHTMLAttributes & { +type InputProps = InputHTMLAttributes & { style?: CSSProperties; - inputRef?: Ref; - onEnter?: (event: KeyboardEvent) => void; - onEscape?: (event: KeyboardEvent) => void; + inputRef?: Ref; + onEnter?: (event: KeyboardEvent) => void; + onEscape?: (event: KeyboardEvent) => void; onChangeValue?: (newValue: string) => void; onUpdate?: (newValue: string) => void; focused?: boolean; - value?: string; }; export function Input({ @@ -41,43 +39,16 @@ export function Input({ onChangeValue, onUpdate, focused, - value = '', ...nativeProps }: InputProps) { - const [content, setContent] = useState(value); + const ref = useRef(null); + useProperFocus(ref, focused); - const onContentChange = useCallback( - evt => { - let updatedContent = evt.currentTarget.innerText; - //just a hack to make it work without mega refactory. should use evt.currentTarget.innerText where needed - evt.currentTarget.value = updatedContent; - - updatedContent = generateTags(updatedContent); - - setContent(updatedContent); - onChangeValue?.(evt.currentTarget.innerText); - }, - [onChangeValue], - ); - - const generateTags = text => { - return text.replace(/(#\w+)(?=\s|$)/g, (match, p1) => { - return `${p1}`; - }); - }; - - useEffect(() => { - if (value !== '') { - setContent(generateTags(value)); - } - },[value]); + const mergedRef = useMergedRefs(ref, inputRef); return ( - { - onUpdate?.(e.target.innerText); - onContentChange(e); + onUpdate?.(e.target.value); nativeProps.onBlur?.(e); }} onChange={e => { - onContentChange(e); + onChangeValue?.(e.target.value); + nativeProps.onChange?.(e); }} - html={content} /> ); } @@ -132,4 +102,4 @@ export function BigInput(props: InputProps) { }} /> ); -} +} \ No newline at end of file diff --git a/packages/desktop-client/src/components/common/InputWithContent.tsx b/packages/desktop-client/src/components/common/InputWithContent.tsx index ca8007f9b53..cf6d5195554 100644 --- a/packages/desktop-client/src/components/common/InputWithContent.tsx +++ b/packages/desktop-client/src/components/common/InputWithContent.tsx @@ -4,6 +4,7 @@ import { type CSSProperties, theme } from '../../style'; import { Input, defaultInputStyle } from './Input'; import { View } from './View'; +import { InputWithTags } from './InputWithTags'; type InputWithContentProps = ComponentProps & { leftContent?: ReactNode; @@ -12,6 +13,7 @@ type InputWithContentProps = ComponentProps & { focusStyle?: CSSProperties; style?: CSSProperties; getStyle?: (focused: boolean) => CSSProperties; + inputWithTags?: boolean; }; export function InputWithContent({ leftContent, @@ -20,9 +22,11 @@ export function InputWithContent({ focusStyle, style, getStyle, + inputWithTags, ...props }: InputWithContentProps) { const [focused, setFocused] = useState(props.focused ?? false); + const InputType: React.ElementType = inputWithTags ? InputWithTags: Input; return ( {leftContent} - & { + style?: CSSProperties; + inputRef?: Ref; + onEnter?: (event: KeyboardEvent) => void; + onEscape?: (event: KeyboardEvent) => void; + onChangeValue?: (newValue: string) => void; + onUpdate?: (newValue: string) => void; + focused?: boolean; + value?: string; +}; + +export function InputWithTags({ + style, + inputRef, + onEnter, + onEscape, + onChangeValue, + onUpdate, + focused, + value = '', + ...nativeProps +}: InputWithTagsProps) { + const tags = useTags(); + const generateTags = text => { + return text.replace(/(#\w[\w-]*)(?=\s|$|\#)/g, (match, p1) => { + const filteredTags = tags.filter(t => t.tag == p1); + + return renderToStaticMarkup( + 0 + ? filteredTags[0].color ?? theme.noteTagBackground + : theme.noteTagBackground, + color: filteredTags[0].textColor ?? theme.noteTagText, + cursor: 'pointer', + }} + > + {p1} + , + ); + }); + }; + const [content, setContent] = useState( + nativeProps.defaultValue + ? generateTags(nativeProps.defaultValue) + : value || '', + ); + const [showAutocomplete, setShowAutocomplete] = useState(false); + const triggerRef = useRef(null); + const edit = useRef(null); + const [hint, setHint] = useState(''); + + useEffect(() => { + if (content) { + if (onChangeValue) { + onChangeValue?.(edit.current?.textContent); + } + + if (onUpdate) { + onUpdate?.(edit.current?.textContent); + } + updateHint(); + } + }, [content]); + + const onContentChange = useCallback( + evt => { + let updatedContent = evt.currentTarget.innerText; + updatedContent = updatedContent.replace('\r\n', '').replace('\n', ''); + //just a hack to make it work without mega refactory. should use evt.currentTarget.innerText where needed + evt.currentTarget.value = updatedContent; + + updatedContent = generateTags(updatedContent); + + setContent(updatedContent); + onChangeValue?.(evt.currentTarget.innerText); + }, + [onChangeValue], + ); + + const handleSetCursorPosition = () => { + const el = edit.current; + if (!el) return; + + const range = document.createRange(); + const selection = window.getSelection(); + + range.selectNodeContents(el); // Select the entire content + range.collapse(false); // Collapse the range to the end point (cursor at the end) + + selection.removeAllRanges(); + selection.addRange(range); + }; + + const updateHint = () => { + const el = edit.current; + if (!el) return; + + const cursorPosition = getCaretPosition(el); + const textBeforeCursor = el.textContent.slice(0, cursorPosition); + + const lastHashIndex = textBeforeCursor.lastIndexOf('#'); + if (lastHashIndex === -1) { + setHint(''); + return; + } + + const newHint = textBeforeCursor.slice(lastHashIndex + 1, cursorPosition); + setHint(newHint); + }; + + useEffect(() => { + if (value !== '') { + setContent(generateTags(value)); + } else { + setContent(''); + setHint(''); + } + }, [value]); + + return ( + + { + nativeProps.onKeyDown?.(e); + + if (e.key === 'Enter' && onEnter) { + onEnter(e); + setShowAutocomplete(false); + e.preventDefault(); + e.stopPropagation(); + } + + if (e.key === 'Escape' && onEscape) { + onEscape(e); + setShowAutocomplete(false); + } + + if (e.key === ' ') { + setShowAutocomplete(false); + } + + if (e.key === '#') { + setShowAutocomplete(true); + } + + if (e.key === 'Backspace') { + if (!edit.current.textContent.includes('#')) { + setShowAutocomplete(false); + } + } + + if (showAutocomplete) { + updateHint(); + } + + onContentChange(e); + }} + onBlur={e => { + onUpdate?.(e.target.innerText); + onContentChange(e); + nativeProps.onBlur?.(e); + }} + onInput={e => { + onContentChange(e); + + if (showAutocomplete) { + updateHint(); // Update hint after the content is modified + } + }} + onChange={e => { + onContentChange(e); + }} + html={content} + /> + true} + placement="bottom start" + > + setShowAutocomplete(false)} + onMenuSelect={item => { + setShowAutocomplete(false); + const el = edit.current; + const cursorPosition = getCaretPosition(el); + const textBeforeCursor = el.textContent.slice(0, cursorPosition); + + const lastHashIndex = textBeforeCursor.lastIndexOf('#'); + if (lastHashIndex === -1) { + return; + } + + // Replace the hint with the selected tag + const newContent = generateTags( + textBeforeCursor.slice(0, lastHashIndex) + + item?.tag + + el.textContent.slice(cursorPosition), + ); + + setContent(generateTags(newContent)); + handleSetCursorPosition(); // Move cursor to the end of the new content + }} + /> + + + ); +} + +export function BigInputWithTags(props: InputWithTagsProps) { + return ( + + ); +} + +export function getCaretPosition(editableDiv) { + const selection = window.getSelection(); + let caretPos = 0; + + if (selection.rangeCount) { + const range = selection.getRangeAt(0); + const preCaretRange = range.cloneRange(); + preCaretRange.selectNodeContents(editableDiv); + preCaretRange.setEnd(range.endContainer, range.endOffset); + caretPos = preCaretRange.toString().length; + } + + return caretPos; +} 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): ReactNode => { - const tagRegex = /(#\w+)(?=\s|$)/g; +const processText = (text: string, tags: TagEntity[]): ReactNode => { + const [tagColors, setTagColors] = useState>(new Map()); + const [tagTextColors, setTagTextColors] = useState(new Map()); - const processedText = text.split(tagRegex).map((part, index) => { - if (tagRegex.test(part)) { + useEffect(() => { + const map = new Map(); + const mapTextColor = new Map(); + + text.split(TAGREGEX).forEach(part => { + if (TAGREGEX.test(part)) { + const filteredTags = tags.filter(t => t.tag == part); + if (filteredTags.length > 0) { + map.set(part, filteredTags[0].color ?? theme.noteTagBackground); + mapTextColor.set( + part, + filteredTags[0].textColor ?? theme.noteTagText, + ); + } else { + map.set(part, theme.noteTagBackground); + mapTextColor.set(part, theme.noteTagText); + } + } + }); + + setTagColors(map); + setTagTextColors(mapTextColor); + }, [tags]); + + const processedText = text.split(TAGREGEX).map((part, index) => { + if (TAGREGEX.test(part)) { return ( - {part} + + {part} + ); } @@ -41,16 +89,64 @@ const processText = (text: string): ReactNode => { }; export const Text = forwardRef((props, ref) => { - const { className = '', style, innerRef, children, ...restProps } = props; + const { + className = '', + style, + innerRef, + children, + textWithTags, + ...restProps + } = props; + + if (textWithTags) { + return ( + + {children} + + ); + } + return ( - {typeof children === 'string' ? processText(children) : children} + {children} ); }); +export 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'; diff --git a/packages/desktop-client/src/components/modals/EditRule.jsx b/packages/desktop-client/src/components/modals/EditRule.jsx index 48ed3a0f0e2..9cc87681bb3 100644 --- a/packages/desktop-client/src/components/modals/EditRule.jsx +++ b/packages/desktop-client/src/components/modals/EditRule.jsx @@ -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', 'tags'].includes(op) : true, ) .map(op => [op, formatOp(op, type)]); diff --git a/packages/desktop-client/src/components/rules/Value.tsx b/packages/desktop-client/src/components/rules/Value.tsx index 3d26c5a0b44..27a726eca48 100644 --- a/packages/desktop-client/src/components/rules/Value.tsx +++ b/packages/desktop-client/src/components/rules/Value.tsx @@ -116,7 +116,7 @@ export function Value({ } else if (value.length === 1) { return ( - [{formatValue(value[0])}] + [{formatValue(value[0])}] ); } @@ -130,7 +130,7 @@ export function Value({ [ {displayed.map((v, i) => { - const text = {formatValue(v)}; + const text = {formatValue(v)}; let spacing; if (inline) { spacing = i !== 0 ? ' ' : ''; @@ -171,11 +171,11 @@ 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 7a7002410bd..3f722eb1282 100644 --- a/packages/desktop-client/src/components/table.tsx +++ b/packages/desktop-client/src/components/table.tsx @@ -43,6 +43,7 @@ import { import { type Binding } from './spreadsheet'; import { type FormatType, useFormat } from './spreadsheet/useFormat'; import { useSheetValue } from './spreadsheet/useSheetValue'; +import { TagAutocomplete } from './autocomplete/TagAutocomplete'; export const ROW_HEIGHT = 32; @@ -349,6 +350,10 @@ function InputValue({ } else if (shouldSaveFromKey(e)) { onUpdate?.(value); } + + if (props.onKeyDown) { + props.onKeyDown(e); + } } const ops = ['+', '-', '*', '/', '^']; @@ -398,18 +403,133 @@ export function InputCell({ return ( {() => ( - + <> + + )} ); } +export function InputCellWithTags({ + inputProps, + onUpdate, + onBlur, + textAlign, + ...props +}: InputCellProps) { + const [showAutocomplete, setShowAutocomplete] = useState(false); + const [hint, setHint] = useState(''); + const edit = useRef(null); + const [content, setContent] = useState(props.value); + + useEffect(() => { + if (content !== undefined) { + if (onUpdate) { + onUpdate?.(edit.current?.value); + } + updateHint(content); + } + }, [content]); + + const getCaretPosition = element => { + if (element) { + const caretPosition = element.selectionStart; + return caretPosition; + } + + return 0; + }; + + const handleSetCursorPosition = () => { + const el = edit.current; + if (!el) return; + + const range = document.createRange(); + const selection = window.getSelection(); + + range.selectNodeContents(el); // Select the entire content + range.collapse(false); // Collapse the range to the end point (cursor at the end) + + selection.removeAllRanges(); + selection.addRange(range); + }; + + const updateHint = newValue => { + const el = edit.current; + if (!el) return; + + const cursorPosition = getCaretPosition(el); + const textBeforeCursor = el.value.slice(0, cursorPosition); + + const lastHashIndex = textBeforeCursor.lastIndexOf('#'); + if (lastHashIndex === -1) { + setHint(''); + return; + } + + const newHint = textBeforeCursor.slice(lastHashIndex + 1, cursorPosition); + setHint(newHint); + }; + + return ( + + { + updateHint(newValue); + }} + onUpdate={onUpdate} + onBlur={onBlur} + style={{ textAlign, ...(inputProps && inputProps.style) }} + onKeyDown={e => { + if (e.key === '#') { + setShowAutocomplete(true); + } + }} + {...inputProps} + /> + + setShowAutocomplete(false)} + onMenuSelect={item => { + setShowAutocomplete(false); + const el = edit.current; + const cursorPosition = getCaretPosition(el); + const textBeforeCursor = el.value.slice(0, cursorPosition); + + let lastHashIndex = textBeforeCursor.lastIndexOf('#'); + if (lastHashIndex === -1) { + return; + } + + const newContent = + textBeforeCursor.slice(0, lastHashIndex) + + item?.tag + + el.value.slice(cursorPosition) + + ' '; + + setContent(newContent); + handleSetCursorPosition(); + }} + /> + + + ); +} + function shouldSaveFromKey(e) { switch (e.key) { case 'Tab': @@ -1368,8 +1488,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 = e.relatedTarget?.closest('[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 137cc61bc5f..f629326367d 100644 --- a/packages/desktop-client/src/components/transactions/TransactionList.jsx +++ b/packages/desktop-client/src/components/transactions/TransactionList.jsx @@ -3,7 +3,7 @@ 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 +103,7 @@ export function TransactionList({ await saveDiff({ added: newTransactions }); onRefetch(); + dispatch(getTags()); }, []); const onSave = useCallback(async transaction => { @@ -120,6 +121,7 @@ export function TransactionList({ onChange(changes.newTransaction, changes.data); saveDiffAndApply(changes.diff, changes, onChange); } + dispatch(getTags()); } }, []); @@ -191,8 +193,8 @@ export function TransactionList({ const onNotesTagClick = useCallback(tag => { onApplyFilter({ field: 'notes', - op: 'matches', - value: `(^|\\s|\\w|#)${escapeRegExp(tag)}($|\\s|#)`, + op: 'tags', + 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 b3737ea25be..4f1440d77b9 100644 --- a/packages/desktop-client/src/components/transactions/TransactionsTable.jsx +++ b/packages/desktop-client/src/components/transactions/TransactionsTable.jsx @@ -19,7 +19,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, @@ -57,7 +57,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'; @@ -80,7 +80,12 @@ import { useTableNavigator, Table, UnexposedCellContent, + InputCellWithTags, } from '../table'; +import { getColorsByTheme, TAGCOLORS, TAGREGEX } from 'loot-core/shared/tag'; +import { useTags } from '../../hooks/useTags'; +import { TwitterPicker } from 'react-color'; +import { TagAutocomplete } from '../autocomplete/TagAutocomplete'; function getDisplayValue(obj, name) { return obj ? obj[name] : ''; @@ -1188,7 +1193,7 @@ const Transaction = memo(function Transaction({ /* Notes field for all transactions */ ) : ( - notesTagFormatter(value, onNotesTagClick)} + onEdit={onEdit} + onNotesTagClick={onNotesTagClick} onExpose={name => !isPreview && onEdit(id, name)} inputProps={{ - value: notes || '', onUpdate: onUpdate.bind(null, 'notes'), }} /> @@ -2425,8 +2430,118 @@ 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 edit = useRef(null); + const [theme, switchTheme] = 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 && ( + + { + selectedItem.color = newColor.hex; + dispatch(updateTags(selectedItem)); + }} + onChangeComplete={color => { + setShowColors(false); + onEdit(null); + }} + /> + + )} + + ); +} +function notesTagFormatter(notes, onNotesTagClick, onContextMenu) { + const tags = useTags(); const words = notes.split(' '); + const [selectedItem, setSelectedItem] = useState(null); + const [pickerPosition, setPickerPosition] = useState({ top: 0, left: 0 }); + const dispatch = useDispatch(); + const [tagColors, setTagColors] = useState(new Map()); + + useEffect(() => { + const map = new Map(); + const mapTextColor = new Map(); + notes.split(TAGREGEX).forEach(part => { + if (TAGREGEX.test(part)) { + const filteredTags = tags.filter(t => t.tag == part); + if (filteredTags.length > 0) { + map.set(part, { + color: filteredTags[0].color ?? theme.noteTagBackground, + textColor: filteredTags[0].textColor ?? theme.noteTagText, + hoverColor: filteredTags[0].hoverColor ?? theme.noteTagBackgroundHover + }); + } else { + map.set(part, { + color: theme.noteTagBackground, + textColor: theme.noteTagText, + hoverColor: theme.noteTagBackgroundHover + }); + } + } + }); + + setTagColors(map); + }, [tags]); + return ( <> {words.map((word, i, arr) => { @@ -2445,23 +2560,39 @@ function notesTagFormatter(notes, onNotesTagClick) { } const validTag = `#${tag}`; + const filteredTags = tags.filter(t => t.tag === validTag); + return ( - + { + if (onContextMenu) { + onContextMenu(e, filteredTags[0]); + } + }} + >
))} - {showColors && selectedItem && ( - - { - selectedItem.color = newColor.hex; - dispatch(updateTags(selectedItem)); - }} - onChangeComplete={color => { - setShowColors(false); - }} - /> - - )} ); } diff --git a/packages/desktop-client/src/components/common/InputWithTags.tsx b/packages/desktop-client/src/components/common/InputWithTags.tsx index 938476c63c4..7fc599e7f74 100644 --- a/packages/desktop-client/src/components/common/InputWithTags.tsx +++ b/packages/desktop-client/src/components/common/InputWithTags.tsx @@ -17,6 +17,7 @@ import { TagAutocomplete } from '../autocomplete/TagAutocomplete'; import { Popover } from './Popover'; import { View } from './View'; import { useTags } from '../../hooks/useTags'; +import { TAGREGEX } from 'loot-core/shared/tag'; export const defaultInputStyle = { outline: 0, @@ -52,7 +53,11 @@ export function InputWithTags({ }: InputWithTagsProps) { const tags = useTags(); const generateTags = text => { - return text.replace(/(#\w[\w-]*)(?=\s|$|\#)/g, (match, p1) => { + return text.replace(TAGREGEX, (match, p1) => { + + if(p1.includes("##")) + return p1; + const filteredTags = tags.filter(t => t.tag == p1); return renderToStaticMarkup( @@ -69,9 +74,9 @@ export function InputWithTags({ maxWidth: '150px', backgroundColor: filteredTags.length > 0 - ? filteredTags[0].color ?? theme.noteTagBackground + ? filteredTags[0]?.color ?? theme.noteTagBackground : theme.noteTagBackground, - color: filteredTags[0].textColor ?? theme.noteTagText, + color: filteredTags[0]?.textColor ?? theme.noteTagText, cursor: 'pointer', }} > @@ -86,6 +91,7 @@ export function InputWithTags({ : value || '', ); const [showAutocomplete, setShowAutocomplete] = useState(false); + const [keyPressed, setKeyPressed] = useState(null); const triggerRef = useRef(null); const edit = useRef(null); const [hint, setHint] = useState(''); @@ -107,7 +113,7 @@ export function InputWithTags({ evt => { let updatedContent = evt.currentTarget.innerText; updatedContent = updatedContent.replace('\r\n', '').replace('\n', ''); - //just a hack to make it work without mega refactory. should use evt.currentTarget.innerText where needed + evt.currentTarget.value = updatedContent; updatedContent = generateTags(updatedContent); @@ -125,8 +131,8 @@ export function InputWithTags({ const range = document.createRange(); const selection = window.getSelection(); - range.selectNodeContents(el); // Select the entire content - range.collapse(false); // Collapse the range to the end point (cursor at the end) + range.selectNodeContents(el); + range.collapse(false); selection.removeAllRanges(); selection.addRange(range); @@ -184,6 +190,15 @@ export function InputWithTags({ all: 'unset', }} onKeyDown={e => { + if (showAutocomplete) { + if (['ArrowLeft', 'ArrowRight', 'Tab', 'Enter'].includes(e.key)) { + setKeyPressed(e.key); + e.preventDefault(); + e.stopPropagation(); + return; + } + } + nativeProps.onKeyDown?.(e); if (e.key === 'Enter' && onEnter) { @@ -207,29 +222,38 @@ export function InputWithTags({ } if (e.key === 'Backspace') { - if (!edit.current.textContent.includes('#')) { - setShowAutocomplete(false); + setShowAutocomplete(false); + + const cursorPosition = getCaretPosition(edit.current); + const textBeforeCursor = edit.current.textContent.slice( + 0, + cursorPosition, + ); + + const lastHashIndex = textBeforeCursor.lastIndexOf('#'); + if (lastHashIndex === -1) { + return; } - } - if (showAutocomplete) { - updateHint(); - } + const newContent = generateTags( + textBeforeCursor.slice(0, lastHashIndex) + + edit.current.textContent.slice(cursorPosition), + ); - onContentChange(e); + setContent(newContent); + } + else { + onContentChange(e); + } }} onBlur={e => { onUpdate?.(e.target.innerText); onContentChange(e); nativeProps.onBlur?.(e); }} - onInput={e => { - onContentChange(e); - - if (showAutocomplete) { - updateHint(); // Update hint after the content is modified - } - }} + onInput={e => + onContentChange(e) + } onChange={e => { onContentChange(e); }} @@ -244,8 +268,12 @@ export function InputWithTags({ setShowAutocomplete(false)} + keyPressed={keyPressed} + onKeyHandled={() => setKeyPressed(null)} onMenuSelect={item => { setShowAutocomplete(false); + + if (!item) return; const el = edit.current; const cursorPosition = getCaretPosition(el); const textBeforeCursor = el.textContent.slice(0, cursorPosition); diff --git a/packages/desktop-client/src/components/table.tsx b/packages/desktop-client/src/components/table.tsx index 3f722eb1282..9ce9643c5a4 100644 --- a/packages/desktop-client/src/components/table.tsx +++ b/packages/desktop-client/src/components/table.tsx @@ -428,6 +428,7 @@ export function InputCellWithTags({ const [hint, setHint] = useState(''); const edit = useRef(null); const [content, setContent] = useState(props.value); + const [keyPressed, setKeyPressed] = useState(null); // Track key presses for TagAutocomplete useEffect(() => { if (content !== undefined) { @@ -490,6 +491,15 @@ export function InputCellWithTags({ onBlur={onBlur} style={{ textAlign, ...(inputProps && inputProps.style) }} onKeyDown={e => { + if (showAutocomplete) { + if (['ArrowLeft', 'ArrowRight', 'Tab', 'Enter'].includes(e.key)) { + setKeyPressed(e.key); + e.preventDefault(); + e.stopPropagation(); + return; + } + } + if (e.key === '#') { setShowAutocomplete(true); } @@ -504,8 +514,13 @@ export function InputCellWithTags({ setShowAutocomplete(false)} + keyPressed={keyPressed} + onKeyHandled={() => setKeyPressed(null)} onMenuSelect={item => { setShowAutocomplete(false); + + if (!item) return; + const el = edit.current; const cursorPosition = getCaretPosition(el); const textBeforeCursor = el.value.slice(0, cursorPosition); diff --git a/packages/loot-core/src/shared/tag.ts b/packages/loot-core/src/shared/tag.ts index 6438f471b31..31a69fe2b56 100644 --- a/packages/loot-core/src/shared/tag.ts +++ b/packages/loot-core/src/shared/tag.ts @@ -1,6 +1,6 @@ import Color from 'color'; -export const TAGREGEX = /(? Date: Tue, 20 Aug 2024 16:01:18 -0300 Subject: [PATCH 20/39] linter ajustments --- .../autocomplete/TagAutocomplete.tsx | 37 +- .../src/components/common/Input.tsx | 5 +- .../components/common/InputWithContent.tsx | 11 +- .../src/components/common/InputWithTags.tsx | 331 +++--------------- .../src/components/common/Text.tsx | 57 ++- .../components/filters/FilterExpression.tsx | 1 - .../src/components/filters/FiltersMenu.jsx | 1 - .../src/components/rules/Value.tsx | 27 +- .../desktop-client/src/components/table.tsx | 126 ++----- .../transactions/TransactionList.jsx | 2 - .../transactions/TransactionsTable.jsx | 24 +- .../src/components/util/GenericInput.jsx | 8 +- .../desktop-client/src/hooks/useTagPopover.ts | 145 ++++++++ packages/desktop-client/src/hooks/useTags.ts | 26 +- .../loot-core/src/client/reducers/queries.ts | 12 +- .../loot-core/src/server/accounts/rules.ts | 18 +- .../src/server/accounts/transaction-rules.ts | 2 +- packages/loot-core/src/server/db/index.ts | 11 +- packages/loot-core/src/server/main.ts | 1 - packages/loot-core/src/shared/tag.ts | 325 ++++++++++++++--- packages/loot-core/src/types/models/tag.d.ts | 21 +- yarn.lock | 6 +- 22 files changed, 652 insertions(+), 545 deletions(-) create mode 100644 packages/desktop-client/src/hooks/useTagPopover.ts diff --git a/packages/desktop-client/src/components/autocomplete/TagAutocomplete.tsx b/packages/desktop-client/src/components/autocomplete/TagAutocomplete.tsx index c29771e28b5..694457998af 100644 --- a/packages/desktop-client/src/components/autocomplete/TagAutocomplete.tsx +++ b/packages/desktop-client/src/components/autocomplete/TagAutocomplete.tsx @@ -1,11 +1,14 @@ -import { useTags } from '../../hooks/useTags'; -import { getNormalisedString } from 'loot-core/shared/normalisation'; -import { View } from '../common/View'; import { useEffect, useState } from 'react'; -import { Button } from '../common/Button2'; + +import { getNormalisedString } from 'loot-core/shared/normalisation'; + +import { useTags } from '../../hooks/useTags'; import { theme } from '../../style'; +import { Button } from '../common/Button2'; +import { Popover } from '../common/Popover'; +import { View } from '../common/View'; -export function TagAutocomplete({ +function TagAutocomplete({ onMenuSelect, hint, clickedOnIt, @@ -14,7 +17,7 @@ export function TagAutocomplete({ }) { const tags = useTags(); const [suggestions, setSuggestions] = useState([]); - const [selectedIndex, setSelectedIndex] = useState(0); + const [selectedIndex, setSelectedIndex] = useState(-1); useEffect(() => { if (tags && tags.length > 0) { @@ -115,3 +118,25 @@ function TagList({ items, onMenuSelect, tags, clickedOnIt, selectedIndex }) { ); } + +export function TagPopover({ + triggerRef, + isOpen, + hint, + onMenuSelect, + keyPressed, + onKeyHandled, + onClose, +}) { + return ( + + + + ); +} diff --git a/packages/desktop-client/src/components/common/Input.tsx b/packages/desktop-client/src/components/common/Input.tsx index b51e57ca31c..37b9730b887 100644 --- a/packages/desktop-client/src/components/common/Input.tsx +++ b/packages/desktop-client/src/components/common/Input.tsx @@ -49,7 +49,7 @@ export function Input({ return ( { + console.log('Input onKeyDown triggered:', e.key); // Add this log nativeProps.onKeyDown?.(e); if (e.key === 'Enter' && onEnter) { @@ -103,4 +104,4 @@ export function BigInput(props: InputProps) { }} /> ); -} \ No newline at end of file +} diff --git a/packages/desktop-client/src/components/common/InputWithContent.tsx b/packages/desktop-client/src/components/common/InputWithContent.tsx index 7691d0a7121..0090a40c3f3 100644 --- a/packages/desktop-client/src/components/common/InputWithContent.tsx +++ b/packages/desktop-client/src/components/common/InputWithContent.tsx @@ -1,10 +1,15 @@ -import { useState, type ComponentProps, type ReactNode } from 'react'; +import { + type ElementType, + useState, + type ComponentProps, + type ReactNode, +} from 'react'; import { type CSSProperties, theme } from '../../style'; import { Input, defaultInputStyle } from './Input'; -import { View } from './View'; import { InputWithTags } from './InputWithTags'; +import { View } from './View'; type InputWithContentProps = ComponentProps & { leftContent?: ReactNode; @@ -26,7 +31,7 @@ export function InputWithContent({ ...props }: InputWithContentProps) { const [focused, setFocused] = useState(props.focused ?? false); - const InputType: React.ElementType = inputWithTags ? InputWithTags: Input; + const InputType: ElementType = inputWithTags ? InputWithTags : Input; return ( & { +type InputWithTagsProps = InputHTMLAttributes & { style?: CSSProperties; - inputRef?: Ref; - onEnter?: (event: KeyboardEvent) => void; - onEscape?: (event: KeyboardEvent) => void; + inputRef?: Ref; + onEnter?: (event: KeyboardEvent) => void; + onEscape?: (event: KeyboardEvent) => void; onChangeValue?: (newValue: string) => void; onUpdate?: (newValue: string) => void; focused?: boolean; - value?: string; }; export function InputWithTags({ @@ -48,284 +33,82 @@ export function InputWithTags({ onChangeValue, onUpdate, focused, - value = '', + className, ...nativeProps }: InputWithTagsProps) { - const tags = useTags(); - const generateTags = text => { - return text.replace(TAGREGEX, (match, p1) => { - - if(p1.includes("##")) - return p1; - - const filteredTags = tags.filter(t => t.tag == p1); - - return renderToStaticMarkup( - 0 - ? filteredTags[0]?.color ?? theme.noteTagBackground - : theme.noteTagBackground, - color: filteredTags[0]?.textColor ?? theme.noteTagText, - cursor: 'pointer', - }} - > - {p1} - , - ); - }); - }; - const [content, setContent] = useState( - nativeProps.defaultValue - ? generateTags(nativeProps.defaultValue) - : value || '', - ); - const [showAutocomplete, setShowAutocomplete] = useState(false); - const [keyPressed, setKeyPressed] = useState(null); - const triggerRef = useRef(null); - const edit = useRef(null); - const [hint, setHint] = useState(''); + const ref = useRef(null); + + const { + content, + setContent, + hint, + showAutocomplete, + setShowAutocomplete, + keyPressed, + setKeyPressed, + handleKeyDown, + handleMenuSelect, + } = useTagPopover(nativeProps.value, onUpdate, ref); + const [inputValue, setInputValue] = useState(content); useEffect(() => { - if (content) { - if (onChangeValue) { - onChangeValue?.(edit.current?.textContent); - } - - if (onUpdate) { - onUpdate?.(edit.current?.textContent); - } - updateHint(); - } + setInputValue(content); + console.log(content); }, [content]); - const onContentChange = useCallback( - evt => { - let updatedContent = evt.currentTarget.innerText; - updatedContent = updatedContent.replace('\r\n', '').replace('\n', ''); - - evt.currentTarget.value = updatedContent; - - updatedContent = generateTags(updatedContent); - - setContent(updatedContent); - onChangeValue?.(evt.currentTarget.innerText); - }, - [onChangeValue], - ); - - const handleSetCursorPosition = () => { - const el = edit.current; - if (!el) return; - - const range = document.createRange(); - const selection = window.getSelection(); - - range.selectNodeContents(el); - range.collapse(false); - - selection.removeAllRanges(); - selection.addRange(range); - }; - - const updateHint = () => { - const el = edit.current; - if (!el) return; - - const cursorPosition = getCaretPosition(el); - const textBeforeCursor = el.textContent.slice(0, cursorPosition); - - const lastHashIndex = textBeforeCursor.lastIndexOf('#'); - if (lastHashIndex === -1) { - setHint(''); - return; - } - - const newHint = textBeforeCursor.slice(lastHashIndex + 1, cursorPosition); - setHint(newHint); - }; - - useEffect(() => { - if (value !== '') { - setContent(generateTags(value)); - } else { - setContent(''); - setHint(''); - } - }, [value]); - return ( - + - { - if (showAutocomplete) { - if (['ArrowLeft', 'ArrowRight', 'Tab', 'Enter'].includes(e.key)) { - setKeyPressed(e.key); - e.preventDefault(); - e.stopPropagation(); - return; - } - } - nativeProps.onKeyDown?.(e); if (e.key === 'Enter' && onEnter) { onEnter(e); - setShowAutocomplete(false); - e.preventDefault(); - e.stopPropagation(); } if (e.key === 'Escape' && onEscape) { onEscape(e); - setShowAutocomplete(false); - } - - if (e.key === ' ') { - setShowAutocomplete(false); } - if (e.key === '#') { - setShowAutocomplete(true); - } - - if (e.key === 'Backspace') { - setShowAutocomplete(false); - - const cursorPosition = getCaretPosition(edit.current); - const textBeforeCursor = edit.current.textContent.slice( - 0, - cursorPosition, - ); - - const lastHashIndex = textBeforeCursor.lastIndexOf('#'); - if (lastHashIndex === -1) { - return; - } - - const newContent = generateTags( - textBeforeCursor.slice(0, lastHashIndex) + - edit.current.textContent.slice(cursorPosition), - ); - - setContent(newContent); - } - else { - onContentChange(e); - } + handleKeyDown(e); }} onBlur={e => { - onUpdate?.(e.target.innerText); - onContentChange(e); + onUpdate?.(content); nativeProps.onBlur?.(e); }} - onInput={e => - onContentChange(e) - } onChange={e => { - onContentChange(e); + setContent(ref.current.value); + onChangeValue?.(ref.current.value); + nativeProps.onChange?.(e); }} - html={content} /> - true} - placement="bottom start" - > - setShowAutocomplete(false)} - keyPressed={keyPressed} - onKeyHandled={() => setKeyPressed(null)} - onMenuSelect={item => { - setShowAutocomplete(false); - - if (!item) return; - const el = edit.current; - const cursorPosition = getCaretPosition(el); - const textBeforeCursor = el.textContent.slice(0, cursorPosition); - - const lastHashIndex = textBeforeCursor.lastIndexOf('#'); - if (lastHashIndex === -1) { - return; - } - - // Replace the hint with the selected tag - const newContent = generateTags( - textBeforeCursor.slice(0, lastHashIndex) + - item?.tag + - el.textContent.slice(cursorPosition), - ); - - setContent(generateTags(newContent)); - handleSetCursorPosition(); // Move cursor to the end of the new content - }} - /> - - - ); -} - -export function BigInputWithTags(props: InputWithTagsProps) { - return ( - + hint={hint} + keyPressed={keyPressed} + onMenuSelect={handleMenuSelect} + onKeyHandled={() => setKeyPressed(null)} + onClose={() => setShowAutocomplete(false)} + /> + ); } - -export function getCaretPosition(editableDiv) { - const selection = window.getSelection(); - let caretPos = 0; - - if (selection.rangeCount) { - const range = selection.getRangeAt(0); - const preCaretRange = range.cloneRange(); - preCaretRange.selectNodeContents(editableDiv); - preCaretRange.setEnd(range.endContainer, range.endOffset); - caretPos = preCaretRange.toString().length; - } - - return caretPos; -} diff --git a/packages/desktop-client/src/components/common/Text.tsx b/packages/desktop-client/src/components/common/Text.tsx index 9c63783268b..eacbd17f47f 100644 --- a/packages/desktop-client/src/components/common/Text.tsx +++ b/packages/desktop-client/src/components/common/Text.tsx @@ -5,16 +5,14 @@ import React, { forwardRef, useEffect, useState, - useRef, - Children, } from 'react'; import { css } from 'glamor'; -import { theme, type CSSProperties } from '../../style'; -import { TAGREGEX } from 'loot-core/shared/tag'; +import { TAGREGEX } from '../../../../loot-core/src/shared/tag'; +import { type TagEntity } from '../../../../loot-core/src/types/models/tag'; import { useTags } from '../../hooks/useTags'; -import { TagEntity } from 'loot-core/types/models/tag'; +import { theme, type CSSProperties } from '../../style'; type TextProps = HTMLProps & { innerRef?: Ref; @@ -24,7 +22,7 @@ type TextProps = HTMLProps & { textWithTags?: boolean; }; -const processText = (text: string, tags: TagEntity[]): ReactNode => { +const ProcessText = (text: string, tags: TagEntity[]): ReactNode => { const [tagColors, setTagColors] = useState>(new Map()); const [tagTextColors, setTagTextColors] = useState(new Map()); @@ -34,7 +32,7 @@ const processText = (text: string, tags: TagEntity[]): ReactNode => { text.split(TAGREGEX).forEach(part => { if (TAGREGEX.test(part)) { - const filteredTags = tags.filter(t => t.tag == part); + const filteredTags = tags.filter(t => t.tag === part); if (filteredTags.length > 0) { map.set(part, filteredTags[0].color ?? theme.noteTagBackground); mapTextColor.set( @@ -50,7 +48,7 @@ const processText = (text: string, tags: TagEntity[]): ReactNode => { setTagColors(map); setTagTextColors(mapTextColor); - }, [tags]); + }, [tags, text]); const processedText = text.split(TAGREGEX).map((part, index) => { if (TAGREGEX.test(part)) { @@ -123,29 +121,28 @@ export const Text = forwardRef((props, ref) => { ); }); -export const TextWithTags = forwardRef( - (props, ref) => { - const { - className = '', - style, - innerRef, - children, - textWithTags, - ...restProps - } = props; +const TextWithTags = forwardRef((props, ref) => { + const { + className = '', + style, + innerRef, + children, + textWithTags, + ...restProps + } = props; - const tags = useTags(); + const tags = useTags(); - return ( - - {typeof children === 'string' ? processText(children, tags) : children} - - ); - }, -); + 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 cd74224c936..34df7694a76 100644 --- a/packages/desktop-client/src/components/filters/FilterExpression.tsx +++ b/packages/desktop-client/src/components/filters/FilterExpression.tsx @@ -77,7 +77,6 @@ export function FilterExpression({ field={field} inline={true} valueIsRaw={ - op === 'contains' || op === 'matches' || op === 'doesNotContain' || diff --git a/packages/desktop-client/src/components/filters/FiltersMenu.jsx b/packages/desktop-client/src/components/filters/FiltersMenu.jsx index 8baacffe99e..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' && ( ({ } else if (value.length === 1) { return ( - [{formatValue(value[0])}] + [ + + {formatValue(value[0])} + + ] ); } @@ -132,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 ? ' ' : ''; @@ -173,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 3ae8f253d71..2f9dfa398c4 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'; @@ -48,7 +50,6 @@ import { } from './spreadsheet'; import { type FormatType, useFormat } from './spreadsheet/useFormat'; import { useSheetValue } from './spreadsheet/useSheetValue'; -import { TagAutocomplete } from './autocomplete/TagAutocomplete'; export const ROW_HEIGHT = 32; @@ -427,123 +428,38 @@ export function InputCellWithTags({ textAlign, ...props }: InputCellProps) { - const [showAutocomplete, setShowAutocomplete] = useState(false); - const [hint, setHint] = useState(''); - const edit = useRef(null); - const [content, setContent] = useState(props.value); - const [keyPressed, setKeyPressed] = useState(null); // Track key presses for TagAutocomplete - - useEffect(() => { - if (content !== undefined) { - if (onUpdate) { - onUpdate?.(edit.current?.value); - } - updateHint(content); - } - }, [content]); - - const getCaretPosition = element => { - if (element) { - const caretPosition = element.selectionStart; - return caretPosition; - } - - return 0; - }; - - const handleSetCursorPosition = () => { - const el = edit.current; - if (!el) return; - - const range = document.createRange(); - const selection = window.getSelection(); - - range.selectNodeContents(el); // Select the entire content - range.collapse(false); // Collapse the range to the end point (cursor at the end) - - selection.removeAllRanges(); - selection.addRange(range); - }; - - const updateHint = newValue => { - const el = edit.current; - if (!el) return; - - const cursorPosition = getCaretPosition(el); - const textBeforeCursor = el.value.slice(0, cursorPosition); - - const lastHashIndex = textBeforeCursor.lastIndexOf('#'); - if (lastHashIndex === -1) { - setHint(''); - return; - } - - const newHint = textBeforeCursor.slice(lastHashIndex + 1, cursorPosition); - setHint(newHint); - }; + const edit = useRef(); + const { + content, + hint, + showAutocomplete, + setShowAutocomplete, + keyPressed, + setKeyPressed, + handleKeyDown, + handleMenuSelect, + } = useTagPopover(props.value, onUpdate, edit); return ( { - updateHint(newValue); - }} onUpdate={onUpdate} onBlur={onBlur} style={{ textAlign, ...(inputProps && inputProps.style) }} - onKeyDown={e => { - if (showAutocomplete) { - if (['ArrowLeft', 'ArrowRight', 'Tab', 'Enter'].includes(e.key)) { - setKeyPressed(e.key); - e.preventDefault(); - e.stopPropagation(); - return; - } - } - - if (e.key === '#') { - setShowAutocomplete(true); - } - }} + onKeyDown={handleKeyDown} {...inputProps} /> - - setShowAutocomplete(false)} - keyPressed={keyPressed} - onKeyHandled={() => setKeyPressed(null)} - onMenuSelect={item => { - setShowAutocomplete(false); - - if (!item) return; - - const el = edit.current; - const cursorPosition = getCaretPosition(el); - const textBeforeCursor = el.value.slice(0, cursorPosition); - - let lastHashIndex = textBeforeCursor.lastIndexOf('#'); - if (lastHashIndex === -1) { - return; - } - - const newContent = - textBeforeCursor.slice(0, lastHashIndex) + - item?.tag + - el.value.slice(cursorPosition) + - ' '; - - setContent(newContent); - handleSetCursorPosition(); - }} - /> - + hint={hint} + keyPressed={keyPressed} + onMenuSelect={handleMenuSelect} + onKeyHandled={() => setKeyPressed(null)} + onClose={() => setShowAutocomplete(false)} + /> ); } diff --git a/packages/desktop-client/src/components/transactions/TransactionList.jsx b/packages/desktop-client/src/components/transactions/TransactionList.jsx index a4541d425cf..ad43582ebbf 100644 --- a/packages/desktop-client/src/components/transactions/TransactionList.jsx +++ b/packages/desktop-client/src/components/transactions/TransactionList.jsx @@ -1,8 +1,6 @@ import React, { useRef, useCallback, useLayoutEffect } from 'react'; import { useDispatch } from 'react-redux'; -import escapeRegExp from 'lodash/escapeRegExp'; - import { getTags, pushModal } from 'loot-core/client/actions'; import { send } from 'loot-core/src/platform/client/fetch'; import { diff --git a/packages/desktop-client/src/components/transactions/TransactionsTable.jsx b/packages/desktop-client/src/components/transactions/TransactionsTable.jsx index 246b406617e..17e4aa13ad6 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'; @@ -46,10 +47,12 @@ import { titleFirst, } from 'loot-core/src/shared/util'; +import { TAGCOLORS, TAGREGEX } 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 { @@ -83,10 +86,6 @@ import { UnexposedCellContent, InputCellWithTags, } from '../table'; -import { getColorsByTheme, TAGCOLORS, TAGREGEX } from 'loot-core/shared/tag'; -import { useTags } from '../../hooks/useTags'; -import { TwitterPicker } from 'react-color'; -import { TagAutocomplete } from '../autocomplete/TagAutocomplete'; function getDisplayValue(obj, name) { return obj ? obj[name] : ''; @@ -2514,8 +2513,7 @@ function NotesCell({ const [selectedItem, setSelectedItem] = useState(null); const [pickerPosition, setPickerPosition] = useState({ top: 0, left: 0 }); const dispatch = useDispatch(); - const edit = useRef(null); - const [theme, switchTheme] = useTheme(); + const { theme } = useTheme(); const handleContextMenu = (e, item) => { e.preventDefault(); @@ -2535,11 +2533,11 @@ function NotesCell({ return ( <> -
+
- notesTagFormatter(value, onNotesTagClick, (e, item) => + NotesTagFormatter(value, onNotesTagClick, (e, item) => handleContextMenu(e, item), ) } @@ -2569,7 +2567,7 @@ function NotesCell({ selectedItem.color = newColor.hex; dispatch(updateTags(selectedItem)); }} - onChangeComplete={color => { + onChangeComplete={() => { setShowColors(false); onEdit(null); }} @@ -2579,20 +2577,16 @@ function NotesCell({ ); } -function notesTagFormatter(notes, onNotesTagClick, onContextMenu) { +function NotesTagFormatter(notes, onNotesTagClick, onContextMenu) { const tags = useTags(); const words = notes.split(' '); - const [selectedItem, setSelectedItem] = useState(null); - const [pickerPosition, setPickerPosition] = useState({ top: 0, left: 0 }); - const dispatch = useDispatch(); const [tagColors, setTagColors] = useState(new Map()); useEffect(() => { const map = new Map(); - const mapTextColor = new Map(); notes.split(TAGREGEX).forEach(part => { if (TAGREGEX.test(part)) { - const filteredTags = tags.filter(t => t.tag == part); + const filteredTags = tags.filter(t => t.tag === part); if (filteredTags.length > 0) { map.set(part, { color: filteredTags[0].color ?? theme.noteTagBackground, diff --git a/packages/desktop-client/src/components/util/GenericInput.jsx b/packages/desktop-client/src/components/util/GenericInput.jsx index e7c907f14ff..b0cd081d00a 100644 --- a/packages/desktop-client/src/components/util/GenericInput.jsx +++ b/packages/desktop-client/src/components/util/GenericInput.jsx @@ -14,6 +14,7 @@ import { FilterAutocomplete } from '../autocomplete/FilterAutocomplete'; import { PayeeAutocomplete } from '../autocomplete/PayeeAutocomplete'; import { ReportAutocomplete } from '../autocomplete/ReportAutocomplete'; import { Input } from '../common/Input'; +import { InputWithTags } from '../common/InputWithTags'; import { View } from '../common/View'; import { Checkbox } from '../forms'; import { DateSelect } from '../select/DateSelect'; @@ -21,7 +22,6 @@ import { RecurringSchedulePicker } from '../select/RecurringSchedulePicker'; import { AmountInput } from './AmountInput'; import { PercentInput } from './PercentInput'; -import { InputWithTags } from '../common/InputWithTags'; export function GenericInput({ field, @@ -63,8 +63,7 @@ export function GenericInput({ inputRef={inputRef} defaultValue={value || ''} placeholder="nothing" - onEnter={e => onChange(e.target.value)} - onBlur={e => onChange(e.target.value)} + onChangeValue={newValue => onChange(newValue)} /> ); } @@ -253,8 +252,7 @@ export function GenericInput({ inputRef={inputRef} defaultValue={value || ''} placeholder="nothing" - onEnter={e => onChange(e.target.value)} - onBlur={e => onChange(e.target.value)} + onChangeValue={newValue => onChange(newValue)} /> ); } diff --git a/packages/desktop-client/src/hooks/useTagPopover.ts b/packages/desktop-client/src/hooks/useTagPopover.ts new file mode 100644 index 00000000000..d97587cf740 --- /dev/null +++ b/packages/desktop-client/src/hooks/useTagPopover.ts @@ -0,0 +1,145 @@ +import { useCallback, useEffect, useState } from 'react'; + +export function useTagPopover(initialValue, onUpdate, componentRef) { + const [showAutocomplete, setShowAutocomplete] = useState(false); + const [hint, setHint] = useState(''); + const [content, setContent] = useState(initialValue); + const [keyPressed, setKeyPressed] = useState(null); + const edit = componentRef; + + const getCaretPosition = useCallback(element => { + if (element) { + return element.selectionStart; + } + return 0; + }, []); + + const handleSetCursorPosition = () => { + const el = edit.current; + if (!el) return; + + const range = document.createRange(); + const selection = window.getSelection(); + + range.selectNodeContents(el); + range.collapse(false); + + selection.removeAllRanges(); + selection.addRange(range); + }; + + const updateHint = useCallback( + newValue => { + const el = edit.current; + if (!el) return; + + const cursorPosition = getCaretPosition(el); + const textBeforeCursor = newValue.slice(0, cursorPosition); + + const lastHashIndex = textBeforeCursor.lastIndexOf('#'); + if (lastHashIndex === -1) { + setHint(''); + return; + } + + setHint(textBeforeCursor.slice(lastHashIndex + 1, cursorPosition)); + }, + [edit, getCaretPosition, setHint], + ); + + useEffect(() => { + if (content !== undefined) { + onUpdate?.(content); + updateHint(content); + } else { + updateHint(''); + } + }, [content, onUpdate, updateHint]); + + const handleKeyDown = e => { + if (showAutocomplete) { + if (e.key === 'Escape') { + setShowAutocomplete(false); + e.preventDefault(); + e.stopPropagation(); + return; + } + + if (['ArrowLeft', 'ArrowRight', 'Tab'].includes(e.key)) { + setKeyPressed(e.key); + e.preventDefault(); + e.stopPropagation(); + return; + } + } + + if (e.key === '#') { + setShowAutocomplete(!showAutocomplete); + } else if ( + !['Shift', 'Escape', 'Enter', 'ArrowLeft', 'ArrowRight'].includes(e.key) + ) { + let cursorPosition = getCaretPosition(edit.current); + if (cursorPosition !== 0) { + const textBeforeCursor = content.slice(0, cursorPosition); + let foundHashtag = false; + while (cursorPosition >= 0) { + if (textBeforeCursor[cursorPosition] === '#') { + foundHashtag = true; + break; + } + + if (textBeforeCursor[cursorPosition] === ' ') { + break; + } + + cursorPosition--; + } + + if (foundHashtag) { + setShowAutocomplete(true); + } + } + } + }; + + const handleMenuSelect = item => { + if (!item) return; + + setContent(''); + + const el = edit.current; + const cursorPosition = getCaretPosition(el); + const textBeforeCursor = el.value.slice(0, cursorPosition); + + const lastHashIndex = textBeforeCursor.lastIndexOf('#'); + if (lastHashIndex === -1) { + setShowAutocomplete(false); + setHint(''); + return; + } + + const newContent = + textBeforeCursor.slice(0, lastHashIndex) + + item.tag + + el.value.slice(cursorPosition) + + ' '; + + setContent(newContent); + handleSetCursorPosition(); + setShowAutocomplete(false); + setHint(''); + }; + + return { + content, + setContent, + hint, + showAutocomplete, + keyPressed, + handleKeyDown, + handleMenuSelect, + handleSetCursorPosition, + setShowAutocomplete, + setKeyPressed, + }; +} diff --git a/packages/desktop-client/src/hooks/useTags.ts b/packages/desktop-client/src/hooks/useTags.ts index 42f78fa996f..f2d897d9dc6 100644 --- a/packages/desktop-client/src/hooks/useTags.ts +++ b/packages/desktop-client/src/hooks/useTags.ts @@ -5,17 +5,15 @@ import { getTags } from 'loot-core/src/client/actions'; import { type State } from 'loot-core/src/client/state-types'; export function useTags() { - const dispatch = useDispatch(); - const tagsLoaded = useSelector( - (state: State) => state.queries.tagsLoaded, - ); - const tags = useSelector((state: State) => state.queries.tags); - - useEffect(() => { - if (!tagsLoaded) { - dispatch(getTags()); - } - }, [tagsLoaded, tags]); - - return tags; - } + const dispatch = useDispatch(); + const tagsLoaded = useSelector((state: State) => state.queries.tagsLoaded); + const tags = useSelector((state: State) => state.queries.tags); + + useEffect(() => { + if (!tagsLoaded) { + dispatch(getTags()); + } + }, [tagsLoaded, tags, dispatch]); + + return tags; +} diff --git a/packages/loot-core/src/client/reducers/queries.ts b/packages/loot-core/src/client/reducers/queries.ts index 97d5e798d43..a84068ba752 100644 --- a/packages/loot-core/src/client/reducers/queries.ts +++ b/packages/loot-core/src/client/reducers/queries.ts @@ -100,12 +100,12 @@ export function update(state = initialState, action: Action): QueriesState { payees: action.payees, payeesLoaded: true, }; - case constants.LOAD_TAGS: - return { - ...state, - tags: action.tags, - tagsLoaded: true, - }; + case constants.LOAD_TAGS: + return { + ...state, + tags: action.tags, + tagsLoaded: true, + }; default: } return state; diff --git a/packages/loot-core/src/server/accounts/rules.ts b/packages/loot-core/src/server/accounts/rules.ts index 345668c2207..fd5fb9b8ccf 100644 --- a/packages/loot-core/src/server/accounts/rules.ts +++ b/packages/loot-core/src/server/accounts/rules.ts @@ -147,18 +147,17 @@ const CONDITION_TYPES = { }, string: { ops: [ - 'is', - + 'contains', - + 'matches', 'oneOf', - + 'isNot', - + 'doesNotContain', - + 'notOneOf', 'hasTags', ], @@ -214,7 +213,12 @@ const CONDITION_TYPES = { return value.filter(Boolean).map(val => val.toLowerCase()); } - if (op === 'contains' || op === 'matches' || op === 'doesNotContain' || op === 'tags') { + if ( + op === 'contains' || + op === 'matches' || + op === 'doesNotContain' || + op === 'tags' + ) { assert( typeof value === 'string' && value.length > 0, 'no-empty-string', diff --git a/packages/loot-core/src/server/accounts/transaction-rules.ts b/packages/loot-core/src/server/accounts/transaction-rules.ts index 8260093e0a3..bdf5621b8f9 100644 --- a/packages/loot-core/src/server/accounts/transaction-rules.ts +++ b/packages/loot-core/src/server/accounts/transaction-rules.ts @@ -1,5 +1,4 @@ // @ts-strict-ignore -import { TAGREGEX } from 'loot-core/shared/tag'; import { currentDay, addDays, @@ -12,6 +11,7 @@ import { sortNumbers, getApproxNumberThreshold, } from '../../shared/rules'; +import { TAGREGEX } from '../../shared/tag'; import { ungroupTransaction } from '../../shared/transactions'; import { partitionByField, fastSetMerge } from '../../shared/util'; import { diff --git a/packages/loot-core/src/server/db/index.ts b/packages/loot-core/src/server/db/index.ts index 6ac7c48678e..3d47bc70077 100644 --- a/packages/loot-core/src/server/db/index.ts +++ b/packages/loot-core/src/server/db/index.ts @@ -13,6 +13,7 @@ import { v4 as uuidv4 } from 'uuid'; import * as fs from '../../platform/server/fs'; import * as sqlite from '../../platform/server/sqlite'; +import { getHoverColor, getTextColorForColor } from '../../shared/tag'; import { groupById } from '../../shared/util'; import { CategoryEntity, @@ -35,7 +36,6 @@ import { import { sendMessages, batchMessages } from '../sync'; import { shoveSortOrders, SORT_INCREMENT } from './sort'; -import { getHoverColor, getTextColorForColor } from '../../shared/tag'; export { toDateRepr, fromDateRepr } from '../models'; @@ -704,12 +704,11 @@ async function insertTags(transaction) { const tags = extractTagsFromNotes(notes); if (tags.length > 0) { - for(const tag of tags) { - const { id } = await first('SELECT id FROM tags where tag = ?', [ - tag, - ]) || { }; + for (const tag of tags) { + const { id } = + (await first('SELECT id FROM tags where tag = ?', [tag])) || {}; - if(!id) { + if (!id) { insertWithUUID('tags', { tag, color: null, diff --git a/packages/loot-core/src/server/main.ts b/packages/loot-core/src/server/main.ts index 4c5b3368f41..ab6c5e5b494 100644 --- a/packages/loot-core/src/server/main.ts +++ b/packages/loot-core/src/server/main.ts @@ -520,7 +520,6 @@ handlers['tag-update'] = async function ({ tag }) { await db.updateTag(tag); }; - handlers['make-filters-from-conditions'] = async function ({ conditions }) { return rules.conditionsToAQL(conditions); }; diff --git a/packages/loot-core/src/shared/tag.ts b/packages/loot-core/src/shared/tag.ts index 31a69fe2b56..ec5cce4cc1b 100644 --- a/packages/loot-core/src/shared/tag.ts +++ b/packages/loot-core/src/shared/tag.ts @@ -1,74 +1,309 @@ import Color from 'color'; -export const TAGREGEX = /(? Color(color).isDark() === isDark); -} +// export function getColorsByTheme(isDark) { +// return TAGCOLORS.filter(color => Color(color).isDark() === isDark); +// } export function getTextColorForColor(color) { const refColor = Color(color); - if(refColor.isDark()) { + if (refColor.isDark()) { return refColor.lighten(0.8).hex(); } @@ -77,4 +312,4 @@ export function getTextColorForColor(color) { export function getHoverColor(color) { return Color(color).lighten(0.2).hex(); -} \ No newline at end of file +} diff --git a/packages/loot-core/src/types/models/tag.d.ts b/packages/loot-core/src/types/models/tag.d.ts index e9257b5c01b..d31e33e40f3 100644 --- a/packages/loot-core/src/types/models/tag.d.ts +++ b/packages/loot-core/src/types/models/tag.d.ts @@ -1,12 +1,11 @@ export interface NewTagEntity { - id?: string; - tag: string; - color: string; - textColor: string; - hoverColor: string; - } - - export interface TagEntity extends NewTagEntity { - id: string; - } - \ No newline at end of file + id?: string; + tag: string; + color: string; + textColor: string; + hoverColor: string; +} + +export interface TagEntity extends NewTagEntity { + id: string; +} diff --git a/yarn.lock b/yarn.lock index 46332e931d1..d1b193914e0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -15869,11 +15869,7 @@ __metadata: languageName: node linkType: hard -<<<<<<< HEAD -"prop-types@npm:15.x, prop-types@npm:^15.0.0, prop-types@npm:^15.5.10, prop-types@npm:^15.6.2, prop-types@npm:^15.7.2, prop-types@npm:^15.8.1": -======= -"prop-types@npm:^15.0.0, prop-types@npm:^15.5.10, prop-types@npm:^15.6.2, prop-types@npm:^15.7.1, prop-types@npm:^15.7.2, prop-types@npm:^15.8.1": ->>>>>>> Tags +"prop-types@npm:15.x, prop-types@npm:^15.0.0, prop-types@npm:^15.5.10, prop-types@npm:^15.6.2, prop-types@npm:^15.7.1, prop-types@npm:^15.7.2, prop-types@npm:^15.8.1": version: 15.8.1 resolution: "prop-types@npm:15.8.1" dependencies: From a690f36927c3cb0d2a503f585d26705961ae32f6 Mon Sep 17 00:00:00 2001 From: Leandro Menezes Date: Tue, 20 Aug 2024 17:03:28 -0300 Subject: [PATCH 21/39] fixes and changes --- package.json | 2 +- .../autocomplete/TagAutocomplete.tsx | 113 ++++++++++++++---- .../src/components/common/Input.tsx | 2 +- .../desktop-client/src/components/table.tsx | 7 +- yarn.lock | 18 ++- 5 files changed, 116 insertions(+), 26 deletions(-) diff --git a/package.json b/package.json index bea19188ca7..c648c2d0876 100644 --- a/package.json +++ b/package.json @@ -45,10 +45,10 @@ "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", - "@types/react-color": "^2", "cross-env": "^7.0.3", "eslint": "^8.57.0", "eslint-config-prettier": "^9.1.0", diff --git a/packages/desktop-client/src/components/autocomplete/TagAutocomplete.tsx b/packages/desktop-client/src/components/autocomplete/TagAutocomplete.tsx index 694457998af..6d16c551103 100644 --- a/packages/desktop-client/src/components/autocomplete/TagAutocomplete.tsx +++ b/packages/desktop-client/src/components/autocomplete/TagAutocomplete.tsx @@ -41,10 +41,14 @@ function TagAutocomplete({ useEffect(() => { if (keyPressed) { if (keyPressed === 'ArrowRight') { - setSelectedIndex(prevIndex => (prevIndex + 1) % suggestions.length); + if (selectedIndex + 1 === suggestions.length) { + setSelectedIndex(-1); + } else { + setSelectedIndex(prevIndex => (prevIndex + 1) % suggestions.length); + } } else if (keyPressed === 'ArrowLeft') { setSelectedIndex(prevIndex => - prevIndex === 0 ? suggestions.length - 1 : prevIndex - 1, + prevIndex === -1 ? suggestions.length - 1 : prevIndex - 1, ); } else if (keyPressed === 'Tab' || keyPressed === 'Enter') { onMenuSelect(suggestions[selectedIndex]); @@ -62,16 +66,24 @@ function TagAutocomplete({ tags={tags} clickedOnIt={clickedOnIt} selectedIndex={selectedIndex} + hint={hint} /> ); } -function TagList({ items, onMenuSelect, tags, clickedOnIt, selectedIndex }) { +function TagList({ + items, + onMenuSelect, + tags, + clickedOnIt, + selectedIndex, + hint, +}) { return ( 0 && No tags found with these terms} )} - {items.map((item, index) => ( - + + {hint.length > 0 && !items.some(item => item.tag === `#${hint}`) && ( - - ))} + )} + {items.map((item, index) => ( + + + + ))} + ); } export function TagPopover({ - triggerRef, - isOpen, - hint, - onMenuSelect, - keyPressed, - onKeyHandled, - onClose, + triggerRef, + isOpen, + hint, + onMenuSelect, + keyPressed, + onKeyHandled, + onClose, }) { return ( diff --git a/packages/desktop-client/src/components/common/Input.tsx b/packages/desktop-client/src/components/common/Input.tsx index 37b9730b887..a02d13c17f1 100644 --- a/packages/desktop-client/src/components/common/Input.tsx +++ b/packages/desktop-client/src/components/common/Input.tsx @@ -49,7 +49,7 @@ export function Input({ return ( setValue_(text)} + onChangeValue={text => { + setValue_(text); + props?.onChangeValue?.(text); + }} onBlur={onBlur_} onUpdate={onUpdate} onKeyDown={onKeyDown} @@ -431,6 +434,7 @@ export function InputCellWithTags({ const edit = useRef(); const { content, + setContent, hint, showAutocomplete, setShowAutocomplete, @@ -446,6 +450,7 @@ export function InputCellWithTags({ inputRef={edit} value={content} onUpdate={onUpdate} + onChangeValue={setContent} onBlur={onBlur} style={{ textAlign, ...(inputProps && inputProps.style) }} onKeyDown={handleKeyDown} diff --git a/yarn.lock b/yarn.lock index c21d9ae1223..ace4219b31a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1964,6 +1964,15 @@ __metadata: languageName: node linkType: hard +"@icons/material@npm:^0.2.4": + version: 0.2.4 + resolution: "@icons/material@npm:0.2.4" + peerDependencies: + react: "*" + checksum: 10/6c248fdb2d226e5af76bb0203d05fd7b9c77003f0197a5ae4baead3d014a27cfb539921fceed89a72e02fa0b583debe3b26b76d84d6f7cb354d3cb2ac00e9353 + languageName: node + linkType: hard + "@internationalized/date@npm:^3.5.4": version: 3.5.4 resolution: "@internationalized/date@npm:3.5.4" @@ -6019,10 +6028,10 @@ __metadata: version: 0.0.0-use.local resolution: "actual@workspace:." dependencies: + "@types/react-color": "npm:^2" "@typescript-eslint/eslint-plugin": "npm:^8.1.0" "@typescript-eslint/parser": "npm:^8.1.0" confusing-browser-globals: "npm:^1.0.11" - "@types/react-color": "npm:^2" cross-env: "npm:^7.0.3" eslint: "npm:^8.57.0" eslint-config-prettier: "npm:^9.1.0" @@ -11273,6 +11282,13 @@ __metadata: languageName: node linkType: hard +"is-arrayish@npm:^0.3.1": + version: 0.3.2 + resolution: "is-arrayish@npm:0.3.2" + checksum: 10/81a78d518ebd8b834523e25d102684ee0f7e98637136d3bdc93fd09636350fa06f1d8ca997ea28143d4d13cb1b69c0824f082db0ac13e1ab3311c10ffea60ade + languageName: node + linkType: hard + "is-async-function@npm:^2.0.0": version: 2.0.0 resolution: "is-async-function@npm:2.0.0" From fa9d4ad735a1f375b78c8b7f5516fae7ac8f2202 Mon Sep 17 00:00:00 2001 From: Leandro Menezes Date: Wed, 21 Aug 2024 16:58:23 -0300 Subject: [PATCH 22/39] more fix --- .../autocomplete/TagAutocomplete.tsx | 69 ++++++++++++++++--- .../transactions/TransactionsTable.jsx | 2 +- .../desktop-client/src/hooks/useTagPopover.ts | 4 +- 3 files changed, 64 insertions(+), 11 deletions(-) diff --git a/packages/desktop-client/src/components/autocomplete/TagAutocomplete.tsx b/packages/desktop-client/src/components/autocomplete/TagAutocomplete.tsx index 6d16c551103..93d421fc902 100644 --- a/packages/desktop-client/src/components/autocomplete/TagAutocomplete.tsx +++ b/packages/desktop-client/src/components/autocomplete/TagAutocomplete.tsx @@ -14,6 +14,7 @@ function TagAutocomplete({ clickedOnIt, keyPressed, onKeyHandled, + element, }) { const tags = useTags(); const [suggestions, setSuggestions] = useState([]); @@ -39,16 +40,24 @@ function TagAutocomplete({ }, [tags, hint]); useEffect(() => { + const minIndex = + hint.length > 0 && !suggestions.some(item => item.tag === `#${hint}`) + ? -1 + : 0; if (keyPressed) { if (keyPressed === 'ArrowRight') { - if (selectedIndex + 1 === suggestions.length) { - setSelectedIndex(-1); + if (selectedIndex + 1 === Math.min(suggestions.length, 10)) { + setSelectedIndex(minIndex); } else { - setSelectedIndex(prevIndex => (prevIndex + 1) % suggestions.length); + setSelectedIndex( + prevIndex => (prevIndex + 1) % Math.min(suggestions.length, 10), + ); } } else if (keyPressed === 'ArrowLeft') { setSelectedIndex(prevIndex => - prevIndex === -1 ? suggestions.length - 1 : prevIndex - 1, + prevIndex === minIndex + ? Math.min(suggestions.length, 10) - 1 + : prevIndex - 1, ); } else if (keyPressed === 'Tab' || keyPressed === 'Enter') { onMenuSelect(suggestions[selectedIndex]); @@ -67,6 +76,7 @@ function TagAutocomplete({ clickedOnIt={clickedOnIt} selectedIndex={selectedIndex} hint={hint} + element={element} /> ); } @@ -78,13 +88,48 @@ function TagList({ clickedOnIt, selectedIndex, hint, + element, }) { + const [width, setWidth] = useState(0); + + 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 ( No tags found. Tags will be added automatically )} - {tags.length > 0 && No tags found with these terms} + {tags.length > 0 && ( + No tags found with these terms ({hint}) + )} )} ); diff --git a/packages/desktop-client/src/components/transactions/TransactionsTable.jsx b/packages/desktop-client/src/components/transactions/TransactionsTable.jsx index 17e4aa13ad6..a4fc123b4dd 100644 --- a/packages/desktop-client/src/components/transactions/TransactionsTable.jsx +++ b/packages/desktop-client/src/components/transactions/TransactionsTable.jsx @@ -2513,7 +2513,7 @@ function NotesCell({ const [selectedItem, setSelectedItem] = useState(null); const [pickerPosition, setPickerPosition] = useState({ top: 0, left: 0 }); const dispatch = useDispatch(); - const { theme } = useTheme(); + const [theme] = useTheme(); const handleContextMenu = (e, item) => { e.preventDefault(); diff --git a/packages/desktop-client/src/hooks/useTagPopover.ts b/packages/desktop-client/src/hooks/useTagPopover.ts index d97587cf740..6ca457201f6 100644 --- a/packages/desktop-client/src/hooks/useTagPopover.ts +++ b/packages/desktop-client/src/hooks/useTagPopover.ts @@ -37,7 +37,7 @@ export function useTagPopover(initialValue, onUpdate, componentRef) { const textBeforeCursor = newValue.slice(0, cursorPosition); const lastHashIndex = textBeforeCursor.lastIndexOf('#'); - if (lastHashIndex === -1) { + if (lastHashIndex === -1 || textBeforeCursor.split(' ').length > 1) { setHint(''); return; } @@ -75,6 +75,8 @@ export function useTagPopover(initialValue, onUpdate, componentRef) { if (e.key === '#') { setShowAutocomplete(!showAutocomplete); + } else if (e.key === ' ') { + setHint(''); } else if ( !['Shift', 'Escape', 'Enter', 'ArrowLeft', 'ArrowRight'].includes(e.key) ) { From 208e82a97a62f63d97de6c4fd024e1658e66a531 Mon Sep 17 00:00:00 2001 From: Leandro Menezes Date: Wed, 21 Aug 2024 19:23:11 -0300 Subject: [PATCH 23/39] Refactory of the tag detection and replacement. --- .../autocomplete/TagAutocomplete.tsx | 4 +- .../src/components/common/Input.tsx | 1 - .../src/components/common/InputWithTags.tsx | 6 +- .../desktop-client/src/components/table.tsx | 4 + .../desktop-client/src/hooks/useTagPopover.ts | 151 +++++++++++------- 5 files changed, 107 insertions(+), 59 deletions(-) diff --git a/packages/desktop-client/src/components/autocomplete/TagAutocomplete.tsx b/packages/desktop-client/src/components/autocomplete/TagAutocomplete.tsx index 93d421fc902..0c1af73af11 100644 --- a/packages/desktop-client/src/components/autocomplete/TagAutocomplete.tsx +++ b/packages/desktop-client/src/components/autocomplete/TagAutocomplete.tsx @@ -177,7 +177,7 @@ function TagList({ color: theme.noteTagText, cursor: 'pointer', transition: 'transform 0.3s ease, background-color 0.3s ease', - transform: selectedIndex === -1 ? 'scale(1.2)' : 'scale(1)', + transform: selectedIndex === -1 ? 'scale(1.1)' : 'scale(1)', zIndex: selectedIndex === -1 ? '1000' : 'unset', }} > @@ -223,7 +223,7 @@ function TagList({ color: item.textColor ?? theme.noteTagText, cursor: 'pointer', transition: 'transform 0.3s ease, background-color 0.3s ease', - transform: index === selectedIndex ? 'scale(1.2)' : 'scale(1)', + transform: index === selectedIndex ? 'scale(1.1)' : 'scale(1)', zIndex: index === selectedIndex ? '1000' : 'unset', textDecorationLine: index === selectedIndex ? 'underline' : 'unset', diff --git a/packages/desktop-client/src/components/common/Input.tsx b/packages/desktop-client/src/components/common/Input.tsx index a02d13c17f1..161b91d1e8b 100644 --- a/packages/desktop-client/src/components/common/Input.tsx +++ b/packages/desktop-client/src/components/common/Input.tsx @@ -67,7 +67,6 @@ export function Input({ )} ${className}`} {...nativeProps} onKeyDown={e => { - console.log('Input onKeyDown triggered:', e.key); // Add this log nativeProps.onKeyDown?.(e); if (e.key === 'Enter' && onEnter) { diff --git a/packages/desktop-client/src/components/common/InputWithTags.tsx b/packages/desktop-client/src/components/common/InputWithTags.tsx index b8c507ba112..aed3a32357f 100644 --- a/packages/desktop-client/src/components/common/InputWithTags.tsx +++ b/packages/desktop-client/src/components/common/InputWithTags.tsx @@ -46,8 +46,10 @@ export function InputWithTags({ setShowAutocomplete, keyPressed, setKeyPressed, + handleKeyUp, handleKeyDown, handleMenuSelect, + updateHint, } = useTagPopover(nativeProps.value, onUpdate, ref); const [inputValue, setInputValue] = useState(content); @@ -88,8 +90,9 @@ export function InputWithTags({ onEscape(e); } - handleKeyDown(e); + handleKeyDown?.(e); }} + onKeyUp={handleKeyUp} onBlur={e => { onUpdate?.(content); nativeProps.onBlur?.(e); @@ -99,6 +102,7 @@ export function InputWithTags({ onChangeValue?.(ref.current.value); nativeProps.onChange?.(e); }} + onFocus={() => updateHint(content)} /> updateHint(content)} onBlur={onBlur} style={{ textAlign, ...(inputProps && inputProps.style) }} + onKeyUp={handleKeyUp} onKeyDown={handleKeyDown} {...inputProps} /> diff --git a/packages/desktop-client/src/hooks/useTagPopover.ts b/packages/desktop-client/src/hooks/useTagPopover.ts index 6ca457201f6..81799156c4b 100644 --- a/packages/desktop-client/src/hooks/useTagPopover.ts +++ b/packages/desktop-client/src/hooks/useTagPopover.ts @@ -28,23 +28,59 @@ export function useTagPopover(initialValue, onUpdate, componentRef) { selection.addRange(range); }; + const extractTagAtCursor = useCallback((text, position) => { + let start = position - 1; + + // Traverse backwards to find the start of the current word or tag + while (start >= 0 && !isWordBoundary(text[start])) { + start--; + } + + // Handle double `##` escape case + if (text[start] === '#' && text[start + 1] === '#') { + return ''; + } + + // Check if the word is a tag, starting with a single # + if (text[start] !== '#' || (start > 0 && text[start - 1] === '#')) { + return ''; + } + + let end = start + 1; + + // Traverse forwards to find the end of the current tag + while (end < text.length && !isWordBoundary(text[end])) { + end++; + } + + // Extract the tag + const tag = text.slice(start, end); + + // Check if there are additional tags within the same word + if (tag.includes('#', 1)) { + const tags = tag.split('#').filter((t) => t.length > 0); + for (let i = 0; i < tags.length; i++) { + const tagStart = start + tag.indexOf(tags[i]); + const tagEnd = tagStart + tags[i].length + 1; + if (position >= tagStart && position <= tagEnd) { + return `#${tags[i]}`; + } + } + } + + return tag; + }, []); + const updateHint = useCallback( newValue => { const el = edit.current; if (!el) return; const cursorPosition = getCaretPosition(el); - const textBeforeCursor = newValue.slice(0, cursorPosition); - - const lastHashIndex = textBeforeCursor.lastIndexOf('#'); - if (lastHashIndex === -1 || textBeforeCursor.split(' ').length > 1) { - setHint(''); - return; - } - - setHint(textBeforeCursor.slice(lastHashIndex + 1, cursorPosition)); + const tag = extractTagAtCursor(newValue, cursorPosition); + setHint(tag?.replace("#","")); }, - [edit, getCaretPosition, setHint], + [edit, getCaretPosition, extractTagAtCursor] ); useEffect(() => { @@ -58,78 +94,81 @@ export function useTagPopover(initialValue, onUpdate, componentRef) { const handleKeyDown = e => { if (showAutocomplete) { - if (e.key === 'Escape') { - setShowAutocomplete(false); - e.preventDefault(); - e.stopPropagation(); - return; - } - - if (['ArrowLeft', 'ArrowRight', 'Tab'].includes(e.key)) { + if (['Escape', 'ArrowLeft', 'ArrowRight', 'Tab', 'Enter'].includes(e.key)) { setKeyPressed(e.key); e.preventDefault(); e.stopPropagation(); return; } } + }; - if (e.key === '#') { - setShowAutocomplete(!showAutocomplete); - } else if (e.key === ' ') { - setHint(''); - } else if ( - !['Shift', 'Escape', 'Enter', 'ArrowLeft', 'ArrowRight'].includes(e.key) - ) { - let cursorPosition = getCaretPosition(edit.current); - if (cursorPosition !== 0) { - const textBeforeCursor = content.slice(0, cursorPosition); - let foundHashtag = false; - while (cursorPosition >= 0) { - if (textBeforeCursor[cursorPosition] === '#') { - foundHashtag = true; - break; - } - - if (textBeforeCursor[cursorPosition] === ' ') { - break; - } - - cursorPosition--; - } + const handleKeyUp = e => { + if (['Escape', 'ArrowLeft', 'ArrowRight', 'Tab'].includes(e.key)) { + return; + } - if (foundHashtag) { - setShowAutocomplete(true); - } - } + const el = edit.current; + if (!el) return; + + const cursorPosition = getCaretPosition(el); + const tag = extractTagAtCursor(content, cursorPosition); + + if (tag) { + setShowAutocomplete(true); + } else { + setShowAutocomplete(false); } }; const handleMenuSelect = item => { if (!item) return; - - setContent(''); + debugger; const el = edit.current; const cursorPosition = getCaretPosition(el); const textBeforeCursor = el.value.slice(0, cursorPosition); + const textAfterCursor = el.value.slice(cursorPosition); - const lastHashIndex = textBeforeCursor.lastIndexOf('#'); - if (lastHashIndex === -1) { - setShowAutocomplete(false); - setHint(''); + // Find the start of the current tag + let tagStart = cursorPosition - 1; + while (tagStart >= 0 && !isWordBoundary(textBeforeCursor[tagStart])) { + tagStart--; + } + + // Ensure it's a valid tag (starting with a single # and not double ##) + if ( + textBeforeCursor[tagStart] !== '#' || + (tagStart > 0 && textBeforeCursor[tagStart + 1] === '#') + ) { return; } + // Find the end of the current tag + let tagEnd = cursorPosition; + while (tagEnd < el.value.length && !isWordBoundary(textAfterCursor[tagEnd - cursorPosition])) { + tagEnd++; + } + + // Replace the tag with the selected item const newContent = - textBeforeCursor.slice(0, lastHashIndex) + + el.value.slice(0, tagStart) + item.tag + - el.value.slice(cursorPosition) + - ' '; + el.value.slice(tagEnd); + // Update the content and reset the autocomplete state setContent(newContent); - handleSetCursorPosition(); setShowAutocomplete(false); setHint(''); + + // Reset the cursor position to the end of the newly inserted tag + const newCursorPosition = tagStart + item.tag.length + 1; + el.setSelectionRange(newCursorPosition, newCursorPosition); + el.focus(); + }; + + const isWordBoundary = (char) => { + return char === ' ' || char === '#' || char === undefined; }; return { @@ -139,9 +178,11 @@ export function useTagPopover(initialValue, onUpdate, componentRef) { showAutocomplete, keyPressed, handleKeyDown, + handleKeyUp, handleMenuSelect, handleSetCursorPosition, setShowAutocomplete, setKeyPressed, + updateHint, }; } From 2e10f1ad7a2d98e462dd1eaa41b1611bf33def32 Mon Sep 17 00:00:00 2001 From: Leandro Menezes Date: Wed, 21 Aug 2024 19:59:25 -0300 Subject: [PATCH 24/39] fixes some errors for filter and search bar --- .../autocomplete/TagAutocomplete.tsx | 9 +++- .../src/components/common/InputWithTags.tsx | 29 +++++++----- .../desktop-client/src/components/table.tsx | 1 + .../src/components/util/GenericInput.jsx | 26 ++++++++--- .../desktop-client/src/hooks/useTagPopover.ts | 45 +++++++++---------- 5 files changed, 66 insertions(+), 44 deletions(-) diff --git a/packages/desktop-client/src/components/autocomplete/TagAutocomplete.tsx b/packages/desktop-client/src/components/autocomplete/TagAutocomplete.tsx index 0c1af73af11..f2f438b67c4 100644 --- a/packages/desktop-client/src/components/autocomplete/TagAutocomplete.tsx +++ b/packages/desktop-client/src/components/autocomplete/TagAutocomplete.tsx @@ -15,6 +15,7 @@ function TagAutocomplete({ keyPressed, onKeyHandled, element, + allowCreate, }) { const tags = useTags(); const [suggestions, setSuggestions] = useState([]); @@ -41,7 +42,7 @@ function TagAutocomplete({ useEffect(() => { const minIndex = - hint.length > 0 && !suggestions.some(item => item.tag === `#${hint}`) + hint.length > 0 && allowCreate && !suggestions.some(item => item.tag === `#${hint}`) ? -1 : 0; if (keyPressed) { @@ -77,6 +78,7 @@ function TagAutocomplete({ selectedIndex={selectedIndex} hint={hint} element={element} + allowCreate={allowCreate} /> ); } @@ -89,6 +91,7 @@ function TagList({ selectedIndex, hint, element, + allowCreate, }) { const [width, setWidth] = useState(0); @@ -158,7 +161,7 @@ function TagList({ alignItems: 'baseline', }} > - {hint.length > 0 && !items.some(item => item.tag === `#${hint}`) && ( + {hint.length > 0 && allowCreate && !items.some(item => item.tag === `#${hint}`) && ( - )} + + Create + {' '} + + #{hint} + + + )} {items.map((item, index) => ( - )} {items.map((item, index) => ( + + + ))} + + )} + {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); + }} + /> + + )} ); } @@ -230,6 +349,7 @@ type TagPopoverProps = { keyPressed: string | null; onKeyHandled: () => void; onClose: () => void; + value: string | null; }; export function TagPopover({ @@ -240,6 +360,7 @@ export function TagPopover({ keyPressed, onKeyHandled, onClose, + value, }: TagPopoverProps) { const [showPopOver, setShowPopOver] = useState(isOpen); @@ -255,6 +376,7 @@ export function TagPopover({ placement="bottom start" > (); const { content, setContent, @@ -457,6 +457,7 @@ export function InputCellWithTags({ {...inputProps} /> setShowColors(isOpen)} placement="bottom start" offset={10} style={{ marginLeft: pickerPosition.left }} From f234783a00e8abbe5aedbc44176da56456cb9fcd Mon Sep 17 00:00:00 2001 From: Leandro Menezes Date: Thu, 22 Aug 2024 15:06:50 -0300 Subject: [PATCH 36/39] typecheck fixes --- .../src/components/autocomplete/TagAutocomplete.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/desktop-client/src/components/autocomplete/TagAutocomplete.tsx b/packages/desktop-client/src/components/autocomplete/TagAutocomplete.tsx index 1d9c311601d..05f7001f0bf 100644 --- a/packages/desktop-client/src/components/autocomplete/TagAutocomplete.tsx +++ b/packages/desktop-client/src/components/autocomplete/TagAutocomplete.tsx @@ -132,8 +132,8 @@ function TagList({ }: TagListProps) { const [width, setWidth] = useState(0); const [showColors, setShowColors] = useState(false); - const [selectedItem, setSelectedItem] = useState(null); - const [uncommitedTags, setUncommitedTags] = useState([]); + const [selectedItem, setSelectedItem] = useState(null); + const [uncommitedTags, setUncommitedTags] = useState([]); const colorRef = useRef(null); const dispatch = useDispatch(); From 9da4d013f27474c10c1ed84fcbce32b866c3e963 Mon Sep 17 00:00:00 2001 From: Leandro Menezes Date: Thu, 22 Aug 2024 16:41:30 -0300 Subject: [PATCH 37/39] hiding the popover when no suggestions are met --- .../autocomplete/TagAutocomplete.tsx | 24 ++++++++----------- .../src/components/common/InputWithTags.tsx | 2 +- .../desktop-client/src/components/table.tsx | 6 ++--- .../desktop-client/src/hooks/useTagPopover.ts | 8 +------ packages/loot-core/src/server/db/index.ts | 6 ++--- 5 files changed, 18 insertions(+), 28 deletions(-) diff --git a/packages/desktop-client/src/components/autocomplete/TagAutocomplete.tsx b/packages/desktop-client/src/components/autocomplete/TagAutocomplete.tsx index 05f7001f0bf..59e3af52a22 100644 --- a/packages/desktop-client/src/components/autocomplete/TagAutocomplete.tsx +++ b/packages/desktop-client/src/components/autocomplete/TagAutocomplete.tsx @@ -58,6 +58,10 @@ function TagAutocomplete({ name: tag.tag, })), ); + + if(filteredTags.length === 0) { + clickedOnIt(); + } } }, [tags, hint]); @@ -185,32 +189,25 @@ function TagList({ style={{ position: 'relative', maxWidth: width - 10, + minWidth: width - 10, flexDirection: 'column', flexWrap: 'wrap', overflow: 'visible', alignItems: 'baseline', }} > - {items.length === 0 && tags.length === 0 && ( + {/* {items.length === 0 && ( - {tags.length === 0 && ( - Tags will be added when saving the transaction. + Tags will be saved when saving the transaction. - )} - {/* {tags.length > 0 && ( - No tags created with these terms ({hint}) - )} */} - )} + )} */} {items.length > 0 && ( { - debugger; colorRef.current = e.target; setSelectedItem(item); setShowColors(true); @@ -265,7 +261,7 @@ function TagList({ ))} )} - {uncommitedTags.length > 0 && ( + {/* {uncommitedTags.length > 0 && ( <> - )} + )} */} {showColors && selectedItem && ( {}), ref); + } = useTagPopover(value?.toString() || '', ref); useEffect(() => { setContent(value?.toString() || ''); diff --git a/packages/desktop-client/src/components/table.tsx b/packages/desktop-client/src/components/table.tsx index 68d8670cd47..78c29b4c2d6 100644 --- a/packages/desktop-client/src/components/table.tsx +++ b/packages/desktop-client/src/components/table.tsx @@ -342,7 +342,7 @@ function InputValue({ } } - function onKeyDown(e) { + function onKeyDown(e: KeyboardEvent) { if (props.onKeyDown) { props.onKeyDown(e); } @@ -357,7 +357,7 @@ function InputValue({ if (value !== defaultValue) { setValue(defaultValue); } - } else if (shouldSaveFromKey(e)) { + } else if (!e.isDefaultPrevented() && shouldSaveFromKey(e)) { onUpdate?.(value); } } @@ -440,7 +440,7 @@ export function InputCellWithTags({ handleKeyUp, handleKeyDown, handleMenuSelect, - } = useTagPopover(props.value, onUpdate || (() => {}), edit); + } = useTagPopover(props.value, edit); return ( <> diff --git a/packages/desktop-client/src/hooks/useTagPopover.ts b/packages/desktop-client/src/hooks/useTagPopover.ts index 4f3c8070ecf..9c8321deadb 100644 --- a/packages/desktop-client/src/hooks/useTagPopover.ts +++ b/packages/desktop-client/src/hooks/useTagPopover.ts @@ -8,7 +8,6 @@ import { export function useTagPopover( initialValue: string, - onNewValue: (value: string) => void, componentRef: MutableRefObject, ) { const [showAutocomplete, setShowAutocomplete] = useState(false); @@ -86,18 +85,13 @@ export function useTagPopover( [], ); - useEffect(() => { - if (content !== undefined) { - onNewValue?.(content); - } - }, [content, onNewValue]); - const handleKeyDown = ( e: KeyboardEvent, ) => { if (showAutocomplete) { if (isCommandKeys(e)) { setShowAutocomplete(false); + e.stopPropagation(); return; } diff --git a/packages/loot-core/src/server/db/index.ts b/packages/loot-core/src/server/db/index.ts index 3d47bc70077..ed01ebc8c04 100644 --- a/packages/loot-core/src/server/db/index.ts +++ b/packages/loot-core/src/server/db/index.ts @@ -687,8 +687,8 @@ export async function getTags() { `); } -export function updateTag(tag) { - update('tags', { +export async function updateTag(tag) { + await update('tags', { id: tag.id, tag: tag.tag, color: tag.color, @@ -709,7 +709,7 @@ async function insertTags(transaction) { (await first('SELECT id FROM tags where tag = ?', [tag])) || {}; if (!id) { - insertWithUUID('tags', { + await insertWithUUID('tags', { tag, color: null, textColor: null, From 571c6b223b32f09c33f821c89d7123f054194585 Mon Sep 17 00:00:00 2001 From: Leandro Menezes Date: Thu, 22 Aug 2024 16:48:54 -0300 Subject: [PATCH 38/39] item selection fix --- .../components/autocomplete/TagAutocomplete.tsx | 15 ++++++--------- .../desktop-client/src/hooks/useTagPopover.ts | 3 ++- 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/packages/desktop-client/src/components/autocomplete/TagAutocomplete.tsx b/packages/desktop-client/src/components/autocomplete/TagAutocomplete.tsx index 59e3af52a22..9f9feeed457 100644 --- a/packages/desktop-client/src/components/autocomplete/TagAutocomplete.tsx +++ b/packages/desktop-client/src/components/autocomplete/TagAutocomplete.tsx @@ -58,25 +58,22 @@ function TagAutocomplete({ name: tag.tag, })), ); + setSelectedIndex(0); - if(filteredTags.length === 0) { + if (filteredTags.length === 0) { clickedOnIt(); } } - }, [tags, hint]); + }, [tags, hint, setSelectedIndex]); useEffect(() => { const minIndex = 0; if (keyPressed) { if (keyPressed === 'ArrowRight') { - if (selectedIndex + 1 === Math.min(suggestions.length, 10)) { - setSelectedIndex(minIndex); - } else { - setSelectedIndex( - prevIndex => (prevIndex + 1) % Math.min(suggestions.length, 10), - ); - } + setSelectedIndex( + prevIndex => (prevIndex + 1) % Math.min(suggestions.length, 10), + ); } else if (keyPressed === 'ArrowLeft') { setSelectedIndex(prevIndex => prevIndex === minIndex diff --git a/packages/desktop-client/src/hooks/useTagPopover.ts b/packages/desktop-client/src/hooks/useTagPopover.ts index 9c8321deadb..d34c52beefc 100644 --- a/packages/desktop-client/src/hooks/useTagPopover.ts +++ b/packages/desktop-client/src/hooks/useTagPopover.ts @@ -1,3 +1,4 @@ +import { TagEntity } from 'loot-core/types/models/tag'; import { type KeyboardEvent, useCallback, @@ -181,7 +182,7 @@ export function useTagPopover( return false; }; - const handleMenuSelect = (item: { tag: string }) => { + const handleMenuSelect = (item: TagEntity) => { setShowAutocomplete(false); setHint(''); From 31bd238297e423ab2c7673d7539d22c9fd4ab6e5 Mon Sep 17 00:00:00 2001 From: Leandro Menezes Date: Thu, 22 Aug 2024 16:51:05 -0300 Subject: [PATCH 39/39] more fixes --- .../src/components/autocomplete/TagAutocomplete.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/desktop-client/src/components/autocomplete/TagAutocomplete.tsx b/packages/desktop-client/src/components/autocomplete/TagAutocomplete.tsx index 9f9feeed457..629028e15f3 100644 --- a/packages/desktop-client/src/components/autocomplete/TagAutocomplete.tsx +++ b/packages/desktop-client/src/components/autocomplete/TagAutocomplete.tsx @@ -371,7 +371,10 @@ export function TagPopover({ { + setShowPopOver(false); + onClose(); + }} keyPressed={keyPressed} onKeyHandled={onKeyHandled} onMenuSelect={onMenuSelect}