Autocomplete changes related to mobile modals PR (#2500)

* Autocomplete changes related to mobile modals PR

* Release notes

* Fix lint error

* AccountDetails

* Code review updates
This commit is contained in:
Joel Jeremy Marquez
2024-03-25 08:23:29 -07:00
committed by GitHub
parent 24e42daa51
commit 5ee7d336ef
31 changed files with 388 additions and 219 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 28 KiB

View File

@@ -1,5 +1,10 @@
// @ts-strict-ignore
import React, { Fragment, type ComponentProps, type ReactNode } from 'react';
import React, {
Fragment,
type ComponentProps,
type ComponentPropsWithoutRef,
type ReactElement,
} from 'react';
import { css } from 'glamor';
@@ -7,11 +12,29 @@ import { type AccountEntity } from 'loot-core/src/types/models';
import { useAccounts } from '../../hooks/useAccounts';
import { useResponsive } from '../../ResponsiveProvider';
import { type CSSProperties, theme } from '../../style';
import { type CSSProperties, theme, styles } from '../../style';
import { TextOneLine } from '../common/TextOneLine';
import { View } from '../common/View';
import { Autocomplete } from './Autocomplete';
import { ItemHeader, type ItemHeaderProps } from './ItemHeader';
import { ItemHeader } from './ItemHeader';
type AccountAutocompleteItem = AccountEntity;
type AccountListProps = {
items: AccountAutocompleteItem[];
getItemProps: (arg: {
item: AccountAutocompleteItem;
}) => ComponentProps<typeof View>;
highlightedIndex: number;
embedded: boolean;
renderAccountItemGroupHeader?: (
props: ComponentPropsWithoutRef<typeof ItemHeader>,
) => ReactElement<typeof ItemHeader>;
renderAccountItem?: (
props: ComponentPropsWithoutRef<typeof AccountItem>,
) => ReactElement<typeof AccountItem>;
};
function AccountList({
items,
@@ -20,7 +43,7 @@ function AccountList({
embedded,
renderAccountItemGroupHeader = defaultRenderAccountItemGroupHeader,
renderAccountItem = defaultRenderAccountItem,
}) {
}: AccountListProps) {
let lastItem = null;
return (
@@ -69,13 +92,19 @@ function AccountList({
);
}
type AccountAutoCompleteProps = {
type AccountAutocompleteProps = ComponentProps<
typeof Autocomplete<AccountAutocompleteItem>
> & {
embedded?: boolean;
includeClosedAccounts: boolean;
renderAccountItemGroupHeader?: (props: ItemHeaderProps) => ReactNode;
renderAccountItem?: (props: AccountItemProps) => ReactNode;
includeClosedAccounts?: boolean;
renderAccountItemGroupHeader?: (
props: ComponentPropsWithoutRef<typeof ItemHeader>,
) => ReactElement<typeof ItemHeader>;
renderAccountItem?: (
props: ComponentPropsWithoutRef<typeof AccountItem>,
) => ReactElement<typeof AccountItem>;
closeOnBlur?: boolean;
} & ComponentProps<typeof Autocomplete>;
};
export function AccountAutocomplete({
embedded,
@@ -84,12 +113,12 @@ export function AccountAutocomplete({
renderAccountItem,
closeOnBlur,
...props
}: AccountAutoCompleteProps) {
let accounts = useAccounts() || [];
}: AccountAutocompleteProps) {
const accounts = useAccounts() || [];
//remove closed accounts if needed
//then sort by closed, then offbudget
accounts = accounts
const accountSuggestions: AccountAutocompleteItem[] = accounts
.filter(item => {
return includeClosedAccounts ? item : !item.closed;
})
@@ -107,7 +136,7 @@ export function AccountAutocomplete({
highlightFirst={true}
embedded={embedded}
closeOnBlur={closeOnBlur}
suggestions={accounts}
suggestions={accountSuggestions}
renderItems={(items, getItemProps, highlightedIndex) => (
<AccountList
items={items}
@@ -124,13 +153,13 @@ export function AccountAutocomplete({
}
function defaultRenderAccountItemGroupHeader(
props: ItemHeaderProps,
): ReactNode {
props: ComponentPropsWithoutRef<typeof ItemHeader>,
): ReactElement<typeof ItemHeader> {
return <ItemHeader {...props} type="account" />;
}
type AccountItemProps = {
item: AccountEntity;
item: AccountAutocompleteItem;
className?: string;
style?: CSSProperties;
highlighted?: boolean;
@@ -145,6 +174,15 @@ export function AccountItem({
...props
}: AccountItemProps) {
const { isNarrowWidth } = useResponsive();
const narrowStyle = isNarrowWidth
? {
...styles.mobileMenuItem,
color: theme.menuItemText,
borderRadius: 0,
borderTop: `1px solid ${theme.pillBorder}`,
}
: {};
return (
<div
// List each account up to a max
@@ -180,17 +218,20 @@ export function AccountItem({
padding: 4,
paddingLeft: 20,
borderRadius: embedded ? 4 : 0,
...narrowStyle,
},
])}`}
data-testid={`${item.name}-account-item`}
data-highlighted={highlighted || undefined}
{...props}
>
{item.name}
<TextOneLine>{item.name}</TextOneLine>
</div>
);
}
function defaultRenderAccountItem(props: AccountItemProps): ReactNode {
function defaultRenderAccountItem(
props: ComponentPropsWithoutRef<typeof AccountItem>,
): ReactElement<typeof AccountItem> {
return <AccountItem {...props} />;
}

View File

@@ -15,12 +15,45 @@ import Downshift, { type StateChangeTypes } from 'downshift';
import { css } from 'glamor';
import { SvgRemove } from '../../icons/v2';
import { theme, type CSSProperties } from '../../style';
import { useResponsive } from '../../ResponsiveProvider';
import { theme, type CSSProperties, styles } from '../../style';
import { Button } from '../common/Button';
import { Input } from '../common/Input';
import { View } from '../common/View';
import { Tooltip } from '../tooltips';
type CommonAutocompleteProps<T extends Item> = {
focused?: boolean;
embedded?: boolean;
containerProps?: HTMLProps<HTMLDivElement>;
labelProps?: { id?: string };
inputProps?: Omit<ComponentProps<typeof Input>, 'onChange'> & {
onChange?: (value: string) => void;
};
suggestions?: T[];
tooltipStyle?: CSSProperties;
tooltipProps?: ComponentProps<typeof Tooltip>;
renderInput?: (props: ComponentProps<typeof Input>) => ReactNode;
renderItems?: (
items: T[],
getItemProps: (arg: { item: T }) => ComponentProps<typeof View>,
idx: number,
value?: string,
) => ReactNode;
itemToString?: (item: T) => string;
shouldSaveFromKey?: (e: KeyboardEvent) => boolean;
filterSuggestions?: (suggestions: T[], value: string) => T[];
openOnFocus?: boolean;
getHighlightedIndex?: (suggestions: T[]) => number | null;
highlightFirst?: boolean;
onUpdate?: (id: T['id'], value: string) => void;
strict?: boolean;
clearOnBlur?: boolean;
clearOnSelect?: boolean;
closeOnBlur?: boolean;
onClose?: () => void;
};
type Item = {
id?: string;
name: string;
@@ -41,7 +74,7 @@ function findItem<T extends Item>(
return value;
}
function getItemName(item: null | string | Item): string {
function getItemName<T extends Item>(item: T | T['name'] | null): string {
if (item == null) {
return '';
} else if (typeof item === 'string') {
@@ -50,7 +83,7 @@ function getItemName(item: null | string | Item): string {
return item.name || '';
}
function getItemId(item: Item | Item['id']) {
function getItemId<T extends Item>(item: T | T['id']) {
if (typeof item === 'string') {
return item;
}
@@ -168,38 +201,12 @@ function defaultItemToString<T extends Item>(item?: T) {
return item ? getItemName(item) : '';
}
type SingleAutocompleteProps<T extends Item> = {
focused?: boolean;
embedded?: boolean;
containerProps?: HTMLProps<HTMLDivElement>;
labelProps?: { id?: string };
inputProps?: Omit<ComponentProps<typeof Input>, 'onChange'> & {
onChange?: (value: string) => void;
};
suggestions?: T[];
tooltipStyle?: CSSProperties;
tooltipProps?: ComponentProps<typeof Tooltip>;
renderInput?: (props: ComponentProps<typeof Input>) => ReactNode;
renderItems?: (
items: T[],
getItemProps: (arg: { item: T }) => ComponentProps<typeof View>,
idx: number,
value?: string,
) => ReactNode;
itemToString?: (item: T) => string;
shouldSaveFromKey?: (e: KeyboardEvent) => boolean;
filterSuggestions?: (suggestions: T[], value: string) => T[];
openOnFocus?: boolean;
getHighlightedIndex?: (suggestions: T[]) => number | null;
highlightFirst?: boolean;
onUpdate?: (id: T['id'], value: string) => void;
strict?: boolean;
type SingleAutocompleteProps<T extends Item> = CommonAutocompleteProps<T> & {
type?: 'single' | never;
onSelect: (id: T['id'], value: string) => void;
tableBehavior?: boolean;
closeOnBlur?: boolean;
value: null | T | T['id'];
isMulti?: boolean;
};
function SingleAutocomplete<T extends Item>({
focused,
embedded = false,
@@ -220,10 +227,11 @@ function SingleAutocomplete<T extends Item>({
onUpdate,
strict,
onSelect,
tableBehavior,
clearOnBlur = true,
clearOnSelect = false,
closeOnBlur = true,
onClose,
value: initialValue,
isMulti = false,
}: SingleAutocompleteProps<T>) {
const [selectedItem, setSelectedItem] = useState(() =>
findItem(strict, suggestions, initialValue),
@@ -239,6 +247,26 @@ function SingleAutocomplete<T extends Item>({
);
const [highlightedIndex, setHighlightedIndex] = useState(null);
const [isOpen, setIsOpen] = useState(embedded);
const open = () => setIsOpen(true);
const close = () => {
setIsOpen(false);
onClose?.();
};
const { isNarrowWidth } = useResponsive();
const narrowInputStyle = isNarrowWidth
? {
...styles.mobileMenuItem,
}
: {};
inputProps = {
...inputProps,
style: {
...narrowInputStyle,
...inputProps.style,
},
};
// Update the selected item if the suggestion list or initial
// input value has changed
@@ -273,10 +301,10 @@ function SingleAutocomplete<T extends Item>({
setSelectedItem(item);
setHighlightedIndex(null);
if (isMulti) {
if (clearOnSelect) {
setValue('');
} else {
setIsOpen(false);
close();
}
if (onSelect) {
@@ -359,11 +387,11 @@ function SingleAutocomplete<T extends Item>({
setValue(value);
setIsChanged(true);
setIsOpen(true);
open();
}}
onStateChange={changes => {
if (
tableBehavior &&
!clearOnBlur &&
changes.type === Downshift.stateChangeTypes.mouseUp
) {
return;
@@ -422,7 +450,7 @@ function SingleAutocomplete<T extends Item>({
inputProps.onFocus?.(e);
if (openOnFocus) {
setIsOpen(true);
open();
}
},
onBlur: e => {
@@ -432,11 +460,11 @@ function SingleAutocomplete<T extends Item>({
if (!closeOnBlur) return;
if (!tableBehavior) {
if (clearOnBlur) {
if (e.target.value === '') {
onSelect?.(null, e.target.value);
setSelectedItem(null);
setIsOpen(false);
close();
return;
}
@@ -446,7 +474,7 @@ function SingleAutocomplete<T extends Item>({
resetState(value);
} else {
setIsOpen(false);
close();
}
},
onKeyDown: (e: KeyboardEvent<HTMLInputElement>) => {
@@ -506,7 +534,11 @@ function SingleAutocomplete<T extends Item>({
setValue(getItemName(originalItem));
setSelectedItem(findItem(strict, suggestions, originalItem));
setHighlightedIndex(null);
setIsOpen(embedded ? true : false);
if (embedded) {
open();
} else {
close();
}
}
},
onChange: (e: ChangeEvent<HTMLInputElement>) => {
@@ -579,36 +611,37 @@ function MultiItem({ name, onRemove }: MultiItemProps) {
);
}
type MultiAutocompleteProps<
T extends Item,
Value = SingleAutocompleteProps<T>['value'],
> = Omit<SingleAutocompleteProps<T>, 'value' | 'onSelect'> & {
value: Value[];
onSelect: (ids: Value[], id?: string) => void;
type MultiAutocompleteProps<T extends Item> = CommonAutocompleteProps<T> & {
type: 'multi';
onSelect: (ids: T['id'][], id?: T['id']) => void;
value: null | T[] | T['id'][];
};
function MultiAutocomplete<T extends Item>({
value: selectedItems,
value: selectedItems = [],
onSelect,
suggestions,
strict,
clearOnBlur = true,
...props
}: MultiAutocompleteProps<T>) {
const [focused, setFocused] = useState(false);
const lastSelectedItems = useRef<typeof selectedItems>();
const selectedItemIds = selectedItems.map(getItemId);
useEffect(() => {
lastSelectedItems.current = selectedItems;
});
function onRemoveItem(id: (typeof selectedItems)[0]) {
const items = selectedItems.filter(i => i !== id);
function onRemoveItem(id: T['id']) {
const items = selectedItemIds.filter(i => i !== id);
onSelect(items);
}
function onAddItem(id: string) {
function onAddItem(id: T['id']) {
if (id) {
id = id.trim();
onSelect([...selectedItems, id], id);
onSelect([...selectedItemIds, id], id);
}
}
@@ -617,7 +650,7 @@ function MultiAutocomplete<T extends Item>({
prevOnKeyDown?: ComponentProps<typeof Input>['onKeyDown'],
) {
if (e.key === 'Backspace' && e.currentTarget.value === '') {
onRemoveItem(selectedItems[selectedItems.length - 1]);
onRemoveItem(selectedItemIds[selectedItems.length - 1]);
}
prevOnKeyDown?.(e);
@@ -626,10 +659,12 @@ function MultiAutocomplete<T extends Item>({
return (
<Autocomplete
{...props}
isMulti
type="single"
value={null}
clearOnBlur={clearOnBlur}
clearOnSelect={true}
suggestions={suggestions.filter(
item => !selectedItems.includes(getItemId(item)),
item => !selectedItemIds.includes(getItemId(item)),
)}
onSelect={onAddItem}
highlightFirst
@@ -721,18 +756,10 @@ type AutocompleteProps<T extends Item> =
| ComponentProps<typeof SingleAutocomplete<T>>
| ComponentProps<typeof MultiAutocomplete<T>>;
function isMultiAutocomplete<T extends Item>(
_props: AutocompleteProps<T>,
multi?: boolean,
): _props is ComponentProps<typeof MultiAutocomplete<T>> {
return multi;
}
export function Autocomplete<T extends Item>({
multi,
...props
}: AutocompleteProps<T> & { multi?: boolean }) {
if (isMultiAutocomplete(props, multi)) {
}: AutocompleteProps<T>) {
if (props.type === 'multi') {
return <MultiAutocomplete {...props} />;
}

View File

@@ -5,6 +5,8 @@ import React, {
type ReactNode,
type SVGProps,
type ComponentType,
type ComponentPropsWithoutRef,
type ReactElement,
} from 'react';
import { css } from 'glamor';
@@ -16,26 +18,35 @@ import {
import { SvgSplit } from '../../icons/v0';
import { useResponsive } from '../../ResponsiveProvider';
import { type CSSProperties, theme } from '../../style';
import { type CSSProperties, theme, styles } from '../../style';
import { Text } from '../common/Text';
import { TextOneLine } from '../common/TextOneLine';
import { View } from '../common/View';
import { Autocomplete, defaultFilterSuggestion } from './Autocomplete';
import { ItemHeader, type ItemHeaderProps } from './ItemHeader';
import { ItemHeader } from './ItemHeader';
type CategoryAutocompleteItem = CategoryEntity & {
group?: CategoryGroupEntity;
};
export type CategoryListProps = {
items: Array<CategoryEntity & { group?: CategoryGroupEntity }>;
items: CategoryAutocompleteItem[];
getItemProps?: (arg: {
item: CategoryEntity;
item: CategoryAutocompleteItem;
}) => Partial<ComponentProps<typeof View>>;
highlightedIndex: number;
embedded?: boolean;
footer?: ReactNode;
renderSplitTransactionButton?: (
props: SplitTransactionButtonProps,
) => ReactNode;
renderCategoryItemGroupHeader?: (props: ItemHeaderProps) => ReactNode;
renderCategoryItem?: (props: CategoryItemProps) => ReactNode;
props: ComponentPropsWithoutRef<typeof SplitTransactionButton>,
) => ReactElement<typeof SplitTransactionButton>;
renderCategoryItemGroupHeader?: (
props: ComponentPropsWithoutRef<typeof ItemHeader>,
) => ReactElement<typeof ItemHeader>;
renderCategoryItem?: (
props: ComponentPropsWithoutRef<typeof CategoryItem>,
) => ReactElement<typeof CategoryItem>;
showHiddenItems?: boolean;
};
function CategoryList({
@@ -84,10 +95,8 @@ function CategoryList({
{renderCategoryItemGroupHeader({
title: groupName,
style: {
color:
showHiddenItems && item.group?.hidden
? theme.pageTextSubdued
: theme.menuAutoCompleteTextHeader,
...(showHiddenItems &&
item.group?.hidden && { color: theme.pageTextSubdued }),
},
})}
</Fragment>
@@ -99,10 +108,8 @@ function CategoryList({
highlighted: highlightedIndex === idx,
embedded,
style: {
color:
showHiddenItems && item.hidden
? theme.pageTextSubdued
: 'inherit',
...(showHiddenItems &&
item.hidden && { color: theme.pageTextSubdued }),
},
})}
</Fragment>
@@ -116,16 +123,20 @@ function CategoryList({
}
type CategoryAutocompleteProps = ComponentProps<
typeof Autocomplete<CategoryGroupEntity>
typeof Autocomplete<CategoryAutocompleteItem>
> & {
categoryGroups: Array<CategoryGroupEntity>;
showSplitOption?: boolean;
renderSplitTransactionButton?: (
props: SplitTransactionButtonProps,
) => ReactNode;
renderCategoryItemGroupHeader?: (props: ItemHeaderProps) => ReactNode;
renderCategoryItem?: (props: CategoryItemProps) => ReactNode;
showHiddenItems?: boolean;
props: ComponentPropsWithoutRef<typeof SplitTransactionButton>,
) => ReactElement<typeof SplitTransactionButton>;
renderCategoryItemGroupHeader?: (
props: ComponentPropsWithoutRef<typeof ItemHeader>,
) => ReactElement<typeof ItemHeader>;
renderCategoryItem?: (
props: ComponentPropsWithoutRef<typeof CategoryItem>,
) => ReactElement<typeof CategoryItem>;
showHiddenCategories?: boolean;
};
export function CategoryAutocomplete({
@@ -136,12 +147,10 @@ export function CategoryAutocomplete({
renderSplitTransactionButton,
renderCategoryItemGroupHeader,
renderCategoryItem,
showHiddenItems,
showHiddenCategories,
...props
}: CategoryAutocompleteProps) {
const categorySuggestions: Array<
CategoryEntity & { group?: CategoryGroupEntity }
> = useMemo(
const categorySuggestions: CategoryAutocompleteItem[] = useMemo(
() =>
categoryGroups.reduce(
(list, group) =>
@@ -190,7 +199,7 @@ export function CategoryAutocomplete({
renderSplitTransactionButton={renderSplitTransactionButton}
renderCategoryItemGroupHeader={renderCategoryItemGroupHeader}
renderCategoryItem={renderCategoryItem}
showHiddenItems={showHiddenItems}
showHiddenItems={showHiddenCategories}
/>
)}
{...props}
@@ -198,7 +207,9 @@ export function CategoryAutocomplete({
);
}
function defaultRenderCategoryItemGroupHeader(props: ItemHeaderProps) {
function defaultRenderCategoryItemGroupHeader(
props: ComponentPropsWithoutRef<typeof ItemHeader>,
): ReactElement<typeof ItemHeader> {
return <ItemHeader {...props} type="category" />;
}
@@ -277,12 +288,12 @@ function SplitTransactionButton({
function defaultRenderSplitTransactionButton(
props: SplitTransactionButtonProps,
) {
): ReactElement<typeof SplitTransactionButton> {
return <SplitTransactionButton {...props} />;
}
type CategoryItemProps = {
item: CategoryEntity & { group?: CategoryGroupEntity };
item: CategoryAutocompleteItem;
className?: string;
style?: CSSProperties;
highlighted?: boolean;
@@ -298,6 +309,15 @@ export function CategoryItem({
...props
}: CategoryItemProps) {
const { isNarrowWidth } = useResponsive();
const narrowStyle = isNarrowWidth
? {
...styles.mobileMenuItem,
color: theme.menuItemText,
borderRadius: 0,
borderTop: `1px solid ${theme.pillBorder}`,
}
: {};
return (
<div
style={style}
@@ -313,18 +333,23 @@ export function CategoryItem({
padding: 4,
paddingLeft: 20,
borderRadius: embedded ? 4 : 0,
...narrowStyle,
},
])}`}
data-testid={`${item.name}-category-item`}
data-highlighted={highlighted || undefined}
{...props}
>
{item.name}
{item.hidden ? ' (hidden)' : null}
<TextOneLine>
{item.name}
{item.hidden ? ' (hidden)' : null}
</TextOneLine>
</div>
);
}
function defaultRenderCategoryItem(props: CategoryItemProps) {
function defaultRenderCategoryItem(
props: ComponentPropsWithoutRef<typeof CategoryItem>,
): ReactElement<typeof CategoryItem> {
return <CategoryItem {...props} />;
}

View File

@@ -1,20 +1,32 @@
import React from 'react';
import { theme } from '../../style/theme';
import { useResponsive } from '../../ResponsiveProvider';
import { styles, theme } from '../../style';
import { type CSSProperties } from '../../style/types';
export type ItemHeaderProps = {
type ItemHeaderProps = {
title: string;
style?: CSSProperties;
type?: string;
};
export function ItemHeader({ title, style, type, ...props }: ItemHeaderProps) {
const { isNarrowWidth } = useResponsive();
const narrowStyle = isNarrowWidth
? {
...styles.largeText,
color: theme.menuItemTextHeader,
paddingTop: 10,
paddingBottom: 10,
}
: {};
return (
<div
style={{
color: theme.menuAutoCompleteTextHeader,
padding: '4px 9px',
...narrowStyle,
...style,
}}
data-testid={`${title}-${type}-item-group`}

View File

@@ -7,6 +7,8 @@ import React, {
type ReactNode,
type ComponentType,
type SVGProps,
type ComponentPropsWithoutRef,
type ReactElement,
} from 'react';
import { useDispatch } from 'react-redux';
@@ -23,8 +25,9 @@ import { useAccounts } from '../../hooks/useAccounts';
import { usePayees } from '../../hooks/usePayees';
import { SvgAdd } from '../../icons/v1';
import { useResponsive } from '../../ResponsiveProvider';
import { type CSSProperties, theme } from '../../style';
import { type CSSProperties, theme, styles } from '../../style';
import { Button } from '../common/Button';
import { TextOneLine } from '../common/TextOneLine';
import { View } from '../common/View';
import {
@@ -32,9 +35,15 @@ import {
defaultFilterSuggestion,
AutocompleteFooter,
} from './Autocomplete';
import { ItemHeader, type ItemHeaderProps } from './ItemHeader';
import { ItemHeader } from './ItemHeader';
function getPayeeSuggestions(payees, focusTransferPayees, accounts) {
type PayeeAutocompleteItem = PayeeEntity;
function getPayeeSuggestions(
payees: PayeeAutocompleteItem[],
focusTransferPayees: boolean,
accounts: AccountEntity[],
): PayeeAutocompleteItem[] {
let activePayees = accounts ? getActivePayees(payees, accounts) : payees;
if (focusTransferPayees && activePayees) {
@@ -44,11 +53,11 @@ function getPayeeSuggestions(payees, focusTransferPayees, accounts) {
return activePayees || [];
}
function makeNew(value, rawPayee) {
if (value === 'new' && !rawPayee.startsWith('new:')) {
function makeNew(id, rawPayee) {
if (id === 'new' && !rawPayee.startsWith('new:')) {
return 'new:' + rawPayee;
}
return value;
return id;
}
// Convert the fully resolved new value into the 'new' id that can be
@@ -60,6 +69,26 @@ function stripNew(value) {
return value;
}
type PayeeListProps = {
items: PayeeAutocompleteItem[];
getItemProps: (arg: {
item: PayeeAutocompleteItem;
}) => ComponentProps<typeof View>;
highlightedIndex: number;
embedded: boolean;
inputValue: string;
renderCreatePayeeButton?: (
props: ComponentPropsWithoutRef<typeof CreatePayeeButton>,
) => ReactNode;
renderPayeeItemGroupHeader?: (
props: ComponentPropsWithoutRef<typeof ItemHeader>,
) => ReactNode;
renderPayeeItem?: (
props: ComponentPropsWithoutRef<typeof PayeeItem>,
) => ReactNode;
footer: ReactNode;
};
function PayeeList({
items,
getItemProps,
@@ -70,8 +99,7 @@ function PayeeList({
renderPayeeItemGroupHeader = defaultRenderPayeeItemGroupHeader,
renderPayeeItem = defaultRenderPayeeItem,
footer,
}) {
const isFiltered = items.filtered;
}: PayeeListProps) {
let createNew = null;
items = [...items];
@@ -112,7 +140,8 @@ function PayeeList({
} else if (type === 'account' && lastType !== type) {
title = 'Transfer To/From';
}
const showMoreMessage = idx === items.length - 1 && isFiltered;
const showMoreMessage =
idx === items.length - 1 && items.length > 100;
lastType = type;
return (
@@ -152,22 +181,24 @@ function PayeeList({
);
}
type PayeeAutocompleteProps = {
value: ComponentProps<typeof Autocomplete>['value'];
inputProps: ComponentProps<typeof Autocomplete>['inputProps'];
type PayeeAutocompleteProps = ComponentProps<
typeof Autocomplete<PayeeAutocompleteItem>
> & {
showMakeTransfer?: boolean;
showManagePayees?: boolean;
tableBehavior: ComponentProps<typeof Autocomplete>['tableBehavior'];
embedded?: boolean;
closeOnBlur: ComponentProps<typeof Autocomplete>['closeOnBlur'];
onUpdate?: (value: string) => void;
onSelect?: (value: string) => void;
onManagePayees: () => void;
renderCreatePayeeButton?: (props: CreatePayeeButtonProps) => ReactNode;
renderPayeeItemGroupHeader?: (props: ItemHeaderProps) => ReactNode;
renderPayeeItem?: (props: PayeeItemProps) => ReactNode;
onManagePayees?: () => void;
renderCreatePayeeButton?: (
props: ComponentPropsWithoutRef<typeof CreatePayeeButton>,
) => ReactElement<typeof CreatePayeeButton>;
renderPayeeItemGroupHeader?: (
props: ComponentPropsWithoutRef<typeof ItemHeader>,
) => ReactElement<typeof ItemHeader>;
renderPayeeItem?: (
props: ComponentPropsWithoutRef<typeof PayeeItem>,
) => ReactElement<typeof PayeeItem>;
accounts?: AccountEntity[];
payees?: PayeeEntity[];
payees?: PayeeAutocompleteItem[];
};
export function PayeeAutocomplete({
@@ -175,9 +206,9 @@ export function PayeeAutocomplete({
inputProps,
showMakeTransfer = true,
showManagePayees = false,
tableBehavior,
embedded,
clearOnBlur = true,
closeOnBlur,
embedded,
onUpdate,
onSelect,
onManagePayees,
@@ -201,7 +232,7 @@ export function PayeeAutocomplete({
const [focusTransferPayees, setFocusTransferPayees] = useState(false);
const [rawPayee, setRawPayee] = useState('');
const hasPayeeInput = !!rawPayee;
const payeeSuggestions = useMemo(() => {
const payeeSuggestions: PayeeAutocompleteItem[] = useMemo(() => {
const suggestions = getPayeeSuggestions(
payees,
focusTransferPayees,
@@ -216,20 +247,22 @@ export function PayeeAutocomplete({
const dispatch = useDispatch();
async function handleSelect(value, rawInputValue) {
if (tableBehavior) {
onSelect?.(makeNew(value, rawInputValue));
async function handleSelect(idOrIds, rawInputValue) {
if (!clearOnBlur) {
onSelect?.(makeNew(idOrIds, rawInputValue), rawInputValue);
} else {
const create = () => dispatch(createPayee(rawInputValue));
const create = payeeName => dispatch(createPayee(payeeName));
if (Array.isArray(value)) {
value = await Promise.all(value.map(v => (v === 'new' ? create() : v)));
if (Array.isArray(idOrIds)) {
idOrIds = await Promise.all(
idOrIds.map(v => (v === 'new' ? create(rawInputValue) : v)),
);
} else {
if (value === 'new') {
value = await create();
if (idOrIds === 'new') {
idOrIds = await create(rawInputValue);
}
}
onSelect?.(value);
onSelect?.(idOrIds, rawInputValue);
}
}
@@ -242,7 +275,7 @@ export function PayeeAutocomplete({
embedded={embedded}
value={stripNew(value)}
suggestions={payeeSuggestions}
tableBehavior={tableBehavior}
clearOnBlur={clearOnBlur}
closeOnBlur={closeOnBlur}
itemToString={item => {
if (!item) {
@@ -262,9 +295,7 @@ export function PayeeAutocomplete({
onFocus: () => setPayeeFieldFocused(true),
onChange: setRawPayee,
}}
onUpdate={(value, inputValue) =>
onUpdate && onUpdate(makeNew(value, inputValue))
}
onUpdate={(id, inputValue) => onUpdate?.(id, makeNew(id, inputValue))}
onSelect={handleSelect}
getHighlightedIndex={suggestions => {
if (suggestions.length > 1 && suggestions[0].id === 'new') {
@@ -309,10 +340,7 @@ export function PayeeAutocomplete({
}
});
const isf = filtered.length > 100;
filtered = filtered.slice(0, 100);
// @ts-expect-error TODO: solve this somehow
filtered.filtered = isf;
if (filtered.length >= 2 && filtered[0].id === 'new') {
if (
@@ -341,7 +369,7 @@ export function PayeeAutocomplete({
type={focusTransferPayees ? 'menuSelected' : 'menu'}
style={showManagePayees && { marginBottom: 5 }}
onClick={() => {
onUpdate?.(null);
onUpdate?.(null, null);
setFocusTransferPayees(!focusTransferPayees);
}}
>
@@ -379,6 +407,13 @@ export function CreatePayeeButton({
...props
}: CreatePayeeButtonProps) {
const { isNarrowWidth } = useResponsive();
const narrowStyle = isNarrowWidth
? {
...styles.mobileMenuItem,
}
: {};
const iconSize = isNarrowWidth ? 14 : 8;
return (
<View
data-testid="create-payee-button"
@@ -399,6 +434,7 @@ export function CreatePayeeButton({
':active': {
backgroundColor: 'rgba(100, 100, 100, .25)',
},
...narrowStyle,
...style,
}}
{...props}
@@ -407,8 +443,8 @@ export function CreatePayeeButton({
<Icon style={{ marginRight: 5, display: 'inline-block' }} />
) : (
<SvgAdd
width={8}
height={8}
width={iconSize}
height={iconSize}
style={{ marginRight: 5, display: 'inline-block' }}
/>
)}
@@ -418,17 +454,19 @@ export function CreatePayeeButton({
}
function defaultRenderCreatePayeeButton(
props: CreatePayeeButtonProps,
): ReactNode {
props: ComponentPropsWithoutRef<typeof CreatePayeeButton>,
): ReactElement<typeof CreatePayeeButton> {
return <CreatePayeeButton {...props} />;
}
function defaultRenderPayeeItemGroupHeader(props: ItemHeaderProps): ReactNode {
function defaultRenderPayeeItemGroupHeader(
props: ComponentPropsWithoutRef<typeof ItemHeader>,
): ReactElement<typeof ItemHeader> {
return <ItemHeader {...props} type="payee" />;
}
type PayeeItemProps = {
item: PayeeEntity;
item: PayeeAutocompleteItem;
className?: string;
style?: CSSProperties;
highlighted?: boolean;
@@ -443,6 +481,15 @@ export function PayeeItem({
...props
}: PayeeItemProps) {
const { isNarrowWidth } = useResponsive();
const narrowStyle = isNarrowWidth
? {
...styles.mobileMenuItem,
color: theme.menuItemText,
borderRadius: 0,
borderTop: `1px solid ${theme.pillBorder}`,
}
: {};
return (
<div
// Downshift calls `setTimeout(..., 250)` in the `onMouseMove`
@@ -477,17 +524,20 @@ export function PayeeItem({
borderRadius: embedded ? 4 : 0,
padding: 4,
paddingLeft: 20,
...narrowStyle,
},
])}`}
data-testid={`${item.name}-payee-item`}
data-highlighted={highlighted || undefined}
{...props}
>
{item.name}
<TextOneLine>{item.name}</TextOneLine>
</div>
);
}
function defaultRenderPayeeItem(props: PayeeItemProps): ReactNode {
function defaultRenderPayeeItem(
props: ComponentPropsWithoutRef<typeof PayeeItem>,
): ReactElement<typeof PayeeItem> {
return <PayeeItem {...props} />;
}

View File

@@ -6,12 +6,14 @@ import { type CustomReportEntity } from 'loot-core/src/types/models/reports';
import { Autocomplete } from './Autocomplete';
import { ReportList } from './ReportList';
type ReportAutocompleteProps = {
embedded?: boolean;
} & ComponentProps<typeof Autocomplete<CustomReportEntity>>;
export function ReportAutocomplete({
embedded,
...props
}: {
embedded?: boolean;
} & ComponentProps<typeof Autocomplete<CustomReportEntity>>) {
}: ReportAutocompleteProps) {
const reports = useReports() || [];
return (

View File

@@ -57,7 +57,7 @@ export function CoverTooltip({
}
},
}}
showHiddenItems={false}
showHiddenCategories={false}
/>
)}
</InitialFocus>

View File

@@ -100,7 +100,7 @@ export function TransferTooltip({
onUpdate={() => {}}
onSelect={(id: string | undefined) => setCategory(id || null)}
inputProps={{ onEnter: () => submit(amount), placeholder: '(none)' }}
showHiddenItems={true}
showHiddenCategories={true}
/>
<View

View File

@@ -21,7 +21,7 @@ export const defaultInputStyle = {
border: '1px solid ' + theme.formInputBorder,
};
export type InputProps = InputHTMLAttributes<HTMLInputElement> & {
type InputProps = InputHTMLAttributes<HTMLInputElement> & {
style?: CSSProperties;
inputRef?: Ref<HTMLInputElement>;
onEnter?: (event: KeyboardEvent<HTMLInputElement>) => void;

View File

@@ -48,7 +48,7 @@ type MenuItem = {
tooltip?: string;
};
export type MenuProps<T extends MenuItem = MenuItem> = {
type MenuProps<T extends MenuItem = MenuItem> = {
header?: ReactNode;
footer?: ReactNode;
items: Array<T | typeof Menu.line>;

View File

@@ -5,7 +5,7 @@ import { syncAndDownload } from 'loot-core/client/actions';
import { SvgAdd } from '../../../icons/v1';
import { SvgSearchAlternate } from '../../../icons/v2';
import { theme } from '../../../style';
import { styles, theme } from '../../../style';
import { ButtonLink } from '../../common/ButtonLink';
import { InputWithContent } from '../../common/InputWithContent';
import { Label } from '../../common/Label';
@@ -26,7 +26,6 @@ function TransactionSearchInput({ accountName, onSearch }) {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: theme.mobilePageBackground,
margin: '11px auto 4px',
padding: 10,
width: '100%',
}}
@@ -53,11 +52,8 @@ function TransactionSearchInput({ accountName, onSearch }) {
style={{
backgroundColor: theme.tableBackground,
border: `1px solid ${theme.formInputBorder}`,
fontSize: 15,
flex: 1,
height: 32,
marginLeft: 4,
padding: 8,
height: styles.mobileMinHeight,
}}
/>
</View>

View File

@@ -63,8 +63,9 @@ export const Transaction = memo(function Transaction({
schedule,
} = transaction;
const isPreview = isPreviewId(id);
let amount = originalAmount;
if (isPreviewId(id)) {
if (isPreview) {
amount = getScheduledAmount(amount);
}
@@ -89,7 +90,6 @@ export const Transaction = memo(function Transaction({
const prettyCategory = specialCategory || categoryName;
const isPreview = isPreviewId(id);
const isReconciled = transaction.reconciled;
const textStyle = isPreview && {
fontStyle: 'italic',
@@ -103,16 +103,15 @@ export const Transaction = memo(function Transaction({
backgroundColor: theme.tableBackground,
border: 'none',
width: '100%',
height: 60,
...(isPreview && {
backgroundColor: theme.tableRowHeaderBackground,
}),
}}
>
<ListItem
style={{
flex: 1,
height: 60,
padding: '5px 10px', // remove padding when Button is back
...(isPreview && {
backgroundColor: theme.tableRowHeaderBackground,
}),
...style,
}}
>

View File

@@ -151,7 +151,7 @@ export function CloseAccount({
setCategoryError(false);
}
}}
showHiddenItems={true}
showHiddenCategories={true}
/>
{categoryError && (

View File

@@ -113,7 +113,7 @@ export function ConfirmCategoryDelete({
placeholder: 'Select category...',
}}
onSelect={category => setTransferCategory(category)}
showHiddenItems={true}
showHiddenCategories={true}
/>
</View>

View File

@@ -162,7 +162,6 @@ export function EditField({ modalProps, name, onSubmit, onClose }) {
onSelect(value);
}}
isCreatable
{...(isNarrowWidth && {
renderCreatePayeeButton: props => (
<CreatePayeeButton

View File

@@ -1,6 +1,6 @@
import React from 'react';
import React, { type ComponentPropsWithoutRef } from 'react';
import { Menu, type MenuProps } from '../common/Menu';
import { Menu } from '../common/Menu';
import { MenuTooltip } from '../common/MenuTooltip';
export function SaveReportMenu({
@@ -14,7 +14,7 @@ export function SaveReportMenu({
savedStatus: string;
listReports: number;
}) {
const savedMenu: MenuProps =
const savedMenu: ComponentPropsWithoutRef<typeof Menu> =
savedStatus === 'saved'
? {
items: [
@@ -27,7 +27,7 @@ export function SaveReportMenu({
items: [],
};
const modifiedMenu: MenuProps =
const modifiedMenu: ComponentPropsWithoutRef<typeof Menu> =
savedStatus === 'modified'
? {
items: [
@@ -48,7 +48,7 @@ export function SaveReportMenu({
items: [],
};
const unsavedMenu: MenuProps = {
const unsavedMenu: ComponentPropsWithoutRef<typeof Menu> = {
items: [
{
name: 'save-report',

View File

@@ -477,7 +477,6 @@ export function ScheduleDetails({ modalProps, actions, id, transaction }) {
onSelect={id =>
dispatch({ type: 'set-field', field: 'payee', value: id })
}
isCreatable
/>
</FormField>

View File

@@ -9,6 +9,7 @@ import React, {
useMemo,
type MutableRefObject,
type KeyboardEvent,
type ComponentProps,
} from 'react';
import { parse, parseISO, format, subDays, addDays, isValid } from 'date-fns';
@@ -27,7 +28,7 @@ import { stringToInteger } from 'loot-core/src/shared/util';
import { useLocalPref } from '../../hooks/useLocalPref';
import { type CSSProperties, theme } from '../../style';
import { Input, type InputProps } from '../common/Input';
import { Input } from '../common/Input';
import { View, type ViewProps } from '../common/View';
import { Tooltip } from '../tooltips';
@@ -172,7 +173,7 @@ function defaultShouldSaveFromKey(e) {
type DateSelectProps = {
containerProps?: ViewProps;
inputProps?: InputProps;
inputProps?: ComponentProps<typeof Input>;
tooltipStyle?: CSSProperties;
value: string;
isOpen?: boolean;
@@ -182,7 +183,7 @@ type DateSelectProps = {
openOnFocus?: boolean;
inputRef?: MutableRefObject<HTMLInputElement>;
shouldSaveFromKey?: (e: KeyboardEvent<HTMLInputElement>) => boolean;
tableBehavior?: boolean;
clearOnBlur?: boolean;
onUpdate?: (selectedDate: string) => void;
onSelect: (selectedDate: string) => void;
};
@@ -199,7 +200,7 @@ export function DateSelect({
openOnFocus = true,
inputRef: originalInputRef,
shouldSaveFromKey = defaultShouldSaveFromKey,
tableBehavior,
clearOnBlur = true,
onUpdate,
onSelect,
}: DateSelectProps) {
@@ -362,7 +363,7 @@ export function DateSelect({
}
inputProps?.onBlur?.(e);
if (!tableBehavior) {
if (clearOnBlur) {
// If value is empty, that drives what gets selected.
// Otherwise the input is reset to whatever is already
// selected

View File

@@ -521,12 +521,11 @@ function PayeeCell({
style: inputStyle,
}}
showManagePayees={true}
tableBehavior={true}
clearOnBlur={false}
focused={true}
onUpdate={onUpdate}
onUpdate={(id, value) => onUpdate?.(value)}
onSelect={onSave}
onManagePayees={() => onManagePayees(payeeId)}
isCreatable
menuPortalTarget={undefined}
/>
);
@@ -917,7 +916,7 @@ const Transaction = memo(function Transaction(props) {
dateFormat={dateFormat}
inputProps={{ onBlur, onKeyDown, style: inputStyle }}
shouldSaveFromKey={shouldSaveFromKey}
tableBehavior={true}
clearOnBlur={true}
onUpdate={onUpdate}
onSelect={onSave}
/>
@@ -962,7 +961,7 @@ const Transaction = memo(function Transaction(props) {
value={accountId}
accounts={accounts}
shouldSaveFromKey={shouldSaveFromKey}
tableBehavior={true}
clearOnBlur={false}
focused={true}
inputProps={{ onBlur, onKeyDown, style: inputStyle }}
onUpdate={onUpdate}
@@ -1176,14 +1175,14 @@ const Transaction = memo(function Transaction(props) {
categoryGroups={categoryGroups}
value={categoryId}
focused={true}
tableBehavior={true}
clearOnBlur={false}
showSplitOption={!isChild && !isParent}
shouldSaveFromKey={shouldSaveFromKey}
inputProps={{ onBlur, onKeyDown, style: inputStyle }}
onUpdate={onUpdate}
onSelect={onSave}
menuPortalTarget={undefined}
showHiddenItems={false}
showHiddenCategories={false}
/>
)}
</CustomCell>

View File

@@ -41,6 +41,7 @@ export function GenericInput({
}
const showPlaceholder = multi ? value.length === 0 : true;
const autocompleteType = multi ? 'multi' : 'single';
let content;
switch (type) {
@@ -49,7 +50,7 @@ export function GenericInput({
case 'payee':
content = (
<PayeeAutocomplete
multi={multi}
type={autocompleteType}
showMakeTransfer={false}
openOnFocus={true}
value={value}
@@ -65,8 +66,8 @@ export function GenericInput({
case 'account':
content = (
<AccountAutocomplete
type={autocompleteType}
value={value}
multi={multi}
openOnFocus={true}
onSelect={onChange}
inputProps={{
@@ -80,12 +81,12 @@ export function GenericInput({
case 'category':
content = (
<CategoryAutocomplete
type={autocompleteType}
categoryGroups={categoryGroups}
value={value}
multi={multi}
openOnFocus={true}
onSelect={onChange}
showHiddenItems={false}
showHiddenCategories={false}
inputProps={{
inputRef,
...(showPlaceholder ? { placeholder: 'nothing' } : null),
@@ -103,9 +104,9 @@ export function GenericInput({
case 'saved':
content = (
<FilterAutocomplete
type={autocompleteType}
saved={saved}
value={value}
multi={multi}
openOnFocus={true}
onSelect={onChange}
inputProps={{
@@ -118,9 +119,9 @@ export function GenericInput({
case 'report':
content = (
<ReportAutocomplete
type={autocompleteType}
saved={savedReports}
value={value}
multi={multi}
openOnFocus={true}
onSelect={onChange}
inputProps={{
@@ -200,7 +201,7 @@ export function GenericInput({
if (multi) {
content = (
<Autocomplete
multi={true}
type={autocompleteType}
suggestions={[]}
value={value}
inputProps={{ inputRef }}

View File

@@ -8,11 +8,21 @@ import { tokens } from '../tokens';
import { theme } from './theme';
import { type CSSProperties } from './types';
const MOBILE_MIN_HEIGHT = 40;
export const styles = {
incomeHeaderHeight: 70,
cardShadow: '0 1px 3px rgba(0,0,0,0.12), 0 1px 2px rgba(0,0,0,0.24)',
monthRightPadding: 5,
menuBorderRadius: 4,
mobileMinHeight: MOBILE_MIN_HEIGHT,
mobileMenuItem: {
fontSize: 17,
fontWeight: 400,
paddingTop: 8,
paddingBottom: 8,
height: MOBILE_MIN_HEIGHT,
},
mobileEditingPadding: 12,
altMenuMaxHeight: 250,
altMenuText: {

View File

@@ -249,8 +249,10 @@ export function initiallyLoadPayees() {
}
export function createPayee(name: string) {
return async () => {
return send('payee-create', { name: name.trim() });
return async (dispatch: Dispatch) => {
const id = await send('payee-create', { name: name.trim() });
dispatch(getPayees());
return id;
};
}

View File

@@ -0,0 +1,6 @@
---
category: Maintenance
authors: [joel-jeremy]
---
Autocomplete changes related to mobile modals.