mirror of
https://github.com/actualbudget/actual.git
synced 2026-03-22 00:13:45 -05:00
* Add filter by category groups * Add tests * Add release notes * [autofix.ci] apply automated fixes * Fix typecheck findings * Fix modal * Address nitpick comment (filterBy) * Fix e2e tests * Make group a subfield of category * Fix test by typing in autocomplete * Replace testId with a11y lookups * Apply new type import style rules * Apply feedback * Improve typing on array reduce, remove manual type coercion --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
718 lines
18 KiB
TypeScript
718 lines
18 KiB
TypeScript
import { createSlice } from '@reduxjs/toolkit';
|
|
import type { PayloadAction } from '@reduxjs/toolkit';
|
|
|
|
import { send } from 'loot-core/platform/client/connection';
|
|
import type { IntegerAmount } from 'loot-core/shared/util';
|
|
import type { File } from 'loot-core/types/file';
|
|
import type {
|
|
AccountEntity,
|
|
CategoryEntity,
|
|
CategoryGroupEntity,
|
|
GoCardlessToken,
|
|
NewRuleEntity,
|
|
NewUserEntity,
|
|
NoteEntity,
|
|
RuleEntity,
|
|
ScheduleEntity,
|
|
TransactionEntity,
|
|
UserAccessEntity,
|
|
UserEntity,
|
|
} from 'loot-core/types/models';
|
|
import type { Template } from 'loot-core/types/models/templates';
|
|
|
|
import { accountQueries } from '@desktop-client/accounts';
|
|
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';
|
|
|
|
const sliceName = 'modals';
|
|
|
|
export type Modal =
|
|
| {
|
|
name: 'import-transactions';
|
|
options: {
|
|
accountId: string;
|
|
filename: string;
|
|
categories?: { list: CategoryEntity[]; grouped: CategoryGroupEntity[] };
|
|
onImported: (didChange: boolean) => void;
|
|
};
|
|
}
|
|
| {
|
|
name: 'add-account';
|
|
options: {
|
|
upgradingAccountId?: string;
|
|
};
|
|
}
|
|
| {
|
|
name: 'add-local-account';
|
|
}
|
|
| {
|
|
name: 'close-account';
|
|
options: {
|
|
account: AccountEntity;
|
|
balance: number;
|
|
canDelete: boolean;
|
|
};
|
|
}
|
|
| {
|
|
name: 'select-linked-accounts';
|
|
options: SelectLinkedAccountsModalProps;
|
|
}
|
|
| {
|
|
name: 'confirm-category-delete';
|
|
options: {
|
|
onDelete: (transferCategoryId: CategoryEntity['id']) => void;
|
|
category?: CategoryEntity['id'];
|
|
group?: CategoryGroupEntity['id'];
|
|
};
|
|
}
|
|
| {
|
|
name: 'load-backup';
|
|
options: {
|
|
budgetId?: string;
|
|
watchUpdates?: boolean;
|
|
backupDisabled?: boolean;
|
|
};
|
|
}
|
|
| {
|
|
name: 'manage-rules';
|
|
options: { payeeId?: string };
|
|
}
|
|
| {
|
|
name: 'edit-rule';
|
|
options: {
|
|
rule: RuleEntity | NewRuleEntity;
|
|
onSave?: (rule: RuleEntity) => void;
|
|
};
|
|
}
|
|
| {
|
|
name: 'merge-unused-payees';
|
|
options: {
|
|
payeeIds: string[];
|
|
targetPayeeId: string;
|
|
};
|
|
}
|
|
| {
|
|
name: 'gocardless-init';
|
|
options: {
|
|
onSuccess: () => void;
|
|
};
|
|
}
|
|
| {
|
|
name: 'simplefin-init';
|
|
options: {
|
|
onSuccess: () => void;
|
|
};
|
|
}
|
|
| {
|
|
name: 'pluggyai-init';
|
|
options: {
|
|
onSuccess: () => void;
|
|
};
|
|
}
|
|
| {
|
|
name: 'gocardless-external-msg';
|
|
options: {
|
|
onMoveExternal: (arg: {
|
|
institutionId: string;
|
|
}) => Promise<
|
|
| { error: 'timeout' }
|
|
| { error: 'unknown'; message?: string }
|
|
| { data: GoCardlessToken }
|
|
>;
|
|
onClose?: (() => void) | undefined;
|
|
onSuccess: (data: GoCardlessToken) => Promise<void>;
|
|
};
|
|
}
|
|
| {
|
|
name: 'delete-budget';
|
|
options: { file: File };
|
|
}
|
|
| {
|
|
name: 'duplicate-budget';
|
|
options: {
|
|
/** The budget file to be duplicated */
|
|
file: File;
|
|
/**
|
|
* Indicates whether the duplication is initiated from the budget
|
|
* management page. This may affect the behavior or UI of the
|
|
* duplication process.
|
|
*/
|
|
managePage?: boolean;
|
|
/**
|
|
* loadBudget indicates whether to open the 'original' budget, the
|
|
* new duplicated 'copy' budget, or no budget ('none'). If 'none'
|
|
* duplicate-budget stays on the same page.
|
|
*/
|
|
loadBudget?: 'none' | 'original' | 'copy';
|
|
/**
|
|
* onComplete is called when the DuplicateFileModal is closed.
|
|
* @param event the event object will pass back the status of the
|
|
* duplicate process.
|
|
* 'success' if the budget was duplicated.
|
|
* 'failed' if the budget could not be duplicated. This will also
|
|
* pass an error on the event object.
|
|
* 'canceled' if the DuplicateFileModal was canceled.
|
|
* @returns
|
|
*/
|
|
onComplete?: (event: {
|
|
status: 'success' | 'failed' | 'canceled';
|
|
error?: Error;
|
|
}) => void;
|
|
};
|
|
}
|
|
| {
|
|
name: 'import';
|
|
}
|
|
| {
|
|
name: 'import-ynab4';
|
|
}
|
|
| {
|
|
name: 'import-ynab5';
|
|
}
|
|
| {
|
|
name: 'import-actual';
|
|
}
|
|
| {
|
|
name: 'out-of-sync-migrations';
|
|
}
|
|
| {
|
|
name: 'files-settings';
|
|
}
|
|
| {
|
|
name: 'confirm-change-document-dir';
|
|
options: {
|
|
currentBudgetDirectory: string;
|
|
newDirectory: string;
|
|
};
|
|
}
|
|
| {
|
|
name: 'create-encryption-key';
|
|
options: { recreate?: boolean };
|
|
}
|
|
| {
|
|
name: 'fix-encryption-key';
|
|
options: {
|
|
hasExistingKey?: boolean;
|
|
cloudFileId?: string;
|
|
onSuccess?: () => void;
|
|
};
|
|
}
|
|
| {
|
|
name: 'edit-field';
|
|
options: {
|
|
name: keyof Pick<TransactionEntity, 'date' | 'amount' | 'notes'>;
|
|
onSubmit: (
|
|
name: keyof Pick<TransactionEntity, 'date' | 'amount' | 'notes'>,
|
|
value:
|
|
| string
|
|
| number
|
|
| {
|
|
useRegex: boolean;
|
|
find: string;
|
|
replace: string;
|
|
},
|
|
mode?: 'prepend' | 'append' | 'replace' | 'findAndReplace' | null,
|
|
) => void;
|
|
onClose?: () => void;
|
|
};
|
|
}
|
|
| {
|
|
name: 'category-autocomplete';
|
|
options: {
|
|
title?: string;
|
|
categoryGroups?: CategoryGroupEntity[];
|
|
onSelect: (categoryId: string, categoryName: string) => void;
|
|
month?: string | undefined;
|
|
showHiddenCategories?: boolean;
|
|
closeOnSelect?: boolean;
|
|
clearOnSelect?: boolean;
|
|
onClose?: () => void;
|
|
};
|
|
}
|
|
| {
|
|
name: 'category-group-autocomplete';
|
|
options: {
|
|
title?: string;
|
|
categoryGroups?: CategoryGroupEntity[];
|
|
onSelect: (categoryGroupId: string, categoryGroupName: string) => void;
|
|
month?: string | undefined;
|
|
showHiddenCategories?: boolean;
|
|
closeOnSelect?: boolean;
|
|
clearOnSelect?: boolean;
|
|
onClose?: () => void;
|
|
};
|
|
}
|
|
| {
|
|
name: 'account-autocomplete';
|
|
options: {
|
|
onSelect: (accountId: string, accountName: string) => void;
|
|
includeClosedAccounts?: boolean;
|
|
hiddenAccounts?: AccountEntity['id'][];
|
|
onClose?: () => void;
|
|
};
|
|
}
|
|
| {
|
|
name: 'payee-autocomplete';
|
|
options: {
|
|
onSelect: (payeeId: string) => void;
|
|
onClose?: () => void;
|
|
};
|
|
}
|
|
| {
|
|
name: 'budget-summary';
|
|
options: {
|
|
month: string;
|
|
};
|
|
}
|
|
| {
|
|
name: 'schedule-edit';
|
|
options: { id?: string; transaction?: TransactionEntity } | null;
|
|
}
|
|
| {
|
|
name: 'schedule-link';
|
|
options: {
|
|
transactionIds: string[];
|
|
getTransaction: (
|
|
transactionId: TransactionEntity['id'],
|
|
) => TransactionEntity;
|
|
accountName?: string;
|
|
onScheduleLinked?: (schedule: ScheduleEntity) => void;
|
|
};
|
|
}
|
|
| {
|
|
name: 'schedules-discover';
|
|
}
|
|
| {
|
|
name: 'schedule-posts-offline-notification';
|
|
}
|
|
| {
|
|
name: 'synced-account-edit';
|
|
options: {
|
|
account: AccountEntity;
|
|
};
|
|
}
|
|
| {
|
|
name: 'account-menu';
|
|
options: {
|
|
accountId: AccountEntity['id'];
|
|
onSave: (account: AccountEntity) => void;
|
|
onCloseAccount: (accountId: AccountEntity['id']) => void;
|
|
onReopenAccount: (accountId: AccountEntity['id']) => void;
|
|
onEditNotes: (id: NoteEntity['id']) => void;
|
|
onClose?: () => void;
|
|
onToggleRunningBalance?: () => void;
|
|
onToggleReconciled?: () => void;
|
|
};
|
|
}
|
|
| {
|
|
name: 'category-menu';
|
|
options: {
|
|
categoryId: CategoryEntity['id'];
|
|
onSave: (category: CategoryEntity) => void;
|
|
onEditNotes: (id: NoteEntity['id']) => void;
|
|
onDelete: (categoryId: CategoryEntity['id']) => void;
|
|
onToggleVisibility: (categoryId: CategoryEntity['id']) => void;
|
|
onClose?: () => void;
|
|
};
|
|
}
|
|
| {
|
|
name: 'envelope-budget-menu';
|
|
options: {
|
|
categoryId: CategoryEntity['id'];
|
|
month: string;
|
|
onUpdateBudget: (amount: number) => void;
|
|
onCopyLastMonthAverage: () => void;
|
|
onSetMonthsAverage: (numberOfMonths: number) => void;
|
|
onApplyBudgetTemplate: () => void;
|
|
};
|
|
}
|
|
| {
|
|
name: 'tracking-budget-menu';
|
|
options: {
|
|
categoryId: CategoryEntity['id'];
|
|
month: string;
|
|
onUpdateBudget: (amount: number) => void;
|
|
onCopyLastMonthAverage: () => void;
|
|
onSetMonthsAverage: (numberOfMonths: number) => void;
|
|
onApplyBudgetTemplate: () => void;
|
|
};
|
|
}
|
|
| {
|
|
name: 'category-group-menu';
|
|
options: {
|
|
groupId: CategoryGroupEntity['id'];
|
|
onSave: (group: CategoryGroupEntity) => void;
|
|
onAddCategory: (
|
|
groupId: CategoryGroupEntity['id'],
|
|
isIncome: CategoryGroupEntity['is_income'],
|
|
) => void;
|
|
onEditNotes: (id: NoteEntity['id']) => void;
|
|
onDelete: (groupId: CategoryGroupEntity['id']) => void;
|
|
onToggleVisibility: (groupId: CategoryGroupEntity['id']) => void;
|
|
onClose?: () => void;
|
|
onApplyBudgetTemplatesInGroup?: (
|
|
categories: Array<CategoryEntity['id']>,
|
|
) => void;
|
|
};
|
|
}
|
|
| {
|
|
name: 'notes';
|
|
options: {
|
|
id: NoteEntity['id'];
|
|
name: string;
|
|
onSave: (id: NoteEntity['id'], contents: string) => void;
|
|
};
|
|
}
|
|
| {
|
|
name: 'tracking-budget-summary';
|
|
options: { month: string };
|
|
}
|
|
| {
|
|
name: 'envelope-budget-summary';
|
|
options: {
|
|
month: string;
|
|
onBudgetAction: (
|
|
month: string,
|
|
type: string,
|
|
args?: unknown,
|
|
) => Promise<void>;
|
|
};
|
|
}
|
|
| {
|
|
name: 'new-category-group';
|
|
options: {
|
|
onValidate?: (value: string) => string | null;
|
|
onSubmit: (value: string) => Promise<void>;
|
|
};
|
|
}
|
|
| {
|
|
name: 'new-category';
|
|
options: {
|
|
onValidate?: (value: string) => string | null;
|
|
onSubmit: (value: string) => Promise<void>;
|
|
};
|
|
}
|
|
| {
|
|
name: 'envelope-balance-menu';
|
|
options: {
|
|
categoryId: CategoryEntity['id'];
|
|
month: string;
|
|
onCarryover?: (carryover: boolean) => void;
|
|
onTransfer?: () => void;
|
|
onCover?: () => void;
|
|
};
|
|
}
|
|
| {
|
|
name: 'envelope-income-balance-menu';
|
|
options: {
|
|
categoryId: CategoryEntity['id'];
|
|
month: string;
|
|
onCarryover: (carryover: boolean) => void;
|
|
onShowActivity: () => void;
|
|
};
|
|
}
|
|
| {
|
|
name: 'envelope-summary-to-budget-menu';
|
|
options: {
|
|
month: string;
|
|
onTransfer: () => void;
|
|
onCover: () => void;
|
|
onHoldBuffer: () => void;
|
|
onResetHoldBuffer: () => void;
|
|
onBudgetAction: (month: string, action: string, arg?: unknown) => void;
|
|
};
|
|
}
|
|
| {
|
|
name: 'tracking-balance-menu';
|
|
options: {
|
|
categoryId: CategoryEntity['id'];
|
|
month: string;
|
|
onCarryover: (carryover: boolean) => void;
|
|
};
|
|
}
|
|
| {
|
|
name: 'transfer';
|
|
options: {
|
|
title: string;
|
|
amount: IntegerAmount;
|
|
categoryId?: CategoryEntity['id'];
|
|
month: string;
|
|
onSubmit: (
|
|
amount: IntegerAmount,
|
|
toCategoryId: CategoryEntity['id'],
|
|
) => void;
|
|
showToBeBudgeted?: boolean;
|
|
};
|
|
}
|
|
| {
|
|
name: 'cover';
|
|
options: {
|
|
title: string;
|
|
amount?: IntegerAmount | null;
|
|
categoryId?: CategoryEntity['id'];
|
|
month: string;
|
|
showToBeBudgeted?: boolean;
|
|
onSubmit: (
|
|
amount: IntegerAmount,
|
|
fromCategoryId: CategoryEntity['id'],
|
|
) => void;
|
|
};
|
|
}
|
|
| {
|
|
name: 'hold-buffer';
|
|
options: {
|
|
month: string;
|
|
onSubmit: (amount: number) => void;
|
|
};
|
|
}
|
|
| {
|
|
name: 'scheduled-transaction-menu';
|
|
options: {
|
|
transactionId: TransactionEntity['id'];
|
|
onPost: (
|
|
transactionId: TransactionEntity['id'],
|
|
today?: boolean,
|
|
) => void;
|
|
onSkip: (transactionId: TransactionEntity['id']) => void;
|
|
onComplete: (transactionId: TransactionEntity['id']) => void;
|
|
};
|
|
}
|
|
| {
|
|
name: 'budget-page-menu';
|
|
options: {
|
|
onAddCategoryGroup: () => void;
|
|
onToggleHiddenCategories: () => void;
|
|
onSwitchBudgetFile: () => void;
|
|
};
|
|
}
|
|
| {
|
|
name: 'envelope-budget-month-menu';
|
|
options: {
|
|
month: string;
|
|
onBudgetAction: (month: string, action: string, arg?: unknown) => void;
|
|
onEditNotes: (id: NoteEntity['id']) => void;
|
|
};
|
|
}
|
|
| {
|
|
name: 'tracking-budget-month-menu';
|
|
options: {
|
|
month: string;
|
|
onBudgetAction: (month: string, action: string, arg?: unknown) => void;
|
|
onEditNotes: (id: NoteEntity['id']) => void;
|
|
};
|
|
}
|
|
| {
|
|
name: 'confirm-transaction-edit';
|
|
options: {
|
|
onConfirm: () => void;
|
|
onCancel?: () => void;
|
|
confirmReason: string;
|
|
};
|
|
}
|
|
| {
|
|
name: 'convert-to-schedule';
|
|
options: {
|
|
onConfirm: () => void;
|
|
onCancel?: () => void;
|
|
isBeyondWindow?: boolean;
|
|
daysUntilTransaction?: number;
|
|
upcomingDays?: number;
|
|
};
|
|
}
|
|
| {
|
|
name: 'confirm-delete';
|
|
options: {
|
|
message: string;
|
|
onConfirm: () => void;
|
|
};
|
|
}
|
|
| {
|
|
name: 'copy-widget-to-dashboard';
|
|
options: {
|
|
onSelect: (dashboardId: string) => void;
|
|
};
|
|
}
|
|
| {
|
|
name: 'edit-user';
|
|
options: {
|
|
user: UserEntity | NewUserEntity;
|
|
onSave: (user: UserEntity) => void;
|
|
};
|
|
}
|
|
| {
|
|
name: 'edit-access';
|
|
options: {
|
|
access: UserAccessEntity;
|
|
onSave: (userAccess: UserAccessEntity) => void;
|
|
};
|
|
}
|
|
| {
|
|
name: 'transfer-ownership';
|
|
options: {
|
|
onSave: () => void;
|
|
};
|
|
}
|
|
| {
|
|
name: 'enable-openid';
|
|
options: {
|
|
onSave?: () => void;
|
|
};
|
|
}
|
|
| {
|
|
name: 'enable-password-auth';
|
|
options: {
|
|
onSave?: () => void;
|
|
};
|
|
}
|
|
| {
|
|
name: 'confirm-unlink-account';
|
|
options: {
|
|
accountName: string;
|
|
isViewBankSyncSettings: boolean;
|
|
onUnlink: () => void;
|
|
};
|
|
}
|
|
| {
|
|
name: 'keyboard-shortcuts';
|
|
}
|
|
| {
|
|
name: 'goal-templates';
|
|
}
|
|
| {
|
|
name: 'schedules-upcoming-length';
|
|
}
|
|
| {
|
|
name: 'payee-category-learning';
|
|
}
|
|
| {
|
|
name: 'category-automations-edit';
|
|
options: {
|
|
categoryId: CategoryEntity['id'];
|
|
};
|
|
}
|
|
| {
|
|
name: 'category-automations-unmigrate';
|
|
options: {
|
|
categoryId: CategoryEntity['id'];
|
|
templates: Template[];
|
|
};
|
|
};
|
|
|
|
type OpenAccountCloseModalPayload = {
|
|
accountId: AccountEntity['id'];
|
|
};
|
|
|
|
export const openAccountCloseModal = createAppAsyncThunk(
|
|
`${sliceName}/openAccountCloseModal`,
|
|
async ({ accountId }: OpenAccountCloseModalPayload, { dispatch, extra }) => {
|
|
const {
|
|
balance,
|
|
numTransactions,
|
|
}: { balance: number; numTransactions: number } = await send(
|
|
'account-properties',
|
|
{
|
|
id: accountId,
|
|
},
|
|
);
|
|
const queryClient = extra.queryClient;
|
|
const accounts = await queryClient.ensureQueryData(accountQueries.list());
|
|
const account = accounts.find(acct => acct.id === accountId);
|
|
|
|
if (!account) {
|
|
throw new Error(`Account with ID ${accountId} does not exist.`);
|
|
}
|
|
|
|
dispatch(
|
|
pushModal({
|
|
modal: {
|
|
name: 'close-account',
|
|
options: {
|
|
account,
|
|
balance,
|
|
canDelete: numTransactions === 0,
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
},
|
|
);
|
|
|
|
type ModalsState = {
|
|
modalStack: Modal[];
|
|
isHidden: boolean;
|
|
};
|
|
|
|
const initialState: ModalsState = {
|
|
modalStack: [],
|
|
isHidden: false,
|
|
};
|
|
|
|
type PushModalPayload = {
|
|
modal: Modal;
|
|
};
|
|
|
|
type ReplaceModalPayload = {
|
|
modal: Modal;
|
|
};
|
|
|
|
type CollapseModalPayload = {
|
|
rootModalName: Modal['name'];
|
|
};
|
|
|
|
const modalsSlice = createSlice({
|
|
name: sliceName,
|
|
initialState,
|
|
reducers: {
|
|
pushModal(state, action: PayloadAction<PushModalPayload>) {
|
|
const modal = action.payload.modal;
|
|
// special case: don't show the keyboard shortcuts modal if there's already a modal open
|
|
if (
|
|
modal.name.endsWith('keyboard-shortcuts') &&
|
|
(state.modalStack.length > 0 ||
|
|
window.document.querySelector(
|
|
'div[data-testid="filters-menu-tooltip"]',
|
|
) !== null)
|
|
) {
|
|
return state;
|
|
}
|
|
state.modalStack = [...state.modalStack, modal];
|
|
},
|
|
replaceModal(state, action: PayloadAction<ReplaceModalPayload>) {
|
|
const modal = action.payload.modal;
|
|
state.modalStack = [modal];
|
|
},
|
|
popModal(state) {
|
|
state.modalStack = state.modalStack.slice(0, -1);
|
|
},
|
|
closeModal(state) {
|
|
state.modalStack = [];
|
|
},
|
|
collapseModals(state, action: PayloadAction<CollapseModalPayload>) {
|
|
const idx = state.modalStack.findIndex(
|
|
m => m.name === action.payload.rootModalName,
|
|
);
|
|
state.modalStack =
|
|
idx < 0 ? state.modalStack : state.modalStack.slice(0, idx);
|
|
},
|
|
},
|
|
extraReducers: builder => {
|
|
builder.addCase(setAppState, (state, action) => {
|
|
state.isHidden = action.payload.loadingText !== null;
|
|
});
|
|
builder.addCase(signOut.fulfilled, () => initialState);
|
|
builder.addCase(resetApp, () => initialState);
|
|
},
|
|
});
|
|
|
|
export const { name, reducer, getInitialState } = modalsSlice;
|
|
|
|
export const actions = {
|
|
...modalsSlice.actions,
|
|
openAccountCloseModal,
|
|
};
|
|
|
|
export const { pushModal, closeModal, collapseModals, popModal, replaceModal } =
|
|
actions;
|