[Mobile] Add banners to alert users of recommended budget actions (#4643)
* Add banners to alert users of recommended budget actions * Update wording for consistency * Release notes * Fix release notes * Code review feedback * Cleanup * Extend playwright timeout * Update Categorize button locator in test * Update VRT * Dummy commit * Streamline cover spending flow * VRT * Remove category from modal when covered and close modal when all categories are covered * Coderabbit suggestions * Update translations * VRT --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 29 KiB |
@@ -144,7 +144,7 @@ export class MobileNavigation {
|
||||
}
|
||||
|
||||
async goToUncategorizedPage() {
|
||||
const button = this.page.getByRole('button', { name: /uncategorized/ });
|
||||
const button = this.page.getByRole('button', { name: 'Categorize' });
|
||||
await button.click();
|
||||
|
||||
return new MobileAccountPage(this.page);
|
||||
|
||||
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 32 KiB |
@@ -2,7 +2,7 @@ import { defineConfig } from '@playwright/test';
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
export default defineConfig({
|
||||
timeout: 45000, // 45 seconds
|
||||
timeout: 60000, // 60 seconds
|
||||
retries: 1,
|
||||
testDir: 'e2e/',
|
||||
reporter: !process.env.CI ? [['html', { open: 'never' }]] : undefined,
|
||||
|
||||
@@ -92,49 +92,48 @@ export function Modals() {
|
||||
}, [location]);
|
||||
|
||||
const modals = modalStack
|
||||
.map(modal => {
|
||||
.map((modal, idx) => {
|
||||
const { name } = modal;
|
||||
const key = `${name}-${idx}`;
|
||||
switch (name) {
|
||||
case 'goal-templates':
|
||||
return budgetId ? <GoalTemplateModal key={name} /> : null;
|
||||
return budgetId ? <GoalTemplateModal key={key} /> : null;
|
||||
|
||||
case 'keyboard-shortcuts':
|
||||
// don't show the hotkey help modal when a budget is not open
|
||||
return budgetId ? <KeyboardShortcutModal key={name} /> : null;
|
||||
return budgetId ? <KeyboardShortcutModal key={key} /> : null;
|
||||
|
||||
case 'import-transactions':
|
||||
return <ImportTransactionsModal key={name} {...modal.options} />;
|
||||
return <ImportTransactionsModal key={key} {...modal.options} />;
|
||||
|
||||
case 'add-account':
|
||||
return <CreateAccountModal key={name} {...modal.options} />;
|
||||
return <CreateAccountModal key={key} {...modal.options} />;
|
||||
|
||||
case 'add-local-account':
|
||||
return <CreateLocalAccountModal key={name} />;
|
||||
return <CreateLocalAccountModal key={key} />;
|
||||
|
||||
case 'close-account':
|
||||
return <CloseAccountModal key={name} {...modal.options} />;
|
||||
return <CloseAccountModal key={key} {...modal.options} />;
|
||||
|
||||
case 'select-linked-accounts':
|
||||
return <SelectLinkedAccountsModal key={name} {...modal.options} />;
|
||||
return <SelectLinkedAccountsModal key={key} {...modal.options} />;
|
||||
|
||||
case 'confirm-category-delete':
|
||||
return <ConfirmCategoryDeleteModal key={name} {...modal.options} />;
|
||||
return <ConfirmCategoryDeleteModal key={key} {...modal.options} />;
|
||||
|
||||
case 'confirm-unlink-account':
|
||||
return <ConfirmUnlinkAccountModal key={name} {...modal.options} />;
|
||||
return <ConfirmUnlinkAccountModal key={key} {...modal.options} />;
|
||||
|
||||
case 'confirm-transaction-edit':
|
||||
return <ConfirmTransactionEditModal key={name} {...modal.options} />;
|
||||
return <ConfirmTransactionEditModal key={key} {...modal.options} />;
|
||||
|
||||
case 'confirm-transaction-delete':
|
||||
return (
|
||||
<ConfirmTransactionDeleteModal key={name} {...modal.options} />
|
||||
);
|
||||
return <ConfirmTransactionDeleteModal key={key} {...modal.options} />;
|
||||
|
||||
case 'load-backup':
|
||||
return (
|
||||
<LoadBackupModal
|
||||
key={name}
|
||||
key={key}
|
||||
watchUpdates
|
||||
{...modal.options}
|
||||
backupDisabled={false}
|
||||
@@ -142,27 +141,27 @@ export function Modals() {
|
||||
);
|
||||
|
||||
case 'manage-rules':
|
||||
return <ManageRulesModal key={name} {...modal.options} />;
|
||||
return <ManageRulesModal key={key} {...modal.options} />;
|
||||
|
||||
case 'edit-rule':
|
||||
return <EditRuleModal key={name} {...modal.options} />;
|
||||
return <EditRuleModal key={key} {...modal.options} />;
|
||||
|
||||
case 'merge-unused-payees':
|
||||
return <MergeUnusedPayeesModal key={name} {...modal.options} />;
|
||||
return <MergeUnusedPayeesModal key={key} {...modal.options} />;
|
||||
|
||||
case 'gocardless-init':
|
||||
return <GoCardlessInitialiseModal key={name} {...modal.options} />;
|
||||
return <GoCardlessInitialiseModal key={key} {...modal.options} />;
|
||||
|
||||
case 'simplefin-init':
|
||||
return <SimpleFinInitialiseModal key={name} {...modal.options} />;
|
||||
return <SimpleFinInitialiseModal key={key} {...modal.options} />;
|
||||
|
||||
case 'pluggyai-init':
|
||||
return <PluggyAiInitialiseModal key={name} {...modal.options} />;
|
||||
return <PluggyAiInitialiseModal key={key} {...modal.options} />;
|
||||
|
||||
case 'gocardless-external-msg':
|
||||
return (
|
||||
<GoCardlessExternalMsgModal
|
||||
key={name}
|
||||
key={key}
|
||||
{...modal.options}
|
||||
onClose={() => {
|
||||
modal.options.onClose?.();
|
||||
@@ -172,73 +171,73 @@ export function Modals() {
|
||||
);
|
||||
|
||||
case 'create-encryption-key':
|
||||
return <CreateEncryptionKeyModal key={name} {...modal.options} />;
|
||||
return <CreateEncryptionKeyModal key={key} {...modal.options} />;
|
||||
|
||||
case 'fix-encryption-key':
|
||||
return <FixEncryptionKeyModal key={name} {...modal.options} />;
|
||||
return <FixEncryptionKeyModal key={key} {...modal.options} />;
|
||||
|
||||
case 'edit-field':
|
||||
return <EditFieldModal key={name} {...modal.options} />;
|
||||
return <EditFieldModal key={key} {...modal.options} />;
|
||||
|
||||
case 'category-autocomplete':
|
||||
return <CategoryAutocompleteModal key={name} {...modal.options} />;
|
||||
return <CategoryAutocompleteModal key={key} {...modal.options} />;
|
||||
|
||||
case 'account-autocomplete':
|
||||
return <AccountAutocompleteModal key={name} {...modal.options} />;
|
||||
return <AccountAutocompleteModal key={key} {...modal.options} />;
|
||||
|
||||
case 'payee-autocomplete':
|
||||
return <PayeeAutocompleteModal key={name} {...modal.options} />;
|
||||
return <PayeeAutocompleteModal key={key} {...modal.options} />;
|
||||
|
||||
case 'payee-category-learning':
|
||||
return <CategoryLearning key={name} />;
|
||||
return <CategoryLearning key={key} />;
|
||||
|
||||
case 'new-category':
|
||||
return <NewCategoryModal key={name} {...modal.options} />;
|
||||
return <NewCategoryModal key={key} {...modal.options} />;
|
||||
|
||||
case 'new-category-group':
|
||||
return <NewCategoryGroupModal key={name} {...modal.options} />;
|
||||
return <NewCategoryGroupModal key={key} {...modal.options} />;
|
||||
|
||||
case 'envelope-budget-summary':
|
||||
return (
|
||||
<NamespaceContext.Provider
|
||||
key={name}
|
||||
key={key}
|
||||
value={monthUtils.sheetForMonth(modal.options.month)}
|
||||
>
|
||||
<EnvelopeBudgetSummaryModal key={name} {...modal.options} />
|
||||
<EnvelopeBudgetSummaryModal key={key} {...modal.options} />
|
||||
</NamespaceContext.Provider>
|
||||
);
|
||||
|
||||
case 'tracking-budget-summary':
|
||||
return <TrackingBudgetSummaryModal key={name} {...modal.options} />;
|
||||
return <TrackingBudgetSummaryModal key={key} {...modal.options} />;
|
||||
|
||||
case 'schedule-edit':
|
||||
return <ScheduleDetails key={name} {...modal.options} />;
|
||||
return <ScheduleDetails key={key} {...modal.options} />;
|
||||
|
||||
case 'schedule-link':
|
||||
return <ScheduleLink key={name} {...modal.options} />;
|
||||
return <ScheduleLink key={key} {...modal.options} />;
|
||||
|
||||
case 'schedules-discover':
|
||||
return <DiscoverSchedules key={name} />;
|
||||
return <DiscoverSchedules key={key} />;
|
||||
|
||||
case 'schedules-upcoming-length':
|
||||
return <UpcomingLength key={name} />;
|
||||
return <UpcomingLength key={key} />;
|
||||
|
||||
case 'schedule-posts-offline-notification':
|
||||
return <PostsOfflineNotification key={name} />;
|
||||
return <PostsOfflineNotification key={key} />;
|
||||
|
||||
case 'synced-account-edit':
|
||||
return <EditSyncAccount key={name} {...modal.options} />;
|
||||
return <EditSyncAccount key={key} {...modal.options} />;
|
||||
|
||||
case 'account-menu':
|
||||
return <AccountMenuModal key={name} {...modal.options} />;
|
||||
return <AccountMenuModal key={key} {...modal.options} />;
|
||||
|
||||
case 'category-menu':
|
||||
return <CategoryMenuModal key={name} {...modal.options} />;
|
||||
return <CategoryMenuModal key={key} {...modal.options} />;
|
||||
|
||||
case 'envelope-budget-menu':
|
||||
return (
|
||||
<NamespaceContext.Provider
|
||||
key={name}
|
||||
key={key}
|
||||
value={monthUtils.sheetForMonth(modal.options.month)}
|
||||
>
|
||||
<EnvelopeBudgetMenuModal {...modal.options} />
|
||||
@@ -248,7 +247,7 @@ export function Modals() {
|
||||
case 'tracking-budget-menu':
|
||||
return (
|
||||
<NamespaceContext.Provider
|
||||
key={name}
|
||||
key={key}
|
||||
value={monthUtils.sheetForMonth(modal.options.month)}
|
||||
>
|
||||
<TrackingBudgetMenuModal {...modal.options} />
|
||||
@@ -256,15 +255,15 @@ export function Modals() {
|
||||
);
|
||||
|
||||
case 'category-group-menu':
|
||||
return <CategoryGroupMenuModal key={name} {...modal.options} />;
|
||||
return <CategoryGroupMenuModal key={key} {...modal.options} />;
|
||||
|
||||
case 'notes':
|
||||
return <NotesModal key={name} {...modal.options} />;
|
||||
return <NotesModal key={key} {...modal.options} />;
|
||||
|
||||
case 'envelope-balance-menu':
|
||||
return (
|
||||
<NamespaceContext.Provider
|
||||
key={name}
|
||||
key={key}
|
||||
value={monthUtils.sheetForMonth(modal.options.month)}
|
||||
>
|
||||
<EnvelopeBalanceMenuModal {...modal.options} />
|
||||
@@ -274,7 +273,7 @@ export function Modals() {
|
||||
case 'envelope-summary-to-budget-menu':
|
||||
return (
|
||||
<NamespaceContext.Provider
|
||||
key={name}
|
||||
key={key}
|
||||
value={monthUtils.sheetForMonth(modal.options.month)}
|
||||
>
|
||||
<EnvelopeToBudgetMenuModal {...modal.options} />
|
||||
@@ -284,7 +283,7 @@ export function Modals() {
|
||||
case 'hold-buffer':
|
||||
return (
|
||||
<NamespaceContext.Provider
|
||||
key={name}
|
||||
key={key}
|
||||
value={monthUtils.sheetForMonth(modal.options.month)}
|
||||
>
|
||||
<HoldBufferModal {...modal.options} />
|
||||
@@ -294,7 +293,7 @@ export function Modals() {
|
||||
case 'tracking-balance-menu':
|
||||
return (
|
||||
<NamespaceContext.Provider
|
||||
key={name}
|
||||
key={key}
|
||||
value={monthUtils.sheetForMonth(modal.options.month)}
|
||||
>
|
||||
<TrackingBalanceMenuModal {...modal.options} />
|
||||
@@ -302,23 +301,21 @@ export function Modals() {
|
||||
);
|
||||
|
||||
case 'transfer':
|
||||
return <TransferModal key={name} {...modal.options} />;
|
||||
return <TransferModal key={key} {...modal.options} />;
|
||||
|
||||
case 'cover':
|
||||
return <CoverModal key={name} {...modal.options} />;
|
||||
return <CoverModal key={key} {...modal.options} />;
|
||||
|
||||
case 'scheduled-transaction-menu':
|
||||
return (
|
||||
<ScheduledTransactionMenuModal key={name} {...modal.options} />
|
||||
);
|
||||
return <ScheduledTransactionMenuModal key={key} {...modal.options} />;
|
||||
|
||||
case 'budget-page-menu':
|
||||
return <BudgetPageMenuModal key={name} {...modal.options} />;
|
||||
return <BudgetPageMenuModal key={key} {...modal.options} />;
|
||||
|
||||
case 'envelope-budget-month-menu':
|
||||
return (
|
||||
<NamespaceContext.Provider
|
||||
key={name}
|
||||
key={key}
|
||||
value={monthUtils.sheetForMonth(modal.options.month)}
|
||||
>
|
||||
<EnvelopeBudgetMonthMenuModal {...modal.options} />
|
||||
@@ -328,7 +325,7 @@ export function Modals() {
|
||||
case 'tracking-budget-month-menu':
|
||||
return (
|
||||
<NamespaceContext.Provider
|
||||
key={name}
|
||||
key={key}
|
||||
value={monthUtils.sheetForMonth(modal.options.month)}
|
||||
>
|
||||
<TrackingBudgetMonthMenuModal {...modal.options} />
|
||||
@@ -338,48 +335,48 @@ export function Modals() {
|
||||
case 'budget-file-selection':
|
||||
return <BudgetFileSelectionModal key={name} />;
|
||||
case 'delete-budget':
|
||||
return <DeleteFileModal key={name} {...modal.options} />;
|
||||
return <DeleteFileModal key={key} {...modal.options} />;
|
||||
case 'duplicate-budget':
|
||||
return <DuplicateFileModal key={name} {...modal.options} />;
|
||||
return <DuplicateFileModal key={key} {...modal.options} />;
|
||||
case 'import':
|
||||
return <ImportModal key={name} />;
|
||||
return <ImportModal key={key} />;
|
||||
case 'files-settings':
|
||||
return <FilesSettingsModal key={name} />;
|
||||
return <FilesSettingsModal key={key} />;
|
||||
case 'confirm-change-document-dir':
|
||||
return (
|
||||
<ConfirmChangeDocumentDirModal key={name} {...modal.options} />
|
||||
);
|
||||
return <ConfirmChangeDocumentDirModal key={key} {...modal.options} />;
|
||||
case 'import-ynab4':
|
||||
return <ImportYNAB4Modal key={name} />;
|
||||
return <ImportYNAB4Modal key={key} />;
|
||||
case 'import-ynab5':
|
||||
return <ImportYNAB5Modal key={name} />;
|
||||
return <ImportYNAB5Modal key={key} />;
|
||||
case 'import-actual':
|
||||
return <ImportActualModal key={name} />;
|
||||
return <ImportActualModal key={key} />;
|
||||
|
||||
case 'out-of-sync-migrations':
|
||||
return <OutOfSyncMigrationsModal key={name} />;
|
||||
return <OutOfSyncMigrationsModal key={key} />;
|
||||
|
||||
case 'edit-access':
|
||||
return <EditUserAccess key={name} {...modal.options} />;
|
||||
return <EditUserAccess key={key} {...modal.options} />;
|
||||
|
||||
case 'edit-user':
|
||||
return <EditUserFinanceApp key={name} {...modal.options} />;
|
||||
return <EditUserFinanceApp key={key} {...modal.options} />;
|
||||
|
||||
case 'transfer-ownership':
|
||||
return <TransferOwnership key={name} {...modal.options} />;
|
||||
return <TransferOwnership key={key} {...modal.options} />;
|
||||
|
||||
case 'enable-openid':
|
||||
return <OpenIDEnableModal key={name} {...modal.options} />;
|
||||
return <OpenIDEnableModal key={key} {...modal.options} />;
|
||||
|
||||
case 'enable-password-auth':
|
||||
return <PasswordEnableModal key={name} {...modal.options} />;
|
||||
return <PasswordEnableModal key={key} {...modal.options} />;
|
||||
|
||||
default:
|
||||
throw new Error('Unknown modal');
|
||||
}
|
||||
})
|
||||
.map((modal, idx) => (
|
||||
<React.Fragment key={modalStack[idx].name}>{modal}</React.Fragment>
|
||||
<React.Fragment key={`${modalStack[idx].name}-${idx}`}>
|
||||
{modal}
|
||||
</React.Fragment>
|
||||
));
|
||||
|
||||
// fragment needed per TS types
|
||||
|
||||
@@ -51,6 +51,7 @@ type CommonAutocompleteProps<T extends Item> = {
|
||||
clearOnBlur?: boolean;
|
||||
clearOnSelect?: boolean;
|
||||
closeOnBlur?: boolean;
|
||||
closeOnSelect?: boolean;
|
||||
onClose?: () => void;
|
||||
};
|
||||
|
||||
@@ -230,6 +231,7 @@ function SingleAutocomplete<T extends Item>({
|
||||
clearOnBlur = true,
|
||||
clearOnSelect = false,
|
||||
closeOnBlur = true,
|
||||
closeOnSelect = !clearOnSelect,
|
||||
onClose,
|
||||
value: initialValue,
|
||||
}: SingleAutocompleteProps<T>) {
|
||||
@@ -306,7 +308,9 @@ function SingleAutocomplete<T extends Item>({
|
||||
|
||||
if (clearOnSelect) {
|
||||
setValue('');
|
||||
} else {
|
||||
}
|
||||
|
||||
if (closeOnSelect) {
|
||||
close();
|
||||
}
|
||||
|
||||
@@ -348,6 +352,7 @@ function SingleAutocomplete<T extends Item>({
|
||||
Downshift.stateChangeTypes.controlledPropUpdatedSelectedItem,
|
||||
// Do nothing if it is a "touch" selection event
|
||||
Downshift.stateChangeTypes.touchEnd,
|
||||
Downshift.stateChangeTypes.mouseUp,
|
||||
// @ts-expect-error Types say there is no type
|
||||
].includes(changes.type)
|
||||
) {
|
||||
@@ -395,7 +400,8 @@ function SingleAutocomplete<T extends Item>({
|
||||
onStateChange={changes => {
|
||||
if (
|
||||
!clearOnBlur &&
|
||||
changes.type === Downshift.stateChangeTypes.mouseUp
|
||||
(changes.type === Downshift.stateChangeTypes.mouseUp ||
|
||||
changes.type === Downshift.stateChangeTypes.touchEnd)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -393,7 +393,7 @@ function CategoryItem({
|
||||
typeof balanceBinding
|
||||
>(balanceBinding);
|
||||
|
||||
const isToBeBudgetedItem = item.id === 'to-be-budgeted';
|
||||
const isToBudgetItem = item.id === 'to-budget';
|
||||
const toBudget = useEnvelopeSheetValue(envelopeBudget.toBudget);
|
||||
|
||||
return (
|
||||
@@ -430,16 +430,13 @@ function CategoryItem({
|
||||
display: !showBalances ? 'none' : undefined,
|
||||
marginLeft: 5,
|
||||
flexShrink: 0,
|
||||
...makeAmountFullStyle(
|
||||
(isToBeBudgetedItem ? toBudget : balance) || 0,
|
||||
{
|
||||
positiveColor: theme.noticeTextMenu,
|
||||
negativeColor: theme.errorTextMenu,
|
||||
},
|
||||
),
|
||||
...makeAmountFullStyle((isToBudgetItem ? toBudget : balance) || 0, {
|
||||
positiveColor: theme.noticeTextMenu,
|
||||
negativeColor: theme.errorTextMenu,
|
||||
}),
|
||||
}}
|
||||
>
|
||||
{isToBeBudgetedItem
|
||||
{isToBudgetItem
|
||||
? toBudget != null
|
||||
? ` ${integerToCurrency(toBudget || 0)}`
|
||||
: null
|
||||
|
||||
@@ -22,16 +22,16 @@ import { getValidMonthBounds } from './MonthsContext';
|
||||
export function addToBeBudgetedGroup(groups: CategoryGroupEntity[]) {
|
||||
return [
|
||||
{
|
||||
id: 'to-be-budgeted',
|
||||
name: t('To Be Budgeted'),
|
||||
id: 'to-budget',
|
||||
name: t('To Budget'),
|
||||
categories: [
|
||||
{
|
||||
id: 'to-be-budgeted',
|
||||
name: t('To Be Budgeted'),
|
||||
cat_group: 'to-be-budgeted',
|
||||
id: 'to-budget',
|
||||
name: t('To Budget'),
|
||||
cat_group: 'to-budget',
|
||||
group: {
|
||||
id: 'to-be-budgeted',
|
||||
name: t('To Be Budgeted'),
|
||||
id: 'to-budget',
|
||||
name: t('To Budget'),
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
import React, { memo, useCallback, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import React, {
|
||||
memo,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { GridList, GridListItem } from 'react-aria-components';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
|
||||
import { Button } from '@actual-app/components/button';
|
||||
import { Card } from '@actual-app/components/card';
|
||||
@@ -12,7 +21,11 @@ import {
|
||||
SvgArrowThickRight,
|
||||
SvgCheveronRight,
|
||||
} from '@actual-app/components/icons/v1';
|
||||
import { SvgCalendar, SvgViewShow } from '@actual-app/components/icons/v2';
|
||||
import {
|
||||
SvgArrowButtonDown1,
|
||||
SvgCalendar,
|
||||
SvgViewShow,
|
||||
} from '@actual-app/components/icons/v2';
|
||||
import { Label } from '@actual-app/components/label';
|
||||
import { styles } from '@actual-app/components/styles';
|
||||
import { Text } from '@actual-app/components/text';
|
||||
@@ -28,6 +41,7 @@ import {
|
||||
trackingBudget,
|
||||
uncategorizedCount,
|
||||
} from 'loot-core/client/queries';
|
||||
import { useSpreadsheet } from 'loot-core/client/SpreadsheetProvider';
|
||||
import * as monthUtils from 'loot-core/shared/months';
|
||||
import { groupById, integerToCurrency } from 'loot-core/shared/util';
|
||||
|
||||
@@ -36,15 +50,16 @@ import { useFeatureFlag } from '../../../hooks/useFeatureFlag';
|
||||
import { useLocale } from '../../../hooks/useLocale';
|
||||
import { useLocalPref } from '../../../hooks/useLocalPref';
|
||||
import { useNavigate } from '../../../hooks/useNavigate';
|
||||
import { usePrevious } from '../../../hooks/usePrevious';
|
||||
import { useSyncedPref } from '../../../hooks/useSyncedPref';
|
||||
import { useUndo } from '../../../hooks/useUndo';
|
||||
import { useDispatch } from '../../../redux';
|
||||
import { BalanceWithCarryover } from '../../budget/BalanceWithCarryover';
|
||||
import { makeAmountGrey, makeBalanceAmountStyle } from '../../budget/util';
|
||||
import { Link } from '../../common/Link';
|
||||
import { MobilePageHeader, Page } from '../../Page';
|
||||
import { PrivacyFilter } from '../../PrivacyFilter';
|
||||
import { CellValue } from '../../spreadsheet/CellValue';
|
||||
import { NamespaceContext } from '../../spreadsheet/NamespaceContext';
|
||||
import { useFormat } from '../../spreadsheet/useFormat';
|
||||
import { useSheetValue } from '../../spreadsheet/useSheetValue';
|
||||
import { MOBILE_NAV_HEIGHT } from '../MobileNavTabs';
|
||||
@@ -112,7 +127,12 @@ function ToBudget({ toBudget, onPress, show3Cols }) {
|
||||
style={{
|
||||
fontSize: 12,
|
||||
fontWeight: '700',
|
||||
color: amount < 0 ? theme.errorText : theme.formInputText,
|
||||
color:
|
||||
amount < 0
|
||||
? theme.errorText
|
||||
: amount > 0
|
||||
? theme.noticeText
|
||||
: theme.formInputText,
|
||||
}}
|
||||
>
|
||||
{format(value, type)}
|
||||
@@ -1043,38 +1063,6 @@ const ExpenseGroup = memo(function ExpenseGroup({
|
||||
);
|
||||
});
|
||||
|
||||
function UncategorizedButton() {
|
||||
const count = useSheetValue(uncategorizedCount());
|
||||
if (count === null || count <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
padding: 5,
|
||||
paddingBottom: 2,
|
||||
}}
|
||||
>
|
||||
<Link
|
||||
variant="button"
|
||||
type="button"
|
||||
buttonVariant="primary"
|
||||
to="/accounts/uncategorized"
|
||||
style={{
|
||||
border: 0,
|
||||
justifyContent: 'flex-start',
|
||||
padding: '1.25em',
|
||||
}}
|
||||
>
|
||||
{count} uncategorized {count === 1 ? 'transaction' : 'transactions'}
|
||||
<View style={{ flex: 1 }} />
|
||||
<SvgArrowThinRight width="15" height="15" />
|
||||
</Link>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function BudgetGroups({
|
||||
type,
|
||||
categoryGroups,
|
||||
@@ -1251,6 +1239,7 @@ export function BudgetTable({
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Banners month={month} onBudgetAction={onBudgetAction} />
|
||||
<BudgetTableHeader
|
||||
type={type}
|
||||
month={month}
|
||||
@@ -1267,7 +1256,6 @@ export function BudgetTable({
|
||||
paddingBottom: MOBILE_NAV_HEIGHT,
|
||||
}}
|
||||
>
|
||||
<UncategorizedButton />
|
||||
<BudgetGroups
|
||||
type={type}
|
||||
categoryGroups={categoryGroups}
|
||||
@@ -1294,6 +1282,387 @@ export function BudgetTable({
|
||||
);
|
||||
}
|
||||
|
||||
function Banner({ type = 'info', children }) {
|
||||
return (
|
||||
<Card
|
||||
style={{
|
||||
height: 50,
|
||||
marginTop: 10,
|
||||
marginBottom: 10,
|
||||
padding: 10,
|
||||
justifyContent: 'center',
|
||||
backgroundColor:
|
||||
type === 'critical'
|
||||
? theme.errorBackground
|
||||
: type === 'warning'
|
||||
? theme.warningBackground
|
||||
: theme.noticeBackground,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function UncategorizedTransactionsBanner(props) {
|
||||
const count = useSheetValue(uncategorizedCount());
|
||||
const navigate = useNavigate();
|
||||
|
||||
if (count === null || count <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<GridListItem textValue="Uncategorized transactions banner" {...props}>
|
||||
<Banner type="warning">
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
}}
|
||||
>
|
||||
<Trans count={count}>
|
||||
You have {{ count }} uncategorized transactions
|
||||
</Trans>
|
||||
<Button
|
||||
onPress={() => navigate('/accounts/uncategorized')}
|
||||
style={PILL_STYLE}
|
||||
>
|
||||
<Text>
|
||||
<Trans>Categorize</Trans>
|
||||
</Text>
|
||||
</Button>
|
||||
</View>
|
||||
</Banner>
|
||||
</GridListItem>
|
||||
);
|
||||
}
|
||||
|
||||
function OverbudgetedBanner({ month, onBudgetAction, ...props }) {
|
||||
const { t } = useTranslation();
|
||||
const toBudgetAmount = useSheetValue(envelopeBudget.toBudget);
|
||||
const dispatch = useDispatch();
|
||||
const { showUndoNotification } = useUndo();
|
||||
const { list: categories } = useCategories();
|
||||
const categoriesById = groupById(categories);
|
||||
|
||||
const openCoverOverbudgetedModal = useCallback(() => {
|
||||
dispatch(
|
||||
pushModal({
|
||||
modal: {
|
||||
name: 'cover',
|
||||
options: {
|
||||
title: t('Cover overbudgeted'),
|
||||
month,
|
||||
showToBeBudgeted: false,
|
||||
onSubmit: categoryId => {
|
||||
onBudgetAction(month, 'cover-overbudgeted', {
|
||||
category: categoryId,
|
||||
});
|
||||
showUndoNotification({
|
||||
message: t('Covered overbudgeted from {{categoryName}}', {
|
||||
categoryName: categoriesById[categoryId].name,
|
||||
}),
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
}, [
|
||||
categoriesById,
|
||||
dispatch,
|
||||
month,
|
||||
onBudgetAction,
|
||||
showUndoNotification,
|
||||
t,
|
||||
]);
|
||||
|
||||
if (!toBudgetAmount || toBudgetAmount >= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<GridListItem textValue="Overbudgeted banner" {...props}>
|
||||
<Banner type="critical">
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
}}
|
||||
>
|
||||
<SvgArrowButtonDown1 style={{ width: 15, height: 15 }} />
|
||||
<Text>
|
||||
<Trans>You have budgeted more than your available funds</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<Button onPress={openCoverOverbudgetedModal} style={PILL_STYLE}>
|
||||
<Trans>Cover</Trans>
|
||||
</Button>
|
||||
</View>
|
||||
</Banner>
|
||||
</GridListItem>
|
||||
);
|
||||
}
|
||||
|
||||
function OverspendingBanner({ month, onBudgetAction, ...props }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { list: categories, grouped: categoryGroups } = useCategories();
|
||||
const categoriesById = groupById(categories);
|
||||
|
||||
const categoryBalanceBindings = useMemo(
|
||||
() =>
|
||||
categories.map(category => [
|
||||
category.id,
|
||||
envelopeBudget.catBalance(category.id),
|
||||
]),
|
||||
[categories],
|
||||
);
|
||||
|
||||
const categoryCarryoverBindings = useMemo(
|
||||
() =>
|
||||
categories.map(category => [
|
||||
category.id,
|
||||
envelopeBudget.catCarryover(category.id),
|
||||
]),
|
||||
[categories],
|
||||
);
|
||||
|
||||
const [overspentByCategory, setOverspentByCategory] = useState({});
|
||||
const [carryoverFlagByCategory, setCarryoverFlagByCategory] = useState({});
|
||||
const sheetName = useContext(NamespaceContext);
|
||||
const spreadsheet = useSpreadsheet();
|
||||
|
||||
useEffect(() => {
|
||||
const unbindList = [];
|
||||
for (const [categoryId, carryoverBinding] of categoryCarryoverBindings) {
|
||||
const unbind = spreadsheet.bind(sheetName, carryoverBinding, result => {
|
||||
const isRolloverEnabled = Boolean(result.value);
|
||||
if (isRolloverEnabled) {
|
||||
setCarryoverFlagByCategory(prev => ({
|
||||
...prev,
|
||||
[categoryId]: result.value,
|
||||
}));
|
||||
} else {
|
||||
// Update to remove covered category.
|
||||
setCarryoverFlagByCategory(prev => {
|
||||
const { [categoryId]: _, ...rest } = prev;
|
||||
return rest;
|
||||
});
|
||||
}
|
||||
});
|
||||
unbindList.push(unbind);
|
||||
}
|
||||
|
||||
return () => {
|
||||
unbindList.forEach(unbind => unbind());
|
||||
};
|
||||
}, [categoryCarryoverBindings, sheetName, spreadsheet]);
|
||||
|
||||
useEffect(() => {
|
||||
const unbindList = [];
|
||||
for (const [categoryId, balanceBinding] of categoryBalanceBindings) {
|
||||
const unbind = spreadsheet.bind(sheetName, balanceBinding, result => {
|
||||
if (result.value < 0) {
|
||||
setOverspentByCategory(prev => ({
|
||||
...prev,
|
||||
[categoryId]: result.value,
|
||||
}));
|
||||
} else if (result.value === 0) {
|
||||
// Update to remove covered category.
|
||||
setOverspentByCategory(prev => {
|
||||
const { [categoryId]: _, ...rest } = prev;
|
||||
return rest;
|
||||
});
|
||||
}
|
||||
});
|
||||
unbindList.push(unbind);
|
||||
}
|
||||
|
||||
return () => {
|
||||
unbindList.forEach(unbind => unbind());
|
||||
};
|
||||
}, [categoryBalanceBindings, sheetName, spreadsheet]);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
|
||||
// Ignore those that has rollover enabled.
|
||||
const overspentCategoryIds = Object.keys(overspentByCategory).filter(
|
||||
id => !carryoverFlagByCategory[id],
|
||||
);
|
||||
|
||||
const categoryGroupsToShow = useMemo(
|
||||
() =>
|
||||
categoryGroups
|
||||
.filter(g =>
|
||||
g.categories?.some(c => overspentCategoryIds.includes(c.id)),
|
||||
)
|
||||
.map(g => ({
|
||||
...g,
|
||||
categories:
|
||||
g.categories?.filter(c => overspentCategoryIds.includes(c.id)) ||
|
||||
[],
|
||||
})),
|
||||
[categoryGroups, overspentCategoryIds],
|
||||
);
|
||||
|
||||
const { showUndoNotification } = useUndo();
|
||||
|
||||
const onOpenCoverCategoryModal = useCallback(
|
||||
categoryId => {
|
||||
const category = categoriesById[categoryId];
|
||||
dispatch(
|
||||
pushModal({
|
||||
modal: {
|
||||
name: 'cover',
|
||||
options: {
|
||||
title: category.name,
|
||||
month,
|
||||
categoryId: category.id,
|
||||
onSubmit: fromCategoryId => {
|
||||
onBudgetAction(month, 'cover-overspending', {
|
||||
to: category.id,
|
||||
from: fromCategoryId,
|
||||
});
|
||||
showUndoNotification({
|
||||
message: t(
|
||||
`Covered {{toCategoryName}} overspending from {{fromCategoryName}}.`,
|
||||
{
|
||||
toCategoryName: category.name,
|
||||
fromCategoryName:
|
||||
fromCategoryId === 'to-budget'
|
||||
? 'To Budget'
|
||||
: categoriesById[fromCategoryId].name,
|
||||
},
|
||||
),
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
},
|
||||
[categoriesById, dispatch, month, onBudgetAction, showUndoNotification, t],
|
||||
);
|
||||
|
||||
const onOpenCategorySelectionModal = useCallback(() => {
|
||||
dispatch(
|
||||
pushModal({
|
||||
modal: {
|
||||
name: 'category-autocomplete',
|
||||
options: {
|
||||
title: t('Cover overspending'),
|
||||
month,
|
||||
categoryGroups: categoryGroupsToShow,
|
||||
onSelect: onOpenCoverCategoryModal,
|
||||
clearOnSelect: true,
|
||||
closeOnSelect: false,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
}, [categoryGroupsToShow, dispatch, month, onOpenCoverCategoryModal, t]);
|
||||
|
||||
const numberOfOverspentCategories = overspentCategoryIds.length;
|
||||
const previousNumberOfOverspentCategories = usePrevious(
|
||||
numberOfOverspentCategories,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (numberOfOverspentCategories < previousNumberOfOverspentCategories) {
|
||||
// Re-render the modal when the overspent categories are covered.
|
||||
dispatch(collapseModals({ rootModalName: 'category-autocomplete' }));
|
||||
onOpenCategorySelectionModal();
|
||||
|
||||
// All overspent categories have been covered.
|
||||
if (numberOfOverspentCategories === 0) {
|
||||
dispatch(collapseModals({ rootModalName: 'category-autocomplete' }));
|
||||
}
|
||||
}
|
||||
}, [
|
||||
dispatch,
|
||||
onOpenCategorySelectionModal,
|
||||
numberOfOverspentCategories,
|
||||
previousNumberOfOverspentCategories,
|
||||
]);
|
||||
|
||||
if (numberOfOverspentCategories === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<GridListItem textValue="Overspent banner" {...props}>
|
||||
<Banner type="critical">
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
}}
|
||||
>
|
||||
<Text>
|
||||
<Trans count={numberOfOverspentCategories}>
|
||||
You have {{ count: numberOfOverspentCategories }} overspent
|
||||
categories
|
||||
</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
<Button onPress={onOpenCategorySelectionModal} style={PILL_STYLE}>
|
||||
<Trans>Cover</Trans>
|
||||
</Button>
|
||||
</View>
|
||||
</Banner>
|
||||
</GridListItem>
|
||||
);
|
||||
}
|
||||
|
||||
function Banners({ month, onBudgetAction }) {
|
||||
const { t } = useTranslation();
|
||||
const [budgetType = 'rollover'] = useSyncedPref('budgetType');
|
||||
|
||||
// Limit to rollover for now.
|
||||
if (budgetType !== 'rollover') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<GridList
|
||||
aria-label={t('Banners')}
|
||||
style={{ backgroundColor: theme.mobilePageBackground }}
|
||||
>
|
||||
<UncategorizedTransactionsBanner />
|
||||
<OverspendingBanner month={month} onBudgetAction={onBudgetAction} />
|
||||
<OverbudgetedBanner month={month} onBudgetAction={onBudgetAction} />
|
||||
</GridList>
|
||||
);
|
||||
}
|
||||
|
||||
function BudgetTableHeader({
|
||||
show3Cols,
|
||||
type,
|
||||
@@ -1559,7 +1928,7 @@ function MonthSelector({
|
||||
}}
|
||||
style={{ ...arrowButtonStyle, opacity: prevEnabled ? 1 : 0.6 }}
|
||||
>
|
||||
<SvgArrowThinLeft width="15" height="15" style={{ margin: -5 }} />
|
||||
<SvgArrowThinLeft width="15" height="15" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="bare"
|
||||
@@ -1567,7 +1936,6 @@ function MonthSelector({
|
||||
textAlign: 'center',
|
||||
fontSize: 16,
|
||||
fontWeight: 500,
|
||||
margin: '0 5px',
|
||||
}}
|
||||
onPress={() => {
|
||||
onOpenMonthMenu?.(month);
|
||||
@@ -1588,7 +1956,7 @@ function MonthSelector({
|
||||
}}
|
||||
style={{ ...arrowButtonStyle, opacity: nextEnabled ? 1 : 0.6 }}
|
||||
>
|
||||
<SvgArrowThinRight width="15" height="15" style={{ margin: -5 }} />
|
||||
<SvgArrowThinRight width="15" height="15" />
|
||||
</Button>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -24,10 +24,13 @@ type CategoryAutocompleteModalProps = Extract<
|
||||
>['options'];
|
||||
|
||||
export function CategoryAutocompleteModal({
|
||||
title,
|
||||
month,
|
||||
onSelect,
|
||||
categoryGroups,
|
||||
showHiddenCategories,
|
||||
closeOnSelect,
|
||||
clearOnSelect,
|
||||
onClose,
|
||||
}: CategoryAutocompleteModalProps) {
|
||||
const { t } = useTranslation();
|
||||
@@ -57,7 +60,7 @@ export function CategoryAutocompleteModal({
|
||||
<ModalHeader
|
||||
title={
|
||||
<ModalTitle
|
||||
title={t('Category')}
|
||||
title={title || t('Category')}
|
||||
getStyle={() => ({ color: theme.menuAutoCompleteText })}
|
||||
/>
|
||||
}
|
||||
@@ -88,6 +91,8 @@ export function CategoryAutocompleteModal({
|
||||
focused={true}
|
||||
embedded={true}
|
||||
closeOnBlur={false}
|
||||
closeOnSelect={closeOnSelect}
|
||||
clearOnSelect={clearOnSelect}
|
||||
showSplitOption={false}
|
||||
onClose={close}
|
||||
{...defaultAutocompleteProps}
|
||||
|
||||