Skip to content

Commit

Permalink
Merge branch 'master' into jfdoming/fix-case-sensitive-regexp-match
Browse files Browse the repository at this point in the history
  • Loading branch information
jfdoming authored Sep 25, 2024
2 parents 7f84dc1 + 0f41e95 commit bf42f57
Show file tree
Hide file tree
Showing 27 changed files with 804 additions and 219 deletions.
Binary file added packages/desktop-client/package.tgz
Binary file not shown.
16 changes: 16 additions & 0 deletions packages/desktop-client/src/browser-preload.browser.js
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,22 @@ global.Actual = {
window.location.reload();
},

reload: () => {
if (window.navigator.serviceWorker == null) return;

// Unregister the service worker handling routing and then reload. This should force the reload
// to query the actual server rather than delegating to the worker
return window.navigator.serviceWorker
.getRegistration('/')
.then(registration => {
if (registration == null) return;
return registration.unregister();
})
.then(() => {
window.location.reload();
});
},

restartElectronServer: () => {},

openFileDialog: async ({ filters = [] }) => {
Expand Down
36 changes: 22 additions & 14 deletions packages/desktop-client/src/components/FinancesApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import { TransactionEdit } from './mobile/transactions/TransactionEdit';
import { Notifications } from './Notifications';
import { ManagePayeesPage } from './payees/ManagePayeesPage';
import { Reports } from './reports';
import { LoadingIndicator } from './reports/LoadingIndicator';
import { NarrowAlternate, WideComponent } from './responsive';
import { Settings } from './settings';
import { FloatableSidebar } from './sidebar';
Expand Down Expand Up @@ -65,19 +66,6 @@ function WideNotSupported({ children, redirectTo = '/budget' }) {
}

function RouterBehaviors() {
const navigate = useNavigate();
const accounts = useAccounts();
const accountsLoaded = useSelector(
(state: State) => state.queries.accountsLoaded,
);
useEffect(() => {
// If there are no accounts, we want to redirect the user to
// the All Accounts screen which will prompt them to add an account
if (accountsLoaded && accounts.length === 0) {
navigate('/accounts');
}
}, [accountsLoaded, accounts]);

const location = useLocation();
const href = useHref(location);
useEffect(() => {
Expand All @@ -91,6 +79,11 @@ export function FinancesApp() {
const dispatch = useDispatch();
const { t } = useTranslation();

const accounts = useAccounts();
const accountsLoaded = useSelector(
(state: State) => state.queries.accountsLoaded,
);

const [lastUsedVersion, setLastUsedVersion] = useLocalPref(
'flags.updateNotificationShownForVersion',
);
Expand Down Expand Up @@ -180,7 +173,22 @@ export function FinancesApp() {
<BankSyncStatus />

<Routes>
<Route path="/" element={<Navigate to="/budget" replace />} />
<Route
path="/"
element={
accountsLoaded ? (
accounts.length > 0 ? (
<Navigate to="/budget" replace />
) : (
// If there are no accounts, we want to redirect the user to
// the All Accounts screen which will prompt them to add an account
<Navigate to="/accounts" replace />
)
) : (
<LoadingIndicator />
)
}
/>

<Route path="/reports/*" element={<Reports />} />

Expand Down
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 bf42f57

Please sign in to comment.