Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix Issue 3331, sort favorite payees before other frequently used payees #3412

Merged
merged 11 commits into from
Sep 25, 2024
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
Loading