Final PR for react-aria-components Modal migration (#3413)

* Final PR for react-aria-components Modal migration

* Complete manager modals migration

* Release notes

* fix lint

* Apps full height

* Fix lint error

* VRT

* Centralize providers

* Feedback
This commit is contained in:
Joel Jeremy Marquez
2024-09-11 11:42:11 -07:00
committed by GitHub
parent 16944a6140
commit 420aad0878
86 changed files with 1523 additions and 2093 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 503 KiB

After

Width:  |  Height:  |  Size: 494 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 577 KiB

After

Width:  |  Height:  |  Size: 565 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 560 KiB

After

Width:  |  Height:  |  Size: 564 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 567 KiB

After

Width:  |  Height:  |  Size: 565 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 639 KiB

After

Width:  |  Height:  |  Size: 638 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 635 KiB

After

Width:  |  Height:  |  Size: 634 KiB

View File

@@ -1,5 +1,7 @@
// @ts-strict-ignore
import React, { useEffect, useState } from 'react';
import { DndProvider } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend';
import {
ErrorBoundary,
useErrorBoundary,
@@ -7,7 +9,8 @@ import {
} from 'react-error-boundary';
import { HotkeysProvider } from 'react-hotkeys-hook';
import { useTranslation } from 'react-i18next';
import { useDispatch, useSelector } from 'react-redux';
import { useDispatch } from 'react-redux';
import { BrowserRouter } from 'react-router-dom';
import {
closeBudget,
@@ -16,8 +19,8 @@ import {
setAppState,
sync,
} from 'loot-core/client/actions';
import { SpreadsheetProvider } from 'loot-core/client/SpreadsheetProvider';
import * as Platform from 'loot-core/src/client/platform';
import { type State } from 'loot-core/src/client/state-types';
import {
init as initConnection,
send,
@@ -27,25 +30,25 @@ import { useMetadataPref } from '../hooks/useMetadataPref';
import { installPolyfills } from '../polyfills';
import { ResponsiveProvider } from '../ResponsiveProvider';
import { styles, hasHiddenScrollbars, ThemeStyle } from '../style';
import { ExposeNavigate } from '../util/router-tools';
import { AppBackground } from './AppBackground';
import { BudgetMonthCountProvider } from './budget/BudgetMonthCountContext';
import { View } from './common/View';
import { DevelopmentTopBar } from './DevelopmentTopBar';
import { FatalError } from './FatalError';
import { FinancesApp } from './FinancesApp';
import { ManagementApp } from './manager/ManagementApp';
import { Modals } from './Modals';
import { ScrollProvider } from './ScrollProvider';
import { SidebarProvider } from './sidebar/SidebarProvider';
import { UpdateNotification } from './UpdateNotification';
type AppInnerProps = {
budgetId: string;
cloudFileId: string;
};
function AppInner({ budgetId, cloudFileId }: AppInnerProps) {
function AppInner() {
const [budgetId] = useMetadataPref('id');
const [cloudFileId] = useMetadataPref('cloudFileId');
const { t } = useTranslation();
const [initializing, setInitializing] = useState(true);
const { showBoundary: showErrorBoundary } = useErrorBoundary();
const loadingText = useSelector((state: State) => state.app.loadingText);
const dispatch = useDispatch();
async function init() {
@@ -74,9 +77,7 @@ function AppInner({ budgetId, cloudFileId }: AppInnerProps) {
);
const budgetId = await send('get-last-opened-backup');
if (budgetId) {
await dispatch(
loadBudget(budgetId, t('Loading the last budget file...')),
);
await dispatch(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)
@@ -99,12 +100,7 @@ function AppInner({ budgetId, cloudFileId }: AppInnerProps) {
useEffect(() => {
async function initAll() {
await Promise.all([installPolyfills(), init()]);
setInitializing(false);
dispatch(
setAppState({
loadingText: null,
}),
);
dispatch(setAppState({ loadingText: null }));
}
initAll().catch(showErrorBoundary);
@@ -114,21 +110,7 @@ function AppInner({ budgetId, cloudFileId }: AppInnerProps) {
global.Actual.updateAppMenu(budgetId);
}, [budgetId]);
return (
<>
{(initializing || !budgetId) && (
<AppBackground initializing={initializing} loadingText={loadingText} />
)}
{!initializing &&
(budgetId ? (
<FinancesApp />
) : (
<ManagementApp isLoading={loadingText != null} />
))}
<UpdateNotification />
</>
);
return budgetId ? <FinancesApp /> : <ManagementApp />;
}
function ErrorFallback({ error }: FallbackProps) {
@@ -141,8 +123,6 @@ function ErrorFallback({ error }: FallbackProps) {
}
export function App() {
const [budgetId] = useMetadataPref('id');
const [cloudFileId] = useMetadataPref('cloudFileId');
const [hiddenScrollbars, setHiddenScrollbars] = useState(
hasHiddenScrollbars(),
);
@@ -176,29 +156,49 @@ export function App() {
}, [dispatch]);
return (
<HotkeysProvider initiallyActiveScopes={['*']}>
<ResponsiveProvider>
<View
style={{ height: '100%', display: 'flex', flexDirection: 'column' }}
>
<View
key={hiddenScrollbars ? 'hidden-scrollbars' : 'scrollbars'}
style={{
flexGrow: 1,
overflow: 'hidden',
...styles.lightScrollbar,
}}
>
<ErrorBoundary FallbackComponent={ErrorFallback}>
{process.env.REACT_APP_REVIEW_ID && !Platform.isPlaywright && (
<DevelopmentTopBar />
)}
<AppInner budgetId={budgetId} cloudFileId={cloudFileId} />
</ErrorBoundary>
<ThemeStyle />
</View>
</View>
</ResponsiveProvider>
</HotkeysProvider>
<BrowserRouter>
<ExposeNavigate />
<HotkeysProvider initiallyActiveScopes={['*']}>
<ResponsiveProvider>
<SpreadsheetProvider>
<SidebarProvider>
<BudgetMonthCountProvider>
<DndProvider backend={HTML5Backend}>
<ScrollProvider>
<View
style={{
height: '100%',
display: 'flex',
flexDirection: 'column',
}}
>
<View
key={
hiddenScrollbars ? 'hidden-scrollbars' : 'scrollbars'
}
style={{
flexGrow: 1,
overflow: 'hidden',
...styles.lightScrollbar,
}}
>
<ErrorBoundary FallbackComponent={ErrorFallback}>
{process.env.REACT_APP_REVIEW_ID &&
!Platform.isPlaywright && <DevelopmentTopBar />}
<AppInner />
</ErrorBoundary>
<ThemeStyle />
<Modals />
<UpdateNotification />
</View>
</View>
</ScrollProvider>
</DndProvider>
</BudgetMonthCountProvider>
</SidebarProvider>
</SpreadsheetProvider>
</ResponsiveProvider>
</HotkeysProvider>
</BrowserRouter>
);
}

View File

@@ -1,4 +1,5 @@
import React from 'react';
import { useSelector } from 'react-redux';
import { useTransition, animated } from 'react-spring';
import { css } from 'glamor';
@@ -11,14 +12,12 @@ import { Block } from './common/Block';
import { View } from './common/View';
type AppBackgroundProps = {
initializing?: boolean;
loadingText?: string;
isLoading?: boolean;
};
export function AppBackground({
initializing,
loadingText,
}: AppBackgroundProps) {
export function AppBackground({ isLoading }: AppBackgroundProps) {
const loadingText = useSelector(state => state.app.loadingText);
const showLoading = isLoading || loadingText !== null;
const transitions = useTransition(loadingText, {
from: { opacity: 0, transform: 'translateY(-100px)' },
enter: { opacity: 1, transform: 'translateY(0)' },
@@ -30,7 +29,7 @@ export function AppBackground({
<>
<Background />
{(loadingText != null || initializing) &&
{showLoading &&
transitions((style, item) => (
<animated.div key={item} style={style}>
<View

View File

@@ -5,7 +5,7 @@ import { LazyLoadFailedError } from 'loot-core/src/shared/errors';
import { Block } from './common/Block';
import { Button } from './common/Button2';
import { Link } from './common/Link';
import { Modal } from './common/Modal';
import { Modal, ModalHeader } from './common/Modal';
import { Paragraph } from './common/Paragraph';
import { Stack } from './common/Stack';
import { Text } from './common/Text';
@@ -176,7 +176,8 @@ export function FatalError({ error }: FatalErrorProps) {
const isLazyLoadError = error instanceof LazyLoadFailedError;
return (
<Modal isCurrent title={isLazyLoadError ? 'Loading Error' : 'Fatal Error'}>
<Modal name="fatal-error" isDismissable={false}>
<ModalHeader title={isLazyLoadError ? 'Loading Error' : 'Fatal Error'} />
<View
style={{
maxWidth: 500,

View File

@@ -1,47 +1,38 @@
// @ts-strict-ignore
import React, { type ReactElement, useEffect, useMemo } from 'react';
import { DndProvider } from 'react-dnd';
import { HTML5Backend as Backend } from 'react-dnd-html5-backend';
import { useSelector } from 'react-redux';
import React, { type ReactElement, useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import {
Route,
Routes,
Navigate,
BrowserRouter,
useLocation,
useHref,
} from 'react-router-dom';
import { SpreadsheetProvider } from 'loot-core/src/client/SpreadsheetProvider';
import { sync } from 'loot-core/client/actions';
import { type State } from 'loot-core/src/client/state-types';
import { checkForUpdateNotification } from 'loot-core/src/client/update-notification';
import * as undo from 'loot-core/src/platform/client/undo';
import { useAccounts } from '../hooks/useAccounts';
import { useActions } from '../hooks/useActions';
import { useNavigate } from '../hooks/useNavigate';
import { useResponsive } from '../ResponsiveProvider';
import { theme } from '../style';
import { ExposeNavigate } from '../util/router-tools';
import { getIsOutdated, getLatestVersion } from '../util/versions';
import { BankSyncStatus } from './BankSyncStatus';
import { BudgetMonthCountProvider } from './budget/BudgetMonthCountContext';
import { View } from './common/View';
import { GlobalKeys } from './GlobalKeys';
import { ManageRulesPage } from './ManageRulesPage';
import { Category } from './mobile/budget/Category';
import { MobileNavTabs } from './mobile/MobileNavTabs';
import { TransactionEdit } from './mobile/transactions/TransactionEdit';
import { Modals } from './Modals';
import { Notifications } from './Notifications';
import { ManagePayeesPage } from './payees/ManagePayeesPage';
import { Reports } from './reports';
import { NarrowAlternate, WideComponent } from './responsive';
import { ScrollProvider } from './ScrollProvider';
import { Settings } from './settings';
import { FloatableSidebar } from './sidebar';
import { SidebarProvider } from './sidebar/SidebarProvider';
import { Titlebar } from './Titlebar';
function NarrowNotSupported({
@@ -95,164 +86,139 @@ function RouterBehaviors() {
return null;
}
function FinancesAppWithoutContext() {
const actions = useActions();
export function FinancesApp() {
const dispatch = useDispatch();
useEffect(() => {
// Wait a little bit to make sure the sync button will get the
// sync start event. This can be improved later.
setTimeout(async () => {
await actions.sync();
await dispatch(sync());
await checkForUpdateNotification(
actions.addNotification,
dispatch,
getIsOutdated,
getLatestVersion,
actions.loadPrefs,
actions.savePrefs,
);
}, 100);
}, []);
return (
<BrowserRouter>
<View style={{ height: '100%' }}>
<RouterBehaviors />
<ExposeNavigate />
<GlobalKeys />
<View style={{ height: '100%' }}>
<GlobalKeys />
<View
style={{
flexDirection: 'row',
backgroundColor: theme.pageBackground,
flex: 1,
}}
>
<FloatableSidebar />
<View
style={{
flexDirection: 'row',
color: theme.pageText,
backgroundColor: theme.pageBackground,
flex: 1,
overflow: 'hidden',
width: '100%',
}}
>
<FloatableSidebar />
<View
style={{
color: theme.pageText,
backgroundColor: theme.pageBackground,
flex: 1,
overflow: 'hidden',
width: '100%',
overflow: 'auto',
position: 'relative',
}}
>
<div
<Titlebar
style={{
flex: 1,
display: 'flex',
overflow: 'auto',
position: 'relative',
WebkitAppRegion: 'drag',
position: 'absolute',
top: 0,
left: 0,
right: 0,
zIndex: 1000,
}}
>
<Titlebar
style={{
WebkitAppRegion: 'drag',
position: 'absolute',
top: 0,
left: 0,
right: 0,
zIndex: 1000,
}}
/>
<Notifications />
<BankSyncStatus />
<Routes>
<Route path="/" element={<Navigate to="/budget" replace />} />
<Route path="/reports/*" element={<Reports />} />
<Route
path="/budget"
element={<NarrowAlternate name="Budget" />}
/>
<Route
path="/schedules"
element={
<NarrowNotSupported>
<WideComponent name="Schedules" />
</NarrowNotSupported>
}
/>
<Route path="/payees" element={<ManagePayeesPage />} />
<Route path="/rules" element={<ManageRulesPage />} />
<Route path="/settings" element={<Settings />} />
<Route
path="/gocardless/link"
element={
<NarrowNotSupported>
<WideComponent name="GoCardlessLink" />
</NarrowNotSupported>
}
/>
<Route
path="/accounts"
element={<NarrowAlternate name="Accounts" />}
/>
<Route
path="/accounts/:id"
element={<NarrowAlternate name="Account" />}
/>
<Route
path="/transactions/:transactionId"
element={
<WideNotSupported>
<TransactionEdit />
</WideNotSupported>
}
/>
<Route
path="/categories/:id"
element={
<WideNotSupported>
<Category />
</WideNotSupported>
}
/>
{/* redirect all other traffic to the budget page */}
<Route path="/*" element={<Navigate to="/budget" replace />} />
</Routes>
<Modals />
</div>
/>
<Notifications />
<BankSyncStatus />
<Routes>
<Route path="/budget" element={<MobileNavTabs />} />
<Route path="/accounts" element={<MobileNavTabs />} />
<Route path="/settings" element={<MobileNavTabs />} />
<Route path="/reports" element={<MobileNavTabs />} />
<Route path="*" element={null} />
<Route path="/" element={<Navigate to="/budget" replace />} />
<Route path="/reports/*" element={<Reports />} />
<Route
path="/budget"
element={<NarrowAlternate name="Budget" />}
/>
<Route
path="/schedules"
element={
<NarrowNotSupported>
<WideComponent name="Schedules" />
</NarrowNotSupported>
}
/>
<Route path="/payees" element={<ManagePayeesPage />} />
<Route path="/rules" element={<ManageRulesPage />} />
<Route path="/settings" element={<Settings />} />
<Route
path="/gocardless/link"
element={
<NarrowNotSupported>
<WideComponent name="GoCardlessLink" />
</NarrowNotSupported>
}
/>
<Route
path="/accounts"
element={<NarrowAlternate name="Accounts" />}
/>
<Route
path="/accounts/:id"
element={<NarrowAlternate name="Account" />}
/>
<Route
path="/transactions/:transactionId"
element={
<WideNotSupported>
<TransactionEdit />
</WideNotSupported>
}
/>
<Route
path="/categories/:id"
element={
<WideNotSupported>
<Category />
</WideNotSupported>
}
/>
{/* redirect all other traffic to the budget page */}
<Route path="/*" element={<Navigate to="/budget" replace />} />
</Routes>
</View>
<Routes>
<Route path="/budget" element={<MobileNavTabs />} />
<Route path="/accounts" element={<MobileNavTabs />} />
<Route path="/settings" element={<MobileNavTabs />} />
<Route path="/reports" element={<MobileNavTabs />} />
<Route path="*" element={null} />
</Routes>
</View>
</View>
</BrowserRouter>
);
}
export function FinancesApp() {
const app = useMemo(() => <FinancesAppWithoutContext />, []);
return (
<SpreadsheetProvider>
<SidebarProvider>
<BudgetMonthCountProvider>
<DndProvider backend={Backend}>
<ScrollProvider>{app}</ScrollProvider>
</DndProvider>
</BudgetMonthCountProvider>
</SidebarProvider>
</SpreadsheetProvider>
</View>
);
}

View File

@@ -4,14 +4,13 @@ import { useDispatch } from 'react-redux';
import { useLocation } from 'react-router-dom';
import { closeModal } from 'loot-core/client/actions';
import { type PopModalAction } from 'loot-core/src/client/state-types/modals';
import { send } from 'loot-core/src/platform/client/fetch';
import * as monthUtils from 'loot-core/src/shared/months';
import { useMetadataPref } from '../hooks/useMetadataPref';
import { useModalState } from '../hooks/useModalState';
import { useSyncServerStatus } from '../hooks/useSyncServerStatus';
import { ModalTitle, ModalHeader } from './common/Modal2';
import { ModalTitle, ModalHeader } from './common/Modal';
import { AccountAutocompleteModal } from './modals/AccountAutocompleteModal';
import { AccountMenuModal } from './modals/AccountMenuModal';
import { BudgetListModal } from './modals/BudgetListModal';
@@ -20,25 +19,30 @@ import { CategoryAutocompleteModal } from './modals/CategoryAutocompleteModal';
import { CategoryGroupMenuModal } from './modals/CategoryGroupMenuModal';
import { CategoryMenuModal } from './modals/CategoryMenuModal';
import { CloseAccountModal } from './modals/CloseAccountModal';
import { ConfirmCategoryDelete } from './modals/ConfirmCategoryDelete';
import { ConfirmTransactionDelete } from './modals/ConfirmTransactionDelete';
import { ConfirmTransactionEdit } from './modals/ConfirmTransactionEdit';
import { ConfirmUnlinkAccount } from './modals/ConfirmUnlinkAccount';
import { ConfirmCategoryDeleteModal } from './modals/ConfirmCategoryDeleteModal';
import { ConfirmTransactionDeleteModal } from './modals/ConfirmTransactionDeleteModal';
import { ConfirmTransactionEditModal } from './modals/ConfirmTransactionEditModal';
import { ConfirmUnlinkAccountModal } from './modals/ConfirmUnlinkAccountModal';
import { CoverModal } from './modals/CoverModal';
import { CreateAccountModal } from './modals/CreateAccountModal';
import { CreateEncryptionKeyModal } from './modals/CreateEncryptionKeyModal';
import { CreateLocalAccountModal } from './modals/CreateLocalAccountModal';
import { EditField } from './modals/EditField';
import { EditRule } from './modals/EditRule';
import { EditFieldModal } from './modals/EditFieldModal';
import { EditRuleModal } from './modals/EditRuleModal';
import { FixEncryptionKeyModal } from './modals/FixEncryptionKeyModal';
import { GoCardlessExternalMsg } from './modals/GoCardlessExternalMsg';
import { GoCardlessInitialise } from './modals/GoCardlessInitialise';
import { GoCardlessExternalMsgModal } from './modals/GoCardlessExternalMsgModal';
import { GoCardlessInitialiseModal } from './modals/GoCardlessInitialiseModal';
import { HoldBufferModal } from './modals/HoldBufferModal';
import { ImportTransactions } from './modals/ImportTransactions';
import { ImportTransactionsModal } from './modals/ImportTransactionsModal';
import { KeyboardShortcutModal } from './modals/KeyboardShortcutModal';
import { LoadBackup } from './modals/LoadBackup';
import { LoadBackupModal } from './modals/LoadBackupModal';
import { DeleteFileModal } from './modals/manager/DeleteFileModal';
import { ImportActualModal } from './modals/manager/ImportActualModal';
import { ImportModal } from './modals/manager/ImportModal';
import { ImportYNAB4Modal } from './modals/manager/ImportYNAB4Modal';
import { ImportYNAB5Modal } from './modals/manager/ImportYNAB5Modal';
import { ManageRulesModal } from './modals/ManageRulesModal';
import { MergeUnusedPayees } from './modals/MergeUnusedPayees';
import { MergeUnusedPayeesModal } from './modals/MergeUnusedPayeesModal';
import { NotesModal } from './modals/NotesModal';
import { PayeeAutocompleteModal } from './modals/PayeeAutocompleteModal';
import { ReportBalanceMenuModal } from './modals/ReportBalanceMenuModal';
@@ -51,8 +55,8 @@ import { RolloverBudgetMonthMenuModal } from './modals/RolloverBudgetMonthMenuMo
import { RolloverBudgetSummaryModal } from './modals/RolloverBudgetSummaryModal';
import { RolloverToBudgetMenuModal } from './modals/RolloverToBudgetMenuModal';
import { ScheduledTransactionMenuModal } from './modals/ScheduledTransactionMenuModal';
import { SelectLinkedAccounts } from './modals/SelectLinkedAccounts';
import { SimpleFinInitialise } from './modals/SimpleFinInitialise';
import { SelectLinkedAccountsModal } from './modals/SelectLinkedAccountsModal';
import { SimpleFinInitialiseModal } from './modals/SimpleFinInitialiseModal';
import { SingleInputModal } from './modals/SingleInputModal';
import { TransferModal } from './modals/TransferModal';
import { DiscoverSchedules } from './schedules/DiscoverSchedules';
@@ -61,19 +65,11 @@ import { ScheduleDetails } from './schedules/ScheduleDetails';
import { ScheduleLink } from './schedules/ScheduleLink';
import { NamespaceContext } from './spreadsheet/NamespaceContext';
export type CommonModalProps = {
onClose: () => PopModalAction;
onBack: () => PopModalAction;
showBack: boolean;
isCurrent: boolean;
isHidden: boolean;
stackIndex: number;
};
export function Modals() {
const location = useLocation();
const dispatch = useDispatch();
const { modalStack } = useModalState();
const [budgetId] = useMetadataPref('id');
useEffect(() => {
if (modalStack.length > 0) {
@@ -81,22 +77,20 @@ export function Modals() {
}
}, [location]);
const syncServerStatus = useSyncServerStatus();
const modals = modalStack
.map(({ name, options }) => {
switch (name) {
case 'keyboard-shortcuts':
return <KeyboardShortcutModal key={name} />;
// don't show the hotkey help modal when a budget is not open
return budgetId ? <KeyboardShortcutModal key={name} /> : null;
case 'import-transactions':
return <ImportTransactions key={name} options={options} />;
return <ImportTransactionsModal key={name} options={options} />;
case 'add-account':
return (
<CreateAccountModal
key={name}
syncServerStatus={syncServerStatus}
upgradingAccountId={options?.upgradingAccountId}
/>
);
@@ -116,7 +110,7 @@ export function Modals() {
case 'select-linked-accounts':
return (
<SelectLinkedAccounts
<SelectLinkedAccountsModal
key={name}
externalAccounts={options.accounts}
requisitionId={options.requisitionId}
@@ -126,7 +120,7 @@ export function Modals() {
case 'confirm-category-delete':
return (
<ConfirmCategoryDelete
<ConfirmCategoryDeleteModal
key={name}
category={options.category}
group={options.group}
@@ -136,7 +130,7 @@ export function Modals() {
case 'confirm-unlink-account':
return (
<ConfirmUnlinkAccount
<ConfirmUnlinkAccountModal
key={name}
accountName={options.accountName}
onUnlink={options.onUnlink}
@@ -145,7 +139,7 @@ export function Modals() {
case 'confirm-transaction-edit':
return (
<ConfirmTransactionEdit
<ConfirmTransactionEditModal
key={name}
onCancel={options.onCancel}
onConfirm={options.onConfirm}
@@ -155,7 +149,7 @@ export function Modals() {
case 'confirm-transaction-delete':
return (
<ConfirmTransactionDelete
<ConfirmTransactionDeleteModal
key={name}
message={options.message}
onConfirm={options.onConfirm}
@@ -164,7 +158,7 @@ export function Modals() {
case 'load-backup':
return (
<LoadBackup
<LoadBackupModal
key={name}
watchUpdates
budgetId={options.budgetId}
@@ -177,7 +171,7 @@ export function Modals() {
case 'edit-rule':
return (
<EditRule
<EditRuleModal
key={name}
defaultRule={options.rule}
onSave={options.onSave}
@@ -186,7 +180,7 @@ export function Modals() {
case 'merge-unused-payees':
return (
<MergeUnusedPayees
<MergeUnusedPayeesModal
key={name}
payeeIds={options.payeeIds}
targetPayeeId={options.targetPayeeId}
@@ -195,17 +189,23 @@ export function Modals() {
case 'gocardless-init':
return (
<GoCardlessInitialise key={name} onSuccess={options.onSuccess} />
<GoCardlessInitialiseModal
key={name}
onSuccess={options.onSuccess}
/>
);
case 'simplefin-init':
return (
<SimpleFinInitialise key={name} onSuccess={options.onSuccess} />
<SimpleFinInitialiseModal
key={name}
onSuccess={options.onSuccess}
/>
);
case 'gocardless-external-msg':
return (
<GoCardlessExternalMsg
<GoCardlessExternalMsgModal
key={name}
onMoveExternal={options.onMoveExternal}
onClose={() => {
@@ -224,7 +224,7 @@ export function Modals() {
case 'edit-field':
return (
<EditField
<EditFieldModal
key={name}
name={options.name}
onSubmit={options.onSubmit}
@@ -566,10 +566,28 @@ export function Modals() {
case 'budget-list':
return <BudgetListModal key={name} />;
case 'delete-budget':
return <DeleteFileModal key={name} file={options.file} />;
case 'import':
return <ImportModal key={name} />;
case 'import-ynab4':
return <ImportYNAB4Modal key={name} />;
case 'import-ynab5':
return <ImportYNAB5Modal key={name} />;
case 'import-actual':
return <ImportActualModal key={name} />;
case 'manager-load-backup':
return (
<LoadBackupModal
key={name}
budgetId={options.budgetId}
backupDisabled={true}
watchUpdates={false}
/>
);
default:
console.error('Unknown modal:', name);
return null;
throw new Error('Unknown modal');
}
})
.map((modal, idx) => (

View File

@@ -1,13 +1,14 @@
import React, { useRef, useState } from 'react';
import { Trans } from 'react-i18next';
import { useSelector } from 'react-redux';
import { useDispatch, useSelector } from 'react-redux';
import { useParams } from 'react-router-dom';
import { t } from 'i18next';
import { unlinkAccount } from 'loot-core/client/actions';
import { authorizeBank } from '../../gocardless';
import { useAccounts } from '../../hooks/useAccounts';
import { useActions } from '../../hooks/useActions';
import { SvgExclamationOutline } from '../../icons/v1';
import { theme } from '../../style';
import { Button } from '../common/Button2';
@@ -75,8 +76,7 @@ function getErrorMessage(type, code) {
export function AccountSyncCheck() {
const accounts = useAccounts();
const failedAccounts = useSelector(state => state.account.failedAccounts);
const { unlinkAccount, pushModal } = useActions();
const dispatch = useDispatch();
const { id } = useParams();
const [open, setOpen] = useState(false);
const triggerRef = useRef(null);
@@ -99,11 +99,11 @@ export function AccountSyncCheck() {
function reauth() {
setOpen(false);
authorizeBank(pushModal, { upgradingAccountId: account.account_id });
authorizeBank(dispatch, { upgradingAccountId: account.account_id });
}
async function unlink() {
unlinkAccount(account.id);
dispatch(unlinkAccount(account.id));
setOpen(false);
}

View File

@@ -1,19 +1,23 @@
// @ts-strict-ignore
import React, {
useEffect,
useRef,
useLayoutEffect,
type ReactNode,
useState,
type ComponentProps,
type ComponentType,
type ReactNode,
type ComponentPropsWithoutRef,
type ComponentPropsWithRef,
} from 'react';
import {
ModalOverlay as ReactAriaModalOverlay,
Modal as ReactAriaModal,
Dialog,
} from 'react-aria-components';
import { useHotkeysContext } from 'react-hotkeys-hook';
import ReactModal from 'react-modal';
import { AutoTextSize } from 'auto-text-size';
import { css } from 'glamor';
import { useModalState } from '../../hooks/useModalState';
import { AnimatedLoading } from '../../icons/AnimatedLoading';
import { SvgLogo } from '../../icons/logo';
import { SvgDelete } from '../../icons/v0';
@@ -26,243 +30,157 @@ import { Text } from './Text';
import { TextOneLine } from './TextOneLine';
import { View } from './View';
export type ModalProps = {
title?: ReactNode;
isCurrent?: boolean;
isHidden?: boolean;
children: ReactNode | (() => ReactNode);
size?: { width?: CSSProperties['width']; height?: CSSProperties['height'] };
padding?: CSSProperties['padding'];
showHeader?: boolean;
leftHeaderContent?: ReactNode;
CloseButton?: ComponentType<ComponentPropsWithRef<typeof ModalCloseButton>>;
showTitle?: boolean;
showOverlay?: boolean;
loading?: boolean;
type ModalProps = ComponentPropsWithRef<typeof ReactAriaModal> & {
name: string;
isLoading?: boolean;
noAnimation?: boolean;
focusAfterClose?: boolean;
stackIndex?: number;
parent?: HTMLElement;
style?: CSSProperties;
contentStyle?: CSSProperties;
overlayStyle?: CSSProperties;
onClose?: () => void;
containerProps?: {
style?: CSSProperties;
};
};
export const Modal = ({
title,
isCurrent,
isHidden,
size,
padding = 10,
showHeader = true,
leftHeaderContent,
CloseButton: CloseButtonComponent = ModalCloseButton,
showTitle = true,
showOverlay = true,
loading = false,
name,
isLoading = false,
noAnimation = false,
focusAfterClose = true,
stackIndex,
parent,
style,
contentStyle,
overlayStyle,
children,
onClose,
containerProps,
...props
}: ModalProps) => {
const { enableScope, disableScope } = useHotkeysContext();
// This deactivates any key handlers in the "app" scope
const scopeId = `modal-${stackIndex}-${title}`;
useEffect(() => {
enableScope(scopeId);
return () => disableScope(scopeId);
}, [enableScope, disableScope, scopeId]);
enableScope(name);
return () => disableScope(name);
}, [enableScope, disableScope, name]);
const { isHidden, isActive, onClose: closeModal } = useModalState();
const handleOnClose = () => {
closeModal();
onClose?.();
};
return (
<ReactModal
isOpen={true}
onRequestClose={onClose}
shouldCloseOnOverlayClick={true}
shouldFocusAfterRender
shouldReturnFocusAfterClose={focusAfterClose}
appElement={document.querySelector('#root') as HTMLElement}
parentSelector={parent && (() => parent)}
<ReactAriaModalOverlay
data-testid={`${name}-modal`}
isDismissable
defaultOpen={true}
onOpenChange={isOpen => !isOpen && handleOnClose?.()}
style={{
content: {
display: 'flex',
height: 'fit-content',
width: 'fit-content',
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
justifyContent: 'center',
alignItems: 'center',
overflow: 'visible',
border: 0,
fontSize: 14,
backgroundColor: 'transparent',
padding: 0,
pointerEvents: 'auto',
margin: 'auto',
...contentStyle,
},
overlay: {
display: 'flex',
zIndex: 3000,
backgroundColor:
showOverlay && stackIndex === 0 ? 'rgba(0, 0, 0, .1)' : 'none',
pointerEvents: showOverlay ? 'auto' : 'none',
...overlayStyle,
...(parent
? {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
}
: {}),
},
position: 'fixed',
inset: 0,
zIndex: 3000,
overflowY: 'auto',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 14,
backdropFilter: 'blur(1px) brightness(0.9)',
...style,
}}
{...props}
>
<ModalContent
noAnimation={noAnimation}
isCurrent={isCurrent}
size={size}
style={{
flex: 1,
padding,
willChange: 'opacity, transform',
maxWidth: '90vw',
minWidth: '90vw',
maxHeight: '90vh',
minHeight: 0,
borderRadius: 6,
//border: '1px solid ' + theme.modalBorder,
color: theme.pageText,
backgroundColor: theme.modalBackground,
opacity: isHidden ? 0 : 1,
[`@media (min-width: ${tokens.breakpoint_small})`]: {
minWidth: tokens.breakpoint_small,
},
...styles.shadowLarge,
...style,
...styles.lightScrollbar,
}}
>
{showHeader && (
<View
<ReactAriaModal>
{modalProps => (
<Dialog
aria-label="Modal dialog"
className={`${css(styles.lightScrollbar)}`}
style={{
justifyContent: 'center',
alignItems: 'center',
position: 'relative',
height: 60,
outline: 'none', // remove focus outline
}}
>
<View
<ModalContentContainer
noAnimation={noAnimation}
isActive={isActive(name)}
{...containerProps}
style={{
position: 'absolute',
left: 0,
flex: 1,
padding: 10,
willChange: 'opacity, transform',
maxWidth: '90vw',
minWidth: '90vw',
maxHeight: '90vh',
minHeight: 0,
borderRadius: 6,
//border: '1px solid ' + theme.modalBorder,
color: theme.pageText,
backgroundColor: theme.modalBackground,
opacity: isHidden ? 0 : 1,
[`@media (min-width: ${tokens.breakpoint_small})`]: {
minWidth: tokens.breakpoint_small,
},
overflowY: 'auto',
...styles.shadowLarge,
...containerProps?.style,
}}
>
{leftHeaderContent}
</View>
{showTitle && (
<View
style={{
textAlign: 'center',
// We need to force a width for the text-overflow
// ellipses to work because we are aligning center.
width: 'calc(100% - 60px)',
}}
>
{!title ? (
<SvgLogo
width={30}
height={30}
style={{ justifyContent: 'center', alignSelf: 'center' }}
<View style={{ paddingTop: 0, flex: 1, flexShrink: 0 }}>
{typeof children === 'function'
? children(modalProps)
: children}
</View>
{isLoading && (
<View
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: theme.pageBackground,
alignItems: 'center',
justifyContent: 'center',
zIndex: 1000,
}}
>
<AnimatedLoading
style={{ width: 20, height: 20 }}
color={theme.pageText}
/>
) : typeof title === 'string' || typeof title === 'number' ? (
<ModalTitle title={`${title}`} />
) : (
title
)}
</View>
)}
{onClose && (
<View
style={{
position: 'absolute',
right: 0,
}}
>
<CloseButtonComponent onPress={onClose} />
</View>
)}
</View>
</View>
)}
</ModalContentContainer>
</Dialog>
)}
<View style={{ paddingTop: 0, flex: 1 }}>
{typeof children === 'function' ? children() : children}
</View>
{loading && (
<View
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: theme.pageBackground,
alignItems: 'center',
justifyContent: 'center',
zIndex: 1000,
}}
>
<AnimatedLoading
style={{ width: 20, height: 20 }}
color={theme.pageText}
/>
</View>
)}
</ModalContent>
</ReactModal>
</ReactAriaModal>
</ReactAriaModalOverlay>
);
};
type ModalContentProps = {
type ModalContentContainerProps = {
style?: CSSProperties;
size?: ModalProps['size'];
noAnimation?: boolean;
isCurrent?: boolean;
stackIndex?: number;
isActive?: boolean;
children: ReactNode;
};
const ModalContent = ({
const ModalContentContainer = ({
style,
size,
noAnimation,
isCurrent,
stackIndex,
isActive,
children,
}: ModalContentProps) => {
const contentRef = useRef(null);
}: ModalContentContainerProps) => {
const contentRef = useRef<HTMLDivElement>(null);
const mounted = useRef(false);
const rotateFactor = useRef(Math.random() * 10 - 5);
useLayoutEffect(() => {
if (contentRef.current == null) {
if (!contentRef.current) {
return;
}
function setProps() {
if (isCurrent) {
if (!contentRef.current) {
return;
}
if (isActive) {
contentRef.current.style.transform = 'translateY(0px) scale(1)';
contentRef.current.style.pointerEvents = 'auto';
} else {
@@ -273,7 +191,7 @@ const ModalContent = ({
if (!mounted.current) {
if (noAnimation) {
contentRef.current.style.opacity = 1;
contentRef.current.style.opacity = '1';
contentRef.current.style.transform = 'translateY(0px) scale(1)';
setTimeout(() => {
@@ -283,7 +201,7 @@ const ModalContent = ({
}
}, 0);
} else {
contentRef.current.style.opacity = 0;
contentRef.current.style.opacity = '0';
contentRef.current.style.transform = 'translateY(10px) scale(1)';
setTimeout(() => {
@@ -291,7 +209,7 @@ const ModalContent = ({
mounted.current = true;
contentRef.current.style.transition =
'opacity .1s, transform .1s cubic-bezier(.42, 0, .58, 1)';
contentRef.current.style.opacity = 1;
contentRef.current.style.opacity = '1';
setProps();
}
}, 0);
@@ -299,15 +217,14 @@ const ModalContent = ({
} else {
setProps();
}
}, [noAnimation, isCurrent, stackIndex]);
}, [noAnimation, isActive]);
return (
<View
innerRef={contentRef}
style={{
...style,
...(size && { width: size.width, height: size.height }),
...(noAnimation && !isCurrent && { display: 'none' }),
...(noAnimation && !isActive && { display: 'none' }),
}}
>
{children}
@@ -322,18 +239,17 @@ type ModalButtonsProps = {
children: ReactNode;
};
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const ModalButtons = ({
export const ModalButtons = ({
style,
leftContent,
focusButton = false,
children,
}: ModalButtonsProps) => {
const containerRef = useRef(null);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (focusButton && containerRef.current) {
const button = containerRef.current.querySelector(
const button = containerRef.current.querySelector<HTMLButtonElement>(
'button:not([data-hidden])',
);
@@ -359,6 +275,77 @@ const ModalButtons = ({
);
};
type ModalHeaderProps = {
leftContent?: ReactNode;
showLogo?: boolean;
title?: ReactNode;
rightContent?: ReactNode;
};
export function ModalHeader({
leftContent,
showLogo,
title,
rightContent,
}: ModalHeaderProps) {
return (
<View
aria-label="Modal header"
style={{
justifyContent: 'center',
alignItems: 'center',
position: 'relative',
height: 60,
}}
>
<View
style={{
position: 'absolute',
left: 0,
}}
>
{leftContent}
</View>
{(title || showLogo) && (
<View
style={{
textAlign: 'center',
// We need to force a width for the text-overflow
// ellipses to work because we are aligning center.
width: 'calc(100% - 60px)',
}}
>
{showLogo && (
<SvgLogo
width={30}
height={30}
style={{ justifyContent: 'center', alignSelf: 'center' }}
/>
)}
{title &&
(typeof title === 'string' || typeof title === 'number' ? (
<ModalTitle title={`${title}`} />
) : (
title
))}
</View>
)}
{rightContent && (
<View
style={{
position: 'absolute',
right: 0,
}}
>
{rightContent}
</View>
)}
</View>
);
}
type ModalTitleProps = {
title: string;
isEditable?: boolean;
@@ -368,7 +355,7 @@ type ModalTitleProps = {
shrinkOnOverflow?: boolean;
};
function ModalTitle({
export function ModalTitle({
title,
isEditable,
getStyle,
@@ -383,14 +370,14 @@ function ModalTitle({
}
};
const _onTitleUpdate = newTitle => {
const _onTitleUpdate = (newTitle: string) => {
if (newTitle !== title) {
onTitleUpdate?.(newTitle);
}
setIsEditing(false);
};
const inputRef = useRef<HTMLInputElement>();
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (isEditing) {
if (inputRef.current) {
@@ -463,11 +450,11 @@ function ModalTitle({
}
type ModalCloseButtonProps = {
onPress: ComponentProps<typeof Button>['onPress'];
onPress: ComponentPropsWithoutRef<typeof Button>['onPress'];
style?: CSSProperties;
};
function ModalCloseButton({ onPress, style }: ModalCloseButtonProps) {
export function ModalCloseButton({ onPress, style }: ModalCloseButtonProps) {
return (
<Button
variant="bare"

View File

@@ -1,468 +0,0 @@
import React, {
useEffect,
useRef,
useLayoutEffect,
useState,
type ReactNode,
type ComponentPropsWithoutRef,
type ComponentPropsWithRef,
} from 'react';
import {
ModalOverlay as ReactAriaModalOverlay,
Modal as ReactAriaModal,
Dialog,
} from 'react-aria-components';
import { useHotkeysContext } from 'react-hotkeys-hook';
import { AutoTextSize } from 'auto-text-size';
import { css } from 'glamor';
import { useModalState } from '../../hooks/useModalState';
import { AnimatedLoading } from '../../icons/AnimatedLoading';
import { SvgLogo } from '../../icons/logo';
import { SvgDelete } from '../../icons/v0';
import { type CSSProperties, styles, theme } from '../../style';
import { tokens } from '../../tokens';
import { Button } from './Button2';
import { Input } from './Input';
import { Text } from './Text';
import { TextOneLine } from './TextOneLine';
import { View } from './View';
type ModalProps = ComponentPropsWithRef<typeof ReactAriaModal> & {
name: string;
isLoading?: boolean;
noAnimation?: boolean;
style?: CSSProperties;
onClose?: () => void;
containerProps?: {
style?: CSSProperties;
};
};
export const Modal = ({
name,
isLoading = false,
noAnimation = false,
style,
children,
onClose,
containerProps,
...props
}: ModalProps) => {
const { enableScope, disableScope } = useHotkeysContext();
// This deactivates any key handlers in the "app" scope
useEffect(() => {
enableScope(name);
return () => disableScope(name);
}, [enableScope, disableScope, name]);
const { isHidden, isActive, onClose: closeModal } = useModalState();
const handleOnClose = () => {
closeModal();
onClose?.();
};
return (
<ReactAriaModalOverlay
data-testid={`${name}-modal`}
isDismissable
defaultOpen={true}
onOpenChange={isOpen => !isOpen && handleOnClose?.()}
style={{
position: 'fixed',
inset: 0,
zIndex: 3000,
overflowY: 'auto',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 14,
backdropFilter: 'blur(1px) brightness(0.9)',
...style,
}}
{...props}
>
<ReactAriaModal>
{modalProps => (
<Dialog
aria-label="Modal dialog"
className={`${css(styles.lightScrollbar)}`}
style={{
outline: 'none', // remove focus outline
}}
>
<ModalContentContainer
noAnimation={noAnimation}
isActive={isActive(name)}
{...containerProps}
style={{
flex: 1,
padding: 10,
willChange: 'opacity, transform',
maxWidth: '90vw',
minWidth: '90vw',
maxHeight: '90vh',
minHeight: 0,
borderRadius: 6,
//border: '1px solid ' + theme.modalBorder,
color: theme.pageText,
backgroundColor: theme.modalBackground,
opacity: isHidden ? 0 : 1,
[`@media (min-width: ${tokens.breakpoint_small})`]: {
minWidth: tokens.breakpoint_small,
},
overflowY: 'auto',
...styles.shadowLarge,
...containerProps?.style,
}}
>
<View style={{ paddingTop: 0, flex: 1, flexShrink: 0 }}>
{typeof children === 'function'
? children(modalProps)
: children}
</View>
{isLoading && (
<View
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: theme.pageBackground,
alignItems: 'center',
justifyContent: 'center',
zIndex: 1000,
}}
>
<AnimatedLoading
style={{ width: 20, height: 20 }}
color={theme.pageText}
/>
</View>
)}
</ModalContentContainer>
</Dialog>
)}
</ReactAriaModal>
</ReactAriaModalOverlay>
);
};
type ModalContentContainerProps = {
style?: CSSProperties;
noAnimation?: boolean;
isActive?: boolean;
children: ReactNode;
};
const ModalContentContainer = ({
style,
noAnimation,
isActive,
children,
}: ModalContentContainerProps) => {
const contentRef = useRef<HTMLDivElement>(null);
const mounted = useRef(false);
const rotateFactor = useRef(Math.random() * 10 - 5);
useLayoutEffect(() => {
if (!contentRef.current) {
return;
}
function setProps() {
if (!contentRef.current) {
return;
}
if (isActive) {
contentRef.current.style.transform = 'translateY(0px) scale(1)';
contentRef.current.style.pointerEvents = 'auto';
} else {
contentRef.current.style.transform = `translateY(-40px) scale(.95) rotate(${rotateFactor.current}deg)`;
contentRef.current.style.pointerEvents = 'none';
}
}
if (!mounted.current) {
if (noAnimation) {
contentRef.current.style.opacity = '1';
contentRef.current.style.transform = 'translateY(0px) scale(1)';
setTimeout(() => {
if (contentRef.current) {
contentRef.current.style.transition =
'opacity .1s, transform .1s cubic-bezier(.42, 0, .58, 1)';
}
}, 0);
} else {
contentRef.current.style.opacity = '0';
contentRef.current.style.transform = 'translateY(10px) scale(1)';
setTimeout(() => {
if (contentRef.current) {
mounted.current = true;
contentRef.current.style.transition =
'opacity .1s, transform .1s cubic-bezier(.42, 0, .58, 1)';
contentRef.current.style.opacity = '1';
setProps();
}
}, 0);
}
} else {
setProps();
}
}, [noAnimation, isActive]);
return (
<View
innerRef={contentRef}
style={{
...style,
...(noAnimation && !isActive && { display: 'none' }),
}}
>
{children}
</View>
);
};
type ModalButtonsProps = {
style?: CSSProperties;
leftContent?: ReactNode;
focusButton?: boolean;
children: ReactNode;
};
export const ModalButtons = ({
style,
leftContent,
focusButton = false,
children,
}: ModalButtonsProps) => {
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (focusButton && containerRef.current) {
const button = containerRef.current.querySelector<HTMLButtonElement>(
'button:not([data-hidden])',
);
if (button) {
button.focus();
}
}
}, [focusButton]);
return (
<View
innerRef={containerRef}
style={{
flexDirection: 'row',
marginTop: 30,
...style,
}}
>
{leftContent}
<View style={{ flex: 1 }} />
{children}
</View>
);
};
type ModalHeaderProps = {
leftContent?: ReactNode;
showLogo?: boolean;
title?: ReactNode;
rightContent?: ReactNode;
};
export function ModalHeader({
leftContent,
showLogo,
title,
rightContent,
}: ModalHeaderProps) {
return (
<View
aria-label="Modal header"
style={{
justifyContent: 'center',
alignItems: 'center',
position: 'relative',
height: 60,
}}
>
<View
style={{
position: 'absolute',
left: 0,
}}
>
{leftContent}
</View>
{(title || showLogo) && (
<View
style={{
textAlign: 'center',
// We need to force a width for the text-overflow
// ellipses to work because we are aligning center.
width: 'calc(100% - 60px)',
}}
>
{showLogo && (
<SvgLogo
width={30}
height={30}
style={{ justifyContent: 'center', alignSelf: 'center' }}
/>
)}
{title &&
(typeof title === 'string' || typeof title === 'number' ? (
<ModalTitle title={`${title}`} />
) : (
title
))}
</View>
)}
{rightContent && (
<View
style={{
position: 'absolute',
right: 0,
}}
>
{rightContent}
</View>
)}
</View>
);
}
type ModalTitleProps = {
title: string;
isEditable?: boolean;
getStyle?: (isEditing: boolean) => CSSProperties;
onEdit?: (isEditing: boolean) => void;
onTitleUpdate?: (newName: string) => void;
shrinkOnOverflow?: boolean;
};
export function ModalTitle({
title,
isEditable,
getStyle,
onTitleUpdate,
shrinkOnOverflow = false,
}: ModalTitleProps) {
const [isEditing, setIsEditing] = useState(false);
const onTitleClick = () => {
if (isEditable) {
setIsEditing(true);
}
};
const _onTitleUpdate = (newTitle: string) => {
if (newTitle !== title) {
onTitleUpdate?.(newTitle);
}
setIsEditing(false);
};
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (isEditing) {
if (inputRef.current) {
inputRef.current.scrollLeft = 0;
}
}
}, [isEditing]);
const style = getStyle?.(isEditing);
return isEditing ? (
<Input
inputRef={inputRef}
style={{
fontSize: 25,
fontWeight: 700,
textAlign: 'center',
...style,
}}
focused={isEditing}
defaultValue={title}
onUpdate={_onTitleUpdate}
onKeyDown={e => {
if (e.key === 'Enter') {
e.preventDefault();
_onTitleUpdate?.(e.currentTarget.value);
}
}}
/>
) : (
<View
style={{
flexDirection: 'row',
justifyContent: 'center',
alignItems: 'center',
}}
>
{shrinkOnOverflow ? (
<AutoTextSize
as={Text}
minFontSizePx={15}
maxFontSizePx={25}
onClick={onTitleClick}
style={{
fontSize: 25,
fontWeight: 700,
textAlign: 'center',
...(isEditable && styles.underlinedText),
...style,
}}
>
{title}
</AutoTextSize>
) : (
<TextOneLine
onClick={onTitleClick}
style={{
fontSize: 25,
fontWeight: 700,
textAlign: 'center',
...(isEditable && styles.underlinedText),
...style,
}}
>
{title}
</TextOneLine>
)}
</View>
);
}
type ModalCloseButtonProps = {
onClick: ComponentPropsWithoutRef<typeof Button>['onPress'];
style?: CSSProperties;
};
export function ModalCloseButton({ onClick, style }: ModalCloseButtonProps) {
return (
<Button
variant="bare"
onPress={onClick}
style={{ padding: '10px 10px' }}
aria-label="Close"
>
<SvgDelete width={10} style={style} />
</Button>
);
}

View File

@@ -5,7 +5,7 @@ import { type RuleConditionEntity } from 'loot-core/types/models';
import { theme } from '../../style';
import { Text } from '../common/Text';
import { View } from '../common/View';
import { FieldSelect } from '../modals/EditRule';
import { FieldSelect } from '../modals/EditRuleModal';
export function ConditionsOpMenu({
conditionsOp,

View File

@@ -1,23 +1,21 @@
import React from 'react';
import { Modal } from '../common/Modal';
import { Modal, ModalHeader } from '../common/Modal';
import { Paragraph } from '../common/Paragraph';
import { View } from '../common/View';
export function GoCardlessLink() {
window.close();
return (
<Modal isCurrent title="Account sync">
{() => (
<View style={{ maxWidth: 500 }}>
<Paragraph>Please wait...</Paragraph>
<Paragraph>
The window should close automatically. If nothing happened you can
close this window or tab.
</Paragraph>
</View>
)}
<Modal name="gocardless-link" isDismissable={false}>
<ModalHeader title="Account sync" />
<View style={{ maxWidth: 500 }}>
<Paragraph>Please wait...</Paragraph>
<Paragraph>
The window should close automatically. If nothing happened you can
close this window or tab.
</Paragraph>
</View>
</Modal>
);
}

View File

@@ -389,19 +389,19 @@ export function BudgetList({ showHeader = true, quickSwitchMode = false }) {
refresh();
}
const onSelect = (file: File): void => {
const onSelect = async (file: File): Promise<void> => {
const isRemoteFile = file.state === 'remote';
if (!id) {
if (isRemoteFile) {
dispatch(downloadBudget(file.cloudFileId));
await dispatch(downloadBudget(file.cloudFileId));
} else {
dispatch(loadBudget(file.id));
await dispatch(loadBudget(file.id));
}
} else if (!isRemoteFile && file.id !== id) {
dispatch(closeAndLoadBudget(file.id));
await dispatch(closeAndLoadBudget(file.id));
} else if (isRemoteFile) {
dispatch(closeAndDownloadBudget(file.cloudFileId));
await dispatch(closeAndDownloadBudget(file.cloudFileId));
}
};

View File

@@ -1,141 +0,0 @@
import React, { useState } from 'react';
import { type File } from 'loot-core/src/types/file';
import { type BoundActions } from '../../hooks/useActions';
import { theme } from '../../style';
import { ButtonWithLoading } from '../common/Button2';
import { Modal } from '../common/Modal';
import { Text } from '../common/Text';
import { View } from '../common/View';
import { type CommonModalProps } from '../Modals';
type DeleteFileProps = {
modalProps: CommonModalProps;
actions: BoundActions;
file: File;
};
export function DeleteFile({ modalProps, actions, file }: DeleteFileProps) {
// If the state is "broken" that means it was created by another
// user. The current user should be able to delete the local file,
// but not the remote one
const isCloudFile = 'cloudFileId' in file && file.state !== 'broken';
const [loadingState, setLoadingState] = useState<'cloud' | 'local' | null>(
null,
);
return (
<Modal
{...modalProps}
title={'Delete ' + file.name}
showOverlay={false}
onClose={modalProps.onBack}
>
{() => (
<View
style={{
padding: 15,
gap: 15,
paddingTop: 0,
paddingBottom: 25,
maxWidth: 512,
lineHeight: '1.5em',
}}
>
{isCloudFile && (
<>
<Text>
This is a <strong>hosted file</strong> which means it is stored
on your server to make it available for download on any device.
You can delete it from the server, which will also remove it
from all of your devices.
</Text>
<ButtonWithLoading
variant="primary"
isLoading={loadingState === 'cloud'}
style={{
backgroundColor: theme.errorText,
alignSelf: 'center',
border: 0,
padding: '10px 30px',
fontSize: 14,
}}
onPress={async () => {
setLoadingState('cloud');
await actions.deleteBudget(
'id' in file ? file.id : undefined,
file.cloudFileId,
);
setLoadingState(null);
modalProps.onBack();
}}
>
Delete file from all devices
</ButtonWithLoading>
</>
)}
{'id' in file && (
<>
{isCloudFile ? (
<Text>
You can also delete just the local copy. This will remove all
local data and the file will be listed as available for
download.
</Text>
) : (
<Text>
{file.state === 'broken' ? (
<>
This is a <strong>hosted file</strong> but it was created
by another user. You can only delete the local copy.
</>
) : (
<>
This a <strong>local file</strong> which is not stored on
a server.
</>
)}{' '}
Deleting it will remove it and all of its backups permanently.
</Text>
)}
<ButtonWithLoading
variant={isCloudFile ? 'normal' : 'primary'}
isLoading={loadingState === 'local'}
style={{
alignSelf: 'center',
marginTop: 10,
padding: '10px 30px',
fontSize: 14,
...(isCloudFile
? {
color: theme.errorText,
borderColor: theme.errorText,
}
: {
border: 0,
backgroundColor: theme.errorText,
}),
}}
onPress={async () => {
setLoadingState('local');
await actions.deleteBudget(file.id);
setLoadingState(null);
modalProps.onBack();
}}
>
Delete file locally
</ButtonWithLoading>
</>
)}
</View>
)}
</Modal>
);
}

View File

@@ -1,89 +0,0 @@
import React, { useState } from 'react';
import { type BoundActions } from '../../hooks/useActions';
import { styles, theme } from '../../style';
import { Block } from '../common/Block';
import { Button } from '../common/Button2';
import { Modal } from '../common/Modal';
import { Text } from '../common/Text';
import { View } from '../common/View';
import { type CommonModalProps } from '../Modals';
function getErrorMessage(error: 'not-ynab4' | boolean) {
switch (error) {
case 'not-ynab4':
return 'This file is not valid. Please select a .ynab4 file';
default:
return 'An unknown error occurred while importing. Please report this as a new issue on Github.';
}
}
type ImportProps = {
modalProps: CommonModalProps;
actions: BoundActions;
};
export function Import({ modalProps, actions }: ImportProps) {
const [error] = useState(false);
function onSelectType(type: 'ynab4' | 'ynab5' | 'actual') {
switch (type) {
case 'ynab4':
actions.pushModal('import-ynab4');
break;
case 'ynab5':
actions.pushModal('import-ynab5');
break;
case 'actual':
actions.pushModal('import-actual');
break;
default:
}
}
const itemStyle = {
padding: 10,
border: '1px solid ' + theme.tableBorder,
borderRadius: 6,
marginBottom: 10,
display: 'block',
};
return (
<Modal {...modalProps} title="Import From" style={{ width: 400 }}>
{() => (
<View style={{ ...styles.smallText, lineHeight: 1.5 }}>
{error && (
<Block style={{ color: theme.errorText, marginBottom: 15 }}>
{getErrorMessage(error)}
</Block>
)}
<Text style={{ marginBottom: 15 }}>
Select an app to import from, and well guide you through the
process.
</Text>
<Button style={itemStyle} onPress={() => onSelectType('ynab4')}>
<span style={{ fontWeight: 700 }}>YNAB4</span>
<View style={{ color: theme.pageTextLight }}>
The old unsupported desktop app
</View>
</Button>
<Button style={itemStyle} onPress={() => onSelectType('ynab5')}>
<span style={{ fontWeight: 700 }}>nYNAB</span>
<View style={{ color: theme.pageTextLight }}>
<div>The newer web app</div>
</View>
</Button>
<Button style={itemStyle} onPress={() => onSelectType('actual')}>
<span style={{ fontWeight: 700 }}>Actual</span>
<View style={{ color: theme.pageTextLight }}>
<div>Import a file exported from Actual</div>
</View>
</Button>
</View>
)}
</Modal>
);
}

View File

@@ -1,99 +0,0 @@
// @ts-strict-ignore
import React, { useState } from 'react';
import { useDispatch } from 'react-redux';
import { importBudget } from 'loot-core/src/client/actions/budgets';
import { styles, theme } from '../../style';
import { Block } from '../common/Block';
import { ButtonWithLoading } from '../common/Button2';
import { Modal, type ModalProps } from '../common/Modal';
import { Paragraph } from '../common/Paragraph';
import { View } from '../common/View';
function getErrorMessage(error: string): string {
switch (error) {
case 'parse-error':
return 'Unable to parse file. Please select a JSON file exported from nYNAB.';
case 'not-ynab5':
return 'This file is not valid. Please select a JSON file exported from nYNAB.';
case 'not-zip-file':
return 'This file is not valid. Please select an unencrypted archive of Actual data.';
case 'invalid-zip-file':
return 'This archive is not a valid Actual export file.';
case 'invalid-metadata-file':
return 'The metadata file in the given archive is corrupted.';
default:
return 'An unknown error occurred while importing. Please report this as a new issue on Github.';
}
}
type ImportProps = {
modalProps?: ModalProps;
};
export function ImportActual({ modalProps }: ImportProps) {
const dispatch = useDispatch();
const [error, setError] = useState<string | null>(null);
const [importing, setImporting] = useState(false);
async function onImport() {
const res = await window.Actual?.openFileDialog({
properties: ['openFile'],
filters: [{ name: 'actual', extensions: ['zip', 'blob'] }],
});
if (res) {
setImporting(true);
setError(null);
try {
await dispatch(importBudget(res[0], 'actual'));
} catch (err) {
setError(err.message);
} finally {
setImporting(false);
}
}
}
return (
<Modal
{...modalProps}
title="Import from Actual export"
style={{ width: 400 }}
>
{() => (
<View style={{ ...styles.smallText, lineHeight: 1.5, marginTop: 20 }}>
{error && (
<Block style={{ color: theme.errorText, marginBottom: 15 }}>
{getErrorMessage(error)}
</Block>
)}
<View style={{ '& > div': { lineHeight: '1.7em' } }}>
<Paragraph>
You can import data from another Actual account or instance. First
export your data from a different account, and it will give you a
compressed file. This file is a simple zip file that contains the{' '}
<code>db.sqlite</code> and <code>metadata.json</code> files.
</Paragraph>
<Paragraph>
Select one of these compressed files and import it here.
</Paragraph>
<View style={{ alignSelf: 'center' }}>
<ButtonWithLoading
variant="primary"
autoFocus
isLoading={importing}
onPress={onImport}
>
Select file...
</ButtonWithLoading>
</View>
</View>
</View>
)}
</Modal>
);
}

View File

@@ -1,88 +0,0 @@
// @ts-strict-ignore
import React, { useState } from 'react';
import { useDispatch } from 'react-redux';
import { importBudget } from 'loot-core/src/client/actions/budgets';
import { styles, theme } from '../../style';
import { Block } from '../common/Block';
import { ButtonWithLoading } from '../common/Button2';
import { Modal, type ModalProps } from '../common/Modal';
import { Paragraph } from '../common/Paragraph';
import { View } from '../common/View';
function getErrorMessage(error: string): string {
switch (error) {
case 'not-ynab4':
return 'This file is not valid. Please select a compressed ynab4 zip file.';
default:
return 'An unknown error occurred while importing. Please report this as a new issue on Github.';
}
}
type ImportProps = {
modalProps?: ModalProps;
};
export function ImportYNAB4({ modalProps }: ImportProps) {
const dispatch = useDispatch();
const [error, setError] = useState<string | null>(null);
const [importing, setImporting] = useState(false);
async function onImport() {
const res = await window.Actual?.openFileDialog({
properties: ['openFile'],
filters: [{ name: 'ynab', extensions: ['zip'] }],
});
if (res) {
setImporting(true);
setError(null);
try {
await dispatch(importBudget(res[0], 'ynab4'));
} catch (err) {
setError(err.message);
} finally {
setImporting(false);
}
}
}
return (
<Modal {...modalProps} title="Import from YNAB4" style={{ width: 400 }}>
{() => (
<View style={{ ...styles.smallText, lineHeight: 1.5, marginTop: 20 }}>
{error && (
<Block style={{ color: theme.errorText, marginBottom: 15 }}>
{getErrorMessage(error)}
</Block>
)}
<View style={{ alignItems: 'center' }}>
<Paragraph>
To import data from YNAB4, locate where your YNAB4 data is stored.
It is usually in your Documents folder under YNAB. Your data is a
directory inside that with the <code>.ynab4</code> suffix.
</Paragraph>
<Paragraph>
When youve located your data,{' '}
<strong>compress it into a zip file</strong>. On macOS,
right-click the folder and select Compress. On Windows,
right-click and select Send to &rarr; Compressed (zipped)
folder. Upload the zipped folder for importing.
</Paragraph>
<View>
<ButtonWithLoading
variant="primary"
autoFocus
isLoading={importing}
onPress={onImport}
>
Select zip file...
</ButtonWithLoading>
</View>
</View>
</View>
)}
</Modal>
);
}

View File

@@ -1,98 +0,0 @@
// @ts-strict-ignore
import React, { useState } from 'react';
import { useDispatch } from 'react-redux';
import { importBudget } from 'loot-core/src/client/actions/budgets';
import { styles, theme } from '../../style';
import { Block } from '../common/Block';
import { ButtonWithLoading } from '../common/Button2';
import { Link } from '../common/Link';
import { Modal, type ModalProps } from '../common/Modal';
import { Paragraph } from '../common/Paragraph';
import { View } from '../common/View';
function getErrorMessage(error: string): string {
switch (error) {
case 'parse-error':
return 'Unable to parse file. Please select a JSON file exported from nYNAB.';
case 'not-ynab5':
return 'This file is not valid. Please select a JSON file exported from nYNAB.';
default:
return 'An unknown error occurred while importing. Please report this as a new issue on Github.';
}
}
type ImportProps = {
modalProps?: ModalProps;
};
export function ImportYNAB5({ modalProps }: ImportProps) {
const dispatch = useDispatch();
const [error, setError] = useState<string | null>(null);
const [importing, setImporting] = useState(false);
async function onImport() {
const res = await window.Actual?.openFileDialog({
properties: ['openFile'],
filters: [{ name: 'ynab', extensions: ['json'] }],
});
if (res) {
setImporting(true);
setError(null);
try {
await dispatch(importBudget(res[0], 'ynab5'));
} catch (err) {
setError(err.message);
} finally {
setImporting(false);
}
}
}
return (
<Modal {...modalProps} title="Import from nYNAB" style={{ width: 400 }}>
{() => (
<View style={{ ...styles.smallText, lineHeight: 1.5, marginTop: 20 }}>
{error && (
<Block style={{ color: theme.errorText, marginBottom: 15 }}>
{getErrorMessage(error)}
</Block>
)}
<View
style={{ alignItems: 'center', '& > div': { lineHeight: '1.7em' } }}
>
<Paragraph>
<Link
variant="external"
to="https://actualbudget.org/docs/migration/nynab"
>
Read here
</Link>{' '}
for instructions on how to migrate your data from YNAB. You need
to export your data as JSON, and that page explains how to do
that.
</Paragraph>
<Paragraph>
Once you have exported your data, select the file and Actual will
import it. Budgets may not match up exactly because things work
slightly differently, but you should be able to fix up any
problems.
</Paragraph>
<View>
<ButtonWithLoading
variant="primary"
autoFocus
isLoading={importing}
onPress={onImport}
>
Select file...
</ButtonWithLoading>
</View>
</View>
</View>
)}
</Modal>
);
}

View File

@@ -1,11 +1,16 @@
import React, { useEffect } from 'react';
import { useSelector } from 'react-redux';
import { Navigate, BrowserRouter, Route, Routes } from 'react-router-dom';
import { useDispatch, useSelector } from 'react-redux';
import { Navigate, Route, Routes } from 'react-router-dom';
import {
getUserData,
loadAllFiles,
setAppState,
} from 'loot-core/client/actions';
import { useActions } from '../../hooks/useActions';
import { theme } from '../../style';
import { tokens } from '../../tokens';
import { ExposeNavigate } from '../../util/router-tools';
import { AppBackground } from '../AppBackground';
import { Text } from '../common/Text';
import { View } from '../common/View';
import { LoggedInUser } from '../LoggedInUser';
@@ -14,7 +19,6 @@ import { useServerVersion } from '../ServerContext';
import { BudgetList } from './BudgetList';
import { ConfigServer } from './ConfigServer';
import { Modals } from './Modals';
import { ServerURL } from './ServerURL';
import { Bootstrap } from './subscribe/Bootstrap';
import { ChangePassword } from './subscribe/ChangePassword';
@@ -47,164 +51,128 @@ function Version() {
);
}
export function ManagementApp({ isLoading }) {
export function ManagementApp() {
const files = useSelector(state => state.budgets.allFiles);
const isLoading = useSelector(state => state.app.loadingText !== null);
const userData = useSelector(state => state.user.data);
const managerHasInitialized = useSelector(
state => state.app.managerHasInitialized,
);
const { setAppState, getUserData, loadAllFiles } = useActions();
const dispatch = useDispatch();
// runs on mount only
useEffect(() => {
// An action may have been triggered from outside, and we don't
// want to override its loading message so we only show the
// initial loader if there isn't already a message
// Remember: this component is remounted every time the user
// closes a budget. That's why we keep `managerHasInitialized` in
// redux so that it persists across renders. This will show the
// loading spinner on first run, but never again since we'll have
// a cached list of files and can show them
if (!managerHasInitialized) {
if (!isLoading) {
setAppState({ loadingText: '' });
}
}
async function fetchData() {
const userData = await getUserData();
const userData = await dispatch(getUserData());
if (userData) {
await loadAllFiles();
await dispatch(loadAllFiles());
}
// TODO: There is a race condition here. The user could perform an
// action that starts loading in between where `isLoading`
// was captured and this would clear it. We really only want to
// ever clear the initial loading screen, so we need a "loading
// id" of some kind.
setAppState({
managerHasInitialized: true,
...(!isLoading ? { loadingText: null } : null),
});
dispatch(setAppState({ managerHasInitialized: true }));
}
fetchData();
}, []);
if (!managerHasInitialized) {
return null;
}
if (isLoading) {
return null;
}
return (
<BrowserRouter>
<ExposeNavigate />
<View style={{ height: '100%', color: theme.pageText }}>
<View
<View style={{ height: '100%', color: theme.pageText }}>
<AppBackground />
<View
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: 40,
WebkitAppRegion: 'drag',
}}
/>
<View
style={{
position: 'absolute',
bottom: 40,
right: 15,
}}
>
<Notifications
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: 40,
WebkitAppRegion: 'drag',
position: 'relative',
left: 'initial',
right: 'initial',
}}
/>
</View>
{managerHasInitialized && !isLoading && (
<View
style={{
alignItems: 'center',
bottom: 0,
justifyContent: 'center',
left: 0,
padding: 20,
position: 'absolute',
bottom: 40,
right: 15,
right: 0,
top: 0,
}}
>
<Notifications
style={{
position: 'relative',
left: 'initial',
right: 'initial',
}}
/>
</View>
{managerHasInitialized && (
<View
style={{
alignItems: 'center',
bottom: 0,
justifyContent: 'center',
left: 0,
padding: 20,
position: 'absolute',
right: 0,
top: 0,
}}
>
{userData && files ? (
<>
<Routes>
<Route path="/config-server" element={<ConfigServer />} />
<Route path="/change-password" element={<ChangePassword />} />
{files && files.length > 0 ? (
<Route path="/" element={<BudgetList />} />
) : (
<Route path="/" element={<WelcomeScreen />} />
)}
{/* Redirect all other pages to this route */}
<Route path="/*" element={<Navigate to="/" />} />
</Routes>
<View
style={{
position: 'absolute',
top: 0,
right: 0,
padding: '6px 10px',
zIndex: 4000,
}}
>
<Routes>
<Route path="/config-server" element={null} />
<Route
path="/*"
element={
<LoggedInUser
hideIfNoServer
style={{ padding: '4px 7px' }}
/>
}
/>
</Routes>
</View>
</>
) : (
{userData && files ? (
<>
<Routes>
<Route path="/login/:method?" element={<Login />} />
<Route path="/error" element={<Error />} />
<Route path="/config-server" element={<ConfigServer />} />
<Route path="/bootstrap" element={<Bootstrap />} />
{/* Redirect all other pages to this route */}
<Route
path="/*"
element={<Navigate to="/bootstrap" replace />}
/>
</Routes>
)}
</View>
)}
<Routes>
<Route path="/config-server" element={null} />
<Route path="/*" element={<ServerURL />} />
</Routes>
<Version />
</View>
<Modals />
</BrowserRouter>
<Route path="/change-password" element={<ChangePassword />} />
{files && files.length > 0 ? (
<Route path="/" element={<BudgetList />} />
) : (
<Route path="/" element={<WelcomeScreen />} />
)}
{/* Redirect all other pages to this route */}
<Route path="/*" element={<Navigate to="/" />} />
</Routes>
<View
style={{
position: 'absolute',
top: 0,
right: 0,
padding: '6px 10px',
zIndex: 4000,
}}
>
<Routes>
<Route path="/config-server" element={null} />
<Route
path="/*"
element={
<LoggedInUser
hideIfNoServer
style={{ padding: '4px 7px' }}
/>
}
/>
</Routes>
</View>
</>
) : (
<Routes>
<Route path="/login/:method?" element={<Login />} />
<Route path="/error" element={<Error />} />
<Route path="/config-server" element={<ConfigServer />} />
<Route path="/bootstrap" element={<Bootstrap />} />
{/* Redirect all other pages to this route */}
<Route path="/*" element={<Navigate to="/bootstrap" replace />} />
</Routes>
)}
</View>
)}
<Routes>
<Route path="/config-server" element={null} />
<Route path="/*" element={<ServerURL />} />
</Routes>
<Version />
</View>
);
}

View File

@@ -1,94 +0,0 @@
import React from 'react';
import { useSelector } from 'react-redux';
import { useActions } from '../../hooks/useActions';
import { View } from '../common/View';
import { CreateEncryptionKeyModal } from '../modals/CreateEncryptionKeyModal';
import { FixEncryptionKeyModal } from '../modals/FixEncryptionKeyModal';
import { LoadBackup } from '../modals/LoadBackup';
import { DeleteFile } from './DeleteFile';
import { Import } from './Import';
import { ImportActual } from './ImportActual';
import { ImportYNAB4 } from './ImportYNAB4';
import { ImportYNAB5 } from './ImportYNAB5';
export function Modals() {
const modalStack = useSelector(state => state.modals.modalStack);
const isHidden = useSelector(state => state.modals.isHidden);
const actions = useActions();
const stack = modalStack.map(({ name, options = {} }, idx) => {
const modalProps = {
onClose: actions.popModal,
onPush: actions.pushModal,
onBack: actions.popModal,
isCurrent: idx === modalStack.length - 1,
isHidden,
stackIndex: idx,
};
switch (name) {
case 'keyboard-shortcuts':
// don't show the hotkey help modal when a budget is not open
return null;
case 'delete-budget':
return (
<DeleteFile
key={name}
modalProps={modalProps}
actions={actions}
file={options.file}
/>
);
case 'import':
return <Import key={name} modalProps={modalProps} actions={actions} />;
case 'import-ynab4':
return (
<ImportYNAB4 key={name} modalProps={modalProps} actions={actions} />
);
case 'import-ynab5':
return (
<ImportYNAB5 key={name} modalProps={modalProps} actions={actions} />
);
case 'import-actual':
return (
<ImportActual key={name} modalProps={modalProps} actions={actions} />
);
case 'load-backup': {
return (
<LoadBackup
budgetId={options.budgetId}
modalProps={{
...modalProps,
onClose: actions.popModal,
}}
backupDisabled={true}
actions={actions}
/>
);
}
case 'create-encryption-key':
return (
<CreateEncryptionKeyModal
key={name}
modalProps={modalProps}
options={options}
/>
);
case 'fix-encryption-key':
return (
<FixEncryptionKeyModal
key={name}
modalProps={modalProps}
options={options}
/>
);
default:
throw new Error('Unknown modal: ' + name);
}
});
return <View style={{ flex: 1, padding: 50 }}>{stack}</View>;
}

View File

@@ -8,7 +8,7 @@ import {
Modal,
ModalTitle,
ModalHeader,
} from '../common/Modal2';
} from '../common/Modal';
import { View } from '../common/View';
import { SectionLabel } from '../forms';
@@ -50,7 +50,7 @@ export function AccountAutocompleteModal({
}
rightContent={
<ModalCloseButton
onClick={close}
onPress={close}
style={{ color: theme.menuAutoCompleteText }}
/>
}

View File

@@ -14,7 +14,7 @@ import {
ModalCloseButton,
ModalHeader,
ModalTitle,
} from '../common/Modal2';
} from '../common/Modal';
import { Popover } from '../common/Popover';
import { View } from '../common/View';
import { Notes } from '../Notes';
@@ -99,7 +99,7 @@ export function AccountMenuModal({
onTitleUpdate={onRename}
/>
}
rightContent={<ModalCloseButton onClick={close} />}
rightContent={<ModalCloseButton onPress={close} />}
/>
<View
style={{

View File

@@ -2,7 +2,7 @@ import React from 'react';
import { useSelector } from 'react-redux';
import { useMetadataPref } from '../../hooks/useMetadataPref';
import { Modal, ModalHeader, ModalCloseButton } from '../common/Modal2';
import { Modal, ModalHeader, ModalCloseButton } from '../common/Modal';
import { Text } from '../common/Text';
import { View } from '../common/View';
import { BudgetList } from '../manager/BudgetList';
@@ -19,7 +19,7 @@ export function BudgetListModal() {
<>
<ModalHeader
title="Switch Budget File"
rightContent={<ModalCloseButton onClick={close} />}
rightContent={<ModalCloseButton onPress={close} />}
/>
<View
style={{

View File

@@ -3,7 +3,7 @@ import React, { type ComponentPropsWithoutRef } from 'react';
import { useLocalPref } from '../../hooks/useLocalPref';
import { type CSSProperties, theme, styles } from '../../style';
import { Menu } from '../common/Menu';
import { Modal, ModalCloseButton, ModalHeader } from '../common/Modal2';
import { Modal, ModalCloseButton, ModalHeader } from '../common/Modal';
type BudgetPageMenuModalProps = ComponentPropsWithoutRef<typeof BudgetPageMenu>;
@@ -25,7 +25,7 @@ export function BudgetPageMenuModal({
<>
<ModalHeader
showLogo
rightContent={<ModalCloseButton onClick={close} />}
rightContent={<ModalCloseButton onPress={close} />}
/>
<BudgetPageMenu
getItemStyle={() => defaultMenuItemStyle}

View File

@@ -10,7 +10,7 @@ import {
Modal,
ModalTitle,
ModalHeader,
} from '../common/Modal2';
} from '../common/Modal';
import { View } from '../common/View';
import { SectionLabel } from '../forms';
import { NamespaceContext } from '../spreadsheet/NamespaceContext';
@@ -56,7 +56,7 @@ export function CategoryAutocompleteModal({
}
rightContent={
<ModalCloseButton
onClick={close}
onPress={close}
style={{ color: theme.menuAutoCompleteText }}
/>
}

View File

@@ -15,7 +15,7 @@ import {
ModalCloseButton,
ModalHeader,
ModalTitle,
} from '../common/Modal2';
} from '../common/Modal';
import { Popover } from '../common/Popover';
import { View } from '../common/View';
import { Notes } from '../Notes';
@@ -106,7 +106,7 @@ export function CategoryGroupMenuModal({
onTitleUpdate={onRename}
/>
}
rightContent={<ModalCloseButton onClick={close} />}
rightContent={<ModalCloseButton onPress={close} />}
/>
<View
style={{

View File

@@ -16,7 +16,7 @@ import {
ModalCloseButton,
ModalHeader,
ModalTitle,
} from '../common/Modal2';
} from '../common/Modal';
import { Popover } from '../common/Popover';
import { View } from '../common/View';
import { Notes } from '../Notes';
@@ -97,7 +97,7 @@ export function CategoryMenuModal({
onTitleUpdate={onRename}
/>
}
rightContent={<ModalCloseButton onClick={close} />}
rightContent={<ModalCloseButton onPress={close} />}
/>
<View
style={{

View File

@@ -20,7 +20,7 @@ import { CategoryAutocomplete } from '../autocomplete/CategoryAutocomplete';
import { Button } from '../common/Button2';
import { FormError } from '../common/FormError';
import { Link } from '../common/Link';
import { Modal, ModalCloseButton, ModalHeader } from '../common/Modal2';
import { Modal, ModalCloseButton, ModalHeader } from '../common/Modal';
import { Paragraph } from '../common/Paragraph';
import { Text } from '../common/Text';
import { View } from '../common/View';
@@ -113,7 +113,7 @@ export function CloseAccountModal({
<>
<ModalHeader
title="Close Account"
rightContent={<ModalCloseButton onClick={close} />}
rightContent={<ModalCloseButton onPress={close} />}
/>
<View>
<Paragraph>

View File

@@ -6,7 +6,7 @@ import { theme } from '../../style';
import { CategoryAutocomplete } from '../autocomplete/CategoryAutocomplete';
import { Block } from '../common/Block';
import { Button } from '../common/Button2';
import { Modal, ModalCloseButton, ModalHeader } from '../common/Modal2';
import { Modal, ModalCloseButton, ModalHeader } from '../common/Modal';
import { Text } from '../common/Text';
import { View } from '../common/View';
@@ -16,7 +16,7 @@ type ConfirmCategoryDeleteProps = {
onDelete: (categoryId: string) => void;
};
export function ConfirmCategoryDelete({
export function ConfirmCategoryDeleteModal({
group: groupId,
category: categoryId,
onDelete,
@@ -61,7 +61,7 @@ export function ConfirmCategoryDelete({
<>
<ModalHeader
title="Confirm Delete"
rightContent={<ModalCloseButton onClick={close} />}
rightContent={<ModalCloseButton onPress={close} />}
/>
<View style={{ lineHeight: 1.5 }}>
{group ? (

View File

@@ -4,7 +4,7 @@ import { useResponsive } from '../../ResponsiveProvider';
import { styles } from '../../style';
import { Button } from '../common/Button2';
import { InitialFocus } from '../common/InitialFocus';
import { Modal, ModalCloseButton, ModalHeader } from '../common/Modal2';
import { Modal, ModalCloseButton, ModalHeader } from '../common/Modal';
import { Paragraph } from '../common/Paragraph';
import { View } from '../common/View';
@@ -13,7 +13,7 @@ type ConfirmTransactionDeleteProps = {
onConfirm: () => void;
};
export function ConfirmTransactionDelete({
export function ConfirmTransactionDeleteModal({
message = 'Are you sure you want to delete the transaction?',
onConfirm,
}: ConfirmTransactionDeleteProps) {
@@ -30,7 +30,7 @@ export function ConfirmTransactionDelete({
<>
<ModalHeader
title="Confirm Delete"
rightContent={<ModalCloseButton onClick={close} />}
rightContent={<ModalCloseButton onPress={close} />}
/>
<View style={{ lineHeight: 1.5 }}>
<Paragraph>{message}</Paragraph>

View File

@@ -4,7 +4,7 @@ import React from 'react';
import { Block } from '../common/Block';
import { Button } from '../common/Button2';
import { InitialFocus } from '../common/InitialFocus';
import { Modal, ModalCloseButton, ModalHeader } from '../common/Modal2';
import { Modal, ModalCloseButton, ModalHeader } from '../common/Modal';
import { View } from '../common/View';
type ConfirmTransactionEditProps = {
@@ -13,7 +13,7 @@ type ConfirmTransactionEditProps = {
confirmReason: string;
};
export function ConfirmTransactionEdit({
export function ConfirmTransactionEditModal({
onCancel,
onConfirm,
confirmReason,
@@ -27,7 +27,7 @@ export function ConfirmTransactionEdit({
<>
<ModalHeader
title="Reconciled Transaction"
rightContent={<ModalCloseButton onClick={close} />}
rightContent={<ModalCloseButton onPress={close} />}
/>
<View style={{ lineHeight: 1.5 }}>
{confirmReason === 'batchDeleteWithReconciled' ? (

View File

@@ -2,7 +2,7 @@ import React from 'react';
import { Button } from '../common/Button2';
import { InitialFocus } from '../common/InitialFocus';
import { Modal, ModalCloseButton, ModalHeader } from '../common/Modal2';
import { Modal, ModalCloseButton, ModalHeader } from '../common/Modal';
import { Paragraph } from '../common/Paragraph';
import { View } from '../common/View';
@@ -11,7 +11,7 @@ type ConfirmUnlinkAccountProps = {
onUnlink: () => void;
};
export function ConfirmUnlinkAccount({
export function ConfirmUnlinkAccountModal({
accountName,
onUnlink,
}: ConfirmUnlinkAccountProps) {
@@ -24,7 +24,7 @@ export function ConfirmUnlinkAccount({
<>
<ModalHeader
title="Confirm Unlink"
rightContent={<ModalCloseButton onClick={close} />}
rightContent={<ModalCloseButton onPress={close} />}
/>
<View style={{ lineHeight: 1.5 }}>
<Paragraph>

View File

@@ -11,7 +11,7 @@ import { useCategories } from '../../hooks/useCategories';
import { styles } from '../../style';
import { addToBeBudgetedGroup } from '../budget/util';
import { Button } from '../common/Button2';
import { Modal, ModalCloseButton, ModalHeader } from '../common/Modal2';
import { Modal, ModalCloseButton, ModalHeader } from '../common/Modal';
import { View } from '../common/View';
import { FieldLabel, TapField } from '../mobile/MobileForms';
@@ -92,7 +92,7 @@ export function CoverModal({
<>
<ModalHeader
title={title}
rightContent={<ModalCloseButton onClick={close} />}
rightContent={<ModalCloseButton onPress={close} />}
/>
<View>
<FieldLabel title="Cover from category:" />

View File

@@ -1,36 +1,34 @@
// @ts-strict-ignore
import React, { useEffect, useRef, useState } from 'react';
import { useDispatch } from 'react-redux';
import { pushModal } from 'loot-core/client/actions';
import { send } from 'loot-core/src/platform/client/fetch';
import { authorizeBank } from '../../gocardless';
import { useActions } from '../../hooks/useActions';
import { useFeatureFlag } from '../../hooks/useFeatureFlag';
import { useGoCardlessStatus } from '../../hooks/useGoCardlessStatus';
import { useSimpleFinStatus } from '../../hooks/useSimpleFinStatus';
import { type SyncServerStatus } from '../../hooks/useSyncServerStatus';
import { useSyncServerStatus } from '../../hooks/useSyncServerStatus';
import { SvgDotsHorizontalTriple } from '../../icons/v1';
import { theme } from '../../style';
import { Button, ButtonWithLoading } from '../common/Button2';
import { InitialFocus } from '../common/InitialFocus';
import { Link } from '../common/Link';
import { Menu } from '../common/Menu';
import { Modal, ModalCloseButton, ModalHeader } from '../common/Modal2';
import { Modal, ModalCloseButton, ModalHeader } from '../common/Modal';
import { Paragraph } from '../common/Paragraph';
import { Popover } from '../common/Popover';
import { Text } from '../common/Text';
import { View } from '../common/View';
type CreateAccountProps = {
syncServerStatus: SyncServerStatus;
upgradingAccountId?: string;
};
export function CreateAccountModal({
syncServerStatus,
upgradingAccountId,
}: CreateAccountProps) {
const actions = useActions();
export function CreateAccountModal({ upgradingAccountId }: CreateAccountProps) {
const syncServerStatus = useSyncServerStatus();
const dispatch = useDispatch();
const [isGoCardlessSetupComplete, setIsGoCardlessSetupComplete] =
useState(null);
const [isSimpleFinSetupComplete, setIsSimpleFinSetupComplete] =
@@ -46,9 +44,9 @@ export function CreateAccountModal({
}
if (upgradingAccountId == null) {
authorizeBank(actions.pushModal);
authorizeBank(dispatch);
} else {
authorizeBank(actions.pushModal, {
authorizeBank(dispatch, {
upgradingAccountId,
});
}
@@ -96,30 +94,38 @@ export function CreateAccountModal({
newAccounts.push(newAccount);
}
actions.pushModal('select-linked-accounts', {
accounts: newAccounts,
syncSource: 'simpleFin',
});
dispatch(
pushModal('select-linked-accounts', {
accounts: newAccounts,
syncSource: 'simpleFin',
}),
);
} catch (err) {
console.error(err);
actions.pushModal('simplefin-init', {
onSuccess: () => setIsSimpleFinSetupComplete(true),
});
dispatch(
pushModal('simplefin-init', {
onSuccess: () => setIsSimpleFinSetupComplete(true),
}),
);
}
setLoadingSimpleFinAccounts(false);
};
const onGoCardlessInit = () => {
actions.pushModal('gocardless-init', {
onSuccess: () => setIsGoCardlessSetupComplete(true),
});
dispatch(
pushModal('gocardless-init', {
onSuccess: () => setIsGoCardlessSetupComplete(true),
}),
);
};
const onSimpleFinInit = () => {
actions.pushModal('simplefin-init', {
onSuccess: () => setIsSimpleFinSetupComplete(true),
});
dispatch(
pushModal('simplefin-init', {
onSuccess: () => setIsSimpleFinSetupComplete(true),
}),
);
};
const onGoCardlessReset = () => {
@@ -153,7 +159,7 @@ export function CreateAccountModal({
};
const onCreateLocalAccount = () => {
actions.pushModal('add-local-account');
dispatch(pushModal('add-local-account'));
};
const { configuredGoCardless } = useGoCardlessStatus();
@@ -182,7 +188,7 @@ export function CreateAccountModal({
<>
<ModalHeader
title={title}
rightContent={<ModalCloseButton onClick={close} />}
rightContent={<ModalCloseButton onPress={close} />}
/>
<View style={{ maxWidth: 500, gap: 30, color: theme.pageText }}>
{upgradingAccountId == null && (

View File

@@ -20,7 +20,7 @@ import {
ModalButtons,
ModalCloseButton,
ModalHeader,
} from '../common/Modal2';
} from '../common/Modal';
import { Paragraph } from '../common/Paragraph';
import { Text } from '../common/Text';
import { View } from '../common/View';
@@ -70,7 +70,7 @@ export function CreateEncryptionKeyModal({
<>
<ModalHeader
title={isRecreating ? 'Generate new key' : 'Enable encryption'}
rightContent={<ModalCloseButton onClick={close} />}
rightContent={<ModalCloseButton onPress={close} />}
/>
<View
style={{

View File

@@ -20,7 +20,7 @@ import {
ModalCloseButton,
ModalHeader,
ModalTitle,
} from '../common/Modal2';
} from '../common/Modal';
import { Text } from '../common/Text';
import { View } from '../common/View';
import { Checkbox } from '../forms';
@@ -60,7 +60,7 @@ export function CreateLocalAccountModal() {
<>
<ModalHeader
title={<ModalTitle title="Create Local Account" shrinkOnOverflow />}
rightContent={<ModalCloseButton onClick={close} />}
rightContent={<ModalCloseButton onPress={close} />}
/>
<View>
<Form onSubmit={onSubmit}>

View File

@@ -10,12 +10,12 @@ import { useResponsive } from '../../ResponsiveProvider';
import { theme } from '../../style';
import { Button } from '../common/Button2';
import { Input } from '../common/Input';
import { Modal, ModalCloseButton, ModalHeader } from '../common/Modal2';
import { Modal, ModalCloseButton, ModalHeader } from '../common/Modal';
import { View } from '../common/View';
import { SectionLabel } from '../forms';
import { DateSelect } from '../select/DateSelect';
export function EditField({ name, onSubmit, onClose }) {
export function EditFieldModal({ name, onSubmit, onClose }) {
const dateFormat = useDateFormat() || 'MM/dd/yyyy';
function onSelectNote(value, mode) {
@@ -233,7 +233,7 @@ export function EditField({ name, onSubmit, onClose }) {
{isNarrowWidth && (
<ModalHeader
title={label}
rightContent={<ModalCloseButton onClick={close} />}
rightContent={<ModalCloseButton onPress={close} />}
/>
)}
<View>

View File

@@ -42,7 +42,7 @@ import { SvgInformationOutline } from '../../icons/v1';
import { styles, theme } from '../../style';
import { Button } from '../common/Button2';
import { Menu } from '../common/Menu';
import { Modal, ModalCloseButton, ModalHeader } from '../common/Modal2';
import { Modal, ModalCloseButton, ModalHeader } from '../common/Modal';
import { Select } from '../common/Select';
import { Stack } from '../common/Stack';
import { Text } from '../common/Text';
@@ -126,7 +126,7 @@ export function OpSelect({
}
return options;
}, [ops, type]);
}, [formatOp, ops, type]);
return (
<View data-testid="op-select">
@@ -294,7 +294,7 @@ function formatAmount(amount) {
function ScheduleDescription({ id }) {
const dateFormat = useDateFormat() || 'MM/dd/yyyy';
const scheduleData = useSchedules({
transform: useCallback(q => q.filter({ id }), []),
transform: useCallback(q => q.filter({ id }), [id]),
});
if (scheduleData == null) {
@@ -705,7 +705,7 @@ const conditionFields = [
['amount-outflow', mapField('amount', { outflow: true })],
]);
export function EditRule({ defaultRule, onSave: originalOnSave }) {
export function EditRuleModal({ defaultRule, onSave: originalOnSave }) {
const [conditions, setConditions] = useState(
defaultRule.conditions.map(parse),
);
@@ -738,7 +738,7 @@ export function EditRule({ defaultRule, onSave: originalOnSave }) {
// Disable undo while this modal is open
setUndoEnabled(false);
return () => setUndoEnabled(true);
}, []);
}, [dispatch]);
useEffect(() => {
// Flash the scrollbar
@@ -950,7 +950,7 @@ export function EditRule({ defaultRule, onSave: originalOnSave }) {
<>
<ModalHeader
title="Rule"
rightContent={<ModalCloseButton onClick={close} />}
rightContent={<ModalCloseButton onPress={close} />}
/>
<View
style={{

View File

@@ -17,7 +17,7 @@ import {
ModalButtons,
ModalCloseButton,
ModalHeader,
} from '../common/Modal2';
} from '../common/Modal';
import { Paragraph } from '../common/Paragraph';
import { Text } from '../common/Text';
import { View } from '../common/View';
@@ -67,7 +67,7 @@ export function FixEncryptionKeyModal({
? 'Unable to decrypt file'
: 'This file is encrypted'
}
rightContent={<ModalCloseButton onClick={close} />}
rightContent={<ModalCloseButton onPress={close} />}
/>
<View
style={{

View File

@@ -16,12 +16,11 @@ import { Error, Warning } from '../alerts';
import { Autocomplete } from '../autocomplete/Autocomplete';
import { Button } from '../common/Button2';
import { Link } from '../common/Link';
import { Modal, ModalCloseButton, ModalHeader } from '../common/Modal2';
import { Modal, ModalCloseButton, ModalHeader } from '../common/Modal';
import { Paragraph } from '../common/Paragraph';
import { View } from '../common/View';
import { FormField, FormLabel } from '../forms';
import { COUNTRY_OPTIONS } from './countries';
import { COUNTRY_OPTIONS } from '../util/countries';
function useAvailableBanks(country: string) {
const [banks, setBanks] = useState<GoCardlessInstitution[]>([]);
@@ -80,7 +79,7 @@ type GoCardlessExternalMsgProps = {
onClose: () => void;
};
export function GoCardlessExternalMsg({
export function GoCardlessExternalMsgModal({
onMoveExternal,
onSuccess,
onClose,
@@ -233,7 +232,7 @@ export function GoCardlessExternalMsg({
<>
<ModalHeader
title="Link Your Bank"
rightContent={<ModalCloseButton onClick={close} />}
rightContent={<ModalCloseButton onPress={close} />}
/>
<View>
<Paragraph style={{ fontSize: 15 }}>

View File

@@ -13,7 +13,7 @@ import {
ModalButtons,
ModalCloseButton,
ModalHeader,
} from '../common/Modal2';
} from '../common/Modal';
import { Text } from '../common/Text';
import { View } from '../common/View';
import { FormField, FormLabel } from '../forms';
@@ -22,7 +22,7 @@ type GoCardlessInitialiseProps = {
onSuccess: () => void;
};
export const GoCardlessInitialise = ({
export const GoCardlessInitialiseModal = ({
onSuccess,
}: GoCardlessInitialiseProps) => {
const [secretId, setSecretId] = useState('');
@@ -60,7 +60,7 @@ export const GoCardlessInitialise = ({
<>
<ModalHeader
title="Set-up GoCardless"
rightContent={<ModalCloseButton onClick={close} />}
rightContent={<ModalCloseButton onPress={close} />}
/>
<View style={{ display: 'flex', gap: 10 }}>
<Text>

View File

@@ -6,7 +6,7 @@ import { styles } from '../../style';
import { useRolloverSheetValue } from '../budget/rollover/RolloverComponents';
import { Button } from '../common/Button2';
import { InitialFocus } from '../common/InitialFocus';
import { Modal, ModalCloseButton, ModalHeader } from '../common/Modal2';
import { Modal, ModalCloseButton, ModalHeader } from '../common/Modal';
import { View } from '../common/View';
import { FieldLabel } from '../mobile/MobileForms';
import { AmountInput } from '../util/AmountInput';
@@ -32,7 +32,7 @@ export function HoldBufferModal({ onSubmit }: HoldBufferModalProps) {
<>
<ModalHeader
title="Hold Buffer"
rightContent={<ModalCloseButton onClick={close} />}
rightContent={<ModalCloseButton onPress={close} />}
/>
<View>
<FieldLabel title="Hold this amount:" />

View File

@@ -16,7 +16,7 @@ import { SvgDownAndRightArrow } from '../../icons/v2';
import { theme, styles } from '../../style';
import { Button, ButtonWithLoading } from '../common/Button2';
import { Input } from '../common/Input';
import { Modal, ModalCloseButton, ModalHeader } from '../common/Modal2';
import { Modal, ModalCloseButton, ModalHeader } from '../common/Modal';
import { Select } from '../common/Select';
import { Stack } from '../common/Stack';
import { Text } from '../common/Text';
@@ -838,7 +838,7 @@ function FieldMappings({
);
}
export function ImportTransactions({ options }) {
export function ImportTransactionsModal({ options }) {
const dateFormat = useDateFormat() || 'MM/dd/yyyy';
const [prefs, savePrefs] = useSyncedPrefs();
const {
@@ -887,90 +887,227 @@ export function ImportTransactions({ options }) {
const [clearOnImport, setClearOnImport] = useState(true);
async function parse(filename, options) {
setLoadingState('parsing');
const getImportPreview = useCallback(
async (
transactions,
filetype,
flipAmount,
fieldMappings,
splitMode,
parseDateFormat,
inOutMode,
outValue,
multiplierAmount,
) => {
const previewTransactions = [];
const filetype = getFileType(filename);
setFilename(filename);
setFileType(filetype);
for (let trans of transactions) {
if (trans.isMatchedTransaction) {
// skip transactions that are matched transaction (existing transaction added to show update changes)
continue;
}
const { errors, transactions: parsedTransactions } =
await parseTransactions(filename, options);
trans = fieldMappings
? applyFieldMappings(trans, fieldMappings)
: trans;
let index = 0;
const transactions = parsedTransactions.map(trans => {
// Add a transient transaction id to match preview with imported transactions
trans.trx_id = index++;
// Select all parsed transactions before first preview run
trans.selected = true;
return trans;
});
const date = isOfxFile(filetype)
? trans.date
: parseDate(trans.date, parseDateFormat);
if (date == null) {
console.log(
`Unable to parse date ${
trans.date || '(empty)'
} with given date format`,
);
break;
}
if (trans.payee == null || !(trans.payee instanceof String)) {
console.log(`Unable·to·parse·payee·${trans.payee || '(empty)'}`);
break;
}
setLoadingState(null);
setError(null);
const { amount } = parseAmountFields(
trans,
splitMode,
inOutMode,
outValue,
flipAmount,
multiplierAmount,
);
if (amount == null) {
console.log(`Transaction on ${trans.date} has no amount`);
break;
}
/// Do fine grained reporting between the old and new OFX importers.
if (errors.length > 0) {
setError({
parsed: true,
message: errors[0].message || 'Internal error',
});
} else {
let flipAmount = false;
let fieldMappings = null;
let splitMode = false;
let parseDateFormat = null;
const category_id = parseCategoryFields(trans, categories.list);
if (category_id != null) {
trans.category = category_id;
}
if (filetype === 'csv' || filetype === 'qif') {
flipAmount =
String(prefs[`flip-amount-${accountId}-${filetype}`]) === 'true';
setFlipAmount(flipAmount);
const {
inflow,
outflow,
inOut,
existing,
ignored,
selected,
selected_merge,
...finalTransaction
} = trans;
previewTransactions.push({
...finalTransaction,
date,
amount: amountToInteger(amount),
cleared: clearOnImport,
});
}
if (filetype === 'csv') {
let mappings = prefs[`csv-mappings-${accountId}`];
mappings = mappings
? JSON.parse(mappings)
: getInitialMappings(transactions);
fieldMappings = mappings;
setFieldMappings(mappings);
// Set initial split mode based on any saved mapping
splitMode = !!(mappings.outflow || mappings.inflow);
setSplitMode(splitMode);
parseDateFormat =
prefs[`parse-date-${accountId}-${filetype}`] ||
getInitialDateFormat(transactions, mappings);
setParseDateFormat(parseDateFormat);
} else if (filetype === 'qif') {
parseDateFormat =
prefs[`parse-date-${accountId}-${filetype}`] ||
getInitialDateFormat(transactions, { date: 'date' });
setParseDateFormat(parseDateFormat);
} else {
setFieldMappings(null);
setParseDateFormat(null);
}
// Reverse the transactions because it's very common for them to
// be ordered ascending, but we show transactions descending by
// date. This is purely cosmetic.
const transactionPreview = await getImportPreview(
transactions.reverse(),
filetype,
flipAmount,
fieldMappings,
splitMode,
parseDateFormat,
inOutMode,
outValue,
multiplierAmount,
// Retreive the transactions that would be updated (along with the existing trx)
const previewTrx = await importPreviewTransactions(
accountId,
previewTransactions,
);
setTransactions(transactionPreview);
}
}
const matchedUpdateMap = previewTrx.reduce((map, entry) => {
map[entry.transaction.trx_id] = entry;
return map;
}, {});
return transactions
.filter(trans => !trans.isMatchedTransaction)
.reduce((previous, current_trx) => {
let next = previous;
const entry = matchedUpdateMap[current_trx.trx_id];
const existing_trx = entry?.existing;
// if the transaction is matched with an existing one for update
current_trx.existing = !!existing_trx;
// if the transaction is an update that will be ignored
// (reconciled transactions or no change detected)
current_trx.ignored = entry?.ignored || false;
current_trx.selected = !current_trx.ignored;
current_trx.selected_merge = current_trx.existing;
next = next.concat({ ...current_trx });
if (existing_trx) {
// add the updated existing transaction in the list, with the
// isMatchedTransaction flag to identify it in display and not send it again
existing_trx.isMatchedTransaction = true;
existing_trx.category = categories.list.find(
cat => cat.id === existing_trx.category,
)?.name;
// add parent transaction attribute to mimic behaviour
existing_trx.trx_id = current_trx.trx_id;
existing_trx.existing = current_trx.existing;
existing_trx.selected = current_trx.selected;
existing_trx.selected_merge = current_trx.selected_merge;
next = next.concat({ ...existing_trx });
}
return next;
}, []);
},
[accountId, categories.list, clearOnImport, importPreviewTransactions],
);
const parse = useCallback(
async (filename, options) => {
setLoadingState('parsing');
const filetype = getFileType(filename);
setFilename(filename);
setFileType(filetype);
const { errors, transactions: parsedTransactions } =
await parseTransactions(filename, options);
let index = 0;
const transactions = parsedTransactions.map(trans => {
// Add a transient transaction id to match preview with imported transactions
trans.trx_id = index++;
// Select all parsed transactions before first preview run
trans.selected = true;
return trans;
});
setLoadingState(null);
setError(null);
/// Do fine grained reporting between the old and new OFX importers.
if (errors.length > 0) {
setError({
parsed: true,
message: errors[0].message || 'Internal error',
});
} else {
let flipAmount = false;
let fieldMappings = null;
let splitMode = false;
let parseDateFormat = null;
if (filetype === 'csv' || filetype === 'qif') {
flipAmount =
String(prefs[`flip-amount-${accountId}-${filetype}`]) === 'true';
setFlipAmount(flipAmount);
}
if (filetype === 'csv') {
let mappings = prefs[`csv-mappings-${accountId}`];
mappings = mappings
? JSON.parse(mappings)
: getInitialMappings(transactions);
fieldMappings = mappings;
setFieldMappings(mappings);
// Set initial split mode based on any saved mapping
splitMode = !!(mappings.outflow || mappings.inflow);
setSplitMode(splitMode);
parseDateFormat =
prefs[`parse-date-${accountId}-${filetype}`] ||
getInitialDateFormat(transactions, mappings);
setParseDateFormat(parseDateFormat);
} else if (filetype === 'qif') {
parseDateFormat =
prefs[`parse-date-${accountId}-${filetype}`] ||
getInitialDateFormat(transactions, { date: 'date' });
setParseDateFormat(parseDateFormat);
} else {
setFieldMappings(null);
setParseDateFormat(null);
}
// Reverse the transactions because it's very common for them to
// be ordered ascending, but we show transactions descending by
// date. This is purely cosmetic.
const transactionPreview = await getImportPreview(
transactions.reverse(),
filetype,
flipAmount,
fieldMappings,
splitMode,
parseDateFormat,
inOutMode,
outValue,
multiplierAmount,
);
setTransactions(transactionPreview);
}
},
[
accountId,
getImportPreview,
inOutMode,
multiplierAmount,
outValue,
parseTransactions,
prefs,
],
);
function onMultiplierChange(e) {
const amt = e;
@@ -990,7 +1127,15 @@ export function ImportTransactions({ options }) {
});
parse(options.filename, parseOptions);
}, [parseTransactions, options.filename]);
}, [
parseTransactions,
options.filename,
delimiter,
hasHeaderRow,
skipLines,
fallbackMissingPayeeToMemo,
parse,
]);
function onSplitMode() {
if (fieldMappings == null) {
@@ -1236,6 +1381,7 @@ export function ImportTransactions({ options }) {
);
setTransactions(transactionPreview);
}, [
getImportPreview,
transactions,
filetype,
flipAmount,
@@ -1249,133 +1395,12 @@ export function ImportTransactions({ options }) {
useEffect(() => {
runImportPreviewCallback();
}, [previewTrigger]);
}, [previewTrigger, runImportPreviewCallback]);
function runImportPreview() {
setPreviewTrigger(value => value + 1);
}
async function getImportPreview(
transactions,
filetype,
flipAmount,
fieldMappings,
splitMode,
parseDateFormat,
inOutMode,
outValue,
multiplierAmount,
) {
const previewTransactions = [];
for (let trans of transactions) {
if (trans.isMatchedTransaction) {
// skip transactions that are matched transaction (existing transaction added to show update changes)
continue;
}
trans = fieldMappings ? applyFieldMappings(trans, fieldMappings) : trans;
const date = isOfxFile(filetype)
? trans.date
: parseDate(trans.date, parseDateFormat);
if (date == null) {
console.log(
`Unable to parse date ${
trans.date || '(empty)'
} with given date format`,
);
break;
}
if (trans.payee == null || !(trans.payee instanceof String)) {
console.log(`Unable·to·parse·payee·${trans.payee || '(empty)'}`);
break;
}
const { amount } = parseAmountFields(
trans,
splitMode,
inOutMode,
outValue,
flipAmount,
multiplierAmount,
);
if (amount == null) {
console.log(`Transaction on ${trans.date} has no amount`);
break;
}
const category_id = parseCategoryFields(trans, categories.list);
if (category_id != null) {
trans.category = category_id;
}
const {
inflow,
outflow,
inOut,
existing,
ignored,
selected,
selected_merge,
...finalTransaction
} = trans;
previewTransactions.push({
...finalTransaction,
date,
amount: amountToInteger(amount),
cleared: clearOnImport,
});
}
// Retreive the transactions that would be updated (along with the existing trx)
const previewTrx = await importPreviewTransactions(
accountId,
previewTransactions,
);
const matchedUpdateMap = previewTrx.reduce((map, entry) => {
map[entry.transaction.trx_id] = entry;
return map;
}, {});
return transactions
.filter(trans => !trans.isMatchedTransaction)
.reduce((previous, current_trx) => {
let next = previous;
const entry = matchedUpdateMap[current_trx.trx_id];
const existing_trx = entry?.existing;
// if the transaction is matched with an existing one for update
current_trx.existing = !!existing_trx;
// if the transaction is an update that will be ignored
// (reconciled transactions or no change detected)
current_trx.ignored = entry?.ignored || false;
current_trx.selected = !current_trx.ignored;
current_trx.selected_merge = current_trx.existing;
next = next.concat({ ...current_trx });
if (existing_trx) {
// add the updated existing transaction in the list, with the
// isMatchedTransaction flag to identify it in display and not send it again
existing_trx.isMatchedTransaction = true;
existing_trx.category = categories.list.find(
cat => cat.id === existing_trx.category,
)?.name;
// add parent transaction attribute to mimic behaviour
existing_trx.trx_id = current_trx.trx_id;
existing_trx.existing = current_trx.existing;
existing_trx.selected = current_trx.selected;
existing_trx.selected_merge = current_trx.selected_merge;
next = next.concat({ ...existing_trx });
}
return next;
}, []);
}
const headers = [
{ name: 'Date', width: 200 },
{ name: 'Payee', width: 'flex' },
@@ -1409,7 +1434,7 @@ export function ImportTransactions({ options }) {
'Import transactions' +
(filetype ? ` (${filetype.toUpperCase()})` : '')
}
rightContent={<ModalCloseButton onClick={close} />}
rightContent={<ModalCloseButton onPress={close} />}
/>
{error && !error.parsed && (
<View style={{ alignItems: 'center', marginBottom: 15 }}>

View File

@@ -1,4 +1,4 @@
import { parseDate } from './ImportTransactions';
import { parseDate } from './ImportTransactionsModal';
describe('Import transactions', function () {
describe('date parsing', function () {

View File

@@ -3,7 +3,7 @@ import { useLocation } from 'react-router-dom';
import * as Platform from 'loot-core/src/client/platform';
import { type CSSProperties } from '../../style';
import { Modal, ModalCloseButton, ModalHeader } from '../common/Modal2';
import { Modal, ModalCloseButton, ModalHeader } from '../common/Modal';
import { Text } from '../common/Text';
import { View } from '../common/View';
@@ -159,7 +159,7 @@ export function KeyboardShortcutModal() {
<>
<ModalHeader
title="Keyboard Shortcuts"
rightContent={<ModalCloseButton onClick={close} />}
rightContent={<ModalCloseButton onPress={close} />}
/>
<View
style={{

View File

@@ -8,7 +8,7 @@ import { useMetadataPref } from '../../hooks/useMetadataPref';
import { theme } from '../../style';
import { Block } from '../common/Block';
import { Button } from '../common/Button2';
import { Modal, ModalCloseButton, ModalHeader } from '../common/Modal2';
import { Modal, ModalCloseButton, ModalHeader } from '../common/Modal';
import { Text } from '../common/Text';
import { View } from '../common/View';
import { Row, Cell } from '../table';
@@ -50,7 +50,7 @@ class BackupTable extends Component {
}
}
export function LoadBackup({ budgetId, watchUpdates, backupDisabled }) {
export function LoadBackupModal({ budgetId, watchUpdates, backupDisabled }) {
const dispatch = useDispatch();
const [backups, setBackups] = useState([]);
const [prefsBudgetId] = useMetadataPref('id');
@@ -76,7 +76,7 @@ export function LoadBackup({ budgetId, watchUpdates, backupDisabled }) {
<>
<ModalHeader
title="Load Backup"
rightContent={<ModalCloseButton onClick={close} />}
rightContent={<ModalCloseButton onPress={close} />}
/>
<View style={{ marginBottom: 30 }}>
<View

View File

@@ -4,7 +4,7 @@ import { useLocation } from 'react-router-dom';
import { isNonProductionEnvironment } from 'loot-core/src/shared/environment';
import { Modal, ModalCloseButton, ModalHeader } from '../common/Modal2';
import { Modal, ModalCloseButton, ModalHeader } from '../common/Modal';
import { ManageRules } from '../ManageRules';
type ManageRulesModalProps = {
@@ -28,7 +28,7 @@ export function ManageRulesModal({ payeeId }: ManageRulesModalProps) {
<>
<ModalHeader
title="Rules"
rightContent={<ModalCloseButton onClick={close} />}
rightContent={<ModalCloseButton onPress={close} />}
/>
<ManageRules isModal payeeId={payeeId} setLoading={setLoading} />
</>

View File

@@ -8,14 +8,14 @@ import { usePayees } from '../../hooks/usePayees';
import { theme } from '../../style';
import { Information } from '../alerts';
import { Button } from '../common/Button2';
import { Modal, ModalButtons } from '../common/Modal2';
import { Modal, ModalButtons } from '../common/Modal';
import { Paragraph } from '../common/Paragraph';
import { Text } from '../common/Text';
import { View } from '../common/View';
const highlightStyle = { color: theme.pageTextPositive };
export function MergeUnusedPayees({ payeeIds, targetPayeeId }) {
export function MergeUnusedPayeesModal({ payeeIds, targetPayeeId }) {
const allPayees = usePayees();
const modalStack = useSelector(state => state.modals.modalStack);
const isEditingRule = !!modalStack.find(m => m.name === 'edit-rule');
@@ -31,7 +31,7 @@ export function MergeUnusedPayees({ payeeIds, targetPayeeId }) {
el.scrollTop = top + 1;
el.scrollTop = top;
}
}, [flashRef.current, true]);
}, []);
// We store the orphaned payees into state because when we merge it,
// it will be deleted and this component will automatically

View File

@@ -4,7 +4,7 @@ import React, { useEffect, useState } from 'react';
import { useNotes } from '../../hooks/useNotes';
import { SvgCheck } from '../../icons/v2';
import { Button } from '../common/Button2';
import { Modal, ModalCloseButton, ModalHeader } from '../common/Modal2';
import { Modal, ModalCloseButton, ModalHeader } from '../common/Modal';
import { View } from '../common/View';
import { Notes } from '../Notes';
@@ -37,7 +37,7 @@ export function NotesModal({ id, name, onSave }: NotesModalProps) {
<>
<ModalHeader
title={`Notes: ${name}`}
rightContent={<ModalCloseButton onClick={close} />}
rightContent={<ModalCloseButton onPress={close} />}
/>
<View
style={{

View File

@@ -11,7 +11,7 @@ import {
Modal,
ModalTitle,
ModalHeader,
} from '../common/Modal2';
} from '../common/Modal';
type PayeeAutocompleteModalProps = {
autocompleteProps: ComponentPropsWithoutRef<typeof PayeeAutocomplete>;
@@ -57,7 +57,7 @@ export function PayeeAutocompleteModal({
}
rightContent={
<ModalCloseButton
onClick={close}
onPress={close}
style={{ color: theme.menuAutoCompleteText }}
/>
}

View File

@@ -14,7 +14,7 @@ import {
ModalCloseButton,
ModalHeader,
ModalTitle,
} from '../common/Modal2';
} from '../common/Modal';
import { Text } from '../common/Text';
import { View } from '../common/View';
@@ -43,7 +43,7 @@ export function ReportBalanceMenuModal({
<>
<ModalHeader
title={<ModalTitle title={category.name} shrinkOnOverflow />}
rightContent={<ModalCloseButton onClick={close} />}
rightContent={<ModalCloseButton onPress={close} />}
/>
<View
style={{

View File

@@ -16,7 +16,7 @@ import {
ModalCloseButton,
ModalHeader,
ModalTitle,
} from '../common/Modal2';
} from '../common/Modal';
import { Text } from '../common/Text';
import { View } from '../common/View';
import { FocusableAmountInput } from '../mobile/transactions/FocusableAmountInput';
@@ -64,7 +64,7 @@ export function ReportBudgetMenuModal({
<>
<ModalHeader
title={<ModalTitle title={category.name} shrinkOnOverflow />}
rightContent={<ModalCloseButton onClick={close} />}
rightContent={<ModalCloseButton onPress={close} />}
/>
<View
style={{

View File

@@ -10,7 +10,7 @@ import { SvgNotesPaper } from '../../icons/v2';
import { type CSSProperties, styles, theme } from '../../style';
import { BudgetMonthMenu } from '../budget/report/budgetsummary/BudgetMonthMenu';
import { Button } from '../common/Button2';
import { Modal, ModalCloseButton, ModalHeader } from '../common/Modal2';
import { Modal, ModalCloseButton, ModalHeader } from '../common/Modal';
import { View } from '../common/View';
import { Notes } from '../Notes';
@@ -66,7 +66,7 @@ export function ReportBudgetMonthMenuModal({
<>
<ModalHeader
title={displayMonth}
rightContent={<ModalCloseButton onClick={close} />}
rightContent={<ModalCloseButton onPress={close} />}
/>
<View
style={{

View File

@@ -7,7 +7,7 @@ import { styles } from '../../style';
import { ExpenseTotal } from '../budget/report/budgetsummary/ExpenseTotal';
import { IncomeTotal } from '../budget/report/budgetsummary/IncomeTotal';
import { Saved } from '../budget/report/budgetsummary/Saved';
import { Modal, ModalCloseButton, ModalHeader } from '../common/Modal2';
import { Modal, ModalCloseButton, ModalHeader } from '../common/Modal';
import { Stack } from '../common/Stack';
import { NamespaceContext } from '../spreadsheet/NamespaceContext';
@@ -25,7 +25,7 @@ export function ReportBudgetSummaryModal({
<>
<ModalHeader
title="Budget Summary"
rightContent={<ModalCloseButton onClick={close} />}
rightContent={<ModalCloseButton onPress={close} />}
/>
<NamespaceContext.Provider value={sheetForMonth(month)}>
<Stack

View File

@@ -14,7 +14,7 @@ import {
ModalCloseButton,
ModalHeader,
ModalTitle,
} from '../common/Modal2';
} from '../common/Modal';
import { Text } from '../common/Text';
import { View } from '../common/View';
@@ -47,7 +47,7 @@ export function RolloverBalanceMenuModal({
<>
<ModalHeader
title={<ModalTitle title={category.name} shrinkOnOverflow />}
rightContent={<ModalCloseButton onClick={close} />}
rightContent={<ModalCloseButton onPress={close} />}
/>
<View
style={{

View File

@@ -16,7 +16,7 @@ import {
ModalCloseButton,
ModalHeader,
ModalTitle,
} from '../common/Modal2';
} from '../common/Modal';
import { Text } from '../common/Text';
import { View } from '../common/View';
import { FocusableAmountInput } from '../mobile/transactions/FocusableAmountInput';
@@ -66,7 +66,7 @@ export function RolloverBudgetMenuModal({
<>
<ModalHeader
title={<ModalTitle title={category.name} shrinkOnOverflow />}
rightContent={<ModalCloseButton onClick={close} />}
rightContent={<ModalCloseButton onPress={close} />}
/>
<View
style={{

View File

@@ -10,7 +10,7 @@ import { SvgNotesPaper } from '../../icons/v2';
import { type CSSProperties, styles, theme } from '../../style';
import { BudgetMonthMenu } from '../budget/rollover/budgetsummary/BudgetMonthMenu';
import { Button } from '../common/Button2';
import { Modal, ModalCloseButton, ModalHeader } from '../common/Modal2';
import { Modal, ModalCloseButton, ModalHeader } from '../common/Modal';
import { View } from '../common/View';
import { Notes } from '../Notes';
@@ -66,7 +66,7 @@ export function RolloverBudgetMonthMenuModal({
<>
<ModalHeader
title={displayMonth}
rightContent={<ModalCloseButton onClick={close} />}
rightContent={<ModalCloseButton onPress={close} />}
/>
<View
style={{

View File

@@ -12,7 +12,7 @@ import { styles } from '../../style';
import { ToBudgetAmount } from '../budget/rollover/budgetsummary/ToBudgetAmount';
import { TotalsList } from '../budget/rollover/budgetsummary/TotalsList';
import { useRolloverSheetValue } from '../budget/rollover/RolloverComponents';
import { Modal, ModalCloseButton, ModalHeader } from '../common/Modal2';
import { Modal, ModalCloseButton, ModalHeader } from '../common/Modal';
import { NamespaceContext } from '../spreadsheet/NamespaceContext';
type RolloverBudgetSummaryModalProps = {
@@ -113,7 +113,7 @@ export function RolloverBudgetSummaryModal({
<>
<ModalHeader
title="Budget Summary"
rightContent={<ModalCloseButton onClick={close} />}
rightContent={<ModalCloseButton onPress={close} />}
/>
<NamespaceContext.Provider value={sheetForMonth(month)}>
<TotalsList

View File

@@ -2,7 +2,7 @@ import React, { type ComponentPropsWithoutRef } from 'react';
import { type CSSProperties, theme, styles } from '../../style';
import { ToBudgetMenu } from '../budget/rollover/budgetsummary/ToBudgetMenu';
import { Modal, ModalCloseButton, ModalHeader } from '../common/Modal2';
import { Modal, ModalCloseButton, ModalHeader } from '../common/Modal';
type RolloverToBudgetMenuModalProps = ComponentPropsWithoutRef<
typeof ToBudgetMenu
@@ -27,7 +27,7 @@ export function RolloverToBudgetMenuModal({
<>
<ModalHeader
showLogo
rightContent={<ModalCloseButton onClick={close} />}
rightContent={<ModalCloseButton onPress={close} />}
/>
<ToBudgetMenu
getItemStyle={() => defaultMenuItemStyle}

View File

@@ -11,7 +11,7 @@ import {
ModalCloseButton,
ModalHeader,
ModalTitle,
} from '../common/Modal2';
} from '../common/Modal';
import { Text } from '../common/Text';
import { View } from '../common/View';
@@ -47,7 +47,7 @@ export function ScheduledTransactionMenuModal({
<>
<ModalHeader
title={<ModalTitle title={schedule.name || ''} shrinkOnOverflow />}
rightContent={<ModalCloseButton onClick={close} />}
rightContent={<ModalCloseButton onPress={close} />}
/>
<View
style={{

View File

@@ -11,7 +11,7 @@ import { useAccounts } from '../../hooks/useAccounts';
import { theme } from '../../style';
import { Autocomplete } from '../autocomplete/Autocomplete';
import { Button } from '../common/Button2';
import { Modal, ModalCloseButton, ModalHeader } from '../common/Modal2';
import { Modal, ModalCloseButton, ModalHeader } from '../common/Modal';
import { Text } from '../common/Text';
import { View } from '../common/View';
import { PrivacyFilter } from '../PrivacyFilter';
@@ -23,7 +23,7 @@ const addOffBudgetAccountOption = {
name: 'Create new account (off-budget)',
};
export function SelectLinkedAccounts({
export function SelectLinkedAccountsModal({
requisitionId,
externalAccounts,
syncSource,
@@ -119,7 +119,7 @@ export function SelectLinkedAccounts({
<>
<ModalHeader
title="Link Accounts"
rightContent={<ModalCloseButton onClick={close} />}
rightContent={<ModalCloseButton onPress={close} />}
/>
<Text style={{ marginBottom: 10 }}>
We found the following accounts. Select which ones you want to add:

View File

@@ -12,7 +12,7 @@ import {
ModalButtons,
ModalCloseButton,
ModalHeader,
} from '../common/Modal2';
} from '../common/Modal';
import { Text } from '../common/Text';
import { View } from '../common/View';
import { FormField, FormLabel } from '../forms';
@@ -21,7 +21,7 @@ type SimpleFinInitialiseProps = {
onSuccess: () => void;
};
export const SimpleFinInitialise = ({
export const SimpleFinInitialiseModal = ({
onSuccess,
}: SimpleFinInitialiseProps) => {
const [token, setToken] = useState('');
@@ -52,7 +52,7 @@ export const SimpleFinInitialise = ({
<>
<ModalHeader
title="Set-up SimpleFIN"
rightContent={<ModalCloseButton onClick={close} />}
rightContent={<ModalCloseButton onPress={close} />}
/>
<View style={{ display: 'flex', gap: 10 }}>
<Text>

View File

@@ -11,7 +11,7 @@ import { styles } from '../../style';
import { Button } from '../common/Button2';
import { FormError } from '../common/FormError';
import { InitialFocus } from '../common/InitialFocus';
import { Modal, ModalCloseButton, type ModalHeader } from '../common/Modal2';
import { Modal, ModalCloseButton, type ModalHeader } from '../common/Modal';
import { View } from '../common/View';
import { InputField } from '../mobile/MobileForms';
@@ -51,7 +51,7 @@ export function SingleInputModal({
<Modal name={name}>
{({ state: { close } }) => (
<>
<Header rightContent={<ModalCloseButton onClick={close} />} />
<Header rightContent={<ModalCloseButton onPress={close} />} />
<Form
onSubmit={e => {
_onSubmit(e);

View File

@@ -8,7 +8,7 @@ import { styles } from '../../style';
import { addToBeBudgetedGroup } from '../budget/util';
import { Button } from '../common/Button2';
import { InitialFocus } from '../common/InitialFocus';
import { Modal, ModalCloseButton, ModalHeader } from '../common/Modal2';
import { Modal, ModalCloseButton, ModalHeader } from '../common/Modal';
import { View } from '../common/View';
import { FieldLabel, TapField } from '../mobile/MobileForms';
import { AmountInput } from '../util/AmountInput';
@@ -71,7 +71,7 @@ export function TransferModal({
<>
<ModalHeader
title={title}
rightContent={<ModalCloseButton onClick={close} />}
rightContent={<ModalCloseButton onPress={close} />}
/>
<View>
<View>

View File

@@ -0,0 +1,145 @@
import React, { useState } from 'react';
import { useDispatch } from 'react-redux';
import { deleteBudget } from 'loot-core/client/actions';
import { type File } from 'loot-core/src/types/file';
import { theme } from '../../../style';
import { ButtonWithLoading } from '../../common/Button2';
import { Modal, ModalCloseButton, ModalHeader } from '../../common/Modal';
import { Text } from '../../common/Text';
import { View } from '../../common/View';
type DeleteFileProps = {
file: File;
};
export function DeleteFileModal({ file }: DeleteFileProps) {
// If the state is "broken" that means it was created by another
// user. The current user should be able to delete the local file,
// but not the remote one
const isCloudFile = 'cloudFileId' in file && file.state !== 'broken';
const dispatch = useDispatch();
const [loadingState, setLoadingState] = useState<'cloud' | 'local' | null>(
null,
);
return (
<Modal name="delete-budget">
{({ state: { close } }) => (
<>
<ModalHeader
title={'Delete ' + file.name}
rightContent={<ModalCloseButton onPress={close} />}
/>
<View
style={{
padding: 15,
gap: 15,
paddingTop: 0,
paddingBottom: 25,
maxWidth: 512,
lineHeight: '1.5em',
}}
>
{isCloudFile && (
<>
<Text>
This is a <strong>hosted file</strong> which means it is
stored on your server to make it available for download on any
device. You can delete it from the server, which will also
remove it from all of your devices.
</Text>
<ButtonWithLoading
variant="primary"
isLoading={loadingState === 'cloud'}
style={{
backgroundColor: theme.errorText,
alignSelf: 'center',
border: 0,
padding: '10px 30px',
fontSize: 14,
}}
onPress={async () => {
setLoadingState('cloud');
await dispatch(
deleteBudget(
'id' in file ? file.id : undefined,
file.cloudFileId,
),
);
setLoadingState(null);
close();
}}
>
Delete file from all devices
</ButtonWithLoading>
</>
)}
{'id' in file && (
<>
{isCloudFile ? (
<Text>
You can also delete just the local copy. This will remove
all local data and the file will be listed as available for
download.
</Text>
) : (
<Text>
{file.state === 'broken' ? (
<>
This is a <strong>hosted file</strong> but it was
created by another user. You can only delete the local
copy.
</>
) : (
<>
This a <strong>local file</strong> which is not stored
on a server.
</>
)}{' '}
Deleting it will remove it and all of its backups
permanently.
</Text>
)}
<ButtonWithLoading
variant={isCloudFile ? 'normal' : 'primary'}
isLoading={loadingState === 'local'}
style={{
alignSelf: 'center',
marginTop: 10,
padding: '10px 30px',
fontSize: 14,
...(isCloudFile
? {
color: theme.errorText,
borderColor: theme.errorText,
}
: {
border: 0,
backgroundColor: theme.errorText,
}),
}}
onPress={async () => {
setLoadingState('local');
await dispatch(deleteBudget(file.id));
setLoadingState(null);
close();
}}
>
Delete file locally
</ButtonWithLoading>
</>
)}
</View>
</>
)}
</Modal>
);
}

View File

@@ -0,0 +1,98 @@
// @ts-strict-ignore
import React, { useState } from 'react';
import { useDispatch } from 'react-redux';
import { importBudget } from 'loot-core/src/client/actions/budgets';
import { styles, theme } from '../../../style';
import { Block } from '../../common/Block';
import { ButtonWithLoading } from '../../common/Button2';
import { Modal, ModalCloseButton, ModalHeader } from '../../common/Modal';
import { Paragraph } from '../../common/Paragraph';
import { View } from '../../common/View';
function getErrorMessage(error: string): string {
switch (error) {
case 'parse-error':
return 'Unable to parse file. Please select a JSON file exported from nYNAB.';
case 'not-ynab5':
return 'This file is not valid. Please select a JSON file exported from nYNAB.';
case 'not-zip-file':
return 'This file is not valid. Please select an unencrypted archive of Actual data.';
case 'invalid-zip-file':
return 'This archive is not a valid Actual export file.';
case 'invalid-metadata-file':
return 'The metadata file in the given archive is corrupted.';
default:
return 'An unknown error occurred while importing. Please report this as a new issue on Github.';
}
}
export function ImportActualModal() {
const dispatch = useDispatch();
const [error, setError] = useState<string | null>(null);
const [importing, setImporting] = useState(false);
async function onImport() {
const res = await window.Actual?.openFileDialog({
properties: ['openFile'],
filters: [{ name: 'actual', extensions: ['zip', 'blob'] }],
});
if (res) {
setImporting(true);
setError(null);
try {
await dispatch(importBudget(res[0], 'actual'));
} catch (err) {
setError(err.message);
} finally {
setImporting(false);
}
}
}
return (
<Modal name="import-actual" containerProps={{ style: { width: 400 } }}>
{({ state: { close } }) => (
<>
<ModalHeader
title="Import from Actual export"
rightContent={<ModalCloseButton onPress={close} />}
/>
<View style={{ ...styles.smallText, lineHeight: 1.5, marginTop: 20 }}>
{error && (
<Block style={{ color: theme.errorText, marginBottom: 15 }}>
{getErrorMessage(error)}
</Block>
)}
<View style={{ '& > div': { lineHeight: '1.7em' } }}>
<Paragraph>
You can import data from another Actual account or instance.
First export your data from a different account, and it will
give you a compressed file. This file is a simple zip file that
contains the <code>db.sqlite</code> and{' '}
<code>metadata.json</code> files.
</Paragraph>
<Paragraph>
Select one of these compressed files and import it here.
</Paragraph>
<View style={{ alignSelf: 'center' }}>
<ButtonWithLoading
variant="primary"
autoFocus
isLoading={importing}
onPress={onImport}
>
Select file...
</ButtonWithLoading>
</View>
</View>
</View>
</>
)}
</Modal>
);
}

View File

@@ -0,0 +1,92 @@
import React, { useState } from 'react';
import { useDispatch } from 'react-redux';
import { pushModal } from 'loot-core/client/actions';
import { styles, theme } from '../../../style';
import { Block } from '../../common/Block';
import { Button } from '../../common/Button2';
import { Modal, ModalCloseButton, ModalHeader } from '../../common/Modal';
import { Text } from '../../common/Text';
import { View } from '../../common/View';
function getErrorMessage(error: 'not-ynab4' | boolean) {
switch (error) {
case 'not-ynab4':
return 'This file is not valid. Please select a .ynab4 file';
default:
return 'An unknown error occurred while importing. Please report this as a new issue on Github.';
}
}
export function ImportModal() {
const dispatch = useDispatch();
const [error] = useState(false);
function onSelectType(type: 'ynab4' | 'ynab5' | 'actual') {
switch (type) {
case 'ynab4':
dispatch(pushModal('import-ynab4'));
break;
case 'ynab5':
dispatch(pushModal('import-ynab5'));
break;
case 'actual':
dispatch(pushModal('import-actual'));
break;
default:
}
}
const itemStyle = {
padding: 10,
border: '1px solid ' + theme.tableBorder,
borderRadius: 6,
marginBottom: 10,
display: 'block',
};
return (
<Modal name="import" containerProps={{ style: { width: 400 } }}>
{({ state: { close } }) => (
<>
<ModalHeader
title="Import From"
rightContent={<ModalCloseButton onPress={close} />}
/>
<View style={{ ...styles.smallText, lineHeight: 1.5 }}>
{error && (
<Block style={{ color: theme.errorText, marginBottom: 15 }}>
{getErrorMessage(error)}
</Block>
)}
<Text style={{ marginBottom: 15 }}>
Select an app to import from, and well guide you through the
process.
</Text>
<Button style={itemStyle} onPress={() => onSelectType('ynab4')}>
<span style={{ fontWeight: 700 }}>YNAB4</span>
<View style={{ color: theme.pageTextLight }}>
The old unsupported desktop app
</View>
</Button>
<Button style={itemStyle} onPress={() => onSelectType('ynab5')}>
<span style={{ fontWeight: 700 }}>nYNAB</span>
<View style={{ color: theme.pageTextLight }}>
<div>The newer web app</div>
</View>
</Button>
<Button style={itemStyle} onPress={() => onSelectType('actual')}>
<span style={{ fontWeight: 700 }}>Actual</span>
<View style={{ color: theme.pageTextLight }}>
<div>Import a file exported from Actual</div>
</View>
</Button>
</View>
</>
)}
</Modal>
);
}

View File

@@ -0,0 +1,91 @@
// @ts-strict-ignore
import React, { useState } from 'react';
import { useDispatch } from 'react-redux';
import { importBudget } from 'loot-core/src/client/actions/budgets';
import { styles, theme } from '../../../style';
import { Block } from '../../common/Block';
import { ButtonWithLoading } from '../../common/Button2';
import { Modal, ModalCloseButton, ModalHeader } from '../../common/Modal';
import { Paragraph } from '../../common/Paragraph';
import { View } from '../../common/View';
function getErrorMessage(error: string): string {
switch (error) {
case 'not-ynab4':
return 'This file is not valid. Please select a compressed ynab4 zip file.';
default:
return 'An unknown error occurred while importing. Please report this as a new issue on Github.';
}
}
export function ImportYNAB4Modal() {
const dispatch = useDispatch();
const [error, setError] = useState<string | null>(null);
const [importing, setImporting] = useState(false);
async function onImport() {
const res = await window.Actual?.openFileDialog({
properties: ['openFile'],
filters: [{ name: 'ynab', extensions: ['zip'] }],
});
if (res) {
setImporting(true);
setError(null);
try {
await dispatch(importBudget(res[0], 'ynab4'));
} catch (err) {
setError(err.message);
} finally {
setImporting(false);
}
}
}
return (
<Modal name="import-ynab4" containerProps={{ style: { width: 400 } }}>
{({ state: { close } }) => (
<>
<ModalHeader
title="Import from YNAB4"
rightContent={<ModalCloseButton onPress={close} />}
/>
<View style={{ ...styles.smallText, lineHeight: 1.5, marginTop: 20 }}>
{error && (
<Block style={{ color: theme.errorText, marginBottom: 15 }}>
{getErrorMessage(error)}
</Block>
)}
<View style={{ alignItems: 'center' }}>
<Paragraph>
To import data from YNAB4, locate where your YNAB4 data is
stored. It is usually in your Documents folder under YNAB. Your
data is a directory inside that with the <code>.ynab4</code>{' '}
suffix.
</Paragraph>
<Paragraph>
When youve located your data,{' '}
<strong>compress it into a zip file</strong>. On macOS,
right-click the folder and select Compress. On Windows,
right-click and select Send to &rarr; Compressed (zipped)
folder. Upload the zipped folder for importing.
</Paragraph>
<View>
<ButtonWithLoading
variant="primary"
autoFocus
isLoading={importing}
onPress={onImport}
>
Select zip file...
</ButtonWithLoading>
</View>
</View>
</View>
</>
)}
</Modal>
);
}

View File

@@ -0,0 +1,103 @@
// @ts-strict-ignore
import React, { useState } from 'react';
import { useDispatch } from 'react-redux';
import { importBudget } from 'loot-core/src/client/actions/budgets';
import { styles, theme } from '../../../style';
import { Block } from '../../common/Block';
import { ButtonWithLoading } from '../../common/Button2';
import { Link } from '../../common/Link';
import { Modal, ModalCloseButton, ModalHeader } from '../../common/Modal';
import { Paragraph } from '../../common/Paragraph';
import { View } from '../../common/View';
function getErrorMessage(error: string): string {
switch (error) {
case 'parse-error':
return 'Unable to parse file. Please select a JSON file exported from nYNAB.';
case 'not-ynab5':
return 'This file is not valid. Please select a JSON file exported from nYNAB.';
default:
return 'An unknown error occurred while importing. Please report this as a new issue on Github.';
}
}
export function ImportYNAB5Modal() {
const dispatch = useDispatch();
const [error, setError] = useState<string | null>(null);
const [importing, setImporting] = useState(false);
async function onImport() {
const res = await window.Actual?.openFileDialog({
properties: ['openFile'],
filters: [{ name: 'ynab', extensions: ['json'] }],
});
if (res) {
setImporting(true);
setError(null);
try {
await dispatch(importBudget(res[0], 'ynab5'));
} catch (err) {
setError(err.message);
} finally {
setImporting(false);
}
}
}
return (
<Modal name="import-ynab5" containerProps={{ style: { width: 400 } }}>
{({ state: { close } }) => (
<>
<ModalHeader
title="Import from nYNAB"
rightContent={<ModalCloseButton onPress={close} />}
/>
<View style={{ ...styles.smallText, lineHeight: 1.5, marginTop: 20 }}>
{error && (
<Block style={{ color: theme.errorText, marginBottom: 15 }}>
{getErrorMessage(error)}
</Block>
)}
<View
style={{
alignItems: 'center',
'& > div': { lineHeight: '1.7em' },
}}
>
<Paragraph>
<Link
variant="external"
to="https://actualbudget.org/docs/migration/nynab"
>
Read here
</Link>{' '}
for instructions on how to migrate your data from YNAB. You need
to export your data as JSON, and that page explains how to do
that.
</Paragraph>
<Paragraph>
Once you have exported your data, select the file and Actual
will import it. Budgets may not match up exactly because things
work slightly differently, but you should be able to fix up any
problems.
</Paragraph>
<View>
<ButtonWithLoading
variant="primary"
autoFocus
isLoading={importing}
onPress={onImport}
>
Select file...
</ButtonWithLoading>
</View>
</View>
</View>
</>
)}
</Modal>
);
}

View File

@@ -18,7 +18,7 @@ import {
import { useSendPlatformRequest } from '../../hooks/useSendPlatformRequest';
import { theme } from '../../style';
import { ButtonWithLoading } from '../common/Button2';
import { Modal, ModalCloseButton, ModalHeader } from '../common/Modal2';
import { Modal, ModalCloseButton, ModalHeader } from '../common/Modal';
import { Paragraph } from '../common/Paragraph';
import { Stack } from '../common/Stack';
import { View } from '../common/View';
@@ -195,7 +195,7 @@ export function DiscoverSchedules() {
<>
<ModalHeader
title={t('Found Schedules')}
rightContent={<ModalCloseButton onClick={close} />}
rightContent={<ModalCloseButton onPress={close} />}
/>
<Paragraph>
<Trans>

View File

@@ -9,7 +9,7 @@ import { send } from 'loot-core/src/platform/client/fetch';
import { useFormatList } from '../../hooks/useFormatList';
import { theme } from '../../style';
import { Button } from '../common/Button2';
import { Modal, ModalCloseButton, ModalHeader } from '../common/Modal2';
import { Modal, ModalCloseButton, ModalHeader } from '../common/Modal';
import { Paragraph } from '../common/Paragraph';
import { Stack } from '../common/Stack';
import { Text } from '../common/Text';
@@ -41,7 +41,7 @@ export function PostsOfflineNotification() {
<>
<ModalHeader
title={t('Post transactions?')}
rightContent={<ModalCloseButton onClick={close} />}
rightContent={<ModalCloseButton onPress={close} />}
/>
<Paragraph>
<Text>

View File

@@ -20,12 +20,12 @@ import { AccountAutocomplete } from '../autocomplete/AccountAutocomplete';
import { PayeeAutocomplete } from '../autocomplete/PayeeAutocomplete';
import { Button } from '../common/Button2';
import { InitialFocus } from '../common/InitialFocus';
import { Modal, ModalCloseButton, ModalHeader } from '../common/Modal2';
import { Modal, ModalCloseButton, ModalHeader } from '../common/Modal';
import { Stack } from '../common/Stack';
import { Text } from '../common/Text';
import { View } from '../common/View';
import { FormField, FormLabel, Checkbox } from '../forms';
import { OpSelect } from '../modals/EditRule';
import { OpSelect } from '../modals/EditRuleModal';
import { DateSelect } from '../select/DateSelect';
import { RecurringSchedulePicker } from '../select/RecurringSchedulePicker';
import { SelectedItemsButton } from '../table';
@@ -466,7 +466,7 @@ export function ScheduleDetails({ id, transaction }) {
? t(`Schedule: {{name}}`, { name: payee.name })
: t('Schedule')
}
rightContent={<ModalCloseButton onClick={close} />}
rightContent={<ModalCloseButton onPress={close} />}
/>
<Stack direction="row" style={{ marginTop: 10 }}>
<FormField style={{ flex: 1 }}>

View File

@@ -15,7 +15,7 @@ import {
import { SvgAdd } from '../../icons/v0';
import { Button } from '../common/Button2';
import { InitialFocus } from '../common/InitialFocus';
import { Modal, ModalCloseButton, ModalHeader } from '../common/Modal2';
import { Modal, ModalCloseButton, ModalHeader } from '../common/Modal';
import { Search } from '../common/Search';
import { Text } from '../common/Text';
import { View } from '../common/View';
@@ -80,7 +80,7 @@ export function ScheduleLink({
<>
<ModalHeader
title={t('Link Schedule')}
rightContent={<ModalCloseButton onClick={close} />}
rightContent={<ModalCloseButton onPress={close} />}
/>
<View
style={{

View File

@@ -1,9 +1,10 @@
import type { pushModal as pushModalAction } from 'loot-core/src/client/actions/modals';
import { type Dispatch } from 'loot-core/client/actions/types';
import { pushModal } from 'loot-core/src/client/actions/modals';
import { send } from 'loot-core/src/platform/client/fetch';
import { type GoCardlessToken } from 'loot-core/src/types/models';
function _authorize(
pushModal: typeof pushModalAction,
dispatch: Dispatch,
upgradingAccountId: string | undefined,
{
onSuccess,
@@ -13,34 +14,36 @@ function _authorize(
onClose?: () => void;
},
) {
pushModal('gocardless-external-msg', {
onMoveExternal: async ({ institutionId }) => {
const resp = await send('gocardless-create-web-token', {
upgradingAccountId,
institutionId,
accessValidForDays: 90,
});
dispatch(
pushModal('gocardless-external-msg', {
onMoveExternal: async ({ institutionId }) => {
const resp = await send('gocardless-create-web-token', {
upgradingAccountId,
institutionId,
accessValidForDays: 90,
});
if ('error' in resp) return resp;
const { link, requisitionId } = resp;
window.Actual?.openURLInBrowser(link);
if ('error' in resp) return resp;
const { link, requisitionId } = resp;
window.Actual?.openURLInBrowser(link);
return send('gocardless-poll-web-token', {
upgradingAccountId,
requisitionId,
});
},
return send('gocardless-poll-web-token', {
upgradingAccountId,
requisitionId,
});
},
onClose,
onSuccess,
});
onClose,
onSuccess,
}),
);
}
export async function authorizeBank(
pushModal: typeof pushModalAction,
dispatch: Dispatch,
{ upgradingAccountId }: { upgradingAccountId?: string } = {},
) {
_authorize(pushModal, upgradingAccountId, {
_authorize(dispatch, upgradingAccountId, {
onSuccess: async data => {
pushModal('select-linked-accounts', {
accounts: data.accounts,

View File

@@ -25,13 +25,13 @@ export function useModalState(): ModalState {
const lastModal = modalStack[modalStack.length - 1];
const isActive = useCallback(
(name: string) => {
if (name === lastModal?.name) {
if (!lastModal || name === lastModal.name) {
return true;
}
return false;
},
[lastModal?.name],
[lastModal],
);
return {

View File

@@ -4,7 +4,7 @@ import { type State } from 'loot-core/src/client/state-types';
import { useServerURL } from '../components/ServerContext';
export type SyncServerStatus = 'offline' | 'no-server' | 'online';
type SyncServerStatus = 'offline' | 'no-server' | 'online';
export function useSyncServerStatus(): SyncServerStatus {
const serverUrl = useServerURL();

View File

@@ -14,7 +14,7 @@ export function loadBackup(budgetId, backupId) {
}
await send('backup-load', { id: budgetId, backupId });
dispatch(loadBudget(budgetId));
await dispatch(loadBudget(budgetId));
};
}

View File

@@ -48,9 +48,9 @@ export function loadAllFiles() {
};
}
export function loadBudget(id: string, loadingText = '', options = {}) {
export function loadBudget(id: string, options = {}) {
return async (dispatch: Dispatch) => {
dispatch(setAppState({ loadingText }));
dispatch(setAppState({ loadingText: t('Loading...') }));
// Loading a budget may fail
const { error } = await send('load-budget', { id, ...options });
@@ -77,15 +77,12 @@ export function loadBudget(id: string, loadingText = '', options = {}) {
} else {
alert(message);
}
} else {
dispatch(closeModal());
dispatch(setAppState({ loadingText: null }));
return;
await dispatch(loadPrefs());
}
dispatch(closeModal());
await dispatch(loadPrefs());
dispatch(setAppState({ loadingText: null }));
};
}
@@ -127,7 +124,8 @@ export function createBudget({ testMode = false, demoMode = false } = {}) {
return async (dispatch: Dispatch) => {
dispatch(
setAppState({
loadingText: testMode || demoMode ? t('Making demo...') : '',
loadingText:
testMode || demoMode ? t('Making demo...') : t('Creating budget...'),
}),
);
@@ -180,7 +178,7 @@ export function uploadBudget(id: string) {
export function closeAndLoadBudget(fileId: string) {
return async (dispatch: Dispatch) => {
await dispatch(closeBudget());
dispatch(loadBudget(fileId, t('Loading...')));
await dispatch(loadBudget(fileId));
};
}

View File

@@ -39,7 +39,7 @@ type FinanceModals = {
};
'select-linked-accounts': {
accounts: unknown[];
requisitionId: string;
requisitionId?: string;
upgradingAccountId?: string;
syncSource?: AccountSyncSource;
};

View File

@@ -1,41 +1,45 @@
import { t } from 'i18next';
// @ts-strict-ignore
import { addNotification, loadPrefs, savePrefs } from './actions';
import { type Dispatch } from './actions/types';
export async function checkForUpdateNotification(
addNotification,
getIsOutdated,
getLatestVersion,
loadPrefs,
savePrefs,
dispatch: Dispatch,
getIsOutdated: (latestVersion: string) => Promise<boolean>,
getLatestVersion: () => Promise<string>,
) {
const latestVersion = await getLatestVersion();
const isOutdated = await getIsOutdated(latestVersion);
if (
!isOutdated ||
(await loadPrefs())['flags.updateNotificationShownForVersion'] ===
(await dispatch(loadPrefs()))['flags.updateNotificationShownForVersion'] ===
latestVersion
) {
return;
}
addNotification({
type: 'message',
title: t('A new version of Actual is available!'),
message: t('Version {{latestVersion}} of Actual was recently released.', {
latestVersion,
}),
sticky: true,
id: 'update-notification',
button: {
title: t('Open changelog'),
action: () => {
window.open('https://actualbudget.org/docs/releases');
dispatch(
addNotification({
type: 'message',
title: t('A new version of Actual is available!'),
message: t('Version {{latestVersion}} of Actual was recently released.', {
latestVersion,
}),
sticky: true,
id: 'update-notification',
button: {
title: t('Open changelog'),
action: () => {
window.open('https://actualbudget.org/docs/releases');
},
},
},
onClose: async () => {
await savePrefs({
'flags.updateNotificationShownForVersion': latestVersion,
});
},
});
onClose: () => {
dispatch(
savePrefs({
'flags.updateNotificationShownForVersion': latestVersion,
}),
);
},
}),
);
}

View File

@@ -0,0 +1,6 @@
---
category: Maintenance
authors: [joel-jeremy]
---
Complete migration of all modals to react-aria-components Modal component.