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
|
Before Width: | Height: | Size: 503 KiB After Width: | Height: | Size: 494 KiB |
|
Before Width: | Height: | Size: 577 KiB After Width: | Height: | Size: 565 KiB |
|
Before Width: | Height: | Size: 560 KiB After Width: | Height: | Size: 564 KiB |
|
Before Width: | Height: | Size: 567 KiB After Width: | Height: | Size: 565 KiB |
|
Before Width: | Height: | Size: 639 KiB After Width: | Height: | Size: 638 KiB |
|
Before Width: | Height: | Size: 635 KiB After Width: | Height: | Size: 634 KiB |
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 we’ll 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 you’ve 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 → 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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 }}
|
||||
/>
|
||||
}
|
||||
|
||||
@@ -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={{
|
||||
|
||||
@@ -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={{
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 }}
|
||||
/>
|
||||
}
|
||||
|
||||
@@ -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={{
|
||||
|
||||
@@ -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={{
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 ? (
|
||||
@@ -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>
|
||||
@@ -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' ? (
|
||||
@@ -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>
|
||||
@@ -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:" />
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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={{
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
@@ -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={{
|
||||
@@ -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={{
|
||||
|
||||
@@ -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 }}>
|
||||
@@ -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>
|
||||
@@ -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:" />
|
||||
|
||||
@@ -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 }}>
|
||||
@@ -1,4 +1,4 @@
|
||||
import { parseDate } from './ImportTransactions';
|
||||
import { parseDate } from './ImportTransactionsModal';
|
||||
|
||||
describe('Import transactions', function () {
|
||||
describe('date parsing', function () {
|
||||
@@ -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={{
|
||||
|
||||
@@ -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
|
||||
@@ -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} />
|
||||
</>
|
||||
|
||||
@@ -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
|
||||
@@ -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={{
|
||||
|
||||
@@ -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 }}
|
||||
/>
|
||||
}
|
||||
|
||||
@@ -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={{
|
||||
|
||||
@@ -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={{
|
||||
|
||||
@@ -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={{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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={{
|
||||
|
||||
@@ -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={{
|
||||
|
||||
@@ -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={{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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={{
|
||||
|
||||
@@ -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:
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 we’ll 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>
|
||||
);
|
||||
}
|
||||
@@ -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 you’ve 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 → 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }}>
|
||||
|
||||
@@ -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={{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -14,7 +14,7 @@ export function loadBackup(budgetId, backupId) {
|
||||
}
|
||||
|
||||
await send('backup-load', { id: budgetId, backupId });
|
||||
dispatch(loadBudget(budgetId));
|
||||
await dispatch(loadBudget(budgetId));
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ type FinanceModals = {
|
||||
};
|
||||
'select-linked-accounts': {
|
||||
accounts: unknown[];
|
||||
requisitionId: string;
|
||||
requisitionId?: string;
|
||||
upgradingAccountId?: string;
|
||||
syncSource?: AccountSyncSource;
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
6
upcoming-release-notes/3413.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Maintenance
|
||||
authors: [joel-jeremy]
|
||||
---
|
||||
|
||||
Complete migration of all modals to react-aria-components Modal component.
|
||||