Convert SelectLinkedAccountsModal to TypeScript (#5059)

* Convert `SelectLinkedAccountsModal` to TypeScript

* Add release notes

* Fix typo caught by Rabbit
This commit is contained in:
Julian Dominguez-Schatz
2025-07-21 09:54:27 -04:00
committed by GitHub
parent 82a3c97222
commit 77e99af297
7 changed files with 187 additions and 85 deletions

View File

@@ -25,7 +25,7 @@ import { getNormalisedString } from 'loot-core/shared/normalisation';
import { useProperFocus } from '@desktop-client/hooks/useProperFocus';
type CommonAutocompleteProps<T extends Item> = {
type CommonAutocompleteProps<T extends AutocompleteItem> = {
focused?: boolean;
embedded?: boolean;
containerProps?: HTMLProps<HTMLDivElement>;
@@ -54,14 +54,14 @@ type CommonAutocompleteProps<T extends Item> = {
onClose?: () => void;
};
type Item = {
export type AutocompleteItem = {
id?: string;
name: string;
};
const inst: { lastChangeType?: StateChangeTypes } = {};
function findItem<T extends Item>(
function findItem<T extends AutocompleteItem>(
strict: boolean,
suggestions: T[],
value: T | T['id'],
@@ -74,7 +74,9 @@ function findItem<T extends Item>(
return value;
}
function getItemName<T extends Item>(item: T | T['name'] | null): string {
function getItemName<T extends AutocompleteItem>(
item: T | T['name'] | null,
): string {
if (item == null) {
return '';
} else if (typeof item === 'string') {
@@ -83,14 +85,14 @@ function getItemName<T extends Item>(item: T | T['name'] | null): string {
return item.name || '';
}
function getItemId<T extends Item>(item: T | T['id']) {
function getItemId<T extends AutocompleteItem>(item: T | T['id']) {
if (typeof item === 'string') {
return item;
}
return item ? item.id : null;
}
export function defaultFilterSuggestion<T extends Item>(
export function defaultFilterSuggestion<T extends AutocompleteItem>(
suggestion: T,
value: string,
) {
@@ -98,7 +100,7 @@ export function defaultFilterSuggestion<T extends Item>(
return getNormalisedString(name).includes(getNormalisedString(value));
}
function defaultFilterSuggestions<T extends Item>(
function defaultFilterSuggestions<T extends AutocompleteItem>(
suggestions: T[],
value: string,
) {
@@ -107,7 +109,7 @@ function defaultFilterSuggestions<T extends Item>(
);
}
function fireUpdate<T extends Item>(
function fireUpdate<T extends AutocompleteItem>(
onUpdate: ((selected: string | null, value: string) => void) | undefined,
strict: boolean,
suggestions: T[],
@@ -143,7 +145,7 @@ function defaultRenderInput(props: ComponentProps<typeof Input>) {
return <Input data-1p-ignore {...props} />;
}
function defaultRenderItems<T extends Item>(
function defaultRenderItems<T extends AutocompleteItem>(
items: T[],
getItemProps: (arg: { item: T }) => ComponentProps<typeof View>,
highlightedIndex: number,
@@ -199,17 +201,18 @@ function defaultShouldSaveFromKey(e: KeyboardEvent) {
return e.code === 'Enter';
}
function defaultItemToString<T extends Item>(item?: T) {
function defaultItemToString<T extends AutocompleteItem>(item?: T) {
return item ? getItemName(item) : '';
}
type SingleAutocompleteProps<T extends Item> = CommonAutocompleteProps<T> & {
type?: 'single' | never;
onSelect: (id: T['id'], value: string) => void;
value: null | T | T['id'];
};
type SingleAutocompleteProps<T extends AutocompleteItem> =
CommonAutocompleteProps<T> & {
type?: 'single' | never;
onSelect: (id: T['id'], value: string) => void;
value: null | T | T['id'];
};
function SingleAutocomplete<T extends Item>({
function SingleAutocomplete<T extends AutocompleteItem>({
focused,
embedded = false,
containerProps,
@@ -649,13 +652,14 @@ const defaultMultiAutocompleteInputClassName = css({
'&[data-focused]': { border: 0, boxShadow: 'none' },
});
type MultiAutocompleteProps<T extends Item> = CommonAutocompleteProps<T> & {
type: 'multi';
onSelect: (ids: T['id'][], id?: T['id']) => void;
value: null | T[] | T['id'][];
};
type MultiAutocompleteProps<T extends AutocompleteItem> =
CommonAutocompleteProps<T> & {
type: 'multi';
onSelect: (ids: T['id'][], id?: T['id']) => void;
value: null | T[] | T['id'][];
};
function MultiAutocomplete<T extends Item>({
function MultiAutocomplete<T extends AutocompleteItem>({
value: selectedItems = [],
onSelect,
suggestions,
@@ -787,11 +791,11 @@ export function AutocompleteFooter({
);
}
type AutocompleteProps<T extends Item> =
type AutocompleteProps<T extends AutocompleteItem> =
| ComponentProps<typeof SingleAutocomplete<T>>
| ComponentProps<typeof MultiAutocomplete<T>>;
export function Autocomplete<T extends Item>({
export function Autocomplete<T extends AutocompleteItem>({
...props
}: AutocompleteProps<T>) {
if (props.type === 'multi') {

View File

@@ -7,13 +7,23 @@ import { theme } from '@actual-app/components/theme';
import { Tooltip } from '@actual-app/components/tooltip';
import { View } from '@actual-app/components/view';
import {
type AccountEntity,
type SyncServerGoCardlessAccount,
type SyncServerPluggyAiAccount,
type SyncServerSimpleFinAccount,
} from 'loot-core/types/models';
import {
linkAccount,
linkAccountPluggyAi,
linkAccountSimpleFin,
unlinkAccount,
} from '@desktop-client/accounts/accountsSlice';
import { Autocomplete } from '@desktop-client/components/autocomplete/Autocomplete';
import {
Autocomplete,
type AutocompleteItem,
} from '@desktop-client/components/autocomplete/Autocomplete';
import {
Modal,
ModalCloseButton,
@@ -25,6 +35,7 @@ import {
Table,
Row,
Field,
Cell,
} from '@desktop-client/components/table';
import { useAccounts } from '@desktop-client/hooks/useAccounts';
import { closeModal } from '@desktop-client/modals/modalsSlice';
@@ -45,31 +56,68 @@ function useAddBudgetAccountOptions() {
return { addOnBudgetAccountOption, addOffBudgetAccountOption };
}
export type SelectLinkedAccountsModalProps =
| {
requisitionId: string;
externalAccounts: SyncServerGoCardlessAccount[];
syncSource: 'goCardless';
}
| {
requisitionId?: undefined;
externalAccounts: SyncServerSimpleFinAccount[];
syncSource: 'simpleFin';
}
| {
requisitionId?: undefined;
externalAccounts: SyncServerPluggyAiAccount[];
syncSource: 'pluggyai';
};
export function SelectLinkedAccountsModal({
requisitionId = undefined,
externalAccounts,
syncSource = undefined,
}) {
const sortedExternalAccounts = useMemo(() => {
const toSort = externalAccounts ? [...externalAccounts] : [];
toSort.sort(
(a, b) =>
getInstitutionName(a)?.localeCompare(getInstitutionName(b)) ||
a.name.localeCompare(b.name),
);
return toSort;
}, [externalAccounts]);
syncSource,
}: SelectLinkedAccountsModalProps) {
const propsWithSortedExternalAccounts =
useMemo<SelectLinkedAccountsModalProps>(() => {
const toSort = externalAccounts ? [...externalAccounts] : [];
toSort.sort(
(a, b) =>
getInstitutionName(a)?.localeCompare(getInstitutionName(b)) ||
a.name.localeCompare(b.name),
);
switch (syncSource) {
case 'simpleFin':
return {
syncSource: 'simpleFin',
externalAccounts: toSort as SyncServerSimpleFinAccount[],
};
case 'pluggyai':
return {
syncSource: 'pluggyai',
externalAccounts: toSort as SyncServerPluggyAiAccount[],
};
case 'goCardless':
return {
syncSource: 'goCardless',
requisitionId: requisitionId!,
externalAccounts: toSort as SyncServerGoCardlessAccount[],
};
}
}, [externalAccounts, syncSource, requisitionId]);
const { t } = useTranslation();
const dispatch = useDispatch();
const localAccounts = useAccounts().filter(a => a.closed === 0);
const [chosenAccounts, setChosenAccounts] = useState(() => {
return Object.fromEntries(
localAccounts
.filter(acc => acc.account_id)
.map(acc => [acc.account_id, acc.id]),
);
});
const [chosenAccounts, setChosenAccounts] = useState<Record<string, string>>(
() => {
return Object.fromEntries(
localAccounts
.filter(acc => acc.account_id)
.map(acc => [acc.account_id, acc.id]),
);
},
);
const { addOnBudgetAccountOption, addOffBudgetAccountOption } =
useAddBudgetAccountOptions();
@@ -86,22 +134,26 @@ export function SelectLinkedAccountsModal({
// Link new accounts
Object.entries(chosenAccounts).forEach(
([chosenExternalAccountId, chosenLocalAccountId]) => {
const externalAccount = sortedExternalAccounts.find(
account => account.account_id === chosenExternalAccountId,
);
const externalAccountIndex =
propsWithSortedExternalAccounts.externalAccounts.findIndex(
account => account.account_id === chosenExternalAccountId,
);
const offBudget = chosenLocalAccountId === addOffBudgetAccountOption.id;
// Skip linking accounts that were previously linked with
// a different bank.
if (!externalAccount) {
if (externalAccountIndex === -1) {
return;
}
// Finally link the matched account
if (syncSource === 'simpleFin') {
if (propsWithSortedExternalAccounts.syncSource === 'simpleFin') {
dispatch(
linkAccountSimpleFin({
externalAccount,
externalAccount:
propsWithSortedExternalAccounts.externalAccounts[
externalAccountIndex
],
upgradingId:
chosenLocalAccountId !== addOnBudgetAccountOption.id &&
chosenLocalAccountId !== addOffBudgetAccountOption.id
@@ -110,10 +162,13 @@ export function SelectLinkedAccountsModal({
offBudget,
}),
);
} else if (syncSource === 'pluggyai') {
} else if (propsWithSortedExternalAccounts.syncSource === 'pluggyai') {
dispatch(
linkAccountPluggyAi({
externalAccount,
externalAccount:
propsWithSortedExternalAccounts.externalAccounts[
externalAccountIndex
],
upgradingId:
chosenLocalAccountId !== addOnBudgetAccountOption.id &&
chosenLocalAccountId !== addOffBudgetAccountOption.id
@@ -125,8 +180,11 @@ export function SelectLinkedAccountsModal({
} else {
dispatch(
linkAccount({
requisitionId,
account: externalAccount,
requisitionId: propsWithSortedExternalAccounts.requisitionId,
account:
propsWithSortedExternalAccounts.externalAccounts[
externalAccountIndex
],
upgradingId:
chosenLocalAccountId !== addOnBudgetAccountOption.id &&
chosenLocalAccountId !== addOffBudgetAccountOption.id
@@ -146,7 +204,13 @@ export function SelectLinkedAccountsModal({
account => !Object.values(chosenAccounts).includes(account.id),
);
function onSetLinkedAccount(externalAccount, localAccountId) {
function onSetLinkedAccount(
externalAccount:
| SyncServerGoCardlessAccount
| SyncServerSimpleFinAccount
| SyncServerPluggyAiAccount,
localAccountId: string | null | undefined,
) {
setChosenAccounts(accounts => {
const updatedAccounts = { ...accounts };
@@ -184,22 +248,29 @@ export function SelectLinkedAccountsModal({
border: '1px solid ' + theme.tableBorder,
}}
>
<TableHeader
headers={[
{ name: t('Institution to Sync'), width: 175 },
{ name: t('Bank Account To Sync'), width: 175 },
{ name: t('Balance'), width: 80 },
{ name: t('Account in Actual'), width: 'flex' },
{ name: t('Actions'), width: 150 },
]}
/>
<TableHeader>
<Cell name={t('Institution to Sync')} width={175} />
<Cell name={t('Bank Account To Sync')} width={175} />
<Cell name={t('Balance')} width={80} />
<Cell name={t('Account in Actual')} width="flex" />
<Cell name={t('Actions')} width={150} />
</TableHeader>
<Table
items={sortedExternalAccounts}
<Table<
SelectLinkedAccountsModalProps['externalAccounts'][number] & {
id: string;
}
>
items={propsWithSortedExternalAccounts.externalAccounts.map(
account => ({
...account,
id: account.account_id,
}),
)}
style={{ backgroundColor: theme.tableHeaderBackground }}
getItemKey={index => index}
renderItem={({ key, item }) => (
<View key={key}>
getItemKey={String}
renderItem={({ item }) => (
<View key={item.id}>
<TableRow
externalAccount={item}
chosenAccount={
@@ -242,7 +313,12 @@ export function SelectLinkedAccountsModal({
);
}
function getInstitutionName(externalAccount) {
function getInstitutionName(
externalAccount:
| SyncServerGoCardlessAccount
| SyncServerSimpleFinAccount
| SyncServerPluggyAiAccount,
) {
if (typeof externalAccount?.institution === 'string') {
return externalAccount?.institution ?? '';
} else if (typeof externalAccount.institution?.name === 'string') {
@@ -251,22 +327,40 @@ function getInstitutionName(externalAccount) {
return '';
}
type TableRowProps = {
externalAccount:
| SyncServerGoCardlessAccount
| SyncServerSimpleFinAccount
| SyncServerPluggyAiAccount;
chosenAccount: { id: string; name: string } | undefined;
unlinkedAccounts: AccountEntity[];
onSetLinkedAccount: (
externalAccount:
| SyncServerGoCardlessAccount
| SyncServerSimpleFinAccount
| SyncServerPluggyAiAccount,
localAccountId: string | null | undefined,
) => void;
};
function TableRow({
externalAccount,
chosenAccount,
unlinkedAccounts,
onSetLinkedAccount,
}) {
const [focusedField, setFocusedField] = useState(null);
}: TableRowProps) {
const [focusedField, setFocusedField] = useState<string | null>(null);
const { addOnBudgetAccountOption, addOffBudgetAccountOption } =
useAddBudgetAccountOptions();
const availableAccountOptions = [
...unlinkedAccounts,
chosenAccount?.id !== addOnBudgetAccountOption.id && chosenAccount,
const availableAccountOptions: AutocompleteItem[] = [...unlinkedAccounts];
if (chosenAccount && chosenAccount.id !== addOnBudgetAccountOption.id) {
availableAccountOptions.push(chosenAccount);
}
availableAccountOptions.push(
addOnBudgetAccountOption,
addOffBudgetAccountOption,
].filter(Boolean);
);
return (
<Row style={{ backgroundColor: theme.tableBackground }}>

View File

@@ -4,7 +4,6 @@ import { send } from 'loot-core/platform/client/fetch';
import { type File } from 'loot-core/types/file';
import {
type AccountEntity,
type AccountSyncSource,
type CategoryEntity,
type CategoryGroupEntity,
type GoCardlessToken,
@@ -19,6 +18,7 @@ import {
} from 'loot-core/types/models';
import { resetApp, setAppState } from '@desktop-client/app/appSlice';
import { type SelectLinkedAccountsModalProps } from '@desktop-client/components/modals/SelectLinkedAccountsModal';
import { createAppAsyncThunk } from '@desktop-client/redux';
import { signOut } from '@desktop-client/users/usersSlice';
@@ -53,12 +53,7 @@ export type Modal =
}
| {
name: 'select-linked-accounts';
options: {
externalAccounts: unknown[];
requisitionId?: string;
upgradingAccountId?: string | undefined;
syncSource?: AccountSyncSource;
};
options: SelectLinkedAccountsModalProps;
}
| {
name: 'confirm-category-delete';

View File

@@ -1,6 +1,6 @@
export type GoCardlessToken = {
id: string;
accounts: unknown[];
accounts: SyncServerGoCardlessAccount[];
};
export type GoCardlessInstitution = {
@@ -75,7 +75,8 @@ export type GoCardlessTransaction = {
};
export type SyncServerGoCardlessAccount = {
institution: string;
balance: number;
institution: string | { name: string };
account_id: string;
mask: string;
name: string;

View File

@@ -10,9 +10,10 @@ export type PluggyAiAccount = {
};
export type SyncServerPluggyAiAccount = {
balance: number;
account_id: string;
institution?: string;
orgDomain?: string;
orgDomain?: string | null;
orgId?: string;
name: string;
};

View File

@@ -19,6 +19,7 @@ export interface SimpleFinBatchSyncResponse {
}
export type SyncServerSimpleFinAccount = {
balance: number;
account_id: string;
institution?: string;
orgDomain?: string;

View File

@@ -0,0 +1,6 @@
---
category: Maintenance
authors: [jfdoming]
---
Convert `SelectLinkedAccountsModal` to TypeScript