[AI] Add a column manager to the transaction table (#8580)

* [AI] Add a column manager to the transaction table

Adds a modal for showing, hiding, and reordering transaction table
columns, saved per view or budget-wide.

Co-Authored-By: Claude Fable 5 <[email protected]>

* [AI] Fix running balance e2e test to use the column manager

The 'Show running balance' menu item was replaced by the column manager,
so this test now toggles the column via the modal instead.

Co-Authored-By: Claude Fable 5 <[email protected]>

* [AI] Address code review feedback for the column manager

- Apply-to-all now clears every stored view override, derived from the
  synced prefs themselves (covers closed/deleted accounts)
- The column manager shows the user's underlying cleared-column
  preference while a reconciliation is in progress
- At least one amount column always stays visible so new transactions
  keep an amount input (enforced at parse time and in the modal)
- Column toggles now have an accessible name (Toggle forwards aria-label)
- E2e: idempotent setTransactionColumnVisibility page-model helper,
  shared openTransactionColumnsModal, and a fixed expected header order
  in the reorder test so a skipped reorder cannot pass

Co-Authored-By: Claude Fable 5 <[email protected]>

* [AI] Assert the exact header cell sequence in the reorder e2e test

Co-Authored-By: Claude Fable 5 <[email protected]>

* [AI] Use a checkbox instead of a toggle for applying columns to all views

---------

Co-authored-by: Claude Fable 5 <[email protected]>
This commit is contained in:
Matiss Janis Aboltins
2026-08-02 12:58:55 +00:00
committed by GitHub
co-authored by Claude Fable 5
parent 98ee00e015
commit f72498016e
23 changed files with 1294 additions and 547 deletions
@@ -13,6 +13,7 @@ type ToggleProps = {
onToggle?: (isOn: boolean) => void;
className?: string;
style?: CSSProperties;
'aria-label'?: string;
};
export const Toggle = ({
@@ -22,11 +23,13 @@ export const Toggle = ({
onToggle,
className,
style,
'aria-label': ariaLabel,
}: ToggleProps) => {
return (
<View style={style} className={className}>
<input
id={id}
aria-label={ariaLabel}
checked={isOn}
disabled={isDisabled}
onChange={e => onToggle?.(e.target.checked)}
+2 -4
View File
@@ -89,8 +89,7 @@ test.describe('Accounts', () => {
credit: '10.00',
});
await accountPage.accountMenuButton.click();
await page.getByRole('button', { name: 'Show running balance' }).click();
await accountPage.setTransactionColumnVisibility('balance', true);
const transaction = accountPage.getNthTransaction(0);
await expect(transaction.balance).toHaveText('10.00');
@@ -140,8 +139,7 @@ test.describe('Accounts', () => {
// Showing the running balance keeps reconciled transactions loaded
// even when they are hidden; they must still be excluded from
// range selection.
await accountPage.accountMenuButton.click();
await page.getByRole('button', { name: 'Show running balance' }).click();
await accountPage.setTransactionColumnVisibility('balance', true);
await accountPage.accountMenuButton.click();
await page
.getByRole('button', { name: 'Hide reconciled transactions' })
@@ -177,6 +177,30 @@ export class AccountPage {
return new CloseAccountModal(this.page.getByTestId('close-account-modal'));
}
/**
* Open the "Manage table columns" modal from the account menu.
*/
async openTransactionColumnsModal() {
await this.accountMenuButton.click();
await this.page
.getByRole('button', { name: 'Manage table columns' })
.click();
return this.page.getByTestId('transaction-table-columns-modal');
}
/**
* Set one column's visibility via the "Manage table columns" modal.
*/
async setTransactionColumnVisibility(columnId: string, visible: boolean) {
const modal = await this.openTransactionColumnsModal();
const toggle = modal.locator(`#toggle-column-${columnId}`);
if ((await toggle.isChecked()) !== visible) {
await modal.locator(`label[for="toggle-column-${columnId}"]`).click();
}
await modal.getByRole('button', { name: 'Save', exact: true }).click();
await modal.waitFor({ state: 'hidden' });
}
/**
* Open the filtering popover.
*/
@@ -289,4 +289,66 @@ test.describe('Transactions', () => {
await expect(page).toMatchThemeScreenshots();
});
test.describe('column manager', () => {
test('hides and reorders columns', async () => {
const header = page.getByTestId('transaction-table-header');
await expect(header).toContainText('Notes');
const modal = await accountPage.openTransactionColumnsModal();
await expect(modal).toBeVisible();
await expect(modal).toMatchThemeScreenshots();
// Hide the notes column
await modal.locator('label[for="toggle-column-notes"]').click();
// Reorder the category column with the keyboard-accessible drag handle.
// Two steps up moves it above the (hidden) notes row and then above
// payee, so the saved order visibly changes in the table header.
const dragHandle = modal.getByRole('button', {
name: 'Reorder Category column',
});
await dragHandle.focus();
await page.keyboard.press('Enter');
await page.keyboard.press('ArrowUp');
await page.keyboard.press('ArrowUp');
await page.keyboard.press('Enter');
await modal.getByRole('button', { name: 'Save', exact: true }).click();
await expect(modal).not.toBeVisible();
// The saved layout has notes hidden and category moved before payee
await expect(header).toHaveText('DateCategoryPayeePaymentDeposit✓');
await expect(page).toMatchThemeScreenshots();
// Reopen: the saved configuration should be reflected in the modal
await accountPage.openTransactionColumnsModal();
await expect(modal.locator('#toggle-column-notes')).not.toBeChecked();
// Reset to default restores the original layout
await modal
.getByRole('button', { name: 'Reset to default', exact: true })
.click();
await modal.getByRole('button', { name: 'Save', exact: true }).click();
await expect(modal).not.toBeVisible();
await expect(header).toHaveText('DatePayeeNotesCategoryPaymentDeposit✓');
});
test('applies columns to all transaction tables when enabled', async () => {
const header = page.getByTestId('transaction-table-header');
// Hide the notes column for every transaction table
const modal = await accountPage.openTransactionColumnsModal();
await modal.locator('label[for="toggle-column-notes"]').click();
await modal.getByText('Apply to all transaction tables').click();
await modal.getByRole('button', { name: 'Save', exact: true }).click();
await expect(modal).not.toBeVisible();
await expect(header).not.toContainText('Notes');
// Another account follows the shared layout
accountPage = await navigation.goToAccountPage('Bank of America');
await expect(header).not.toContainText('Notes');
});
});
});
+1
View File
@@ -73,6 +73,7 @@
"#components/reports/util": "./src/components/reports/util.ts",
"#components/schedules": "./src/components/schedules/index.tsx",
"#components/schedules/schedule-edit-utils": "./src/components/schedules/schedule-edit-utils.ts",
"#components/transactions/table/columns": "./src/components/transactions/table/columns.ts",
"#components/transactions/table/utils": "./src/components/transactions/table/utils.ts",
"#components/util/accountValidation": "./src/components/util/accountValidation.ts",
"#components/util/countries": "./src/components/util/countries.ts",
@@ -79,6 +79,7 @@ import { TrackingBalanceMenuModal } from './modals/TrackingBalanceMenuModal';
import { TrackingBudgetMenuModal } from './modals/TrackingBudgetMenuModal';
import { TrackingBudgetMonthMenuModal } from './modals/TrackingBudgetMonthMenuModal';
import { TrackingBudgetSummaryModal } from './modals/TrackingBudgetSummaryModal';
import { TransactionTableColumnsModal } from './modals/TransactionTableColumnsModal';
import { TransferModal } from './modals/TransferModal';
import { TransferOwnership } from './modals/TransferOwnership';
import { UnmigrateBudgetAutomationsModal } from './modals/UnmigrateBudgetAutomationsModal';
@@ -154,6 +155,9 @@ export function Modals() {
case 'confirm-transaction-edit':
return <ConfirmTransactionEditModal key={key} {...modal.options} />;
case 'transaction-table-columns':
return <TransactionTableColumnsModal key={key} {...modal.options} />;
case 'convert-to-schedule':
return <ConvertToScheduleModal key={key} {...modal.options} />;
@@ -49,6 +49,10 @@ import { markAccountRead } from '#accounts/accountsSlice';
import * as reconciliation from '#accounts/reconciliation';
import { FeatureErrorFallback } from '#components/FeatureErrorFallback';
import type { SavedFilter } from '#components/filters/SavedFilterMenuButton';
import type {
TransactionTableColumn,
TransactionTableColumnId,
} from '#components/transactions/table/columns';
import { TransactionList } from '#components/transactions/TransactionList';
import { validateAccountName } from '#components/util/accountValidation';
import { useAccountPreviewTransactions } from '#hooks/useAccountPreviewTransactions';
@@ -69,6 +73,10 @@ import { useSyncedPref } from '#hooks/useSyncedPref';
import { useTransactionBatchActions } from '#hooks/useTransactionBatchActions';
import { useTransactionFilters } from '#hooks/useTransactionFilters';
import { calculateRunningBalancesBottomUp } from '#hooks/useTransactions';
import {
SPECIAL_VIEW_IDS,
useTransactionTableColumns,
} from '#hooks/useTransactionTableColumns';
import {
openAccountCloseModal,
pushModal,
@@ -212,17 +220,17 @@ type AccountInternalProps = {
| undefined;
filterConditions: RuleConditionEntity[];
showBalances?: boolean;
setShowBalances: (newValue: boolean) => void;
showNetWorthChart: boolean;
setShowNetWorthChart: (newValue: boolean) => void;
showCleared?: boolean;
setShowCleared: (newValue: boolean) => void;
showReconciled: boolean;
setShowReconciled: (newValue: boolean) => void;
showGroup: boolean;
setShowGroup: (newValue: boolean) => void;
showExtraBalances?: boolean;
setShowExtraBalances: (newValue: boolean) => void;
transactionColumns: TransactionTableColumn[];
columnOrder: TransactionTableColumnId[];
saveColumns: (columns: TransactionTableColumn[], applyToAll: boolean) => void;
modalShowing?: boolean;
accounts: AccountEntity[];
newTransactions: Array<TransactionEntity['id']>;
@@ -272,7 +280,6 @@ type AccountInternalState = {
showCleared?: boolean | undefined;
prevShowCleared?: boolean | undefined;
showReconciled: boolean;
showGroup: boolean;
nameError: string;
isAdding: boolean;
modalShowing?: boolean;
@@ -323,7 +330,6 @@ class AccountInternal extends PureComponent<
balances: null,
showCleared: props.showCleared,
showReconciled: props.showReconciled,
showGroup: props.showGroup,
nameError: '',
isAdding: false,
sort: null,
@@ -570,7 +576,6 @@ class AccountInternal extends PureComponent<
balances: null,
showCleared: nextProps.showCleared,
showReconciled: nextProps.showReconciled,
showGroup: nextProps.showGroup,
reconcileAmount: null,
},
() => {
@@ -811,12 +816,10 @@ class AccountInternal extends PureComponent<
| 'close'
| 'reopen'
| 'export'
| 'toggle-balance'
| 'remove-sorting'
| 'toggle-cleared'
| 'toggle-reconciled'
| 'toggle-group'
| 'toggle-net-worth-chart',
| 'toggle-net-worth-chart'
| 'manage-columns',
) => {
const accountId = this.props.accountId!;
const account = this.props.accounts.find(
@@ -862,26 +865,6 @@ class AccountInternal extends PureComponent<
const accountName = this.getAccountTitle(account, accountId);
void this.onExport(accountName);
break;
case 'toggle-balance':
if (this.state.showBalances) {
this.props.setShowBalances(false);
this.setState({ showBalances: false, balances: null });
} else {
this.props.setShowBalances(true);
this.setState(
{
transactions: [],
filterConditions: [],
search: '',
sort: null,
showBalances: true,
},
() => {
this.fetchTransactions();
},
);
}
break;
case 'remove-sorting': {
this.setState({ sort: null }, () => {
const filterConditions = this.state.filterConditions;
@@ -896,15 +879,6 @@ class AccountInternal extends PureComponent<
});
break;
}
case 'toggle-cleared':
if (this.state.showCleared) {
this.props.setShowCleared(false);
this.setState({ showCleared: false });
} else {
this.props.setShowCleared(true);
this.setState({ showCleared: true });
}
break;
case 'toggle-reconciled':
if (this.state.showReconciled) {
this.props.setShowReconciled(false);
@@ -918,15 +892,6 @@ class AccountInternal extends PureComponent<
);
}
break;
case 'toggle-group':
if (this.state.showGroup) {
this.props.setShowGroup(false);
this.setState({ showGroup: false });
} else {
this.props.setShowGroup(true);
this.setState({ showGroup: true });
}
break;
case 'toggle-net-worth-chart':
if (this.props.showNetWorthChart) {
this.props.setShowNetWorthChart(false);
@@ -934,10 +899,110 @@ class AccountInternal extends PureComponent<
this.props.setShowNetWorthChart(true);
}
break;
case 'manage-columns':
this.onManageColumns();
break;
default:
}
};
showAccountColumn = () => {
const accountId = this.props.accountId;
return !accountId || SPECIAL_VIEW_IDS.includes(accountId);
};
onManageColumns = () => {
const columns = this.props.transactionColumns
.filter(
column =>
(column.id !== 'account' || this.showAccountColumn()) &&
(column.id !== 'balance' || this.canCalculateBalance()),
)
.map(column => {
// Balance and cleared visibility can be temporarily overridden in
// component state (e.g. while reconciling) and may still come from
// the old per-account prefs, so state is the source of truth here.
if (column.id === 'balance') {
return { ...column, hidden: !this.state.showBalances };
}
if (column.id === 'cleared') {
// During reconciliation the cleared column is temporarily forced
// visible, so show the user's underlying preference instead
const showCleared =
this.state.reconcileAmount != null
? this.state.prevShowCleared
: this.state.showCleared;
return { ...column, hidden: !showCleared };
}
// Group visibility may come from the legacy pref fallback rather
// than the saved config, so the resolved prop is the source of truth
if (column.id === 'group') {
return { ...column, hidden: !this.props.showGroup };
}
return column;
});
this.props.dispatch(
pushModal({
modal: {
name: 'transaction-table-columns',
options: {
columns,
onSave: this.onSaveColumns,
},
},
}),
);
};
onSaveColumns = (columns: TransactionTableColumn[], applyToAll: boolean) => {
// Columns that aren't managed in the current view (e.g. the account
// column on a single-account page) keep their previous position and
// visibility so a save here doesn't clobber them.
const merged = [...columns];
this.props.transactionColumns.forEach((column, index) => {
if (!merged.some(c => c.id === column.id)) {
merged.splice(Math.min(index, merged.length), 0, column);
}
});
this.props.saveColumns(merged, applyToAll);
// Toggling the balance column changes which queries run, so mirror the
// change into component state and refetch when needed.
const balance = columns.find(column => column.id === 'balance');
const isBalanceVisible = balance && !balance.hidden;
if (balance && isBalanceVisible !== !!this.state.showBalances) {
if (!isBalanceVisible) {
this.setState({ showBalances: false, balances: null });
} else {
this.setState(
{
transactions: [],
filterConditions: [],
search: '',
sort: null,
showBalances: true,
},
() => {
this.fetchTransactions();
},
);
}
}
const cleared = columns.find(column => column.id === 'cleared');
const isClearedVisible = cleared && !cleared.hidden;
if (cleared && isClearedVisible !== !!this.state.showCleared) {
// Also update prevShowCleared so finishing a reconciliation restores
// the visibility chosen here, not the stale pre-reconcile value
this.setState({
showCleared: isClearedVisible,
prevShowCleared: isClearedVisible,
});
}
};
getAccountTitle(account?: AccountEntity, id?: string) {
const { filterName } = this.props.location.state || {};
@@ -1716,7 +1781,6 @@ class AccountInternal extends PureComponent<
balances,
showCleared,
showReconciled,
showGroup,
filteredAmount,
} = this.state;
@@ -1789,14 +1853,10 @@ class AccountInternal extends PureComponent<
accountsSyncing={accountsSyncing}
accounts={accounts}
transactions={transactions}
showBalances={showBalances ?? false}
showExtraBalances={showExtraBalances ?? false}
showCleared={showCleared ?? false}
showReconciled={showReconciled ?? false}
showGroup={showGroup}
showEmptyMessage={showEmptyMessage ?? false}
balanceQuery={balanceQuery}
canCalculateBalance={this?.canCalculateBalance ?? undefined}
filteredAmount={filteredAmount}
isFiltered={transactionsFiltered ?? false}
isSorted={this.state.sort !== null}
@@ -1858,13 +1918,9 @@ class AccountInternal extends PureComponent<
showBalances={!!allBalances}
showReconciled={showReconciled}
showCleared={!!showCleared}
showGroup={showGroup}
showAccount={
!accountId ||
accountId === 'offbudget' ||
accountId === 'onbudget' ||
accountId === 'uncategorized'
}
showGroup={this.props.showGroup}
showAccount={this.showAccountColumn()}
columnOrder={this.props.columnOrder}
allowReorder={
!!accountId &&
accountId !== 'offbudget' &&
@@ -1986,24 +2042,24 @@ export function Account() {
const dateFormat = useDateFormat() || 'MM/dd/yyyy';
const [hideFraction] = useSyncedPref('hideFraction');
const [expandSplits] = useLocalPref('expand-splits');
const [showBalances, setShowBalances] = useSyncedPref(
`show-balances-${params.id}`,
);
const [showNetWorthChart, setShowNetWorthChart] = useSyncedPref(
`show-account-${params.id}-net-worth-chart`,
);
const [hideCleared, setHideCleared] = useSyncedPref(
`hide-cleared-${params.id}`,
);
const [hideReconciled, setHideReconciled] = useSyncedPref(
`hide-reconciled-${params.id}`,
);
const [showGroup, setShowGroup] = useSyncedPref(
`show-group-${params.id || 'all-accounts'}`,
);
const [showExtraBalances, setShowExtraBalances] = useSyncedPref(
`show-extra-balances-${params.id || 'all-accounts'}`,
);
const {
transactionColumns,
columnOrder,
showBalances,
showCleared,
showGroup,
saveColumns,
} = useTransactionTableColumns(params.id);
const modalShowing = useSelector(state => state.modals.modalStack.length > 0);
const accountsSyncing = useSelector(state => state.account.accountsSyncing);
const filterConditions = location?.state?.filterConditions || [];
@@ -2046,22 +2102,20 @@ export function Account() {
dateFormat={dateFormat}
hideFraction={String(hideFraction) === 'true'}
expandSplits={expandSplits}
showBalances={String(showBalances) === 'true'}
setShowBalances={showBalances =>
setShowBalances(String(showBalances))
}
showBalances={showBalances}
showNetWorthChart={String(showNetWorthChart) === 'true'}
setShowNetWorthChart={val => setShowNetWorthChart(String(val))}
showCleared={String(hideCleared) !== 'true'}
setShowCleared={val => setHideCleared(String(!val))}
showCleared={showCleared}
showReconciled={String(hideReconciled) !== 'true'}
setShowReconciled={val => setHideReconciled(String(!val))}
showGroup={String(showGroup) === 'true'}
setShowGroup={val => setShowGroup(String(val))}
showGroup={showGroup}
showExtraBalances={String(showExtraBalances) === 'true'}
setShowExtraBalances={extraBalances =>
setShowExtraBalances(String(extraBalances))
}
transactionColumns={transactionColumns}
columnOrder={columnOrder}
saveColumns={saveColumns}
payees={payees}
modalShowing={modalShowing}
accountsSyncing={accountsSyncing}
@@ -67,15 +67,11 @@ type AccountHeaderProps = {
accountsSyncing: string[];
accounts: AccountEntity[];
transactions: TransactionEntity[];
showBalances: boolean;
showExtraBalances: boolean;
showCleared: boolean;
showReconciled: boolean;
showGroup: boolean;
showEmptyMessage: boolean;
balanceQuery: ComponentProps<typeof ReconcilingMessage>['balanceQuery'];
reconcileAmount?: number | null;
canCalculateBalance?: () => boolean;
isFiltered: boolean;
filteredAmount?: number | null;
isSorted: boolean;
@@ -143,15 +139,11 @@ export function AccountHeader({
accountsSyncing,
accounts,
transactions,
showBalances,
showExtraBalances,
showCleared,
showReconciled,
showGroup,
showEmptyMessage,
balanceQuery,
reconcileAmount,
canCalculateBalance,
isFiltered,
filteredAmount,
isSorted,
@@ -511,14 +503,8 @@ export function AccountHeader({
account={account}
canSync={canSync}
showNetWorthChart={showNetWorthChart}
canShowBalances={
canCalculateBalance ? canCalculateBalance() : false
}
isSorted={isSorted}
showBalances={showBalances}
showCleared={showCleared}
showReconciled={showReconciled}
showGroup={showGroup}
onMenuSelect={onMenuSelect}
/>
</Dialog>
@@ -551,18 +537,16 @@ export function AccountHeader({
]
: []),
{ name: 'export', text: t('Export') },
{
name: 'toggle-group',
text: showGroup
? t('Hide category group')
: t('Show category group'),
},
{
name: 'toggle-net-worth-chart',
text: showNetWorthChart
? t('Hide balance chart')
: t('Show balance chart'),
},
{
name: 'manage-columns',
text: t('Manage table columns'),
},
]}
/>
</Dialog>
@@ -736,11 +720,7 @@ type AccountMenuProps = {
account: AccountEntity;
canSync: boolean;
showNetWorthChart: boolean;
showBalances: boolean;
canShowBalances: boolean;
showCleared: boolean;
showReconciled: boolean;
showGroup: boolean;
isSorted: boolean;
onMenuSelect: (
item:
@@ -749,12 +729,10 @@ type AccountMenuProps = {
| 'close'
| 'reopen'
| 'export'
| 'toggle-balance'
| 'remove-sorting'
| 'toggle-cleared'
| 'toggle-reconciled'
| 'toggle-group'
| 'toggle-net-worth-chart',
| 'toggle-net-worth-chart'
| 'manage-columns',
) => void;
};
@@ -762,11 +740,7 @@ function AccountMenu({
account,
canSync,
showNetWorthChart,
showBalances,
canShowBalances,
showCleared,
showReconciled,
showGroup,
isSorted,
onMenuSelect,
}: AccountMenuProps) {
@@ -788,16 +762,6 @@ function AccountMenu({
} as const,
]
: []),
...(canShowBalances
? [
{
name: 'toggle-balance',
text: showBalances
? t('Hide running balance')
: t('Show running balance'),
} as const,
]
: []),
{
name: 'toggle-net-worth-chart',
text: showNetWorthChart
@@ -805,10 +769,8 @@ function AccountMenu({
: t('Show balance chart'),
},
{
name: 'toggle-cleared',
text: showCleared
? t('Hide "cleared" checkboxes')
: t('Show "cleared" checkboxes'),
name: 'manage-columns',
text: t('Manage table columns'),
},
{
name: 'toggle-reconciled',
@@ -816,10 +778,6 @@ function AccountMenu({
? t('Hide reconciled transactions')
: t('Show reconciled transactions'),
},
{
name: 'toggle-group',
text: showGroup ? t('Hide category group') : t('Show category group'),
},
{ name: 'export', text: t('Export') },
...(account && !account.closed
? canSync
@@ -0,0 +1,100 @@
import React from 'react';
import { GridListItem } from 'react-aria-components';
import { Trans, useTranslation } from 'react-i18next';
import { Button } from '@actual-app/components/button';
import { SvgMenu } from '@actual-app/components/icons/v1';
import { Text } from '@actual-app/components/text';
import { theme } from '@actual-app/components/theme';
import { Toggle } from '@actual-app/components/toggle';
import { css } from '@emotion/css';
import { isTransactionTableColumnLocked } from '#components/transactions/table/columns';
import type {
TransactionTableColumn,
TransactionTableColumnId,
} from '#components/transactions/table/columns';
type TransactionTableColumnListItemProps = {
column: TransactionTableColumn;
label: string;
onToggle: (id: TransactionTableColumnId, isVisible: boolean) => void;
};
export function TransactionTableColumnListItem({
column,
label,
onToggle,
}: TransactionTableColumnListItemProps) {
const { t } = useTranslation();
const isLocked = isTransactionTableColumnLocked(column.id);
return (
<GridListItem
id={column.id}
textValue={label}
className={css({
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
gap: 10,
padding: '8px 10px',
marginBottom: 4,
borderRadius: 6,
border: '1px solid ' + theme.tableBorder,
backgroundColor: theme.tableBackground,
outline: 'none',
'&[data-hovered]': {
backgroundColor: theme.tableRowBackgroundHover,
},
'&[data-dragging]': {
opacity: 0.5,
},
'&[data-focus-visible]': {
boxShadow: '0 0 0 2px ' + theme.formInputBorderSelected,
},
})}
>
<Button
slot="drag"
variant="bare"
aria-label={t('Reorder {{ columnName }} column', {
columnName: label,
})}
style={{
cursor: 'grab',
color: theme.pageTextSubdued,
padding: 4,
}}
>
<SvgMenu width={12} height={12} />
</Button>
<Text
style={{
flex: 1,
color: column.hidden ? theme.pageTextSubdued : theme.tableText,
}}
>
{label}
</Text>
{isLocked ? (
<Text
style={{
color: theme.pageTextSubdued,
fontStyle: 'italic',
fontSize: 12,
}}
>
<Trans>Always shown</Trans>
</Text>
) : (
<Toggle
id={`toggle-column-${column.id}`}
aria-label={label}
isOn={!column.hidden}
onToggle={isVisible => onToggle(column.id, isVisible)}
/>
)}
</GridListItem>
);
}
@@ -0,0 +1,213 @@
import React, { useState } from 'react';
import type { DragItem } from 'react-aria';
import { DropIndicator, GridList, useDragAndDrop } from 'react-aria-components';
import { Trans, useTranslation } from 'react-i18next';
import { Button } from '@actual-app/components/button';
import { Text } from '@actual-app/components/text';
import { theme } from '@actual-app/components/theme';
import { View } from '@actual-app/components/view';
import { css } from '@emotion/css';
import {
Modal,
ModalButtons,
ModalCloseButton,
ModalHeader,
} from '#components/common/Modal';
import { Checkbox } from '#components/forms';
import { TransactionTableColumnListItem } from '#components/modals/TransactionTableColumnListItem';
import {
getDefaultTransactionTableColumns,
useTransactionTableColumnLabels,
} from '#components/transactions/table/columns';
import type {
TransactionTableColumn,
TransactionTableColumnId,
} from '#components/transactions/table/columns';
import type { Modal as ModalType } from '#modals/modalsSlice';
type TransactionTableColumnsModalProps = Extract<
ModalType,
{ name: 'transaction-table-columns' }
>['options'];
export function TransactionTableColumnsModal({
columns: initialColumns,
onSave,
}: TransactionTableColumnsModalProps) {
const { t } = useTranslation();
const columnLabels = useTransactionTableColumnLabels();
const [columns, setColumns] =
useState<TransactionTableColumn[]>(initialColumns);
const [applyToAll, setApplyToAll] = useState(false);
const onToggleColumn = (id: TransactionTableColumnId, isVisible: boolean) => {
setColumns(prev => {
// Keep at least one amount column visible — new transactions need an
// amount input
if (!isVisible && (id === 'payment' || id === 'deposit')) {
const other = id === 'payment' ? 'deposit' : 'payment';
if (prev.find(column => column.id === other)?.hidden) {
return prev;
}
}
return prev.map(column =>
column.id === id ? { ...column, hidden: !isVisible } : column,
);
});
};
const onResetToDefault = () => {
// Only reset the columns that are available in this view
setColumns(
getDefaultTransactionTableColumns().filter(column =>
initialColumns.some(c => c.id === column.id),
),
);
};
const { dragAndDropHooks } = useDragAndDrop({
getItems: keys =>
[...keys].map(key => ({ 'text/plain': String(key) }) satisfies DragItem),
renderDropIndicator: target => (
<DropIndicator
target={target}
className={css({
'&[data-drop-target]': {
height: 4,
backgroundColor: theme.tableBorderSeparator,
opacity: 1,
borderRadius: 4,
},
})}
/>
),
onReorder: e => {
const [key] = e.keys;
const targetId = e.target.key;
setColumns(prev => {
const moved = prev.find(c => c.id === key);
if (!moved || key === targetId) {
return prev;
}
const remaining = prev.filter(c => c.id !== key);
const targetIdx = remaining.findIndex(c => c.id === targetId);
if (targetIdx === -1) {
return prev;
}
const insertAt =
e.target.dropPosition === 'after' ? targetIdx + 1 : targetIdx;
return [
...remaining.slice(0, insertAt),
moved,
...remaining.slice(insertAt),
];
});
},
});
return (
<Modal
name="transaction-table-columns"
containerProps={{ style: { width: 400 } }}
>
{({ state }) => (
<>
<ModalHeader
title={t('Table columns')}
rightContent={<ModalCloseButton onPress={() => state.close()} />}
/>
<View style={{ gap: 15 }}>
<Text style={{ color: theme.pageTextLight, lineHeight: 1.5 }}>
<Trans>
Choose which columns appear in the transaction table and drag
them into the order you prefer.
</Trans>
</Text>
<GridList
aria-label={t('Transaction table columns')}
items={columns}
dragAndDropHooks={dragAndDropHooks}
dependencies={[columnLabels]}
className={css({ display: 'flex', flexDirection: 'column' })}
>
{column => (
<TransactionTableColumnListItem
key={column.id}
column={column}
label={columnLabels[column.id]}
onToggle={onToggleColumn}
/>
)}
</GridList>
<View
style={{
flexDirection: 'row',
alignItems: 'flex-start',
padding: '8px 10px',
borderRadius: 6,
backgroundColor: theme.tableRowBackgroundHover,
}}
>
<Checkbox
id="apply-columns-to-all"
checked={applyToAll}
onChange={e => setApplyToAll(e.target.checked)}
style={{ marginTop: 2 }}
/>
<label
htmlFor="apply-columns-to-all"
style={{ flex: 1, userSelect: 'none', cursor: 'pointer' }}
>
<Text style={{ display: 'block' }}>
<Trans>Apply to all transaction tables</Trans>
</Text>
<Text
style={{
display: 'block',
color: theme.pageTextSubdued,
fontSize: 12,
marginTop: 2,
}}
>
<Trans>
Use this layout everywhere, not just in the current view.
</Trans>
</Text>
</label>
</View>
<ModalButtons
style={{ marginTop: 0 }}
leftContent={
<Button variant="bare" onPress={onResetToDefault}>
<Trans>Reset to default</Trans>
</Button>
}
>
<Button style={{ marginRight: 10 }} onPress={() => state.close()}>
<Trans>Cancel</Trans>
</Button>
<Button
variant="primary"
onPress={() => {
onSave(columns, applyToAll);
state.close();
}}
>
<Trans>Save</Trans>
</Button>
</ModalButtons>
</View>
</>
)}
</Modal>
);
}
@@ -269,6 +269,7 @@ type TransactionListProps = Pick<
| 'showGroup'
| 'showReconciled'
| 'showSelection'
| 'columnOrder'
| 'sortField'
| 'transactions'
> & {
@@ -304,6 +305,7 @@ export function TransactionList({
showCleared,
showGroup,
showAccount,
columnOrder,
isAdding,
isNew,
isMatched,
@@ -744,6 +746,7 @@ export function TransactionList({
showAccount={showAccount}
showCategory
showGroup={showGroup}
columnOrder={columnOrder}
currentAccountId={account && account.id}
currentCategoryId={category && category.id}
isAdding={isAdding}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,156 @@
import { useTranslation } from 'react-i18next';
// The set of columns in the transaction table, in their default order.
export const TRANSACTION_TABLE_COLUMN_IDS = [
'date',
'account',
'payee',
'notes',
'group',
'category',
'payment',
'deposit',
'balance',
'cleared',
] as const;
export type TransactionTableColumnId =
(typeof TRANSACTION_TABLE_COLUMN_IDS)[number];
export type TransactionTableColumn = {
id: TransactionTableColumnId;
hidden: boolean;
};
// The date column can be reordered but never hidden: it drives keyboard
// navigation (new transactions start editing on the date field) so it must
// always be visible.
export function isTransactionTableColumnLocked(
id: TransactionTableColumnId,
): boolean {
return id === 'date';
}
// Display-only columns render plain values with no editing, so they are
// excluded from keyboard-focusable fields.
export function isTransactionTableColumnDisplayOnly(
id: TransactionTableColumnId,
): boolean {
return id === 'balance' || id === 'group';
}
// Child (split) transactions render the date/account cells as blank
// placeholders, so those columns can't be focused or edited in child rows.
export function isTransactionTableColumnAvailableInChildRows(
id: TransactionTableColumnId,
): boolean {
return id !== 'date' && id !== 'account';
}
// User-facing column names, shared by the column manager modal and the
// table header.
export function useTransactionTableColumnLabels(): Record<
TransactionTableColumnId,
string
> {
const { t } = useTranslation();
return {
date: t('Date'),
account: t('Account'),
payee: t('Payee'),
notes: t('Notes'),
group: t('Category group'),
category: t('Category'),
payment: t('Payment'),
deposit: t('Deposit'),
balance: t('Running balance'),
cleared: t('Cleared'),
};
}
function isColumnHiddenByDefault(id: TransactionTableColumnId): boolean {
// The running balance and category group columns are opt-in, matching the
// app's historical default of not showing them
return id === 'balance' || id === 'group';
}
export function getDefaultTransactionTableColumns(): TransactionTableColumn[] {
return TRANSACTION_TABLE_COLUMN_IDS.map(id => ({
id,
hidden: isColumnHiddenByDefault(id),
}));
}
function isKnownColumnId(id: unknown): id is TransactionTableColumnId {
return (
typeof id === 'string' &&
(TRANSACTION_TABLE_COLUMN_IDS as readonly string[]).includes(id)
);
}
/**
* Parse the serialized column configuration from the synced pref. Invalid or
* unknown entries are dropped, and any columns missing from the saved value
* (e.g. because they were added in a later version, or were not available in
* the view when it was saved) are inserted at their default position.
*/
export function parseTransactionTableColumns(
raw: string | undefined,
): TransactionTableColumn[] {
let saved: TransactionTableColumn[] = [];
if (raw) {
try {
const parsed: unknown = JSON.parse(raw);
if (Array.isArray(parsed)) {
for (const entry of parsed) {
if (
entry &&
typeof entry === 'object' &&
'id' in entry &&
isKnownColumnId(entry.id) &&
!saved.some(c => c.id === entry.id)
) {
const hidden =
'hidden' in entry &&
entry.hidden === true &&
!isTransactionTableColumnLocked(entry.id);
saved.push({ id: entry.id, hidden });
}
}
}
} catch {
// Malformed pref value; fall back to the defaults
saved = [];
}
}
// At least one amount column must stay visible — new transactions need an
// amount input. Restore both when a saved config hides the whole pair.
const payment = saved.find(c => c.id === 'payment');
const deposit = saved.find(c => c.id === 'deposit');
if (payment?.hidden && deposit?.hidden) {
payment.hidden = false;
deposit.hidden = false;
}
// Insert any missing columns at their default relative position
for (const id of TRANSACTION_TABLE_COLUMN_IDS) {
if (saved.some(c => c.id === id)) {
continue;
}
const defaultIdx = TRANSACTION_TABLE_COLUMN_IDS.indexOf(id);
const insertAt = saved.findIndex(
c => TRANSACTION_TABLE_COLUMN_IDS.indexOf(c.id) > defaultIdx,
);
const column = { id, hidden: isColumnHiddenByDefault(id) };
if (insertAt === -1) {
saved.push(column);
} else {
saved.splice(insertAt, 0, column);
}
}
return saved;
}
@@ -0,0 +1,109 @@
import { useMemo } from 'react';
import type { SyncedPrefs } from '@actual-app/core/types/prefs';
import { parseTransactionTableColumns } from '#components/transactions/table/columns';
import type { TransactionTableColumn } from '#components/transactions/table/columns';
import { useSyncedPref } from '#hooks/useSyncedPref';
import { saveSyncedPrefs } from '#prefs/prefsSlice';
import { useDispatch, useSelector } from '#redux';
// Pseudo-account views that have their own per-view prefs alongside real
// account ids ('all-accounts' is the view with no account id in the URL).
export const SPECIAL_VIEW_IDS: string[] = [
'onbudget',
'offbudget',
'uncategorized',
'all-accounts',
];
/**
* The transaction-table column configuration for one view. Columns saved for
* the specific view win over the budget-wide configuration, and budgets that
* haven't used the column manager yet fall back to the old per-account
* visibility prefs (`show-balances-*` / `hide-cleared-*`).
*/
export function useTransactionTableColumns(accountId: string | undefined) {
const dispatch = useDispatch();
const [legacyShowBalances] = useSyncedPref(`show-balances-${accountId}`);
const [legacyHideCleared] = useSyncedPref(`hide-cleared-${accountId}`);
const [legacyShowGroup] = useSyncedPref(
`show-group-${accountId || 'all-accounts'}`,
);
const [viewColumnsConfig, setViewColumnsConfig] = useSyncedPref(
`transaction-table-columns-${accountId || 'all-accounts'}`,
);
const [globalColumnsConfig] = useSyncedPref('transaction-table-columns');
const syncedPrefs = useSelector(state => state.prefs.synced);
const columnsConfig = viewColumnsConfig || globalColumnsConfig;
// Memoized manually (the React Compiler bails out on the consuming
// components): `columnOrder` crosses the `memo()` boundary of every
// transaction row, so its identity must only change when the config does.
const transactionColumns = useMemo(
() => parseTransactionTableColumns(columnsConfig),
[columnsConfig],
);
const showBalances = columnsConfig
? !transactionColumns.find(column => column.id === 'balance')?.hidden
: String(legacyShowBalances) === 'true';
const showCleared = columnsConfig
? !transactionColumns.find(column => column.id === 'cleared')?.hidden
: String(legacyHideCleared) !== 'true';
const showGroup = columnsConfig
? !transactionColumns.find(column => column.id === 'group')?.hidden
: String(legacyShowGroup) === 'true';
// The balance/cleared/group columns stay in the order even when hidden:
// their visibility is controlled by the show* flags, which can come from
// the legacy prefs or component-state overrides instead of the config.
const columnOrder = useMemo(
() =>
transactionColumns
.filter(
column =>
column.id === 'balance' ||
column.id === 'cleared' ||
column.id === 'group' ||
!column.hidden,
)
.map(column => column.id),
[transactionColumns],
);
const saveColumns = (
columns: TransactionTableColumn[],
applyToAll: boolean,
) => {
const serialized = JSON.stringify(columns);
if (applyToAll) {
// Save the budget-wide configuration and clear every stored
// view-specific override in one batch so all tables actually show
// this layout. Deriving the keys from the stored prefs also covers
// views that no longer exist (e.g. closed or deleted accounts).
const overrideKeys = (
Object.keys(syncedPrefs) as (keyof SyncedPrefs)[]
).filter(
key => key.startsWith('transaction-table-columns-') && syncedPrefs[key],
);
void dispatch(
saveSyncedPrefs({
prefs: {
'transaction-table-columns': serialized,
...Object.fromEntries(overrideKeys.map(key => [key, ''])),
},
}),
);
} else {
setViewColumnsConfig(serialized);
}
};
return {
transactionColumns,
columnOrder,
showBalances,
showCleared,
showGroup,
saveColumns,
};
}
@@ -25,6 +25,7 @@ import type { PayloadAction } from '@reduxjs/toolkit';
import { accountQueries } from '#accounts';
import { resetApp, setAppState } from '#app/appSlice';
import type { SelectLinkedAccountsModalProps } from '#components/modals/SelectLinkedAccountsModal';
import type { TransactionTableColumn } from '#components/transactions/table/columns';
import { createAppAsyncThunk } from '#redux';
import { signOut } from '#users/usersSlice';
@@ -580,6 +581,16 @@ export type Modal =
confirmReason: ConfirmTransactionEditReason;
};
}
| {
name: 'transaction-table-columns';
options: {
columns: TransactionTableColumn[];
onSave: (
columns: TransactionTableColumn[],
applyToAll: boolean,
) => void;
};
}
| {
name: 'convert-to-schedule';
options: {
+6
View File
@@ -29,10 +29,16 @@ export type SyncedPrefs = Partial<
| 'defaultCurrencyCode'
| `show-account-${string}-net-worth-chart`
| `side-nav.show-balance-history-${string}`
// @deprecated: superseded by `transaction-table-columns-${string}`; only
// read as a fallback for budgets that never used the column manager
| `show-balances-${string}`
| `show-extra-balances-${string}`
// @deprecated: superseded by `transaction-table-columns-${string}`; only
// read as a fallback for budgets that never used the column manager
| `hide-cleared-${string}`
| `hide-reconciled-${string}`
| 'transaction-table-columns'
| `transaction-table-columns-${string}`
| `show-group-${string}`
// TODO: pull from src/components/modals/ImportTransactions.js
| `parse-date-${string}-${'csv' | 'qif'}`
@@ -0,0 +1,6 @@
---
category: Enhancements
authors: [MatissJanis]
---
Add a column manager to the transaction table for showing, hiding, and reordering columns