Skip to content

Commit

Permalink
Fix Issue 3331, sort favorite payees before other frequently used pay…
Browse files Browse the repository at this point in the history
…ees (#3412)

* add tests for payee dropdown bug on new transactions on all accounts page

* add tests for bugfixes

* sort favorite payees first, add tests for PayeeAutocomplete.getPayeeSuggestions

* lint and release notes

* fix release note number

* lint fixes

* add missing file in previous lint fix

* fix typecheck and linting errors

---------

Co-authored-by: youngcw <calebyoung94@gmail.com>
  • Loading branch information
qedi-r and youngcw authored Sep 25, 2024
1 parent 7e88930 commit 0f41e95
Show file tree
Hide file tree
Showing 5 changed files with 400 additions and 21 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,239 @@
import { render, type Screen, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { vi } from 'vitest';

import { generateAccount } from 'loot-core/src/mocks';
import { TestProvider } from 'loot-core/src/mocks/redux';
import type { AccountEntity, PayeeEntity } from 'loot-core/types/models';

import { useCommonPayees } from '../../hooks/usePayees';
import { ResponsiveProvider } from '../../ResponsiveProvider';

import {
PayeeAutocomplete,
type PayeeAutocompleteItem,
type PayeeAutocompleteProps,
} from './PayeeAutocomplete';

const PAYEE_SELECTOR = '[data-testid][role=option]';
const PAYEE_SECTION_SELECTOR = '[data-testid$="-item-group"]';

const payees = [
makePayee('Bob', { favorite: true }),
makePayee('Alice', { favorite: true }),
makePayee('This guy on the side of the road'),
];

const accounts: AccountEntity[] = [
generateAccount('Bank of Montreal', false, false),
];
const defaultProps = {
value: null,
embedded: true,
payees,
accounts,
};

function makePayee(name: string, options?: { favorite: boolean }): PayeeEntity {
return {
id: name.toLowerCase() + '-id',
name,
favorite: options?.favorite ?? false,
transfer_acct: undefined,
};
}

function extractPayeesAndHeaderNames(screen: Screen) {
return [
...screen
.getByTestId('autocomplete')
.querySelectorAll(`${PAYEE_SELECTOR}, ${PAYEE_SECTION_SELECTOR}`),
]
.map(e => e.getAttribute('data-testid'))
.map(firstOrIncorrect);
}

function renderPayeeAutocomplete(
props?: Partial<PayeeAutocompleteProps>,
): HTMLElement {
const autocompleteProps = {
...defaultProps,
...props,
};

render(
<TestProvider>
<ResponsiveProvider>
<div data-testid="autocomplete-test">
<PayeeAutocomplete
{...autocompleteProps}
onSelect={vi.fn()}
type="single"
value={null}
embedded={false}
/>
</div>
</ResponsiveProvider>
</TestProvider>,
);
return screen.getByTestId('autocomplete-test');
}

// Not good, see `Autocomplete.js` for details
function waitForAutocomplete() {
return new Promise(resolve => setTimeout(resolve, 0));
}

async function clickAutocomplete(autocomplete: HTMLElement) {
const input = autocomplete.querySelector(`input`);
if (input != null) {
await userEvent.click(input);
}
await waitForAutocomplete();
}

vi.mock('../../hooks/usePayees', () => ({
useCommonPayees: vi.fn(),
usePayees: vi.fn().mockReturnValue([]),
}));

function firstOrIncorrect(id: string | null): string {
return id?.split('-', 1)[0] || 'incorrect';
}

describe('PayeeAutocomplete.getPayeeSuggestions', () => {
beforeEach(() => {
vi.mocked(useCommonPayees).mockReturnValue([]);
});

test('favorites get sorted alphabetically', async () => {
const autocomplete = renderPayeeAutocomplete();
await clickAutocomplete(autocomplete);

expect(
[
...screen.getByTestId('autocomplete').querySelectorAll(PAYEE_SELECTOR),
].map(e => e.getAttribute('data-testid')),
).toStrictEqual([
'Alice-payee-item',
'Bob-payee-item',
'This guy on the side of the road-payee-item',
]);
});

test('list with less than the maximum favorites adds common payees', async () => {
//Note that the payees list assumes the payees are already sorted
const payees: PayeeAutocompleteItem[] = [
makePayee('Alice'),
makePayee('Bob'),
makePayee('Eve', { favorite: true }),
makePayee('Bruce'),
makePayee('Carol'),
makePayee('Natasha'),
makePayee('Steve'),
makePayee('Tony'),
];
vi.mocked(useCommonPayees).mockReturnValue([
makePayee('Bruce'),
makePayee('Natasha'),
makePayee('Steve'),
makePayee('Tony'),
makePayee('Carol'),
]);
const expectedPayeeOrder = [
'Suggested Payees',
'Eve',
'Bruce',
'Natasha',
'Steve',
'Tony',
'Payees',
'Alice',
'Bob',
'Carol',
];
await clickAutocomplete(renderPayeeAutocomplete({ payees }));

expect(
[
...screen
.getByTestId('autocomplete')
.querySelectorAll(`${PAYEE_SELECTOR}, ${PAYEE_SECTION_SELECTOR}`),
]
.map(e => e.getAttribute('data-testid'))
.map(firstOrIncorrect),
).toStrictEqual(expectedPayeeOrder);
});

test('list with more than the maximum favorites only lists favorites', async () => {
//Note that the payees list assumes the payees are already sorted
const payees = [
makePayee('Alice', { favorite: true }),
makePayee('Bob', { favorite: true }),
makePayee('Eve', { favorite: true }),
makePayee('Bruce', { favorite: true }),
makePayee('Carol', { favorite: true }),
makePayee('Natasha'),
makePayee('Steve'),
makePayee('Tony', { favorite: true }),
];
vi.mocked(useCommonPayees).mockReturnValue([
makePayee('Bruce'),
makePayee('Natasha'),
makePayee('Steve'),
makePayee('Tony'),
makePayee('Carol'),
]);
const expectedPayeeOrder = [
'Suggested Payees',
'Alice',
'Bob',
'Bruce',
'Carol',
'Eve',
'Tony',
'Payees',
'Natasha',
'Steve',
];
const autocomplete = renderPayeeAutocomplete({ payees });
await clickAutocomplete(autocomplete);

expect(extractPayeesAndHeaderNames(screen)).toStrictEqual(
expectedPayeeOrder,
);
});

test('list with no favorites shows just the payees list', async () => {
//Note that the payees list assumes the payees are already sorted
const payees = [
makePayee('Alice'),
makePayee('Bob'),
makePayee('Eve'),
makePayee('Natasha'),
makePayee('Steve'),
];
const expectedPayeeOrder = ['Alice', 'Bob', 'Eve', 'Natasha', 'Steve'];
const autocomplete = renderPayeeAutocomplete({ payees });
await clickAutocomplete(autocomplete);

expect(
[
...screen
.getByTestId('autocomplete')
.querySelectorAll('[data-testid][role=option]'),
]
.map(e => e.getAttribute('data-testid'))
.flatMap(firstOrIncorrect),
).toStrictEqual(expectedPayeeOrder);
expect(
[
...screen
.getByTestId('autocomplete')
.querySelectorAll('[data-testid$="-item-group"]'),
]
.map(e => e.getAttribute('data-testid'))
.flatMap(firstOrIncorrect),
).toStrictEqual(['Payees']);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -39,38 +39,45 @@ import {
} from './Autocomplete';
import { ItemHeader } from './ItemHeader';

type PayeeAutocompleteItem = PayeeEntity;
export type PayeeAutocompleteItem = PayeeEntity;

const MAX_AUTO_SUGGESTIONS = 5;

function getPayeeSuggestions(
commonPayees: PayeeAutocompleteItem[],
payees: PayeeAutocompleteItem[],
): (PayeeAutocompleteItem & PayeeItemType)[] {
const favoritePayees = payees
.filter(p => p.favorite)
.map(p => {
return { ...p, itemType: determineItemType(p, true) };
})
.sort((a, b) => a.name.localeCompare(b.name));

let additionalCommonPayees: (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);
.slice(0, MAX_AUTO_SUGGESTIONS - favoritePayees.length)
.map(p => {
return { ...p, itemType: determineItemType(p, true) };
})
.sort((a, b) => a.name.localeCompare(b.name));
}
const frequentPayees: (PayeeAutocompleteItem & PayeeItemType)[] =
favoritePayees.concat(additionalCommonPayees).map(p => {
return { ...p, itemType: 'common_payee' };
});
}

if (favoritePayees.length + additionalCommonPayees.length) {
const filteredPayees: (PayeeAutocompleteItem & PayeeItemType)[] = payees
.filter(p => !frequentPayees.find(fp => fp.id === p.id))
.filter(p => !favoritePayees.find(fp => fp.id === p.id))
.filter(p => !additionalCommonPayees.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 favoritePayees.concat(additionalCommonPayees).concat(filteredPayees);
}

return payees.map(p => {
Expand Down Expand Up @@ -245,7 +252,7 @@ function PayeeList({
);
}

type PayeeAutocompleteProps = ComponentProps<
export type PayeeAutocompleteProps = ComponentProps<
typeof Autocomplete<PayeeAutocompleteItem>
> & {
showMakeTransfer?: boolean;
Expand Down
Loading

0 comments on commit 0f41e95

Please sign in to comment.