Skip to content

Commit

Permalink
Recently used and favorite payees (#2814)
Browse files Browse the repository at this point in the history
* add idea of common payee, a top 10 frequently used payee

* add button in payee to mark as favorite

* cleanup

* minor fixes

* add release notes and make favorite optional

* fix TransactionsTable test

* lint and release notes

* rename section, resort list to ensure both are sorted

* don't show common, move bookmarked to menu

* add a limit on adding common payees

* linting

* reduce to 5 commonly used payees by default

* linting

* more linting

* update migrate timestamp

* more linting

* fix api name, bump migrate timestamp

* Add star to payee dropdown and rename section to 'Suggested Payees'

---------

Co-authored-by: youngcw <[email protected]>
  • Loading branch information
qedi-r and youngcw authored Jul 20, 2024
1 parent d032fce commit 89a8f10
Show file tree
Hide file tree
Showing 20 changed files with 249 additions and 22 deletions.
4 changes: 4 additions & 0 deletions packages/api/methods.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,10 @@ export function deleteCategory(id, transferCategoryId?) {
return send('api/category-delete', { id, transferCategoryId });
}

export function getCommonPayees() {
return send('api/common-payees-get');
}

export function getPayees() {
return send('api/payees-get');
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,8 @@ import {
} from 'loot-core/src/types/models';

import { useAccounts } from '../../hooks/useAccounts';
import { usePayees } from '../../hooks/usePayees';
import { SvgAdd } from '../../icons/v1';
import { useCommonPayees, usePayees } from '../../hooks/usePayees';
import { SvgAdd, SvgBookmark } from '../../icons/v1';
import { useResponsive } from '../../ResponsiveProvider';
import { type CSSProperties, theme, styles } from '../../style';
import { Button } from '../common/Button';
Expand All @@ -39,11 +39,48 @@ import { ItemHeader } from './ItemHeader';

type PayeeAutocompleteItem = PayeeEntity;

const MAX_AUTO_SUGGESTIONS = 5;

function getPayeeSuggestions(
commonPayees: PayeeAutocompleteItem[],
payees: PayeeAutocompleteItem[],
): (PayeeAutocompleteItem & PayeeItemType)[] {
if (commonPayees?.length > 0) {
const favoritePayees = payees.filter(p => p.favorite);
let additionalCommonPayees: PayeeAutocompleteItem[] = [];
if (favoritePayees.length < MAX_AUTO_SUGGESTIONS) {
additionalCommonPayees = commonPayees
.filter(
p => !(p.favorite || favoritePayees.map(fp => fp.id).includes(p.id)),
)
.slice(0, MAX_AUTO_SUGGESTIONS - favoritePayees.length);
}
const frequentPayees: (PayeeAutocompleteItem & PayeeItemType)[] =
favoritePayees.concat(additionalCommonPayees).map(p => {
return { ...p, itemType: 'common_payee' };
});

const filteredPayees: (PayeeAutocompleteItem & PayeeItemType)[] = payees
.filter(p => !frequentPayees.find(fp => fp.id === p.id))
.map<PayeeAutocompleteItem & PayeeItemType>(p => {
return { ...p, itemType: determineItemType(p, false) };
});

return frequentPayees
.sort((a, b) => a.name.localeCompare(b.name))
.concat(filteredPayees);
}

return payees.map(p => {
return { ...p, itemType: determineItemType(p, false) };
});
}

function filterActivePayees(
payees: PayeeAutocompleteItem[],
focusTransferPayees: boolean,
accounts: AccountEntity[],
): PayeeAutocompleteItem[] {
) {
let activePayees = accounts ? getActivePayees(payees, accounts) : payees;

if (focusTransferPayees && activePayees) {
Expand All @@ -70,7 +107,8 @@ function stripNew(value) {
}

type PayeeListProps = {
items: PayeeAutocompleteItem[];
items: (PayeeAutocompleteItem & PayeeItemType)[];
commonPayees: PayeeEntity[];
getItemProps: (arg: {
item: PayeeAutocompleteItem;
}) => ComponentProps<typeof View>;
Expand All @@ -89,6 +127,25 @@ type PayeeListProps = {
footer: ReactNode;
};

type ItemTypes = 'account' | 'payee' | 'common_payee';
type PayeeItemType = {
itemType: ItemTypes;
};

function determineItemType(
item: PayeeAutocompleteItem,
isCommon: boolean,
): ItemTypes {
if (item.transfer_acct) {
return 'account';
}
if (isCommon) {
return 'common_payee';
} else {
return 'payee';
}
}

function PayeeList({
items,
getItemProps,
Expand Down Expand Up @@ -133,16 +190,19 @@ function PayeeList({
})}

{items.map((item, idx) => {
const type = item.transfer_acct ? 'account' : 'payee';
const itemType = item.itemType;
let title;
if (type === 'payee' && lastType !== type) {

if (itemType === 'common_payee' && lastType !== itemType) {
title = 'Suggested Payees';
} else if (itemType === 'payee' && lastType !== itemType) {
title = 'Payees';
} else if (type === 'account' && lastType !== type) {
} else if (itemType === 'account' && lastType !== itemType) {
title = 'Transfer To/From';
}
const showMoreMessage =
idx === items.length - 1 && items.length > 100;
lastType = type;
lastType = itemType;

return (
<Fragment key={item.id}>
Expand Down Expand Up @@ -219,6 +279,7 @@ export function PayeeAutocomplete({
payees,
...props
}: PayeeAutocompleteProps) {
const commonPayees = useCommonPayees();
const retrievedPayees = usePayees();
if (!payees) {
payees = retrievedPayees;
Expand All @@ -233,17 +294,21 @@ export function PayeeAutocomplete({
const [rawPayee, setRawPayee] = useState('');
const hasPayeeInput = !!rawPayee;
const payeeSuggestions: PayeeAutocompleteItem[] = useMemo(() => {
const suggestions = getPayeeSuggestions(
payees,
const suggestions = getPayeeSuggestions(commonPayees, payees);
const filteredSuggestions = filterActivePayees(
suggestions,
focusTransferPayees,
accounts,
);

if (!hasPayeeInput) {
return suggestions;
return filteredSuggestions;
}
return [{ id: 'new', name: '' }, ...suggestions];
}, [payees, focusTransferPayees, accounts, hasPayeeInput]);
filteredSuggestions.forEach(s => {
console.log(s.name + ' ' + s.id);
});
return [{ id: 'new', favorite: false, name: '' }, ...filteredSuggestions];
}, [commonPayees, payees, focusTransferPayees, accounts, hasPayeeInput]);

const dispatch = useDispatch();

Expand Down Expand Up @@ -356,6 +421,7 @@ export function PayeeAutocomplete({
renderItems={(items, getItemProps, highlightedIndex, inputValue) => (
<PayeeList
items={items}
commonPayees={commonPayees}
getItemProps={getItemProps}
highlightedIndex={highlightedIndex}
inputValue={inputValue}
Expand Down Expand Up @@ -489,7 +555,19 @@ function PayeeItem({
borderTop: `1px solid ${theme.pillBorder}`,
}
: {};

const iconSize = isNarrowWidth ? 14 : 8;
let paddingLeftOverFromIcon = 20;
let itemIcon = undefined;
if (item.favorite) {
itemIcon = (
<SvgBookmark
width={iconSize}
height={iconSize}
style={{ marginRight: 5, display: 'inline-block' }}
/>
);
paddingLeftOverFromIcon -= iconSize + 5;
}
return (
<div
// Downshift calls `setTimeout(..., 250)` in the `onMouseMove`
Expand Down Expand Up @@ -524,15 +602,18 @@ function PayeeItem({
: theme.menuAutoCompleteItemText,
borderRadius: embedded ? 4 : 0,
padding: 4,
paddingLeft: 20,
paddingLeft: paddingLeftOverFromIcon,
...narrowStyle,
},
])}`}
data-testid={`${item.name}-payee-item`}
data-highlighted={highlighted || undefined}
{...props}
>
<TextOneLine>{item.name}</TextOneLine>
<TextOneLine>
{itemIcon}
{item.name}
</TextOneLine>
</div>
);
}
Expand Down
17 changes: 17 additions & 0 deletions packages/desktop-client/src/components/payees/ManagePayees.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,22 @@ export const ManagePayees = forwardRef(
selected.dispatch({ type: 'select-none' });
}

function onFavorite() {
const allFavorited = [...selected.items]
.map(id => payeesById[id].favorite)
.every(f => f === 1);
if (allFavorited) {
onBatchChange({
updated: [...selected.items].map(id => ({ id, favorite: 0 })),
});
} else {
onBatchChange({
updated: [...selected.items].map(id => ({ id, favorite: 1 })),
});
}
selected.dispatch({ type: 'select-none' });
}

async function onMerge() {
const ids = [...selected.items];
await props.onMerge(ids);
Expand Down Expand Up @@ -262,6 +278,7 @@ export const ManagePayees = forwardRef(
onClose={() => setMenuOpen(false)}
onDelete={onDelete}
onMerge={onMerge}
onFavorite={onFavorite}
/>
</Popover>
</View>
Expand Down
13 changes: 13 additions & 0 deletions packages/desktop-client/src/components/payees/PayeeMenu.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { type PayeeEntity } from 'loot-core/src/types/models';

import { SvgDelete, SvgMerge } from '../../icons/v0';
import { SvgBookmark } from '../../icons/v1';
import { theme } from '../../style';
import { Menu } from '../common/Menu';
import { View } from '../common/View';
Expand All @@ -10,6 +11,7 @@ type PayeeMenuProps = {
selectedPayees: Set<PayeeEntity['id']>;
onDelete: () => void;
onMerge: () => Promise<void>;
onFavorite: () => Promise<void>;
onClose: () => void;
};

Expand All @@ -18,6 +20,7 @@ export function PayeeMenu({
selectedPayees,
onDelete,
onMerge,
onFavorite,
onClose,
}: PayeeMenuProps) {
// Transfer accounts are never editable
Expand All @@ -36,6 +39,9 @@ export function PayeeMenu({
case 'merge':
onMerge();
break;
case 'favorite':
onFavorite();
break;
default:
}
}}
Expand All @@ -61,6 +67,13 @@ export function PayeeMenu({
text: 'Delete',
disabled: isDisabled,
},
{
icon: SvgBookmark,
iconSize: 9,
name: 'favorite',
text: 'Favorite',
disabled: isDisabled,
},
{
icon: SvgMerge,
iconSize: 9,
Expand Down
28 changes: 25 additions & 3 deletions packages/desktop-client/src/components/payees/PayeeTableRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,17 @@ import { memo } from 'react';
import { type PayeeEntity } from 'loot-core/src/types/models';

import { useSelectedDispatch } from '../../hooks/useSelected';
import { SvgArrowThinRight } from '../../icons/v1';
import { SvgArrowThinRight, SvgBookmark } from '../../icons/v1';
import { type CSSProperties, theme } from '../../style';
import { Text } from '../common/Text';
import { Cell, CellButton, InputCell, Row, SelectCell } from '../table';
import {
Cell,
CellButton,
CustomCell,
InputCell,
Row,
SelectCell,
} from '../table';

type RuleButtonProps = {
ruleCount: number;
Expand Down Expand Up @@ -52,7 +59,7 @@ function RuleButton({ ruleCount, focused, onEdit, onClick }: RuleButtonProps) {
);
}

type EditablePayeeFields = keyof Pick<PayeeEntity, 'name'>;
type EditablePayeeFields = keyof Pick<PayeeEntity, 'name' | 'favorite'>;

type PayeeTableRowProps = {
payee: PayeeEntity;
Expand Down Expand Up @@ -126,6 +133,21 @@ export const PayeeTableRow = memo(
dispatchSelected({ type: 'select', id: payee.id, event: e });
}}
/>
<CustomCell
width={10}
exposed={!payee.transfer_acct}
onBlur={() => {}}
onUpdate={() => {}}
onClick={() => {}}
>
{() => {
if (payee.favorite) {
return <SvgBookmark />;
} else {
return;
}
}}
</CustomCell>
<InputCell
value={(payee.transfer_acct ? 'Transfer: ' : '') + payee.name}
valueStyle={
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,8 @@ vi.mock('../../hooks/useFeatureFlag', () => vi.fn().mockReturnValue(false));

const accounts = [generateAccount('Bank of America')];
const payees = [
{ id: 'payed-to', name: 'Payed To' },
{ id: 'guy', name: 'This guy on the side of the road' },
{ id: 'payed-to', favorite: true, name: 'Payed To' },
{ id: 'guy', favorite: false, name: 'This guy on the side of the road' },
];
const categoryGroups = generateCategoryGroups([
{
Expand Down Expand Up @@ -130,6 +130,7 @@ function LiveTransactionTable(props) {
{...props}
transactions={transactions}
loadMoreTransactions={() => {}}
commonPayees={[]}
payees={payees}
addNotification={n => console.log(n)}
onSave={onSave}
Expand Down
17 changes: 16 additions & 1 deletion packages/desktop-client/src/hooks/usePayees.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,24 @@
import { useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';

import { getPayees } from 'loot-core/src/client/actions';
import { getCommonPayees, getPayees } from 'loot-core/src/client/actions';
import { type State } from 'loot-core/src/client/state-types';

export function useCommonPayees() {
const dispatch = useDispatch();
const commonPayeesLoaded = useSelector(
(state: State) => state.queries.commonPayeesLoaded,
);

useEffect(() => {
if (!commonPayeesLoaded) {
dispatch(getCommonPayees());
}
}, []);

return useSelector(state => state.queries.commonPayees);
}

export function usePayees() {
const dispatch = useDispatch();
const payeesLoaded = useSelector(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
BEGIN TRANSACTION;

ALTER TABLE payees ADD COLUMN favorite INTEGER DEFAULT 0 DEFAULT FALSE;

COMMIT;
Loading

0 comments on commit 89a8f10

Please sign in to comment.