diff --git a/packages/desktop-client/src/components/accounts/TransactionsTable.js b/packages/desktop-client/src/components/accounts/TransactionsTable.js index 3c93f8aff4844f2fbf927e5893f93546713d710f..9a8980c8e8c6463b5764dcc4c65ff35245a2bc29 100644 --- a/packages/desktop-client/src/components/accounts/TransactionsTable.js +++ b/packages/desktop-client/src/components/accounts/TransactionsTable.js @@ -36,10 +36,11 @@ import { amountToInteger, titleFirst, } from 'loot-core/src/shared/util'; -import AccountAutocomplete from 'loot-design/src/components/AccountAutocomplete'; +import LegacyAccountAutocomplete from 'loot-design/src/components/AccountAutocomplete'; import CategoryAutocomplete from 'loot-design/src/components/CategorySelect'; import { View, Text, Tooltip, Button } from 'loot-design/src/components/common'; import DateSelect from 'loot-design/src/components/DateSelect'; +import NewAccountAutocomplete from 'loot-design/src/components/NewAccountAutocomplete'; import NewPayeeAutocomplete from 'loot-design/src/components/NewPayeeAutocomplete'; import LegacyPayeeAutocomplete from 'loot-design/src/components/PayeeAutocomplete'; import { @@ -528,6 +529,11 @@ export const Transaction = React.memo(function Transaction(props) { onToggleSplit, } = props; + const isNewAutocompleteEnabled = useFeatureFlag('newAutocomplete'); + const AccountAutocomplete = isNewAutocompleteEnabled + ? NewAccountAutocomplete + : LegacyAccountAutocomplete; + let dispatchSelected = useSelectedDispatch(); let [prevShowZero, setPrevShowZero] = useState(showZeroInDeposit); diff --git a/packages/desktop-client/src/components/schedules/EditSchedule.js b/packages/desktop-client/src/components/schedules/EditSchedule.js index c78715e1adefb6528a79ee6be5844416ff4eca1c..90d7ebbdc32772d0fa03b7e7556d71be111758d0 100644 --- a/packages/desktop-client/src/components/schedules/EditSchedule.js +++ b/packages/desktop-client/src/components/schedules/EditSchedule.js @@ -8,7 +8,7 @@ import q, { runQuery, liveQuery } from 'loot-core/src/client/query-helpers'; import { send, sendCatch } from 'loot-core/src/platform/client/fetch'; import * as monthUtils from 'loot-core/src/shared/months'; import { extractScheduleConds } from 'loot-core/src/shared/schedules'; -import AccountAutocomplete from 'loot-design/src/components/AccountAutocomplete'; +import LegacyAccountAutocomplete from 'loot-design/src/components/AccountAutocomplete'; import { Stack, View, Text, Button } from 'loot-design/src/components/common'; import DateSelect from 'loot-design/src/components/DateSelect'; import { @@ -16,6 +16,7 @@ import { FormLabel, Checkbox, } from 'loot-design/src/components/forms'; +import NewAccountAutocomplete from 'loot-design/src/components/NewAccountAutocomplete'; import NewPayeeAutocomplete from 'loot-design/src/components/NewPayeeAutocomplete'; import LegacyPayeeAutocomplete from 'loot-design/src/components/PayeeAutocomplete'; import RecurringSchedulePicker from 'loot-design/src/components/RecurringSchedulePicker'; @@ -427,6 +428,9 @@ export default function ScheduleDetails() { const PayeeAutocomplete = isNewAutocompleteEnabled ? NewPayeeAutocomplete : LegacyPayeeAutocomplete; + const AccountAutocomplete = isNewAutocompleteEnabled + ? NewAccountAutocomplete + : LegacyAccountAutocomplete; return ( <Page diff --git a/packages/desktop-client/src/components/util/GenericInput.js b/packages/desktop-client/src/components/util/GenericInput.js index b2307439b30da5be74f58fc2050ced1c3b864a68..a6f6d88f20e60d9529ab1f687efecbcfec551cdc 100644 --- a/packages/desktop-client/src/components/util/GenericInput.js +++ b/packages/desktop-client/src/components/util/GenericInput.js @@ -2,12 +2,14 @@ import React from 'react'; import { useSelector } from 'react-redux'; import { getMonthYearFormat } from 'loot-core/src/shared/months'; -import AccountAutocomplete from 'loot-design/src/components/AccountAutocomplete'; -import Autocomplete from 'loot-design/src/components/Autocomplete'; +import LegacyAccountAutocomplete from 'loot-design/src/components/AccountAutocomplete'; +import LegacyAutocomplete from 'loot-design/src/components/Autocomplete'; import CategoryAutocomplete from 'loot-design/src/components/CategorySelect'; import { View, Input } from 'loot-design/src/components/common'; import DateSelect from 'loot-design/src/components/DateSelect'; import { Checkbox } from 'loot-design/src/components/forms'; +import NewAccountAutocomplete from 'loot-design/src/components/NewAccountAutocomplete'; +import NewAutocomplete from 'loot-design/src/components/NewAutocomplete'; import NewPayeeAutocomplete from 'loot-design/src/components/NewPayeeAutocomplete'; import LegacyPayeeAutocomplete from 'loot-design/src/components/PayeeAutocomplete'; import RecurringSchedulePicker from 'loot-design/src/components/RecurringSchedulePicker'; @@ -28,6 +30,9 @@ export default function GenericInput({ const PayeeAutocomplete = isNewAutocompleteEnabled ? NewPayeeAutocomplete : LegacyPayeeAutocomplete; + const AccountAutocomplete = isNewAutocompleteEnabled + ? NewAccountAutocomplete + : LegacyAccountAutocomplete; let { payees, accounts, categoryGroups, dateFormat } = useSelector(state => { return { @@ -163,15 +168,26 @@ export default function GenericInput({ <Checkbox checked={value} value={value} - onChange={e => onChange(!value)} + onChange={() => onChange(!value)} /> ); break; default: if (multi) { - content = ( - <Autocomplete + content = isNewAutocompleteEnabled ? ( + <NewAutocomplete + ref={inputRef} + isMulti + isCreatable + formatCreateLabel={inputValue => `Add "${inputValue}"`} + noOptionsMessage={() => null} + value={value.map(v => ({ value: v, label: v }))} + onSelect={onChange} + onCreateOption={selected => onChange([...value, selected])} + /> + ) : ( + <LegacyAutocomplete multi={true} suggestions={[]} value={value} diff --git a/packages/loot-design/src/components/NewAccountAutocomplete.js b/packages/loot-design/src/components/NewAccountAutocomplete.js new file mode 100644 index 0000000000000000000000000000000000000000..ef11d569ca8ce3a4148adbaf2abfb846e5c9ed4c --- /dev/null +++ b/packages/loot-design/src/components/NewAccountAutocomplete.js @@ -0,0 +1,62 @@ +import React, { useMemo } from 'react'; + +import { useCachedAccounts } from 'loot-core/src/client/data-hooks/accounts'; + +import Autocomplete from './NewAutocomplete'; + +export default function AccountAutocomplete({ + value, + includeClosedAccounts = true, + multi = false, + ...props +}) { + const accounts = useCachedAccounts() || []; + + const availableAccounts = useMemo( + () => + includeClosedAccounts ? accounts : accounts.filter(item => !item.closed), + [accounts, includeClosedAccounts], + ); + + const options = useMemo( + () => [ + { + label: 'For Budget', + options: availableAccounts + .filter(item => !item.offbudget) + .map(item => ({ + label: item.name, + value: item.id, + })), + }, + { + label: 'Off Budget', + options: availableAccounts + .filter(item => item.offbudget) + .map(item => ({ + label: item.name, + value: item.id, + })), + }, + ], + [availableAccounts], + ); + + const allOptions = useMemo( + () => options.reduce((carry, { options }) => [...carry, ...options], []), + [options], + ); + + return ( + <Autocomplete + options={options} + value={ + multi + ? allOptions.filter(item => value.includes(item.value)) + : allOptions.find(item => item.value === value) + } + isMulti={multi} + {...props} + /> + ); +} diff --git a/packages/loot-design/src/components/NewAutocomplete.js b/packages/loot-design/src/components/NewAutocomplete.js new file mode 100644 index 0000000000000000000000000000000000000000..dc2bf91f623285c3f3d6f6e6380468880fe7e600 --- /dev/null +++ b/packages/loot-design/src/components/NewAutocomplete.js @@ -0,0 +1,107 @@ +import React, { useState } from 'react'; +import Select from 'react-select'; + +import CreatableSelect from 'react-select/creatable'; + +import styles from './autocomplete-styles'; +import { NullComponent } from './common'; + +const Autocomplete = React.forwardRef( + ( + { + value, + options = [], + focused = false, + embedded = false, + onSelect, + onCreateOption, + isCreatable = false, + components = {}, + ...props + }, + ref, + ) => { + const [initialValue] = useState(value); + const [isOpen, setIsOpen] = useState(focused); + + const filterOption = (option, input) => { + return ( + option.data?.__isNew__ || + option.label.toLowerCase().includes(input?.toLowerCase()) + ); + }; + + const onChange = async selected => { + // Clear button clicked + if (!selected) { + onSelect(null); + return; + } + + // Create a new option + if (selected.__isNew__) { + onCreateOption(selected); + return; + } + + // Close the menu when making a successful selection + if (!Array.isArray(selected)) { + setIsOpen(false); + } + + // Multi-select has multiple selections + if (Array.isArray(selected)) { + onSelect(selected.map(option => option.value)); + return; + } + + onSelect(selected.value); + }; + + const onKeyDown = event => { + if (event.code === 'Escape') { + onSelect(initialValue); + setIsOpen(false); + return; + } + + if (!isOpen) { + setIsOpen(true); + } + }; + + const Component = isCreatable ? CreatableSelect : Select; + + return ( + <Component + ref={ref} + value={value} + menuIsOpen={isOpen || embedded} + autoFocus={embedded} + options={options} + placeholder="(none)" + captureMenuScroll={false} + onChange={onChange} + onKeyDown={onKeyDown} + onCreateOption={onCreateOption} + onBlur={() => setIsOpen(false)} + onFocus={() => setIsOpen(true)} + isClearable + filterOption={filterOption} + components={{ + IndicatorSeparator: NullComponent, + DropdownIndicator: NullComponent, + ...components, + }} + maxMenuHeight={200} + styles={styles} + embedded={embedded} + menuPlacement="auto" + menuPortalTarget={embedded ? undefined : document.body} + {...props} + /> + ); + }, +); + +export default Autocomplete; diff --git a/packages/loot-design/src/components/NewPayeeAutocomplete.js b/packages/loot-design/src/components/NewPayeeAutocomplete.js index 1812c88e399ce1cc9a2ae287df2b3c98d5d5a62b..c6310f60f38d189196d79dbffef7c1a15d00bfcb 100644 --- a/packages/loot-design/src/components/NewPayeeAutocomplete.js +++ b/packages/loot-design/src/components/NewPayeeAutocomplete.js @@ -1,8 +1,6 @@ -import React, { useState, useMemo, useRef } from 'react'; +import React, { useState, useMemo } from 'react'; import { useDispatch } from 'react-redux'; -import Select, { components as SelectComponents } from 'react-select'; - -import Creatable from 'react-select/creatable'; +import { components as SelectComponents } from 'react-select'; import { createPayee } from 'loot-core/src/client/actions/queries'; import { useCachedAccounts } from 'loot-core/src/client/data-hooks/accounts'; @@ -13,8 +11,8 @@ import { colors } from '../style'; import Add from '../svg/v1/Add'; import { AutocompleteFooter, AutocompleteFooterButton } from './Autocomplete'; -import styles from './autocomplete-styles'; -import { View, NullComponent } from './common'; +import { View } from './common'; +import Autocomplete from './NewAutocomplete'; function getPayeeSuggestions(payees, focusTransferPayees, accounts) { let activePayees = @@ -54,21 +52,15 @@ function MenuListWithFooter(props) { export default function PayeeAutocomplete({ value, - focused = false, + multi = false, showMakeTransfer = true, showManagePayees = false, defaultFocusTransferPayees = false, - embedded = false, - multi = false, isCreatable = false, onSelect, onManagePayees, ...props }) { - const selectRef = useRef(); - const [initialValue] = useState(value); - const [isOpen, setIsOpen] = useState(focused); - const [inputValue, setInputValue] = useState(); const payees = useCachedPayees(); const accounts = useCachedAccounts(); @@ -86,85 +78,36 @@ export default function PayeeAutocomplete({ const dispatch = useDispatch(); - const onChange = async selected => { - // Clear button clicked - if (!selected) { - onSelect(null); - return; - } - - // Close the menu when making a successful selection - if (!Array.isArray(selected)) { - setIsOpen(false); - } - - const existingOption = allOptions.find(option => - filterOption(option, selected.label), - ); - if (selected.__isNew__) { - // Prevent creating duplicates - if (existingOption) { - onSelect(existingOption.value); - return; - } - - // This is actually a new payee, so create it - onSelect(await dispatch(createPayee(selected.value))); - return; - } - - // Multi-select has multiple selections - if (Array.isArray(selected)) { - onSelect(selected.map(option => option.value)); - return; - } - - onSelect(selected.value); - }; - - const filterOption = (option, input) => { - return ( - option.data?.__isNew__ || - option.label.toLowerCase().includes(input?.toLowerCase()) - ); - }; - - const onKeyDown = event => { - const ESC = 27; - - if (event.keyCode === ESC) { - onSelect(initialValue); - setIsOpen(false); - return; - } - - if (!isOpen) { - setIsOpen(true); - } - }; - const useCreatableComponent = isCreatable && focusTransferPayees === false; - const Component = useCreatableComponent ? Creatable : Select; + const [inputValue, setInputValue] = useState(); return ( - <Component - ref={selectRef} - menuIsOpen={isOpen || embedded} - autoFocus={embedded} + <Autocomplete options={options} value={ multi ? allOptions.filter(item => value.includes(item.value)) : allOptions.find(item => item.value === value) } + isMulti={multi} inputValue={inputValue} - placeholder="(none)" - captureMenuScroll={false} - onChange={onChange} - onKeyDown={onKeyDown} - onBlur={() => setIsOpen(false)} - onFocus={() => setIsOpen(true)} onInputChange={setInputValue} + onSelect={onSelect} + onCreateOption={async selected => { + const existingOption = allOptions.find(option => + option.label.toLowerCase().includes(selected.label?.toLowerCase()), + ); + + // Prevent creating duplicates + if (existingOption) { + onSelect(existingOption.value); + return; + } + + // This is actually a new option, so create it + onSelect(await dispatch(createPayee(selected.value))); + }} + isCreatable={useCreatableComponent} createOptionPosition="first" formatCreateLabel={inputValue => ( <View @@ -189,19 +132,9 @@ export default function PayeeAutocomplete({ Create Payee "{inputValue}" </View> )} - isClearable - filterOption={filterOption} components={{ MenuList: MenuListWithFooter, - IndicatorSeparator: NullComponent, - DropdownIndicator: NullComponent, }} - maxMenuHeight={200} - styles={styles} - embedded={embedded} - isMulti={multi} - menuPlacement="auto" - menuPortalTarget={embedded ? undefined : document.body} footer={ <AutocompleteFooter show={showMakeTransfer || showManagePayees}> {showMakeTransfer && ( diff --git a/packages/loot-design/src/components/modals/EditField.js b/packages/loot-design/src/components/modals/EditField.js index e628a19b52e9f80fd44f5701fe12be509f9a07f1..ce88888a9b951cc34680d06de0d49a086af5a0b9 100644 --- a/packages/loot-design/src/components/modals/EditField.js +++ b/packages/loot-design/src/components/modals/EditField.js @@ -8,11 +8,12 @@ import { currentDay, dayFromDate } from 'loot-core/src/shared/months'; import { amountToInteger } from 'loot-core/src/shared/util'; import { colors } from '../../style'; -import AccountAutocomplete from '../AccountAutocomplete'; +import LegacyAccountAutocomplete from '../AccountAutocomplete'; import CategoryAutocomplete from '../CategorySelect'; import { View, Modal, Input } from '../common'; import DateSelect from '../DateSelect'; import { SectionLabel } from '../forms'; +import NewAccountAutocomplete from '../NewAccountAutocomplete'; import NewPayeeAutocomplete from '../NewPayeeAutocomplete'; import LegacyPayeeAutocomplete from '../PayeeAutocomplete'; @@ -51,6 +52,10 @@ function EditField({ ? NewPayeeAutocomplete : LegacyPayeeAutocomplete; + const AccountAutocomplete = isNewAutocompleteEnabled + ? NewAccountAutocomplete + : LegacyAccountAutocomplete; + switch (name) { case 'date': { let today = currentDay(); diff --git a/upcoming-release-notes/778.md b/upcoming-release-notes/778.md new file mode 100644 index 0000000000000000000000000000000000000000..c621575323e78213c5eb91522ccc680a0c8a3725 --- /dev/null +++ b/upcoming-release-notes/778.md @@ -0,0 +1,6 @@ +--- +category: Maintenance +authors: [MatissJanis] +--- + +Further autocomplete component refactors: AccountAutocomplete & GenericInput