diff --git a/.eslintrc.js b/.eslintrc.js index 06c284f01a9..85e9ec47f58 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -299,6 +299,7 @@ module.exports = { './packages/desktop-client/src/components/settings/index.*', './packages/desktop-client/src/components/sidebar.*', './packages/desktop-client/src/components/transactions/MobileTransaction.*', + './packages/desktop-client/src/components/transactions/TransactionsTable.*', './packages/desktop-client/src/components/util/AmountInput.*', './packages/desktop-client/src/components/util/DisplayId.*', './packages/desktop-client/src/components/util/LoadComponent.*', diff --git a/packages/desktop-client/e2e/mobile.test.js b/packages/desktop-client/e2e/mobile.test.js new file mode 100644 index 00000000000..d39d0cfb1e1 --- /dev/null +++ b/packages/desktop-client/e2e/mobile.test.js @@ -0,0 +1,137 @@ +import { test, expect } from '@playwright/test'; + +import { ConfigurationPage } from './page-models/configuration-page'; +import { MobileNavigation } from './page-models/mobile-navigation'; + +test.describe('Mobile', () => { + let page; + let navigation; + let configurationPage; + + test.beforeEach(async ({ browser }) => { + page = await browser.newPage(); + navigation = new MobileNavigation(page); + configurationPage = new ConfigurationPage(page); + + await page.setViewportSize({ + width: 350, + height: 600, + }); + await page.goto('/'); + await configurationPage.createTestFile(); + }); + + test.afterEach(async () => { + await page.close(); + }); + + test('loads the budget page with budgeted amounts', async () => { + const budgetPage = await navigation.goToBudgetPage(); + + await expect(budgetPage.categoryNames).toHaveText([ + 'Food', + 'Restaurants', + 'Entertainment', + 'Clothing', + 'General', + 'Gift', + 'Medical', + 'Savings', + 'Cell', + 'Internet', + 'Mortgage', + 'Water', + 'Power', + ]); + }); + + test('opens the accounts page and asserts on balances', async () => { + const accountsPage = await navigation.goToAccountsPage(); + + const account = await accountsPage.getNthAccount(0); + + expect(account.name).toEqual('Ally Savings'); + expect(account.balance).toBeGreaterThan(0); + }); + + test('opens individual account page and checks that filtering is working', async () => { + const accountsPage = await navigation.goToAccountsPage(); + const accountPage = await accountsPage.openNthAccount(1); + + await expect(accountPage.heading).toHaveText('Bank of America'); + expect(await accountPage.getBalance()).toBeGreaterThan(0); + + await expect(accountPage.noTransactionsFoundError).not.toBeVisible(); + + await accountPage.searchByText('nothing should be found'); + await expect(accountPage.noTransactionsFoundError).toBeVisible(); + await expect(accountPage.transactions).toHaveCount(0); + + await accountPage.searchByText('Kroger'); + await expect(accountPage.transactions).not.toHaveCount(0); + }); + + test('creates a transaction via footer button', async () => { + const transactionEntryPage = await navigation.goToTransactionEntryPage(); + + await expect(transactionEntryPage.header).toHaveText('New Transaction'); + + await transactionEntryPage.amountField.fill('12.34'); + await transactionEntryPage.fillField( + page.getByTestId('payee-field'), + 'Kroger', + ); + await transactionEntryPage.fillField( + page.getByTestId('category-field'), + 'Clothing', + ); + await transactionEntryPage.fillField( + page.getByTestId('account-field'), + 'Ally Savings', + ); + + const accountPage = await transactionEntryPage.createTransaction(); + + await expect(accountPage.transactions.nth(0)).toHaveText( + 'KrogerClothing-12.34', + ); + }); + + test('creates a transaction from `/accounts/:id` page', async () => { + const accountsPage = await navigation.goToAccountsPage(); + const accountPage = await accountsPage.openNthAccount(1); + const transactionEntryPage = await accountPage.clickCreateTransaction(); + + await expect(transactionEntryPage.header).toHaveText('New Transaction'); + + await transactionEntryPage.amountField.fill('12.34'); + await transactionEntryPage.fillField( + page.getByTestId('payee-field'), + 'Kroger', + ); + await transactionEntryPage.fillField( + page.getByTestId('category-field'), + 'Clothing', + ); + + await transactionEntryPage.createTransaction(); + + await expect(accountPage.transactions.nth(0)).toHaveText( + 'KrogerClothing-12.34', + ); + }); + + test('checks that settings page can be opened', async () => { + const settingsPage = await navigation.goToSettingsPage(); + + const downloadPromise = page.waitForEvent('download'); + + await settingsPage.exportData(); + + const download = await downloadPromise; + + expect(await download.suggestedFilename()).toMatch( + /^\d{4}-\d{2}-\d{2}-.*.zip$/, + ); + }); +}); diff --git a/packages/desktop-client/e2e/page-models/mobile-account-page.js b/packages/desktop-client/e2e/page-models/mobile-account-page.js new file mode 100644 index 00000000000..9e58489fb74 --- /dev/null +++ b/packages/desktop-client/e2e/page-models/mobile-account-page.js @@ -0,0 +1,39 @@ +import { MobileTransactionEntryPage } from './mobile-transaction-entry-page'; + +export class MobileAccountPage { + constructor(page) { + this.page = page; + + this.heading = page.getByRole('heading'); + this.balance = page.getByTestId('account-balance'); + this.noTransactionsFoundError = page.getByText('No transactions'); + this.searchBox = page.getByPlaceholder(/^Search/); + this.transactionList = page.getByLabel('transaction list'); + this.transactions = this.transactionList.getByRole('button'); + this.createTransactionButton = page.getByRole('button', { + name: 'Add Transaction', + }); + } + + /** + * Retrieve the balance of the account as a number + */ + async getBalance() { + return parseInt(await this.balance.textContent(), 10); + } + + /** + * Search by the given term + */ + async searchByText(term) { + await this.searchBox.fill(term); + } + + /** + * Go to transaction creation page + */ + async clickCreateTransaction() { + this.createTransactionButton.click(); + return new MobileTransactionEntryPage(this.page); + } +} diff --git a/packages/desktop-client/e2e/page-models/mobile-accounts-page.js b/packages/desktop-client/e2e/page-models/mobile-accounts-page.js new file mode 100644 index 00000000000..5c0b8c601c6 --- /dev/null +++ b/packages/desktop-client/e2e/page-models/mobile-accounts-page.js @@ -0,0 +1,33 @@ +import { MobileAccountPage } from './mobile-account-page'; + +export class MobileAccountsPage { + constructor(page) { + this.page = page; + + this.accounts = this.page.getByTestId('account'); + } + + /** + * Get the name and balance of the nth account + */ + async getNthAccount(idx) { + const accountRow = this.accounts.nth(idx); + + return { + name: await accountRow.getByTestId('account-name').textContent(), + balance: parseInt( + await accountRow.getByTestId('account-balance').textContent(), + 10, + ), + }; + } + + /** + * Click on the n-th account to open it up + */ + async openNthAccount(idx) { + await this.accounts.nth(idx).getByRole('button').click(); + + return new MobileAccountPage(this.page); + } +} diff --git a/packages/desktop-client/e2e/page-models/mobile-budget-page.js b/packages/desktop-client/e2e/page-models/mobile-budget-page.js new file mode 100644 index 00000000000..c5d843fd220 --- /dev/null +++ b/packages/desktop-client/e2e/page-models/mobile-budget-page.js @@ -0,0 +1,9 @@ +export class MobileBudgetPage { + constructor(page) { + this.page = page; + + this.categoryNames = page + .getByTestId('budget-groups') + .getByTestId('category-name'); + } +} diff --git a/packages/desktop-client/e2e/page-models/mobile-navigation.js b/packages/desktop-client/e2e/page-models/mobile-navigation.js new file mode 100644 index 00000000000..eeabd4b4c0d --- /dev/null +++ b/packages/desktop-client/e2e/page-models/mobile-navigation.js @@ -0,0 +1,38 @@ +import { MobileAccountsPage } from './mobile-accounts-page'; +import { MobileBudgetPage } from './mobile-budget-page'; +import { MobileTransactionEntryPage } from './mobile-transaction-entry-page'; +import { SettingsPage } from './settings-page'; + +export class MobileNavigation { + constructor(page) { + this.page = page; + } + + async goToBudgetPage() { + const link = this.page.getByRole('link', { name: 'Budget' }); + await link.click(); + + return new MobileBudgetPage(this.page); + } + + async goToAccountsPage() { + const link = this.page.getByRole('link', { name: 'Accounts' }); + await link.click(); + + return new MobileAccountsPage(this.page); + } + + async goToTransactionEntryPage() { + const link = this.page.getByRole('link', { name: 'Transaction' }); + await link.click(); + + return new MobileTransactionEntryPage(this.page); + } + + async goToSettingsPage() { + const link = this.page.getByRole('link', { name: 'Settings' }); + await link.click(); + + return new SettingsPage(this.page); + } +} diff --git a/packages/desktop-client/e2e/page-models/mobile-transaction-entry-page.js b/packages/desktop-client/e2e/page-models/mobile-transaction-entry-page.js new file mode 100644 index 00000000000..c4281c2b991 --- /dev/null +++ b/packages/desktop-client/e2e/page-models/mobile-transaction-entry-page.js @@ -0,0 +1,23 @@ +import { MobileAccountPage } from './mobile-account-page'; + +export class MobileTransactionEntryPage { + constructor(page) { + this.page = page; + + this.header = page.getByRole('heading'); + this.amountField = page.getByTestId('amount-input'); + this.add = page.getByRole('button', { name: 'Add transaction' }); + } + + async fillField(fieldLocator, content) { + await fieldLocator.click(); + await this.page.locator('css=[role=combobox] input').fill(content); + await this.page.keyboard.press('Enter'); + } + + async createTransaction() { + await this.add.click(); + + return new MobileAccountPage(this.page); + } +} diff --git a/packages/desktop-client/package.json b/packages/desktop-client/package.json index 93eec398069..4221044e30e 100644 --- a/packages/desktop-client/package.json +++ b/packages/desktop-client/package.json @@ -43,6 +43,7 @@ "react-dnd": "^16.0.1", "react-dnd-html5-backend": "^16.0.1", "react-dom": "18.2.0", + "react-error-boundary": "^4.0.11", "react-merge-refs": "^1.1.0", "react-modal": "3.16.1", "react-redux": "7.2.1", diff --git a/packages/desktop-client/src/components/App.js b/packages/desktop-client/src/components/App.js deleted file mode 100644 index 4befb0ffeba..00000000000 --- a/packages/desktop-client/src/components/App.js +++ /dev/null @@ -1,162 +0,0 @@ -import React, { Component } from 'react'; -import { useSelector } from 'react-redux'; - -import { css } from 'glamor'; - -import { - init as initConnection, - send, -} from 'loot-core/src/platform/client/fetch'; - -import { useActions } from '../hooks/useActions'; -import installPolyfills from '../polyfills'; -import { ResponsiveProvider } from '../ResponsiveProvider'; -import { styles, hasHiddenScrollbars, ThemeStyle } from '../style'; - -import AppBackground from './AppBackground'; -import DevelopmentTopBar from './DevelopmentTopBar'; -import FatalError from './FatalError'; -import FinancesApp from './FinancesApp'; -import ManagementApp from './manager/ManagementApp'; -import MobileWebMessage from './MobileWebMessage'; -import UpdateNotification from './UpdateNotification'; - -class App extends Component { - state = { - fatalError: null, - initializing: true, - hiddenScrollbars: hasHiddenScrollbars(), - }; - - async init() { - const socketName = await global.Actual.getServerSocket(); - - try { - await initConnection(socketName); - } catch (e) { - if (e.type === 'app-init-failure') { - this.setState({ initializing: false, fatalError: e }); - return; - } else { - throw e; - } - } - - // Load any global prefs - await this.props.loadGlobalPrefs(); - - // Open the last opened budget, if any - const budgetId = await send('get-last-opened-backup'); - if (budgetId) { - await this.props.loadBudget(budgetId); - - // Check to see if this file has been remotely deleted (but - // don't block on this in case they are offline or something) - send('get-remote-files').then(files => { - if (files) { - let remoteFile = files.find(f => f.fileId === this.props.cloudFileId); - if (remoteFile && remoteFile.deleted) { - this.props.closeBudget(); - } - } - }); - } - } - - async componentDidMount() { - await Promise.all([installPolyfills(), this.init()]); - - this.setState({ initializing: false }); - - let checkScrollbars = () => { - if (this.state.hiddenScrollbars !== hasHiddenScrollbars()) { - this.setState({ hiddenScrollbars: hasHiddenScrollbars() }); - } - }; - window.addEventListener('focus', checkScrollbars); - this.cleanup = () => window.removeEventListener('focus', checkScrollbars); - } - - componentDidCatch(error) { - this.setState({ fatalError: error }); - } - - componentDidUpdate(prevProps) { - if (this.props.budgetId !== prevProps.budgetId) { - global.Actual.updateAppMenu(!!this.props.budgetId); - } - } - - render() { - const { budgetId, loadingText } = this.props; - const { fatalError, initializing, hiddenScrollbars } = this.state; - - return ( - -
- {process.env.REACT_APP_REVIEW_ID && } -
- {fatalError ? ( - <> - - - - ) : initializing ? ( - - ) : budgetId ? ( - - ) : ( - <> - - - - )} - - - -
-
- -
- ); - } -} - -export default function AppWrapper() { - let budgetId = useSelector( - state => state.prefs.local && state.prefs.local.id, - ); - let cloudFileId = useSelector( - state => state.prefs.local && state.prefs.local.cloudFileId, - ); - let loadingText = useSelector(state => state.app.loadingText); - let { loadBudget, closeBudget, loadGlobalPrefs } = useActions(); - - return ( - - ); -} diff --git a/packages/desktop-client/src/components/App.tsx b/packages/desktop-client/src/components/App.tsx new file mode 100644 index 00000000000..ea0762201d0 --- /dev/null +++ b/packages/desktop-client/src/components/App.tsx @@ -0,0 +1,179 @@ +import React, { useEffect, useState } from 'react'; +import { + ErrorBoundary, + useErrorBoundary, + type FallbackProps, +} from 'react-error-boundary'; +import { useSelector } from 'react-redux'; + +import { + init as initConnection, + send, +} from 'loot-core/src/platform/client/fetch'; +import { type GlobalPrefs } from 'loot-core/src/types/prefs'; + +import { useActions } from '../hooks/useActions'; +import installPolyfills from '../polyfills'; +import { ResponsiveProvider } from '../ResponsiveProvider'; +import { styles, hasHiddenScrollbars, ThemeStyle } from '../style'; + +import AppBackground from './AppBackground'; +import View from './common/View'; +import DevelopmentTopBar from './DevelopmentTopBar'; +import FatalError from './FatalError'; +import FinancesApp from './FinancesApp'; +import ManagementApp from './manager/ManagementApp'; +import MobileWebMessage from './MobileWebMessage'; +import UpdateNotification from './UpdateNotification'; + +type AppProps = { + budgetId: string; + cloudFileId: string; + loadingText: string; + loadBudget: ( + id: string, + loadingText?: string, + options?: object, + ) => Promise; + closeBudget: () => Promise; + loadGlobalPrefs: () => Promise; +}; + +function App({ + budgetId, + cloudFileId, + loadingText, + loadBudget, + closeBudget, + loadGlobalPrefs, +}: AppProps) { + const [initializing, setInitializing] = useState(true); + const { showBoundary: showErrorBoundary } = useErrorBoundary(); + + async function init() { + const socketName = await global.Actual.getServerSocket(); + + await initConnection(socketName); + + // Load any global prefs + await loadGlobalPrefs(); + + // Open the last opened budget, if any + const budgetId = await send('get-last-opened-backup'); + if (budgetId) { + await loadBudget(budgetId); + + // Check to see if this file has been remotely deleted (but + // don't block on this in case they are offline or something) + send('get-remote-files').then(files => { + if (files) { + let remoteFile = files.find(f => f.fileId === cloudFileId); + if (remoteFile && remoteFile.deleted) { + closeBudget(); + } + } + }); + } + } + + useEffect(() => { + async function initAll() { + await Promise.all([installPolyfills(), init()]); + setInitializing(false); + } + + initAll().catch(showErrorBoundary); + }, []); + + useEffect(() => { + global.Actual.updateAppMenu(!!budgetId); + }, [budgetId]); + + return ( + <> + {initializing ? ( + + ) : budgetId ? ( + + ) : ( + <> + + + + )} + + + + + ); +} + +function ErrorFallback({ error }: FallbackProps) { + return ( + <> + + + + ); +} + +function AppWrapper() { + let budgetId = useSelector( + state => state.prefs.local && state.prefs.local.id, + ); + let cloudFileId = useSelector( + state => state.prefs.local && state.prefs.local.cloudFileId, + ); + let loadingText = useSelector(state => state.app.loadingText); + let { loadBudget, closeBudget, loadGlobalPrefs } = useActions(); + const [hiddenScrollbars, setHiddenScrollbars] = useState( + hasHiddenScrollbars(), + ); + + useEffect(() => { + function checkScrollbars() { + if (hiddenScrollbars !== hasHiddenScrollbars()) { + setHiddenScrollbars(hasHiddenScrollbars()); + } + } + + window.addEventListener('focus', checkScrollbars); + + return () => window.removeEventListener('focus', checkScrollbars); + }, []); + + return ( + + + + + {process.env.REACT_APP_REVIEW_ID && } + + + + + + + ); +} + +export default AppWrapper; diff --git a/packages/desktop-client/src/components/AppBackground.js b/packages/desktop-client/src/components/AppBackground.tsx similarity index 84% rename from packages/desktop-client/src/components/AppBackground.js rename to packages/desktop-client/src/components/AppBackground.tsx index 697e20f02da..1af44ea58b3 100644 --- a/packages/desktop-client/src/components/AppBackground.js +++ b/packages/desktop-client/src/components/AppBackground.tsx @@ -9,7 +9,12 @@ import Background from './Background'; import Block from './common/Block'; import View from './common/View'; -function AppBackground({ initializing, loadingText }) { +type AppBackgroundProps = { + initializing?: boolean; + loadingText?: string; +}; + +function AppBackground({ initializing, loadingText }: AppBackgroundProps) { return ( <> diff --git a/packages/desktop-client/src/components/FatalError.js b/packages/desktop-client/src/components/FatalError.js deleted file mode 100644 index 23c2346e42c..00000000000 --- a/packages/desktop-client/src/components/FatalError.js +++ /dev/null @@ -1,186 +0,0 @@ -import React, { Component, useState } from 'react'; - -import { theme } from '../style'; - -import Block from './common/Block'; -import Button from './common/Button'; -import ExternalLink from './common/ExternalLink'; -import LinkButton from './common/LinkButton'; -import Modal from './common/Modal'; -import Paragraph from './common/Paragraph'; -import Stack from './common/Stack'; -import Text from './common/Text'; -import View from './common/View'; -import { Checkbox } from './forms'; - -class FatalError extends Component { - state = { showError: false }; - - renderSimple(error) { - let msg; - if (error.IDBFailure) { - // IndexedDB wasn't able to open the database - msg = ( - - Your browser doesn’t support IndexedDB in this environment, a feature - that Actual requires to run. This might happen if you are in private - browsing mode. Please try a different browser or turn off private - browsing. - - ); - } else if (error.SharedArrayBufferMissing) { - // SharedArrayBuffer isn't available - msg = ( - - Actual requires access to SharedArrayBuffer in order to - function properly. If you’re seeing this error, either your browser - does not support SharedArrayBuffer, or your server is not - sending the appropriate headers, or you are not using HTTPS. See{' '} - - our troubleshooting documentation - {' '} - to learn more. - - ); - } else { - // This indicates the backend failed to initialize. Show the - // user something at least so they aren't looking at a blank - // screen - msg = ( - - There was a problem loading the app in this browser version. If this - continues to be a problem, you can{' '} - - download the desktop app - - . - - ); - } - - return ( - - - {msg} - - Please get{' '} - - in touch - {' '} - for support - - - - ); - } - - render() { - const { buttonText, error } = this.props; - const { showError } = this.state; - - if (error.type === 'app-init-failure') { - return this.renderSimple(error); - } - - return ( - - {() => ( - - - There was an unrecoverable error in the UI. Sorry! - - - If this error persists, please get{' '} - - in touch - {' '} - so it can be investigated. - - - - - - this.setState({ showError: true })}> - Show Error - - {showError && ( - - {error.stack} - - )} - - - )} - - ); - } -} -export default FatalError; - -function SharedArrayBufferOverride() { - let [expanded, setExpanded] = useState(false); - let [understand, setUnderstand] = useState(false); - - return expanded ? ( - <> - - Actual uses SharedArrayBuffer to allow usage from multiple - tabs at once and to ensure correct behavior when switching files. While - it can run without access to SharedArrayBuffer, you may - encounter data loss or notice multiple budget files being merged with - each other. - - - - - ) : ( - setExpanded(true)} style={{ marginLeft: 5 }}> - Advanced options - - ); -} diff --git a/packages/desktop-client/src/components/FatalError.tsx b/packages/desktop-client/src/components/FatalError.tsx new file mode 100644 index 00000000000..1d8a7a70b20 --- /dev/null +++ b/packages/desktop-client/src/components/FatalError.tsx @@ -0,0 +1,189 @@ +import React, { useState } from 'react'; + +import Block from './common/Block'; +import Button from './common/Button'; +import ExternalLink from './common/ExternalLink'; +import LinkButton from './common/LinkButton'; +import Modal from './common/Modal'; +import Paragraph from './common/Paragraph'; +import Stack from './common/Stack'; +import Text from './common/Text'; +import View from './common/View'; +import { Checkbox } from './forms'; + +type AppError = Error & { + type?: string; + IDBFailure?: boolean; + SharedArrayBufferMissing?: boolean; +}; + +type FatalErrorProps = { + buttonText: string; + error: Error | AppError; +}; + +type RenderSimpleProps = { + error: Error | AppError; +}; + +function RenderSimple({ error }: RenderSimpleProps) { + let msg; + if ('IDBFailure' in error && error.IDBFailure) { + // IndexedDB wasn't able to open the database + msg = ( + + Your browser doesn’t support IndexedDB in this environment, a feature + that Actual requires to run. This might happen if you are in private + browsing mode. Please try a different browser or turn off private + browsing. + + ); + } else if ( + 'SharedArrayBufferMissing' in error && + error.SharedArrayBufferMissing + ) { + // SharedArrayBuffer isn't available + msg = ( + + Actual requires access to SharedArrayBuffer in order to + function properly. If you’re seeing this error, either your browser does + not support SharedArrayBuffer, or your server is not + sending the appropriate headers, or you are not using HTTPS. See{' '} + + our troubleshooting documentation + {' '} + to learn more. + + ); + } else { + // This indicates the backend failed to initialize. Show the + // user something at least so they aren't looking at a blank + // screen + msg = ( + + There was a problem loading the app in this browser version. If this + continues to be a problem, you can{' '} + + download the desktop app + + . + + ); + } + + return ( + + {msg} + + Please get{' '} + + in touch + {' '} + for support + + + ); +} + +function RenderUIError() { + return ( + <> + There was an unrecoverable error in the UI. Sorry! + + If this error persists, please get{' '} + + in touch + {' '} + so it can be investigated. + + + ); +} + +function SharedArrayBufferOverride() { + let [expanded, setExpanded] = useState(false); + let [understand, setUnderstand] = useState(false); + + return expanded ? ( + <> + + Actual uses SharedArrayBuffer to allow usage from multiple + tabs at once and to ensure correct behavior when switching files. While + it can run without access to SharedArrayBuffer, you may + encounter data loss or notice multiple budget files being merged with + each other. + + + + + ) : ( + setExpanded(true)} style={{ marginLeft: 5 }}> + Advanced options + + ); +} + +function FatalError({ buttonText, error }: FatalErrorProps) { + let [showError, setShowError] = useState(false); + + const showSimpleRender = 'type' in error && error.type === 'app-init-failure'; + + return ( + + + {showSimpleRender ? : } + + + + + setShowError(true)}>Show Error + {showError && ( + + {error.stack} + + )} + + + + ); +} + +export default FatalError; diff --git a/packages/desktop-client/src/components/FinancesApp.tsx b/packages/desktop-client/src/components/FinancesApp.tsx index 76858fdad6a..26f3852e41b 100644 --- a/packages/desktop-client/src/components/FinancesApp.tsx +++ b/packages/desktop-client/src/components/FinancesApp.tsx @@ -21,6 +21,7 @@ import checkForUpdateNotification from 'loot-core/src/client/update-notification import * as undo from 'loot-core/src/platform/client/undo'; import { useActions } from '../hooks/useActions'; +import Add from '../icons/v1/Add'; import Cog from '../icons/v1/Cog'; import PiggyBank from '../icons/v1/PiggyBank'; import Wallet from '../icons/v1/Wallet'; @@ -108,6 +109,7 @@ function MobileNavTabs() { > + ); @@ -264,6 +266,14 @@ function FinancesApp() { } /> + + + + } + /> diff --git a/packages/desktop-client/src/components/Modals.tsx b/packages/desktop-client/src/components/Modals.tsx index 43a8c4c7a0b..9524ef06f67 100644 --- a/packages/desktop-client/src/components/Modals.tsx +++ b/packages/desktop-client/src/components/Modals.tsx @@ -5,6 +5,7 @@ import { send } from 'loot-core/src/platform/client/fetch'; import { useActions } from '../hooks/useActions'; import useSyncServerStatus from '../hooks/useSyncServerStatus'; +import { type CommonModalProps } from '../types/modals'; import BudgetSummary from './modals/BudgetSummary'; import CloseAccount from './modals/CloseAccount'; @@ -43,7 +44,7 @@ export default function Modals() { let modals = modalStack .map(({ name, options }, idx) => { - const modalProps = { + const modalProps: CommonModalProps = { onClose: actions.popModal, onBack: actions.popModal, showBack: idx > 0, diff --git a/packages/desktop-client/src/components/Titlebar.tsx b/packages/desktop-client/src/components/Titlebar.tsx index a98f594c947..afe4b8729e0 100644 --- a/packages/desktop-client/src/components/Titlebar.tsx +++ b/packages/desktop-client/src/components/Titlebar.tsx @@ -149,6 +149,21 @@ export function SyncButton({ style }: SyncButtonProps) { return unlisten; }, []); + const mobileColor = + syncState === 'error' + ? colors.r7 + : syncState === 'disabled' || + syncState === 'offline' || + syncState === 'local' + ? colors.n9 + : style.color; + const activeStyle = css( + // mobile + media(`(max-width: ${tokens.breakpoint_small})`, { + color: mobileColor, + }), + ); + return ( - + + + +