mirror of
https://github.com/actualbudget/actual.git
synced 2026-03-11 12:43:09 -05:00
Convert SelectLinkedAccountsModal to TypeScript (#5059)
* Convert `SelectLinkedAccountsModal` to TypeScript * Add release notes * Fix typo caught by Rabbit
This commit is contained in:
committed by
GitHub
parent
82a3c97222
commit
77e99af297
@@ -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') {
|
||||
|
||||
@@ -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 }}>
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -19,6 +19,7 @@ export interface SimpleFinBatchSyncResponse {
|
||||
}
|
||||
|
||||
export type SyncServerSimpleFinAccount = {
|
||||
balance: number;
|
||||
account_id: string;
|
||||
institution?: string;
|
||||
orgDomain?: string;
|
||||
|
||||
6
upcoming-release-notes/5059.md
Normal file
6
upcoming-release-notes/5059.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Maintenance
|
||||
authors: [jfdoming]
|
||||
---
|
||||
|
||||
Convert `SelectLinkedAccountsModal` to TypeScript
|
||||
Reference in New Issue
Block a user