Files
actual/packages/desktop-client/src/modals/modalsSlice.ts
Roy 5943ae3df5 Add filter option for category groups (#6834)
* 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>
2026-02-14 15:03:11 +00:00

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;