Compare commits
5 Commits
react-aria
...
move-redux
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a61d66f23f | ||
|
|
c1b475777b | ||
|
|
b1a83123f5 | ||
|
|
71eb54e9f3 | ||
|
|
3e2f96a32c |
55
.github/workflows/update-vrt.yml
vendored
@@ -1,55 +0,0 @@
|
||||
name: /update-vrt
|
||||
on:
|
||||
issue_comment:
|
||||
types: [ created ]
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
contents: write
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.issue.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
update-vrt:
|
||||
name: Update VRT
|
||||
runs-on: ubuntu-latest
|
||||
if: |
|
||||
github.event.issue.pull_request &&
|
||||
contains(github.event.comment.body, '/update-vrt')
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.41.1-jammy
|
||||
steps:
|
||||
- name: Get PR branch
|
||||
# Until https://github.com/xt0rted/pull-request-comment-branch/issues/322 is resolved we use the forked version
|
||||
uses: gotson/pull-request-comment-branch@head-repo-owner-dist
|
||||
id: comment-branch
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
repository: ${{ steps.comment-branch.outputs.head_owner }}/${{ steps.comment-branch.outputs.head_repo }}
|
||||
ref: ${{ steps.comment-branch.outputs.head_ref }}
|
||||
- name: Set up environment
|
||||
uses: ./.github/actions/setup
|
||||
- name: Wait for Netlify build to finish
|
||||
id: netlify
|
||||
env:
|
||||
COMMIT_SHA: ${{ steps.comment-branch.outputs.head_sha }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: ./.github/actions/netlify-wait-for-build
|
||||
- name: Run VRT Tests on Netlify URL
|
||||
run: yarn vrt --update-snapshots
|
||||
env:
|
||||
E2E_START_URL: ${{ steps.netlify.outputs.url }}
|
||||
- name: Commit and push changes
|
||||
run: |
|
||||
git config --system --add safe.directory "*"
|
||||
git config --global user.name "github-actions[bot]"
|
||||
git config --global user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git add "**/*.png"
|
||||
if git diff --staged --quiet; then
|
||||
echo "No changes to commit"
|
||||
exit 0
|
||||
fi
|
||||
git commit -m "Update VRT"
|
||||
git push origin HEAD:${{ steps.comment-branch.outputs.head_ref }}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@actual-app/api",
|
||||
"version": "24.11.0",
|
||||
"version": "24.10.1",
|
||||
"license": "MIT",
|
||||
"description": "An API for Actual",
|
||||
"engines": {
|
||||
|
||||
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 40 KiB |
@@ -30,7 +30,7 @@ test.describe.parallel('Reports', () => {
|
||||
test('loads net worth and cash flow reports', async () => {
|
||||
const reports = await reportsPage.getAvailableReportList();
|
||||
|
||||
expect(reports).toEqual(['Net Worth', 'Cash Flow', 'Monthly Spending']);
|
||||
expect(reports).toEqual(['Net Worth', 'Cash Flow']);
|
||||
await expect(page).toMatchThemeScreenshots();
|
||||
});
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 73 KiB After Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 72 KiB After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 72 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 109 KiB After Width: | Height: | Size: 109 KiB |
|
Before Width: | Height: | Size: 109 KiB After Width: | Height: | Size: 109 KiB |
|
Before Width: | Height: | Size: 109 KiB After Width: | Height: | Size: 109 KiB |
7
packages/desktop-client/globals.d.ts
vendored
@@ -1,5 +1,7 @@
|
||||
import { type CSSObject } from '@emotion/css/dist/declarations/src/create-instance';
|
||||
|
||||
import { type State } from './src/state';
|
||||
|
||||
// Allow images to be imported
|
||||
declare module '*.png';
|
||||
|
||||
@@ -7,3 +9,8 @@ declare module 'react' {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions, @typescript-eslint/no-empty-object-type
|
||||
interface CSSProperties extends CSSObject {}
|
||||
}
|
||||
|
||||
declare module 'react-redux' {
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-object-type, @typescript-eslint/consistent-type-definitions
|
||||
interface DefaultRootState extends State {}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@actual-app/web",
|
||||
"version": "24.11.0",
|
||||
"version": "24.10.1",
|
||||
"license": "MIT",
|
||||
"files": [
|
||||
"build"
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
// @ts-strict-ignore
|
||||
import { type ReactNode, createContext, useContext } from 'react';
|
||||
|
||||
import { useWindowSize } from 'usehooks-ts';
|
||||
import { useViewportSize } from '@react-aria/utils';
|
||||
|
||||
import { breakpoints } from '../../tokens';
|
||||
import { breakpoints } from './tokens';
|
||||
|
||||
type TResponsiveContext = {
|
||||
atLeastMediumWidth: boolean;
|
||||
@@ -14,14 +15,20 @@ type TResponsiveContext = {
|
||||
width: number;
|
||||
};
|
||||
|
||||
const ResponsiveContext = createContext<TResponsiveContext | undefined>(
|
||||
undefined,
|
||||
);
|
||||
const ResponsiveContext = createContext<TResponsiveContext>(null);
|
||||
|
||||
export function ResponsiveProvider(props: { children: ReactNode }) {
|
||||
const { height, width } = useWindowSize({
|
||||
debounceDelay: 250,
|
||||
});
|
||||
/*
|
||||
* Ensure we render on every viewport size change,
|
||||
* even though we're interested in document.documentElement.client<Width|Height>
|
||||
* clientWidth/Height are the document size, do not change on pinch-zoom,
|
||||
* and are what our `min-width` media queries are reading
|
||||
* Viewport size changes on pinch-zoom, which may be useful later when dealing with on-screen keyboards
|
||||
*/
|
||||
useViewportSize();
|
||||
|
||||
const height = document.documentElement.clientHeight;
|
||||
const width = document.documentElement.clientWidth;
|
||||
|
||||
// Possible view modes: narrow, small, medium, wide
|
||||
// To check if we're at least small width, check !isNarrowWidth
|
||||
@@ -45,9 +52,5 @@ export function ResponsiveProvider(props: { children: ReactNode }) {
|
||||
}
|
||||
|
||||
export function useResponsive() {
|
||||
const context = useContext(ResponsiveContext);
|
||||
if (!context) {
|
||||
throw new Error('useResponsive must be used within a ResponsiveProvider');
|
||||
}
|
||||
return context;
|
||||
return useContext(ResponsiveContext);
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import { initBackend as initSQLBackend } from 'absurd-sql/dist/indexeddb-main-thread';
|
||||
import { registerSW } from 'virtual:pwa-register';
|
||||
|
||||
import * as Platform from 'loot-core/src/client/platform';
|
||||
|
||||
@@ -40,19 +39,6 @@ function createBackendWorker() {
|
||||
|
||||
createBackendWorker();
|
||||
|
||||
let isUpdateReadyForDownload = false;
|
||||
let markUpdateReadyForDownload;
|
||||
const isUpdateReadyForDownloadPromise = new Promise(resolve => {
|
||||
markUpdateReadyForDownload = () => {
|
||||
isUpdateReadyForDownload = true;
|
||||
resolve(true);
|
||||
};
|
||||
});
|
||||
const updateSW = registerSW({
|
||||
immediate: true,
|
||||
onNeedRefresh: markUpdateReadyForDownload,
|
||||
});
|
||||
|
||||
global.Actual = {
|
||||
IS_DEV,
|
||||
ACTUAL_VERSION,
|
||||
@@ -154,14 +140,7 @@ global.Actual = {
|
||||
window.open(url, '_blank');
|
||||
},
|
||||
onEventFromMain: () => {},
|
||||
isUpdateReadyForDownload: () => isUpdateReadyForDownload,
|
||||
waitForUpdateReadyForDownload: () => isUpdateReadyForDownloadPromise,
|
||||
applyAppUpdate: async () => {
|
||||
updateSW();
|
||||
|
||||
// Wait for the app to reload
|
||||
await new Promise(() => {});
|
||||
},
|
||||
applyAppUpdate: () => {},
|
||||
updateAppMenu: () => {},
|
||||
|
||||
ipcConnect: () => {},
|
||||
|
||||
@@ -12,13 +12,6 @@ import { useTranslation } from 'react-i18next';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
|
||||
import {
|
||||
closeBudget,
|
||||
loadBudget,
|
||||
loadGlobalPrefs,
|
||||
setAppState,
|
||||
sync,
|
||||
} from 'loot-core/client/actions';
|
||||
import { SpreadsheetProvider } from 'loot-core/client/SpreadsheetProvider';
|
||||
import * as Platform from 'loot-core/src/client/platform';
|
||||
import {
|
||||
@@ -28,6 +21,14 @@ import {
|
||||
|
||||
import { useMetadataPref } from '../hooks/useMetadataPref';
|
||||
import { installPolyfills } from '../polyfills';
|
||||
import { ResponsiveProvider } from '../ResponsiveProvider';
|
||||
import {
|
||||
closeBudget,
|
||||
loadBudget,
|
||||
loadGlobalPrefs,
|
||||
setAppState,
|
||||
sync,
|
||||
} from '../state/actions';
|
||||
import { styles, hasHiddenScrollbars, ThemeStyle, useTheme } from '../style';
|
||||
import { ExposeNavigate } from '../util/router-tools';
|
||||
|
||||
@@ -39,7 +40,6 @@ import { FatalError } from './FatalError';
|
||||
import { FinancesApp } from './FinancesApp';
|
||||
import { ManagementApp } from './manager/ManagementApp';
|
||||
import { Modals } from './Modals';
|
||||
import { ResponsiveProvider } from './responsive/ResponsiveProvider';
|
||||
import { ScrollProvider } from './ScrollProvider';
|
||||
import { SidebarProvider } from './sidebar/SidebarProvider';
|
||||
import { UpdateNotification } from './UpdateNotification';
|
||||
@@ -51,20 +51,8 @@ function AppInner() {
|
||||
const { showBoundary: showErrorBoundary } = useErrorBoundary();
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const maybeUpdate = async <T,>(cb?: () => T): Promise<T> => {
|
||||
if (global.Actual.isUpdateReadyForDownload()) {
|
||||
dispatch(
|
||||
setAppState({
|
||||
loadingText: t('Downloading and applying update...'),
|
||||
}),
|
||||
);
|
||||
await global.Actual.applyAppUpdate();
|
||||
}
|
||||
return cb?.();
|
||||
};
|
||||
|
||||
async function init() {
|
||||
const socketName = await maybeUpdate(() => global.Actual.getServerSocket());
|
||||
const socketName = await global.Actual.getServerSocket();
|
||||
|
||||
dispatch(
|
||||
setAppState({
|
||||
@@ -98,16 +86,14 @@ function AppInner() {
|
||||
loadingText: t('Retrieving remote files...'),
|
||||
}),
|
||||
);
|
||||
|
||||
const files = await send('get-remote-files');
|
||||
if (files) {
|
||||
const remoteFile = files.find(f => f.fileId === cloudFileId);
|
||||
if (remoteFile && remoteFile.deleted) {
|
||||
dispatch(closeBudget());
|
||||
send('get-remote-files').then(files => {
|
||||
if (files) {
|
||||
const remoteFile = files.find(f => f.fileId === cloudFileId);
|
||||
if (remoteFile && remoteFile.deleted) {
|
||||
dispatch(closeBudget());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await maybeUpdate();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,8 +3,7 @@ import { Trans } from 'react-i18next';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useTransition, animated } from 'react-spring';
|
||||
|
||||
import { type State } from 'loot-core/src/client/state-types';
|
||||
|
||||
import { type State } from '../state';
|
||||
import { theme, styles } from '../style';
|
||||
|
||||
import { AnimatedRefresh } from './AnimatedRefresh';
|
||||
|
||||
@@ -4,6 +4,7 @@ import { SvgPencil1 } from '../icons/v2';
|
||||
import { theme } from '../style';
|
||||
|
||||
import { Button } from './common/Button2';
|
||||
import { InitialFocus } from './common/InitialFocus';
|
||||
import { Input } from './common/Input';
|
||||
import { View } from './common/View';
|
||||
|
||||
@@ -32,24 +33,24 @@ export function EditablePageHeaderTitle({
|
||||
|
||||
if (isEditing) {
|
||||
return (
|
||||
<Input
|
||||
autoFocus
|
||||
autoSelect
|
||||
defaultValue={title}
|
||||
onEnter={e => onSaveValue(e.currentTarget.value)}
|
||||
onBlur={e => onSaveValue(e.target.value)}
|
||||
onEscape={() => setIsEditing(false)}
|
||||
style={{
|
||||
fontSize: 25,
|
||||
fontWeight: 500,
|
||||
marginTop: -3,
|
||||
marginBottom: -3,
|
||||
marginLeft: -6,
|
||||
paddingTop: 2,
|
||||
paddingBottom: 2,
|
||||
width: Math.max(20, title.length) + 'ch',
|
||||
}}
|
||||
/>
|
||||
<InitialFocus>
|
||||
<Input
|
||||
defaultValue={title}
|
||||
onEnter={e => onSaveValue(e.currentTarget.value)}
|
||||
onBlur={e => onSaveValue(e.target.value)}
|
||||
onEscape={() => setIsEditing(false)}
|
||||
style={{
|
||||
fontSize: 25,
|
||||
fontWeight: 500,
|
||||
marginTop: -3,
|
||||
marginBottom: -3,
|
||||
marginLeft: -6,
|
||||
paddingTop: 2,
|
||||
paddingBottom: 2,
|
||||
width: Math.max(20, title.length) + 'ch',
|
||||
}}
|
||||
/>
|
||||
</InitialFocus>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -10,14 +10,15 @@ import {
|
||||
useHref,
|
||||
} from 'react-router-dom';
|
||||
|
||||
import { addNotification, sync } from 'loot-core/client/actions';
|
||||
import { type State } from 'loot-core/src/client/state-types';
|
||||
import * as undo from 'loot-core/src/platform/client/undo';
|
||||
|
||||
import { useAccounts } from '../hooks/useAccounts';
|
||||
import { useLocalPref } from '../hooks/useLocalPref';
|
||||
import { useMetaThemeColor } from '../hooks/useMetaThemeColor';
|
||||
import { useNavigate } from '../hooks/useNavigate';
|
||||
import { useResponsive } from '../ResponsiveProvider';
|
||||
import { type State } from '../state';
|
||||
import { addNotification, sync } from '../state/actions';
|
||||
import { theme } from '../style';
|
||||
import { getIsOutdated, getLatestVersion } from '../util/versions';
|
||||
|
||||
@@ -33,7 +34,6 @@ import { ManagePayeesPage } from './payees/ManagePayeesPage';
|
||||
import { Reports } from './reports';
|
||||
import { LoadingIndicator } from './reports/LoadingIndicator';
|
||||
import { NarrowAlternate, WideComponent } from './responsive';
|
||||
import { useResponsive } from './responsive/ResponsiveProvider';
|
||||
import { Settings } from './settings';
|
||||
import { FloatableSidebar } from './sidebar';
|
||||
import { Titlebar } from './Titlebar';
|
||||
@@ -100,29 +100,6 @@ export function FinancesApp() {
|
||||
}, 100);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
async function run() {
|
||||
await global.Actual.waitForUpdateReadyForDownload();
|
||||
dispatch(
|
||||
addNotification({
|
||||
type: 'message',
|
||||
title: t('A new version of Actual is available!'),
|
||||
message: t('Click the button below to reload and apply the update.'),
|
||||
sticky: true,
|
||||
id: 'update-reload-notification',
|
||||
button: {
|
||||
title: t('Update now'),
|
||||
action: async () => {
|
||||
await global.Actual.applyAppUpdate();
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
run();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
async function run() {
|
||||
const latestVersion = await getLatestVersion();
|
||||
|
||||
@@ -6,11 +6,10 @@ import { useLocation } from 'react-router-dom';
|
||||
|
||||
import { useToggle } from 'usehooks-ts';
|
||||
|
||||
import { openDocsForCurrentPage } from 'loot-core/client/actions';
|
||||
import { pushModal } from 'loot-core/client/actions/modals';
|
||||
|
||||
import { useFeatureFlag } from '../hooks/useFeatureFlag';
|
||||
import { SvgHelp } from '../icons/v2/Help';
|
||||
import { pushModal } from '../state/actions';
|
||||
import { openUrl } from '../util/router-tools';
|
||||
|
||||
import { Button } from './common/Button2';
|
||||
import { Menu } from './common/Menu';
|
||||
@@ -46,6 +45,26 @@ const HelpButton = forwardRef<HTMLButtonElement, HelpButtonProps>(
|
||||
|
||||
HelpButton.displayName = 'HelpButton';
|
||||
|
||||
const getPageDocs = (page: string) => {
|
||||
switch (page) {
|
||||
case '/budget':
|
||||
return 'https://actualbudget.org/docs/getting-started/envelope-budgeting';
|
||||
case '/reports':
|
||||
return 'https://actualbudget.org/docs/reports/';
|
||||
case '/schedules':
|
||||
return 'https://actualbudget.org/docs/schedules';
|
||||
case '/payees':
|
||||
return 'https://actualbudget.org/docs/transactions/payees';
|
||||
case '/rules':
|
||||
return 'https://actualbudget.org/docs/budgeting/rules';
|
||||
case '/settings':
|
||||
return 'https://actualbudget.org/docs/settings';
|
||||
default:
|
||||
// All pages under /accounts, plus any missing pages
|
||||
return 'https://actualbudget.org/docs';
|
||||
}
|
||||
};
|
||||
|
||||
export const HelpMenu = () => {
|
||||
const showGoalTemplates = useFeatureFlag('goalTemplatesEnabled');
|
||||
const { t } = useTranslation();
|
||||
@@ -58,7 +77,7 @@ export const HelpMenu = () => {
|
||||
const handleItemSelect = (item: HelpMenuItem) => {
|
||||
switch (item) {
|
||||
case 'docs':
|
||||
dispatch(openDocsForCurrentPage());
|
||||
openUrl(getPageDocs(page));
|
||||
break;
|
||||
case 'keyboard-shortcuts':
|
||||
dispatch(pushModal('keyboard-shortcuts'));
|
||||
|
||||
@@ -3,9 +3,8 @@ import React, { useState, useEffect, useRef, type CSSProperties } from 'react';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import { type State } from 'loot-core/src/client/state-types';
|
||||
|
||||
import { useActions } from '../hooks/useActions';
|
||||
import { type State } from '../state';
|
||||
import { theme, styles } from '../style';
|
||||
|
||||
import { Button } from './common/Button2';
|
||||
|
||||
@@ -9,8 +9,6 @@ import React, {
|
||||
} from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
|
||||
import { pushModal } from 'loot-core/src/client/actions/modals';
|
||||
import { initiallyLoadPayees } from 'loot-core/src/client/actions/queries';
|
||||
import { send } from 'loot-core/src/platform/client/fetch';
|
||||
import * as undo from 'loot-core/src/platform/client/undo';
|
||||
import { getNormalisedString } from 'loot-core/src/shared/normalisation';
|
||||
@@ -23,6 +21,7 @@ import { useCategories } from '../hooks/useCategories';
|
||||
import { usePayees } from '../hooks/usePayees';
|
||||
import { useSchedules } from '../hooks/useSchedules';
|
||||
import { useSelected, SelectedProvider } from '../hooks/useSelected';
|
||||
import { initiallyLoadPayees, pushModal } from '../state/actions';
|
||||
import { theme } from '../style';
|
||||
|
||||
import { Button } from './common/Button2';
|
||||
@@ -204,13 +203,6 @@ export function ManageRules({
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
async function onDeleteRule(id: string) {
|
||||
setLoading(true);
|
||||
await send('rule-delete', id);
|
||||
await loadRules();
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
const onEditRule = useCallback(rule => {
|
||||
dispatch(
|
||||
pushModal('edit-rule', {
|
||||
@@ -294,7 +286,7 @@ export function ManageRules({
|
||||
<Search
|
||||
placeholder="Filter rules..."
|
||||
value={filter}
|
||||
onChangeValue={onSearchChange}
|
||||
onChange={onSearchChange}
|
||||
/>
|
||||
</View>
|
||||
<View style={{ flex: 1 }}>
|
||||
@@ -313,7 +305,6 @@ export function ManageRules({
|
||||
hoveredRule={hoveredRule}
|
||||
onHover={onHover}
|
||||
onEditRule={onEditRule}
|
||||
onDeleteRule={rule => onDeleteRule(rule.id)}
|
||||
/>
|
||||
)}
|
||||
</SimpleTable>
|
||||
|
||||
@@ -3,14 +3,10 @@ import React, { useEffect } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
|
||||
import { closeModal } from 'loot-core/client/actions';
|
||||
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 { closeModal } from '../state/actions';
|
||||
|
||||
import { ModalTitle, ModalHeader } from './common/Modal';
|
||||
import { AccountAutocompleteModal } from './modals/AccountAutocompleteModal';
|
||||
import { AccountMenuModal } from './modals/AccountMenuModal';
|
||||
import { BudgetListModal } from './modals/BudgetListModal';
|
||||
@@ -67,7 +63,6 @@ import { DiscoverSchedules } from './schedules/DiscoverSchedules';
|
||||
import { PostsOfflineNotification } from './schedules/PostsOfflineNotification';
|
||||
import { ScheduleDetails } from './schedules/ScheduleDetails';
|
||||
import { ScheduleLink } from './schedules/ScheduleLink';
|
||||
import { NamespaceContext } from './spreadsheet/NamespaceContext';
|
||||
|
||||
export function Modals() {
|
||||
const location = useLocation();
|
||||
@@ -84,520 +79,244 @@ export function Modals() {
|
||||
const modals = modalStack
|
||||
.map(({ name, options }) => {
|
||||
switch (name) {
|
||||
case 'goal-templates':
|
||||
return budgetId ? <GoalTemplateModal key={name} /> : null;
|
||||
case GoalTemplateModal.modalName:
|
||||
return budgetId ? (
|
||||
<GoalTemplateModal key={name} name={name} {...options} />
|
||||
) : null;
|
||||
|
||||
case 'keyboard-shortcuts':
|
||||
case KeyboardShortcutModal.modalName:
|
||||
// don't show the hotkey help modal when a budget is not open
|
||||
return budgetId ? <KeyboardShortcutModal key={name} /> : null;
|
||||
return budgetId ? (
|
||||
<KeyboardShortcutModal key={name} name={name} {...options} />
|
||||
) : null;
|
||||
|
||||
// Must be `case ImportTransactionsModal.modalName` once component is migrated to TS
|
||||
case 'import-transactions':
|
||||
return <ImportTransactionsModal key={name} options={options} />;
|
||||
|
||||
case 'add-account':
|
||||
return (
|
||||
<CreateAccountModal
|
||||
key={name}
|
||||
upgradingAccountId={options?.upgradingAccountId}
|
||||
/>
|
||||
<ImportTransactionsModal key={name} name={name} {...options} />
|
||||
);
|
||||
|
||||
case 'add-local-account':
|
||||
return <CreateLocalAccountModal key={name} />;
|
||||
case CreateAccountModal.modalName:
|
||||
return <CreateAccountModal key={name} name={name} {...options} />;
|
||||
|
||||
case 'close-account':
|
||||
case CreateLocalAccountModal.modalName:
|
||||
return (
|
||||
<CloseAccountModal
|
||||
key={name}
|
||||
account={options.account}
|
||||
balance={options.balance}
|
||||
canDelete={options.canDelete}
|
||||
/>
|
||||
<CreateLocalAccountModal key={name} name={name} {...options} />
|
||||
);
|
||||
|
||||
case CloseAccountModal.modalName:
|
||||
return <CloseAccountModal key={name} name={name} {...options} />;
|
||||
|
||||
// Must be `case SelectLinkedAccountsModal.modalName` once component is migrated to TS
|
||||
case 'select-linked-accounts':
|
||||
return (
|
||||
<SelectLinkedAccountsModal
|
||||
key={name}
|
||||
externalAccounts={options.accounts}
|
||||
requisitionId={options.requisitionId}
|
||||
syncSource={options.syncSource}
|
||||
/>
|
||||
<SelectLinkedAccountsModal key={name} name={name} {...options} />
|
||||
);
|
||||
|
||||
case 'confirm-category-delete':
|
||||
case ConfirmCategoryDeleteModal.modalName:
|
||||
return (
|
||||
<ConfirmCategoryDeleteModal
|
||||
key={name}
|
||||
category={options.category}
|
||||
group={options.group}
|
||||
onDelete={options.onDelete}
|
||||
/>
|
||||
<ConfirmCategoryDeleteModal key={name} name={name} {...options} />
|
||||
);
|
||||
|
||||
case 'confirm-unlink-account':
|
||||
case ConfirmUnlinkAccountModal.modalName:
|
||||
return (
|
||||
<ConfirmUnlinkAccountModal
|
||||
key={name}
|
||||
accountName={options.accountName}
|
||||
onUnlink={options.onUnlink}
|
||||
/>
|
||||
<ConfirmUnlinkAccountModal key={name} name={name} {...options} />
|
||||
);
|
||||
|
||||
case 'confirm-transaction-edit':
|
||||
case ConfirmTransactionEditModal.modalName:
|
||||
return (
|
||||
<ConfirmTransactionEditModal
|
||||
key={name}
|
||||
onCancel={options.onCancel}
|
||||
onConfirm={options.onConfirm}
|
||||
confirmReason={options.confirmReason}
|
||||
/>
|
||||
<ConfirmTransactionEditModal key={name} name={name} {...options} />
|
||||
);
|
||||
|
||||
case 'confirm-transaction-delete':
|
||||
case ConfirmTransactionDeleteModal.modalName:
|
||||
return (
|
||||
<ConfirmTransactionDeleteModal
|
||||
key={name}
|
||||
message={options.message}
|
||||
onConfirm={options.onConfirm}
|
||||
name={name}
|
||||
{...options}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'load-backup':
|
||||
return (
|
||||
<LoadBackupModal
|
||||
key={name}
|
||||
watchUpdates
|
||||
budgetId={options.budgetId}
|
||||
backupDisabled={false}
|
||||
/>
|
||||
);
|
||||
case LoadBackupModal.modalName:
|
||||
return <LoadBackupModal key={name} name={name} {...options} />;
|
||||
|
||||
case 'manage-rules':
|
||||
return <ManageRulesModal key={name} payeeId={options?.payeeId} />;
|
||||
case ManageRulesModal.modalName:
|
||||
return <ManageRulesModal key={name} name={name} {...options} />;
|
||||
|
||||
// Must be `case EditRuleModal.modalName` once component is migrated to TS
|
||||
case 'edit-rule':
|
||||
return (
|
||||
<EditRuleModal
|
||||
key={name}
|
||||
defaultRule={options.rule}
|
||||
onSave={options.onSave}
|
||||
/>
|
||||
);
|
||||
return <EditRuleModal key={name} name={name} {...options} />;
|
||||
|
||||
// Must be `case MergeUnusedPayeesModal.modalName` once component is migrated to TS
|
||||
case 'merge-unused-payees':
|
||||
return <MergeUnusedPayeesModal key={name} name={name} {...options} />;
|
||||
|
||||
case GoCardlessInitialiseModal.modalName:
|
||||
return (
|
||||
<MergeUnusedPayeesModal
|
||||
key={name}
|
||||
payeeIds={options.payeeIds}
|
||||
targetPayeeId={options.targetPayeeId}
|
||||
/>
|
||||
<GoCardlessInitialiseModal key={name} name={name} {...options} />
|
||||
);
|
||||
|
||||
case 'gocardless-init':
|
||||
case SimpleFinInitialiseModal.modalName:
|
||||
return (
|
||||
<GoCardlessInitialiseModal
|
||||
key={name}
|
||||
onSuccess={options.onSuccess}
|
||||
/>
|
||||
<SimpleFinInitialiseModal key={name} name={name} {...options} />
|
||||
);
|
||||
|
||||
case 'simplefin-init':
|
||||
case GoCardlessExternalMsgModal.modalName:
|
||||
return (
|
||||
<SimpleFinInitialiseModal
|
||||
key={name}
|
||||
onSuccess={options.onSuccess}
|
||||
/>
|
||||
<GoCardlessExternalMsgModal key={name} name={name} {...options} />
|
||||
);
|
||||
|
||||
case 'gocardless-external-msg':
|
||||
case CreateEncryptionKeyModal.modalName:
|
||||
return (
|
||||
<GoCardlessExternalMsgModal
|
||||
key={name}
|
||||
onMoveExternal={options.onMoveExternal}
|
||||
onClose={() => {
|
||||
options.onClose?.();
|
||||
send('gocardless-poll-web-token-stop');
|
||||
}}
|
||||
onSuccess={options.onSuccess}
|
||||
/>
|
||||
<CreateEncryptionKeyModal key={name} name={name} {...options} />
|
||||
);
|
||||
|
||||
case 'create-encryption-key':
|
||||
return <CreateEncryptionKeyModal key={name} options={options} />;
|
||||
|
||||
case 'fix-encryption-key':
|
||||
return <FixEncryptionKeyModal key={name} options={options} />;
|
||||
case FixEncryptionKeyModal.modalName:
|
||||
return <FixEncryptionKeyModal key={name} name={name} {...options} />;
|
||||
|
||||
// Must be `case EditFieldModal.modalName` once component is migrated to TS
|
||||
case 'edit-field':
|
||||
return <EditFieldModal key={name} name={name} {...options} />;
|
||||
|
||||
case CategoryAutocompleteModal.modalName:
|
||||
return (
|
||||
<EditFieldModal
|
||||
key={name}
|
||||
name={options.name}
|
||||
onSubmit={options.onSubmit}
|
||||
onClose={options.onClose}
|
||||
/>
|
||||
<CategoryAutocompleteModal key={name} name={name} {...options} />
|
||||
);
|
||||
|
||||
case 'category-autocomplete':
|
||||
case AccountAutocompleteModal.modalName:
|
||||
return (
|
||||
<CategoryAutocompleteModal
|
||||
key={name}
|
||||
autocompleteProps={{
|
||||
value: null,
|
||||
onSelect: options.onSelect,
|
||||
categoryGroups: options.categoryGroups,
|
||||
showHiddenCategories: options.showHiddenCategories,
|
||||
}}
|
||||
month={options.month}
|
||||
onClose={options.onClose}
|
||||
/>
|
||||
<AccountAutocompleteModal key={name} name={name} {...options} />
|
||||
);
|
||||
|
||||
case 'account-autocomplete':
|
||||
return (
|
||||
<AccountAutocompleteModal
|
||||
key={name}
|
||||
autocompleteProps={{
|
||||
value: null,
|
||||
onSelect: options.onSelect,
|
||||
includeClosedAccounts: options.includeClosedAccounts,
|
||||
}}
|
||||
onClose={options.onClose}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'payee-autocomplete':
|
||||
return (
|
||||
<PayeeAutocompleteModal
|
||||
key={name}
|
||||
autocompleteProps={{
|
||||
value: null,
|
||||
onSelect: options.onSelect,
|
||||
}}
|
||||
onClose={options.onClose}
|
||||
/>
|
||||
);
|
||||
case PayeeAutocompleteModal.modalName:
|
||||
return <PayeeAutocompleteModal key={name} name={name} {...options} />;
|
||||
|
||||
// Create a new component for this modal
|
||||
case 'new-category':
|
||||
return (
|
||||
<SingleInputModal
|
||||
key={name}
|
||||
name={name}
|
||||
Header={props => (
|
||||
<ModalHeader
|
||||
{...props}
|
||||
title={<ModalTitle title="New Category" shrinkOnOverflow />}
|
||||
/>
|
||||
)}
|
||||
inputPlaceholder="Category name"
|
||||
buttonText="Add"
|
||||
onValidate={options.onValidate}
|
||||
onSubmit={options.onSubmit}
|
||||
/>
|
||||
);
|
||||
return <SingleInputModal key={name} name={name} {...options} />;
|
||||
|
||||
// Create a new component for this modal
|
||||
case 'new-category-group':
|
||||
return <SingleInputModal key={name} name={name} {...options} />;
|
||||
|
||||
case EnvelopeBudgetSummaryModal.modalName:
|
||||
return (
|
||||
<SingleInputModal
|
||||
key={name}
|
||||
name={name}
|
||||
Header={props => (
|
||||
<ModalHeader
|
||||
{...props}
|
||||
title={
|
||||
<ModalTitle title="New Category Group" shrinkOnOverflow />
|
||||
}
|
||||
/>
|
||||
)}
|
||||
inputPlaceholder="Category group name"
|
||||
buttonText="Add"
|
||||
onValidate={options.onValidate}
|
||||
onSubmit={options.onSubmit}
|
||||
/>
|
||||
<EnvelopeBudgetSummaryModal key={name} name={name} {...options} />
|
||||
);
|
||||
|
||||
case 'envelope-budget-summary':
|
||||
case TrackingBudgetSummaryModal.modalName:
|
||||
return (
|
||||
<NamespaceContext.Provider
|
||||
key={name}
|
||||
value={monthUtils.sheetForMonth(options.month)}
|
||||
>
|
||||
<EnvelopeBudgetSummaryModal
|
||||
key={name}
|
||||
month={options.month}
|
||||
onBudgetAction={options.onBudgetAction}
|
||||
/>
|
||||
</NamespaceContext.Provider>
|
||||
);
|
||||
|
||||
case 'tracking-budget-summary':
|
||||
return (
|
||||
<TrackingBudgetSummaryModal key={name} month={options.month} />
|
||||
<TrackingBudgetSummaryModal key={name} name={name} {...options} />
|
||||
);
|
||||
|
||||
// Must be `case ScheduleDetails.modalName` once component is migrated to TS
|
||||
case 'schedule-edit':
|
||||
return (
|
||||
<ScheduleDetails
|
||||
key={name}
|
||||
id={options?.id || null}
|
||||
transaction={options?.transaction || null}
|
||||
/>
|
||||
);
|
||||
return <ScheduleDetails key={name} name={name} {...options} />;
|
||||
|
||||
case 'schedule-link':
|
||||
return (
|
||||
<ScheduleLink
|
||||
key={name}
|
||||
transactionIds={options?.transactionIds}
|
||||
getTransaction={options?.getTransaction}
|
||||
accountName={options?.accountName}
|
||||
onScheduleLinked={options?.onScheduleLinked}
|
||||
/>
|
||||
);
|
||||
case ScheduleLink.modalName:
|
||||
return <ScheduleLink key={name} name={name} {...options} />;
|
||||
|
||||
case 'schedules-discover':
|
||||
return <DiscoverSchedules key={name} />;
|
||||
case DiscoverSchedules.modalName:
|
||||
return <DiscoverSchedules key={name} name={name} {...options} />;
|
||||
|
||||
// Must be `case PostsOfflineNotification.modalName` once component is migrated to TS
|
||||
case 'schedule-posts-offline-notification':
|
||||
return <PostsOfflineNotification key={name} />;
|
||||
return <PostsOfflineNotification key={name} name={name} />;
|
||||
|
||||
case 'account-menu':
|
||||
case AccountMenuModal.modalName:
|
||||
return <AccountMenuModal key={name} name={name} {...options} />;
|
||||
|
||||
case CategoryMenuModal.modalName:
|
||||
return <CategoryMenuModal key={name} name={name} {...options} />;
|
||||
|
||||
case EnvelopeBudgetMenuModal.modalName:
|
||||
return (
|
||||
<AccountMenuModal
|
||||
key={name}
|
||||
accountId={options.accountId}
|
||||
onSave={options.onSave}
|
||||
onEditNotes={options.onEditNotes}
|
||||
onCloseAccount={options.onCloseAccount}
|
||||
onReopenAccount={options.onReopenAccount}
|
||||
onClose={options.onClose}
|
||||
/>
|
||||
<EnvelopeBudgetMenuModal key={name} name={name} {...options} />
|
||||
);
|
||||
|
||||
case 'category-menu':
|
||||
case TrackingBudgetMenuModal.modalName:
|
||||
return (
|
||||
<CategoryMenuModal
|
||||
key={name}
|
||||
categoryId={options.categoryId}
|
||||
onSave={options.onSave}
|
||||
onEditNotes={options.onEditNotes}
|
||||
onDelete={options.onDelete}
|
||||
onToggleVisibility={options.onToggleVisibility}
|
||||
onClose={options.onClose}
|
||||
/>
|
||||
<TrackingBudgetMenuModal key={name} name={name} {...options} />
|
||||
);
|
||||
|
||||
case 'envelope-budget-menu':
|
||||
case CategoryGroupMenuModal.modalName:
|
||||
return <CategoryGroupMenuModal key={name} name={name} {...options} />;
|
||||
|
||||
case NotesModal.modalName:
|
||||
return <NotesModal key={name} name={name} {...options} />;
|
||||
|
||||
case EnvelopeBalanceMenuModal.modalName:
|
||||
return (
|
||||
<NamespaceContext.Provider
|
||||
key={name}
|
||||
value={monthUtils.sheetForMonth(options.month)}
|
||||
>
|
||||
<EnvelopeBudgetMenuModal
|
||||
categoryId={options.categoryId}
|
||||
onUpdateBudget={options.onUpdateBudget}
|
||||
onCopyLastMonthAverage={options.onCopyLastMonthAverage}
|
||||
onSetMonthsAverage={options.onSetMonthsAverage}
|
||||
onApplyBudgetTemplate={options.onApplyBudgetTemplate}
|
||||
/>
|
||||
</NamespaceContext.Provider>
|
||||
<EnvelopeBalanceMenuModal key={name} name={name} {...options} />
|
||||
);
|
||||
|
||||
case 'tracking-budget-menu':
|
||||
case EnvelopeToBudgetMenuModal.modalName:
|
||||
return (
|
||||
<NamespaceContext.Provider
|
||||
key={name}
|
||||
value={monthUtils.sheetForMonth(options.month)}
|
||||
>
|
||||
<TrackingBudgetMenuModal
|
||||
categoryId={options.categoryId}
|
||||
onUpdateBudget={options.onUpdateBudget}
|
||||
onCopyLastMonthAverage={options.onCopyLastMonthAverage}
|
||||
onSetMonthsAverage={options.onSetMonthsAverage}
|
||||
onApplyBudgetTemplate={options.onApplyBudgetTemplate}
|
||||
/>
|
||||
</NamespaceContext.Provider>
|
||||
<EnvelopeToBudgetMenuModal key={name} name={name} {...options} />
|
||||
);
|
||||
|
||||
case 'category-group-menu':
|
||||
case HoldBufferModal.modalName:
|
||||
return <HoldBufferModal key={name} name={name} {...options} />;
|
||||
|
||||
case TrackingBalanceMenuModal.modalName:
|
||||
return (
|
||||
<CategoryGroupMenuModal
|
||||
key={name}
|
||||
groupId={options.groupId}
|
||||
onSave={options.onSave}
|
||||
onAddCategory={options.onAddCategory}
|
||||
onEditNotes={options.onEditNotes}
|
||||
onSaveNotes={options.onSaveNotes}
|
||||
onDelete={options.onDelete}
|
||||
onToggleVisibility={options.onToggleVisibility}
|
||||
onClose={options.onClose}
|
||||
/>
|
||||
<TrackingBalanceMenuModal key={name} name={name} {...options} />
|
||||
);
|
||||
|
||||
case 'notes':
|
||||
return (
|
||||
<NotesModal
|
||||
key={name}
|
||||
id={options.id}
|
||||
name={options.name}
|
||||
onSave={options.onSave}
|
||||
/>
|
||||
);
|
||||
case TransferModal.modalName:
|
||||
return <TransferModal key={name} name={name} {...options} />;
|
||||
|
||||
case 'envelope-balance-menu':
|
||||
return (
|
||||
<NamespaceContext.Provider
|
||||
key={name}
|
||||
value={monthUtils.sheetForMonth(options.month)}
|
||||
>
|
||||
<EnvelopeBalanceMenuModal
|
||||
categoryId={options.categoryId}
|
||||
onCarryover={options.onCarryover}
|
||||
onTransfer={options.onTransfer}
|
||||
onCover={options.onCover}
|
||||
/>
|
||||
</NamespaceContext.Provider>
|
||||
);
|
||||
case CoverModal.modalName:
|
||||
return <CoverModal key={name} name={name} {...options} />;
|
||||
|
||||
case 'envelope-summary-to-budget-menu':
|
||||
return (
|
||||
<NamespaceContext.Provider
|
||||
key={name}
|
||||
value={monthUtils.sheetForMonth(options.month)}
|
||||
>
|
||||
<EnvelopeToBudgetMenuModal
|
||||
onTransfer={options.onTransfer}
|
||||
onCover={options.onCover}
|
||||
onHoldBuffer={options.onHoldBuffer}
|
||||
onResetHoldBuffer={options.onResetHoldBuffer}
|
||||
/>
|
||||
</NamespaceContext.Provider>
|
||||
);
|
||||
|
||||
case 'hold-buffer':
|
||||
return (
|
||||
<NamespaceContext.Provider
|
||||
key={name}
|
||||
value={monthUtils.sheetForMonth(options.month)}
|
||||
>
|
||||
<HoldBufferModal
|
||||
month={options.month}
|
||||
onSubmit={options.onSubmit}
|
||||
/>
|
||||
</NamespaceContext.Provider>
|
||||
);
|
||||
|
||||
case 'tracking-balance-menu':
|
||||
return (
|
||||
<NamespaceContext.Provider
|
||||
key={name}
|
||||
value={monthUtils.sheetForMonth(options.month)}
|
||||
>
|
||||
<TrackingBalanceMenuModal
|
||||
categoryId={options.categoryId}
|
||||
onCarryover={options.onCarryover}
|
||||
/>
|
||||
</NamespaceContext.Provider>
|
||||
);
|
||||
|
||||
case 'transfer':
|
||||
return (
|
||||
<TransferModal
|
||||
key={name}
|
||||
title={options.title}
|
||||
categoryId={options.categoryId}
|
||||
month={options.month}
|
||||
amount={options.amount}
|
||||
onSubmit={options.onSubmit}
|
||||
showToBeBudgeted={options.showToBeBudgeted}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'cover':
|
||||
return (
|
||||
<CoverModal
|
||||
key={name}
|
||||
title={options.title}
|
||||
categoryId={options.categoryId}
|
||||
month={options.month}
|
||||
showToBeBudgeted={options.showToBeBudgeted}
|
||||
onSubmit={options.onSubmit}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'scheduled-transaction-menu':
|
||||
case ScheduledTransactionMenuModal.modalName:
|
||||
return (
|
||||
<ScheduledTransactionMenuModal
|
||||
key={name}
|
||||
transactionId={options.transactionId}
|
||||
onPost={options.onPost}
|
||||
onSkip={options.onSkip}
|
||||
name={name}
|
||||
{...options}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'budget-page-menu':
|
||||
case BudgetPageMenuModal.modalName:
|
||||
return <BudgetPageMenuModal key={name} name={name} {...options} />;
|
||||
|
||||
case EnvelopeBudgetMonthMenuModal.modalName:
|
||||
return (
|
||||
<BudgetPageMenuModal
|
||||
key={name}
|
||||
onAddCategoryGroup={options.onAddCategoryGroup}
|
||||
onToggleHiddenCategories={options.onToggleHiddenCategories}
|
||||
onSwitchBudgetFile={options.onSwitchBudgetFile}
|
||||
/>
|
||||
<EnvelopeBudgetMonthMenuModal key={name} name={name} {...options} />
|
||||
);
|
||||
|
||||
case 'envelope-budget-month-menu':
|
||||
case TrackingBudgetMonthMenuModal.modalName:
|
||||
return (
|
||||
<NamespaceContext.Provider
|
||||
key={name}
|
||||
value={monthUtils.sheetForMonth(options.month)}
|
||||
>
|
||||
<EnvelopeBudgetMonthMenuModal
|
||||
month={options.month}
|
||||
onBudgetAction={options.onBudgetAction}
|
||||
onEditNotes={options.onEditNotes}
|
||||
/>
|
||||
</NamespaceContext.Provider>
|
||||
<TrackingBudgetMonthMenuModal key={name} name={name} {...options} />
|
||||
);
|
||||
|
||||
case 'tracking-budget-month-menu':
|
||||
return (
|
||||
<NamespaceContext.Provider
|
||||
key={name}
|
||||
value={monthUtils.sheetForMonth(options.month)}
|
||||
>
|
||||
<TrackingBudgetMonthMenuModal
|
||||
month={options.month}
|
||||
onBudgetAction={options.onBudgetAction}
|
||||
onEditNotes={options.onEditNotes}
|
||||
/>
|
||||
</NamespaceContext.Provider>
|
||||
);
|
||||
|
||||
case 'budget-list':
|
||||
return <BudgetListModal key={name} />;
|
||||
case 'delete-budget':
|
||||
return <DeleteFileModal key={name} file={options.file} />;
|
||||
case 'import':
|
||||
return <ImportModal key={name} />;
|
||||
case 'files-settings':
|
||||
return <FilesSettingsModal key={name} />;
|
||||
case 'confirm-change-document-dir':
|
||||
case BudgetListModal.modalName:
|
||||
return <BudgetListModal key={name} name={name} {...options} />;
|
||||
case DeleteFileModal.modalName:
|
||||
return <DeleteFileModal key={name} name={name} {...options} />;
|
||||
case ImportModal.modalName:
|
||||
return <ImportModal key={name} name={name} />;
|
||||
case FilesSettingsModal.modalName:
|
||||
return <FilesSettingsModal key={name} name={name} {...options} />;
|
||||
case ConfirmChangeDocumentDirModal.modalName:
|
||||
return (
|
||||
<ConfirmChangeDocumentDirModal
|
||||
key={name}
|
||||
currentBudgetDirectory={options.currentBudgetDirectory}
|
||||
newDirectory={options.newDirectory}
|
||||
name={name}
|
||||
{...options}
|
||||
/>
|
||||
);
|
||||
case 'import-ynab4':
|
||||
return <ImportYNAB4Modal key={name} />;
|
||||
case 'import-ynab5':
|
||||
return <ImportYNAB5Modal key={name} />;
|
||||
case 'import-actual':
|
||||
return <ImportActualModal key={name} />;
|
||||
case 'out-of-sync-migrations':
|
||||
return <OutOfSyncMigrationsModal key={name} />;
|
||||
case ImportYNAB4Modal.modalName:
|
||||
return <ImportYNAB4Modal key={name} name={name} {...options} />;
|
||||
case ImportYNAB5Modal.modalName:
|
||||
return <ImportYNAB5Modal key={name} name={name} {...options} />;
|
||||
case ImportActualModal.modalName:
|
||||
return <ImportActualModal key={name} name={name} {...options} />;
|
||||
case OutOfSyncMigrationsModal.modalName:
|
||||
return (
|
||||
<OutOfSyncMigrationsModal key={name} name={name} {...options} />
|
||||
);
|
||||
|
||||
default:
|
||||
throw new Error('Unknown modal');
|
||||
|
||||
@@ -5,11 +5,11 @@ import ReactMarkdown from 'react-markdown';
|
||||
import { css } from '@emotion/css';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
|
||||
import { useResponsive } from '../ResponsiveProvider';
|
||||
import { theme } from '../style';
|
||||
import { remarkBreaks, sequentialNewlinesPlugin } from '../util/markdown';
|
||||
|
||||
import { Text } from './common/Text';
|
||||
import { useResponsive } from './responsive/ResponsiveProvider';
|
||||
|
||||
const remarkPlugins = [sequentialNewlinesPlugin, remarkGfm, remarkBreaks];
|
||||
|
||||
|
||||
@@ -10,12 +10,12 @@ import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
import { css } from '@emotion/css';
|
||||
|
||||
import { removeNotification } from 'loot-core/client/actions';
|
||||
import { type State } from 'loot-core/src/client/state-types';
|
||||
import type { NotificationWithId } from 'loot-core/src/client/state-types/notifications';
|
||||
|
||||
import { AnimatedLoading } from '../icons/AnimatedLoading';
|
||||
import { SvgDelete } from '../icons/v0';
|
||||
import { useResponsive } from '../ResponsiveProvider';
|
||||
import { type State } from '../state';
|
||||
import { removeNotification } from '../state/actions';
|
||||
import type { NotificationWithId } from '../state/notifications';
|
||||
import { styles, theme } from '../style';
|
||||
|
||||
import { Button, ButtonWithLoading } from './common/Button2';
|
||||
@@ -23,7 +23,6 @@ import { Link } from './common/Link';
|
||||
import { Stack } from './common/Stack';
|
||||
import { Text } from './common/Text';
|
||||
import { View } from './common/View';
|
||||
import { useResponsive } from './responsive/ResponsiveProvider';
|
||||
|
||||
function compileMessage(
|
||||
message: string,
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import React, { type ReactNode, type CSSProperties } from 'react';
|
||||
|
||||
import { useResponsive } from '../ResponsiveProvider';
|
||||
import { theme, styles } from '../style';
|
||||
|
||||
import { Text } from './common/Text';
|
||||
import { View } from './common/View';
|
||||
import { useResponsive } from './responsive/ResponsiveProvider';
|
||||
|
||||
const HEADER_HEIGHT = 50;
|
||||
|
||||
|
||||
@@ -8,9 +8,9 @@ import React, {
|
||||
import { css } from '@emotion/css';
|
||||
|
||||
import { usePrivacyMode } from '../hooks/usePrivacyMode';
|
||||
import { useResponsive } from '../ResponsiveProvider';
|
||||
|
||||
import { View } from './common/View';
|
||||
import { useResponsive } from './responsive/ResponsiveProvider';
|
||||
|
||||
type ConditionalPrivacyFilterProps = {
|
||||
children: ReactNode;
|
||||
|
||||
@@ -3,12 +3,12 @@ import React, { useRef, useState, type CSSProperties } from 'react';
|
||||
import type { Theme } from 'loot-core/src/types/prefs';
|
||||
|
||||
import { SvgMoonStars, SvgSun, SvgSystem } from '../icons/v2';
|
||||
import { useResponsive } from '../ResponsiveProvider';
|
||||
import { themeOptions, useTheme } from '../style';
|
||||
|
||||
import { Button } from './common/Button2';
|
||||
import { Menu } from './common/Menu';
|
||||
import { Popover } from './common/Popover';
|
||||
import { useResponsive } from './responsive/ResponsiveProvider';
|
||||
|
||||
type ThemeSelectorProps = {
|
||||
style?: CSSProperties;
|
||||
|
||||
@@ -7,10 +7,7 @@ import { css } from '@emotion/css';
|
||||
import * as Platform from 'loot-core/src/client/platform';
|
||||
import * as queries from 'loot-core/src/client/queries';
|
||||
import { listen } from 'loot-core/src/platform/client/fetch';
|
||||
import {
|
||||
isDevelopmentEnvironment,
|
||||
isElectron,
|
||||
} from 'loot-core/src/shared/environment';
|
||||
import { isDevelopmentEnvironment } from 'loot-core/src/shared/environment';
|
||||
|
||||
import { useActions } from '../hooks/useActions';
|
||||
import { useGlobalPref } from '../hooks/useGlobalPref';
|
||||
@@ -24,6 +21,7 @@ import {
|
||||
SvgViewHide,
|
||||
SvgViewShow,
|
||||
} from '../icons/v2';
|
||||
import { useResponsive } from '../ResponsiveProvider';
|
||||
import { theme, styles } from '../style';
|
||||
|
||||
import { AccountSyncCheck } from './accounts/AccountSyncCheck';
|
||||
@@ -36,7 +34,6 @@ import { Text } from './common/Text';
|
||||
import { View } from './common/View';
|
||||
import { HelpMenu } from './HelpMenu';
|
||||
import { LoggedInUser } from './LoggedInUser';
|
||||
import { useResponsive } from './responsive/ResponsiveProvider';
|
||||
import { useServerURL } from './ServerContext';
|
||||
import { useSidebar } from './sidebar/SidebarProvider';
|
||||
import { useSheetValue } from './spreadsheet/useSheetValue';
|
||||
@@ -343,7 +340,7 @@ export function Titlebar({ style }: TitlebarProps) {
|
||||
<PrivacyButton />
|
||||
{serverURL ? <SyncButton /> : null}
|
||||
<LoggedInUser />
|
||||
{!isElectron() && <HelpMenu />}
|
||||
<HelpMenu />
|
||||
</SpaceBetween>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import React from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import { type State } from 'loot-core/src/client/state-types';
|
||||
|
||||
import { useActions } from '../hooks/useActions';
|
||||
import { SvgClose } from '../icons/v1';
|
||||
import { type State } from '../state';
|
||||
import { theme } from '../style';
|
||||
|
||||
import { Button } from './common/Button2';
|
||||
|
||||
@@ -15,7 +15,6 @@ import { t } from 'i18next';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import { validForTransfer } from 'loot-core/client/transfer';
|
||||
import { type UndoState } from 'loot-core/server/undo';
|
||||
import { useFilters } from 'loot-core/src/client/data-hooks/filters';
|
||||
import {
|
||||
SchedulesProvider,
|
||||
@@ -45,6 +44,7 @@ import {
|
||||
type TransactionEntity,
|
||||
type TransactionFilterEntity,
|
||||
} from 'loot-core/src/types/models';
|
||||
import { type UndoState } from 'loot-core/types/server-events';
|
||||
|
||||
import { useAccounts } from '../../hooks/useAccounts';
|
||||
import { useActions } from '../../hooks/useActions';
|
||||
@@ -1803,15 +1803,6 @@ class AccountInternal extends PureComponent<
|
||||
sortField={this.state.sort?.field}
|
||||
ascDesc={this.state.sort?.ascDesc}
|
||||
onChange={this.onTransactionsChange}
|
||||
onBatchDelete={this.onBatchDelete}
|
||||
onBatchDuplicate={this.onBatchDuplicate}
|
||||
onBatchLinkSchedule={this.onBatchLinkSchedule}
|
||||
onBatchUnlinkSchedule={this.onBatchUnlinkSchedule}
|
||||
onCreateRule={this.onCreateRule}
|
||||
onScheduleAction={this.onScheduleAction}
|
||||
onMakeAsNonSplitTransactions={
|
||||
this.onMakeAsNonSplitTransactions
|
||||
}
|
||||
onRefetch={this.refetchTransactions}
|
||||
onCloseAddTransaction={() =>
|
||||
this.setState({ isAdding: false })
|
||||
@@ -1866,9 +1857,9 @@ export function Account() {
|
||||
const location = useLocation();
|
||||
|
||||
const { grouped: categoryGroups } = useCategories();
|
||||
const newTransactions = useSelector(state => state.queries.newTransactions);
|
||||
const newTransactions = useSelector(state => state.account.newTransactions);
|
||||
const matchedTransactions = useSelector(
|
||||
state => state.queries.matchedTransactions,
|
||||
state => state.account.matchedTransactions,
|
||||
);
|
||||
const accounts = useAccounts();
|
||||
const payees = usePayees();
|
||||
|
||||
@@ -1,23 +1,21 @@
|
||||
import React, { useCallback, useRef, useState } from 'react';
|
||||
import React, { useRef, useState } from 'react';
|
||||
import { Trans } from 'react-i18next';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useParams } from 'react-router-dom';
|
||||
|
||||
import { t } from 'i18next';
|
||||
|
||||
import { unlinkAccount } from 'loot-core/client/actions';
|
||||
import { type AccountEntity } from 'loot-core/types/models';
|
||||
|
||||
import { authorizeBank } from '../../gocardless';
|
||||
import { useAccounts } from '../../hooks/useAccounts';
|
||||
import { SvgExclamationOutline } from '../../icons/v1';
|
||||
import { unlinkAccount } from '../../state/actions';
|
||||
import { theme } from '../../style';
|
||||
import { Button } from '../common/Button2';
|
||||
import { Link } from '../common/Link';
|
||||
import { Popover } from '../common/Popover';
|
||||
import { View } from '../common/View';
|
||||
|
||||
function getErrorMessage(type: string, code: string) {
|
||||
function getErrorMessage(type, code) {
|
||||
switch (type.toUpperCase()) {
|
||||
case 'ITEM_ERROR':
|
||||
switch (code.toUpperCase()) {
|
||||
@@ -82,29 +80,7 @@ export function AccountSyncCheck() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const triggerRef = useRef(null);
|
||||
|
||||
const reauth = useCallback(
|
||||
(acc: AccountEntity) => {
|
||||
setOpen(false);
|
||||
|
||||
if (acc.account_id) {
|
||||
authorizeBank(dispatch, { upgradingAccountId: acc.account_id });
|
||||
}
|
||||
},
|
||||
[dispatch],
|
||||
);
|
||||
|
||||
const unlink = useCallback(
|
||||
(acc: AccountEntity) => {
|
||||
if (acc.id) {
|
||||
dispatch(unlinkAccount(acc.id));
|
||||
}
|
||||
|
||||
setOpen(false);
|
||||
},
|
||||
[dispatch],
|
||||
);
|
||||
|
||||
if (!failedAccounts || !id) {
|
||||
if (!failedAccounts) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -114,15 +90,22 @@ export function AccountSyncCheck() {
|
||||
}
|
||||
|
||||
const account = accounts.find(account => account.id === id);
|
||||
if (!account) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { type, code } = error;
|
||||
const showAuth =
|
||||
(type === 'ITEM_ERROR' && code === 'ITEM_LOGIN_REQUIRED') ||
|
||||
(type === 'INVALID_INPUT' && code === 'INVALID_ACCESS_TOKEN');
|
||||
|
||||
function reauth() {
|
||||
setOpen(false);
|
||||
|
||||
authorizeBank(dispatch, { upgradingAccountId: account.account_id });
|
||||
}
|
||||
|
||||
async function unlink() {
|
||||
dispatch(unlinkAccount(account.id));
|
||||
setOpen(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<View>
|
||||
<Button
|
||||
@@ -164,20 +147,20 @@ export function AccountSyncCheck() {
|
||||
<View style={{ justifyContent: 'flex-end', flexDirection: 'row' }}>
|
||||
{showAuth ? (
|
||||
<>
|
||||
<Button onPress={() => unlink(account)}>
|
||||
<Button onPress={unlink}>
|
||||
<Trans>Unlink</Trans>
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
autoFocus
|
||||
onPress={() => reauth(account)}
|
||||
onPress={reauth}
|
||||
style={{ marginLeft: 5 }}
|
||||
>
|
||||
<Trans>Reauthorize</Trans>
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button onPress={() => unlink(account)}>
|
||||
<Button onPress={unlink}>
|
||||
<Trans>Unlink account</Trans>
|
||||
</Button>
|
||||
)}
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
import { theme, styles } from '../../style';
|
||||
import { AnimatedRefresh } from '../AnimatedRefresh';
|
||||
import { Button } from '../common/Button2';
|
||||
import { InitialFocus } from '../common/InitialFocus';
|
||||
import { Input } from '../common/Input';
|
||||
import { Menu } from '../common/Menu';
|
||||
import { MenuButton } from '../common/MenuButton';
|
||||
@@ -344,8 +345,8 @@ export function AccountHeader({
|
||||
<Search
|
||||
placeholder={t('Search')}
|
||||
value={search}
|
||||
onChangeValue={onSearch}
|
||||
ref={searchInput}
|
||||
onChange={onSearch}
|
||||
inputRef={searchInput}
|
||||
/>
|
||||
{workingHard ? (
|
||||
<View>
|
||||
@@ -573,24 +574,24 @@ function AccountNameField({
|
||||
if (editingName) {
|
||||
return (
|
||||
<Fragment>
|
||||
<Input
|
||||
autoFocus
|
||||
autoSelect
|
||||
defaultValue={accountName}
|
||||
onEnter={e => onSaveName(e.target.value)}
|
||||
onBlur={e => onSaveName(e.target.value)}
|
||||
onEscape={() => onExposeName(false)}
|
||||
style={{
|
||||
fontSize: 25,
|
||||
fontWeight: 500,
|
||||
marginTop: -3,
|
||||
marginBottom: -4,
|
||||
marginLeft: -6,
|
||||
paddingTop: 2,
|
||||
paddingBottom: 2,
|
||||
width: Math.max(20, accountName.length) + 'ch',
|
||||
}}
|
||||
/>
|
||||
<InitialFocus>
|
||||
<Input
|
||||
defaultValue={accountName}
|
||||
onEnter={e => onSaveName(e.currentTarget.value)}
|
||||
onBlur={e => onSaveName(e.target.value)}
|
||||
onEscape={() => onExposeName(false)}
|
||||
style={{
|
||||
fontSize: 25,
|
||||
fontWeight: 500,
|
||||
marginTop: -3,
|
||||
marginBottom: -4,
|
||||
marginLeft: -6,
|
||||
paddingTop: 2,
|
||||
paddingBottom: 2,
|
||||
width: Math.max(20, accountName.length) + 'ch',
|
||||
}}
|
||||
/>
|
||||
</InitialFocus>
|
||||
{saveNameError && (
|
||||
<View style={{ color: theme.warningText }}>{saveNameError}</View>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import React, { type FormEvent, useCallback, useRef, useState } from 'react';
|
||||
import { Form } from 'react-aria-components';
|
||||
import React, { useState } from 'react';
|
||||
import { Trans } from 'react-i18next';
|
||||
|
||||
import * as queries from 'loot-core/src/client/queries';
|
||||
@@ -10,6 +9,7 @@ import { type AccountEntity } from 'loot-core/types/models';
|
||||
import { SvgCheckCircle1 } from '../../icons/v2';
|
||||
import { styles, theme } from '../../style';
|
||||
import { Button } from '../common/Button2';
|
||||
import { InitialFocus } from '../common/InitialFocus';
|
||||
import { Input } from '../common/Input';
|
||||
import { Text } from '../common/Text';
|
||||
import { View } from '../common/View';
|
||||
@@ -124,49 +124,43 @@ export function ReconcileMenu({
|
||||
});
|
||||
const format = useFormat();
|
||||
const [inputValue, setInputValue] = useState<string | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [inputFocused, setInputFocused] = useState(false);
|
||||
|
||||
const onSubmit = useCallback(
|
||||
(e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
function onSubmit() {
|
||||
if (inputValue === '') {
|
||||
setInputFocused(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (inputValue === '') {
|
||||
inputRef.current?.focus();
|
||||
return;
|
||||
}
|
||||
const amount =
|
||||
inputValue != null ? currencyToInteger(inputValue) : clearedBalance;
|
||||
|
||||
const amount =
|
||||
inputValue != null ? currencyToInteger(inputValue) : clearedBalance;
|
||||
|
||||
onReconcile(amount);
|
||||
onClose();
|
||||
},
|
||||
[clearedBalance, inputValue, onClose, onReconcile],
|
||||
);
|
||||
onReconcile(amount);
|
||||
onClose();
|
||||
}
|
||||
|
||||
return (
|
||||
<Form onSubmit={onSubmit}>
|
||||
<View style={{ padding: '5px 8px' }}>
|
||||
<Text>
|
||||
<Trans>
|
||||
Enter the current balance of your bank account that you want to
|
||||
reconcile with:
|
||||
</Trans>
|
||||
</Text>
|
||||
{clearedBalance != null && (
|
||||
<View style={{ padding: '5px 8px' }}>
|
||||
<Text>
|
||||
<Trans>
|
||||
Enter the current balance of your bank account that you want to
|
||||
reconcile with:
|
||||
</Trans>
|
||||
</Text>
|
||||
{clearedBalance != null && (
|
||||
<InitialFocus>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
defaultValue={format(clearedBalance, 'financial')}
|
||||
onChangeValue={setInputValue}
|
||||
style={{ margin: '7px 0' }}
|
||||
autoFocus
|
||||
autoSelect
|
||||
focused={inputFocused}
|
||||
onEnter={onSubmit}
|
||||
/>
|
||||
)}
|
||||
<Button variant="primary" type="submit">
|
||||
<Trans>Reconcile</Trans>
|
||||
</Button>
|
||||
</View>
|
||||
</Form>
|
||||
</InitialFocus>
|
||||
)}
|
||||
<Button variant="primary" onPress={onSubmit}>
|
||||
<Trans>Reconcile</Trans>
|
||||
</Button>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,10 +13,10 @@ import { css, cx } from '@emotion/css';
|
||||
import { type AccountEntity } from 'loot-core/src/types/models';
|
||||
|
||||
import { useAccounts } from '../../hooks/useAccounts';
|
||||
import { useResponsive } from '../../ResponsiveProvider';
|
||||
import { theme, styles } from '../../style';
|
||||
import { TextOneLine } from '../common/TextOneLine';
|
||||
import { View } from '../common/View';
|
||||
import { useResponsive } from '../responsive/ResponsiveProvider';
|
||||
|
||||
import { Autocomplete } from './Autocomplete';
|
||||
import { ItemHeader } from './ItemHeader';
|
||||
@@ -170,7 +170,7 @@ type AccountItemProps = {
|
||||
|
||||
function AccountItem({
|
||||
item,
|
||||
className = '',
|
||||
className,
|
||||
highlighted,
|
||||
embedded,
|
||||
...props
|
||||
|
||||
@@ -4,12 +4,11 @@ import React, {
|
||||
useRef,
|
||||
useEffect,
|
||||
useMemo,
|
||||
type ComponentProps,
|
||||
type HTMLProps,
|
||||
type ReactNode,
|
||||
type KeyboardEvent,
|
||||
type ChangeEvent,
|
||||
type ComponentPropsWithRef,
|
||||
type ComponentPropsWithoutRef,
|
||||
} from 'react';
|
||||
|
||||
import { css, cx } from '@emotion/css';
|
||||
@@ -18,24 +17,26 @@ import Downshift, { type StateChangeTypes } from 'downshift';
|
||||
import { getNormalisedString } from 'loot-core/src/shared/normalisation';
|
||||
|
||||
import { SvgRemove } from '../../icons/v2';
|
||||
import { useResponsive } from '../../ResponsiveProvider';
|
||||
import { theme, styles } from '../../style';
|
||||
import { Button } from '../common/Button';
|
||||
import { Input } from '../common/Input';
|
||||
import { Popover } from '../common/Popover';
|
||||
import { View } from '../common/View';
|
||||
import { useResponsive } from '../responsive/ResponsiveProvider';
|
||||
|
||||
type CommonAutocompleteProps<T extends Item> = {
|
||||
autoFocus?: boolean;
|
||||
focused?: boolean;
|
||||
embedded?: boolean;
|
||||
containerProps?: HTMLProps<HTMLDivElement>;
|
||||
labelProps?: { id?: string };
|
||||
inputProps?: ComponentPropsWithRef<typeof Input>;
|
||||
inputProps?: Omit<ComponentProps<typeof Input>, 'onChange'> & {
|
||||
onChange?: (value: string) => void;
|
||||
};
|
||||
suggestions?: T[];
|
||||
renderInput?: (props: ComponentPropsWithRef<typeof Input>) => ReactNode;
|
||||
renderInput?: (props: ComponentProps<typeof Input>) => ReactNode;
|
||||
renderItems?: (
|
||||
items: T[],
|
||||
getItemProps: (arg: { item: T }) => ComponentPropsWithRef<typeof View>,
|
||||
getItemProps: (arg: { item: T }) => ComponentProps<typeof View>,
|
||||
idx: number,
|
||||
value?: string,
|
||||
) => ReactNode;
|
||||
@@ -137,14 +138,14 @@ function fireUpdate<T extends Item>(
|
||||
onUpdate?.(selected, value);
|
||||
}
|
||||
|
||||
function defaultRenderInput(props: ComponentPropsWithRef<typeof Input>) {
|
||||
function defaultRenderInput(props: ComponentProps<typeof Input>) {
|
||||
// data-1p-ignore disables 1Password autofill behaviour
|
||||
return <Input data-1p-ignore {...props} />;
|
||||
}
|
||||
|
||||
function defaultRenderItems<T extends Item>(
|
||||
items: T[],
|
||||
getItemProps: (arg: { item: T }) => ComponentPropsWithRef<typeof View>,
|
||||
getItemProps: (arg: { item: T }) => ComponentProps<typeof View>,
|
||||
highlightedIndex: number,
|
||||
) {
|
||||
return (
|
||||
@@ -209,7 +210,7 @@ type SingleAutocompleteProps<T extends Item> = CommonAutocompleteProps<T> & {
|
||||
};
|
||||
|
||||
function SingleAutocomplete<T extends Item>({
|
||||
autoFocus,
|
||||
focused,
|
||||
embedded = false,
|
||||
containerProps,
|
||||
labelProps = {},
|
||||
@@ -449,8 +450,8 @@ function SingleAutocomplete<T extends Item>({
|
||||
<View ref={triggerRef} style={{ flexShrink: 0 }}>
|
||||
{renderInput(
|
||||
getInputProps({
|
||||
focused,
|
||||
...inputProps,
|
||||
autoFocus,
|
||||
onFocus: e => {
|
||||
inputProps.onFocus?.(e);
|
||||
|
||||
@@ -549,9 +550,8 @@ function SingleAutocomplete<T extends Item>({
|
||||
}
|
||||
},
|
||||
onChange: (e: ChangeEvent<HTMLInputElement>) => {
|
||||
const { onChangeValue, onChange } = inputProps || {};
|
||||
onChangeValue?.(e.target.value);
|
||||
onChange?.(e);
|
||||
const { onChange } = inputProps || {};
|
||||
onChange?.(e.target.value);
|
||||
},
|
||||
}),
|
||||
)}
|
||||
@@ -641,6 +641,7 @@ function MultiAutocomplete<T extends Item>({
|
||||
clearOnBlur = true,
|
||||
...props
|
||||
}: MultiAutocompleteProps<T>) {
|
||||
const [focused, setFocused] = useState(false);
|
||||
const selectedItemIds = selectedItems.map(getItemId);
|
||||
|
||||
function onRemoveItem(id: T['id']) {
|
||||
@@ -657,7 +658,7 @@ function MultiAutocomplete<T extends Item>({
|
||||
|
||||
function onKeyDown(
|
||||
e: KeyboardEvent<HTMLInputElement>,
|
||||
prevOnKeyDown?: ComponentPropsWithoutRef<typeof Input>['onKeyDown'],
|
||||
prevOnKeyDown?: ComponentProps<typeof Input>['onKeyDown'],
|
||||
) {
|
||||
if (e.key === 'Backspace' && e.currentTarget.value === '') {
|
||||
onRemoveItem(selectedItemIds[selectedItems.length - 1]);
|
||||
@@ -681,7 +682,7 @@ function MultiAutocomplete<T extends Item>({
|
||||
strict={strict}
|
||||
renderInput={inputProps => (
|
||||
<View
|
||||
className={`${css({
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
flexDirection: 'row',
|
||||
@@ -689,11 +690,11 @@ function MultiAutocomplete<T extends Item>({
|
||||
backgroundColor: theme.tableBackground,
|
||||
borderRadius: 4,
|
||||
border: '1px solid ' + theme.formInputBorder,
|
||||
'&:focus-within': {
|
||||
...(focused && {
|
||||
border: '1px solid ' + theme.formInputBorderSelected,
|
||||
boxShadow: '0 1px 1px ' + theme.formInputShadowSelected,
|
||||
},
|
||||
})} ${inputProps.className || ''}`}
|
||||
}),
|
||||
}}
|
||||
>
|
||||
{selectedItems.map((item, idx) => {
|
||||
item = findItem(strict, suggestions, item);
|
||||
@@ -710,14 +711,21 @@ function MultiAutocomplete<T extends Item>({
|
||||
<Input
|
||||
{...inputProps}
|
||||
onKeyDown={e => onKeyDown(e, inputProps.onKeyDown)}
|
||||
className={String(
|
||||
css({
|
||||
flex: 1,
|
||||
minWidth: 30,
|
||||
border: 0,
|
||||
'&[data-focused]': { border: 0, boxShadow: 'none' },
|
||||
}),
|
||||
)}
|
||||
onFocus={e => {
|
||||
setFocused(true);
|
||||
inputProps.onFocus(e);
|
||||
}}
|
||||
onBlur={e => {
|
||||
setFocused(false);
|
||||
inputProps.onBlur(e);
|
||||
}}
|
||||
style={{
|
||||
flex: 1,
|
||||
minWidth: 30,
|
||||
border: 0,
|
||||
':focus': { border: 0, boxShadow: 'none' },
|
||||
...inputProps.style,
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
@@ -753,8 +761,8 @@ export function AutocompleteFooter({
|
||||
}
|
||||
|
||||
type AutocompleteProps<T extends Item> =
|
||||
| ComponentPropsWithoutRef<typeof SingleAutocomplete<T>>
|
||||
| ComponentPropsWithoutRef<typeof MultiAutocomplete<T>>;
|
||||
| ComponentProps<typeof SingleAutocomplete<T>>
|
||||
| ComponentProps<typeof MultiAutocomplete<T>>;
|
||||
|
||||
export function Autocomplete<T extends Item>({
|
||||
...props
|
||||
|
||||
@@ -25,13 +25,13 @@ import {
|
||||
import { useCategories } from '../../hooks/useCategories';
|
||||
import { useSyncedPref } from '../../hooks/useSyncedPref';
|
||||
import { SvgSplit } from '../../icons/v0';
|
||||
import { useResponsive } from '../../ResponsiveProvider';
|
||||
import { theme, styles } from '../../style';
|
||||
import { useEnvelopeSheetValue } from '../budget/envelope/EnvelopeBudgetComponents';
|
||||
import { makeAmountFullStyle } from '../budget/util';
|
||||
import { Text } from '../common/Text';
|
||||
import { TextOneLine } from '../common/TextOneLine';
|
||||
import { View } from '../common/View';
|
||||
import { useResponsive } from '../responsive/ResponsiveProvider';
|
||||
import { useSheetValue } from '../spreadsheet/useSheetValue';
|
||||
|
||||
import { Autocomplete, defaultFilterSuggestion } from './Autocomplete';
|
||||
@@ -88,8 +88,7 @@ function CategoryList({
|
||||
<View>
|
||||
<View
|
||||
style={{
|
||||
overflowY: 'auto',
|
||||
willChange: 'transform',
|
||||
overflow: 'auto',
|
||||
padding: '5px 0',
|
||||
...(!embedded && { maxHeight: 175 }),
|
||||
}}
|
||||
@@ -365,7 +364,7 @@ type CategoryItemProps = {
|
||||
|
||||
function CategoryItem({
|
||||
item,
|
||||
className = '',
|
||||
className,
|
||||
style,
|
||||
highlighted,
|
||||
embedded,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { type CSSProperties } from 'react';
|
||||
|
||||
import { useResponsive } from '../../ResponsiveProvider';
|
||||
import { styles, theme } from '../../style';
|
||||
import { useResponsive } from '../responsive/ResponsiveProvider';
|
||||
|
||||
type ItemHeaderProps = {
|
||||
title: string;
|
||||
|
||||
@@ -7,7 +7,7 @@ import { TestProvider } from 'loot-core/src/mocks/redux';
|
||||
import type { AccountEntity, PayeeEntity } from 'loot-core/types/models';
|
||||
|
||||
import { useCommonPayees } from '../../hooks/usePayees';
|
||||
import { ResponsiveProvider } from '../responsive/ResponsiveProvider';
|
||||
import { ResponsiveProvider } from '../../ResponsiveProvider';
|
||||
|
||||
import {
|
||||
PayeeAutocomplete,
|
||||
|
||||
@@ -16,8 +16,6 @@ import { useDispatch } from 'react-redux';
|
||||
|
||||
import { css, cx } from '@emotion/css';
|
||||
|
||||
import { createPayee } from 'loot-core/src/client/actions/queries';
|
||||
import { getActivePayees } from 'loot-core/src/client/reducers/queries';
|
||||
import { getNormalisedString } from 'loot-core/src/shared/normalisation';
|
||||
import {
|
||||
type AccountEntity,
|
||||
@@ -27,11 +25,13 @@ import {
|
||||
import { useAccounts } from '../../hooks/useAccounts';
|
||||
import { useCommonPayees, usePayees } from '../../hooks/usePayees';
|
||||
import { SvgAdd, SvgBookmark } from '../../icons/v1';
|
||||
import { useResponsive } from '../../ResponsiveProvider';
|
||||
import { createPayee } from '../../state/actions';
|
||||
import { getActivePayees } from '../../state/queries';
|
||||
import { theme, styles } from '../../style';
|
||||
import { Button } from '../common/Button';
|
||||
import { TextOneLine } from '../common/TextOneLine';
|
||||
import { View } from '../common/View';
|
||||
import { useResponsive } from '../responsive/ResponsiveProvider';
|
||||
|
||||
import {
|
||||
Autocomplete,
|
||||
@@ -341,6 +341,8 @@ export function PayeeAutocomplete({
|
||||
}
|
||||
}
|
||||
|
||||
const [payeeFieldFocused, setPayeeFieldFocused] = useState(false);
|
||||
|
||||
return (
|
||||
<Autocomplete
|
||||
key={focusTransferPayees ? 'transfers' : 'all'}
|
||||
@@ -358,11 +360,16 @@ export function PayeeAutocomplete({
|
||||
}
|
||||
return item.name;
|
||||
}}
|
||||
focused={payeeFieldFocused}
|
||||
inputProps={{
|
||||
...inputProps,
|
||||
autoCapitalize: 'words',
|
||||
onBlur: () => setRawPayee(''),
|
||||
onChangeValue: setRawPayee,
|
||||
onBlur: () => {
|
||||
setRawPayee('');
|
||||
setPayeeFieldFocused(false);
|
||||
},
|
||||
onFocus: () => setPayeeFieldFocused(true),
|
||||
onChange: setRawPayee,
|
||||
}}
|
||||
onUpdate={(id, inputValue) => onUpdate?.(id, makeNew(id, inputValue))}
|
||||
onSelect={handleSelect}
|
||||
@@ -549,7 +556,7 @@ type PayeeItemProps = {
|
||||
|
||||
function PayeeItem({
|
||||
item,
|
||||
className = '',
|
||||
className,
|
||||
highlighted,
|
||||
embedded,
|
||||
...props
|
||||
|
||||
@@ -11,10 +11,10 @@ import { css } from '@emotion/css';
|
||||
|
||||
import { useFeatureFlag } from '../../hooks/useFeatureFlag';
|
||||
import { SvgArrowThinRight } from '../../icons/v1';
|
||||
import { useResponsive } from '../../ResponsiveProvider';
|
||||
import { theme, styles } from '../../style';
|
||||
import { Tooltip } from '../common/Tooltip';
|
||||
import { View } from '../common/View';
|
||||
import { useResponsive } from '../responsive/ResponsiveProvider';
|
||||
import { type Binding } from '../spreadsheet';
|
||||
import { CellValue, CellValueText } from '../spreadsheet/CellValue';
|
||||
import { useFormat } from '../spreadsheet/useFormat';
|
||||
|
||||
@@ -28,7 +28,6 @@ export const BudgetCategories = memo(
|
||||
onSaveGroup,
|
||||
onDeleteCategory,
|
||||
onDeleteGroup,
|
||||
onApplyBudgetTemplatesInGroup,
|
||||
onReorderCategory,
|
||||
onReorderGroup,
|
||||
}) => {
|
||||
@@ -246,7 +245,6 @@ export const BudgetCategories = memo(
|
||||
onReorderCategory={onReorderCategory}
|
||||
onToggleCollapse={onToggleCollapse}
|
||||
onShowNewCategory={onShowNewCategory}
|
||||
onApplyBudgetTemplatesInGroup={onApplyBudgetTemplatesInGroup}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
|
||||
@@ -28,7 +28,6 @@ export function BudgetTable(props) {
|
||||
onDeleteCategory,
|
||||
onSaveGroup,
|
||||
onDeleteGroup,
|
||||
onApplyBudgetTemplatesInGroup,
|
||||
onReorderCategory,
|
||||
onReorderGroup,
|
||||
onShowActivity,
|
||||
@@ -236,7 +235,6 @@ export function BudgetTable(props) {
|
||||
onReorderGroup={_onReorderGroup}
|
||||
onBudgetAction={onBudgetAction}
|
||||
onShowActivity={onShowActivity}
|
||||
onApplyBudgetTemplatesInGroup={onApplyBudgetTemplatesInGroup}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -25,9 +25,6 @@ type ExpenseGroupProps = {
|
||||
onEditName?: ComponentProps<typeof SidebarGroup>['onEdit'];
|
||||
onSave?: ComponentProps<typeof SidebarGroup>['onSave'];
|
||||
onDelete?: ComponentProps<typeof SidebarGroup>['onDelete'];
|
||||
onApplyBudgetTemplatesInGroup?: ComponentProps<
|
||||
typeof SidebarGroup
|
||||
>['onApplyBudgetTemplatesInGroup'];
|
||||
onDragChange: OnDragChangeCallback<
|
||||
ComponentProps<typeof SidebarGroup>['group']
|
||||
>;
|
||||
@@ -46,7 +43,6 @@ export function ExpenseGroup({
|
||||
onEditName,
|
||||
onSave,
|
||||
onDelete,
|
||||
onApplyBudgetTemplatesInGroup,
|
||||
onDragChange,
|
||||
onReorderGroup,
|
||||
onReorderCategory,
|
||||
@@ -129,7 +125,6 @@ export function ExpenseGroup({
|
||||
onEdit={onEditName}
|
||||
onSave={onSave}
|
||||
onDelete={onDelete}
|
||||
onApplyBudgetTemplatesInGroup={onApplyBudgetTemplatesInGroup}
|
||||
onShowNewCategory={onShowNewCategory}
|
||||
/>
|
||||
<RenderMonths component={MonthComponent} args={{ group }} />
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
type CategoryEntity,
|
||||
} from 'loot-core/src/types/models';
|
||||
|
||||
import { useFeatureFlag } from '../../hooks/useFeatureFlag';
|
||||
import { SvgCheveronDown } from '../../icons/v1';
|
||||
import { theme } from '../../style';
|
||||
import { Button } from '../common/Button2';
|
||||
@@ -52,7 +51,6 @@ export function SidebarCategory({
|
||||
const temporary = category.id === 'new';
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const triggerRef = useRef(null);
|
||||
const contextMenusEnabled = useFeatureFlag('contextMenus');
|
||||
|
||||
const displayed = (
|
||||
<View
|
||||
@@ -63,13 +61,6 @@ export function SidebarCategory({
|
||||
WebkitUserSelect: 'none',
|
||||
opacity: category.hidden || categoryGroup?.hidden ? 0.33 : undefined,
|
||||
backgroundColor: 'transparent',
|
||||
height: 20,
|
||||
}}
|
||||
ref={triggerRef}
|
||||
onContextMenu={e => {
|
||||
if (!contextMenusEnabled) return;
|
||||
e.preventDefault();
|
||||
setMenuOpen(true);
|
||||
}}
|
||||
>
|
||||
<div
|
||||
@@ -83,7 +74,7 @@ export function SidebarCategory({
|
||||
>
|
||||
{category.name}
|
||||
</div>
|
||||
<View style={{ flexShrink: 0, marginLeft: 5 }}>
|
||||
<View style={{ flexShrink: 0, marginLeft: 5 }} ref={triggerRef}>
|
||||
<Button
|
||||
variant="bare"
|
||||
className="hover-visible"
|
||||
@@ -103,7 +94,6 @@ export function SidebarCategory({
|
||||
isOpen={menuOpen}
|
||||
onOpenChange={() => setMenuOpen(false)}
|
||||
style={{ width: 200 }}
|
||||
isNonModal
|
||||
>
|
||||
<Menu
|
||||
onMenuSelect={type => {
|
||||
|
||||
@@ -3,7 +3,6 @@ import React, { type CSSProperties, useRef, useState } from 'react';
|
||||
import { type ConnectDragSource } from 'react-dnd';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useFeatureFlag } from '../../hooks/useFeatureFlag';
|
||||
import { SvgExpandArrow } from '../../icons/v0';
|
||||
import { SvgCheveronDown } from '../../icons/v1';
|
||||
import { theme } from '../../style';
|
||||
@@ -33,7 +32,6 @@ type SidebarGroupProps = {
|
||||
onEdit?: (id: string) => void;
|
||||
onSave?: (group: object) => Promise<void>;
|
||||
onDelete?: (id: string) => Promise<void>;
|
||||
onApplyBudgetTemplatesInGroup?: (categories: object[]) => void;
|
||||
onShowNewCategory?: (groupId: string) => void;
|
||||
onHideNewGroup?: () => void;
|
||||
onToggleCollapse?: (id: string) => void;
|
||||
@@ -49,18 +47,15 @@ export function SidebarGroup({
|
||||
onEdit,
|
||||
onSave,
|
||||
onDelete,
|
||||
onApplyBudgetTemplatesInGroup,
|
||||
onShowNewCategory,
|
||||
onHideNewGroup,
|
||||
onToggleCollapse,
|
||||
}: SidebarGroupProps) {
|
||||
const { t } = useTranslation();
|
||||
const isGoalTemplatesEnabled = useFeatureFlag('goalTemplatesEnabled');
|
||||
|
||||
const temporary = group.id === 'new';
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const triggerRef = useRef(null);
|
||||
const contextMenusEnabled = useFeatureFlag('contextMenus');
|
||||
|
||||
const displayed = (
|
||||
<View
|
||||
@@ -69,17 +64,10 @@ export function SidebarGroup({
|
||||
alignItems: 'center',
|
||||
userSelect: 'none',
|
||||
WebkitUserSelect: 'none',
|
||||
height: 20,
|
||||
}}
|
||||
ref={triggerRef}
|
||||
onClick={() => {
|
||||
onToggleCollapse(group.id);
|
||||
}}
|
||||
onContextMenu={e => {
|
||||
if (!contextMenusEnabled) return;
|
||||
e.preventDefault();
|
||||
setMenuOpen(true);
|
||||
}}
|
||||
>
|
||||
{!dragPreview && (
|
||||
<SvgExpandArrow
|
||||
@@ -107,7 +95,7 @@ export function SidebarGroup({
|
||||
</div>
|
||||
{!dragPreview && (
|
||||
<>
|
||||
<View style={{ marginLeft: 5, flexShrink: 0 }}>
|
||||
<View style={{ marginLeft: 5, flexShrink: 0 }} ref={triggerRef}>
|
||||
<Button
|
||||
variant="bare"
|
||||
className="hover-visible"
|
||||
@@ -123,7 +111,6 @@ export function SidebarGroup({
|
||||
isOpen={menuOpen}
|
||||
onOpenChange={() => setMenuOpen(false)}
|
||||
style={{ width: 200 }}
|
||||
isNonModal
|
||||
>
|
||||
<Menu
|
||||
onMenuSelect={type => {
|
||||
@@ -135,12 +122,6 @@ export function SidebarGroup({
|
||||
onDelete(group.id);
|
||||
} else if (type === 'toggle-visibility') {
|
||||
onSave({ ...group, hidden: !group.hidden });
|
||||
} else if (type === 'apply-multiple-category-template') {
|
||||
onApplyBudgetTemplatesInGroup?.(
|
||||
group.categories
|
||||
.filter(c => !c['hidden'])
|
||||
.map(c => c['id']),
|
||||
);
|
||||
}
|
||||
setMenuOpen(false);
|
||||
}}
|
||||
@@ -152,14 +133,6 @@ export function SidebarGroup({
|
||||
text: group.hidden ? t('Show') : t('Hide'),
|
||||
},
|
||||
onDelete && { name: 'delete', text: t('Delete') },
|
||||
...(isGoalTemplatesEnabled
|
||||
? [
|
||||
{
|
||||
name: 'apply-multiple-category-template',
|
||||
text: t('Apply budget templates'),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]}
|
||||
/>
|
||||
</Popover>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useCallback, useRef, useState } from 'react';
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { envelopeBudget } from 'loot-core/src/client/queries';
|
||||
|
||||
@@ -23,20 +23,10 @@ export function BalanceMovementMenu({
|
||||
const catBalance = useEnvelopeSheetValue(
|
||||
envelopeBudget.catBalance(categoryId),
|
||||
);
|
||||
const [menu, _setMenu] = useState('menu');
|
||||
|
||||
const ref = useRef<HTMLSpanElement>(null);
|
||||
// Keep focus inside the popover on menu change
|
||||
const setMenu = useCallback(
|
||||
(menu: string) => {
|
||||
ref.current?.focus();
|
||||
_setMenu(menu);
|
||||
},
|
||||
[ref],
|
||||
);
|
||||
const [menu, setMenu] = useState('menu');
|
||||
|
||||
return (
|
||||
<span tabIndex={-1} ref={ref}>
|
||||
<>
|
||||
{menu === 'menu' && (
|
||||
<BalanceMenu
|
||||
categoryId={categoryId}
|
||||
@@ -80,6 +70,6 @@ export function BalanceMovementMenu({
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import React, { type FormEvent, useCallback, useMemo, useState } from 'react';
|
||||
import { Form } from 'react-aria-components';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
|
||||
import { type CategoryEntity } from 'loot-core/src/types/models';
|
||||
@@ -7,6 +6,7 @@ import { type CategoryEntity } from 'loot-core/src/types/models';
|
||||
import { useCategories } from '../../../hooks/useCategories';
|
||||
import { CategoryAutocomplete } from '../../autocomplete/CategoryAutocomplete';
|
||||
import { Button } from '../../common/Button2';
|
||||
import { InitialFocus } from '../../common/InitialFocus';
|
||||
import { View } from '../../common/View';
|
||||
import { addToBeBudgetedGroup, removeCategoriesFromGroups } from '../util';
|
||||
|
||||
@@ -39,55 +39,52 @@ export function CoverMenu({
|
||||
: categoryGroups;
|
||||
}, [categoryId, showToBeBudgeted, originalCategoryGroups]);
|
||||
|
||||
const onSubmitInner = useCallback(
|
||||
(e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (fromCategoryId) {
|
||||
onSubmit(fromCategoryId);
|
||||
}
|
||||
onClose();
|
||||
},
|
||||
[fromCategoryId, onSubmit, onClose],
|
||||
);
|
||||
|
||||
function submit() {
|
||||
if (fromCategoryId) {
|
||||
onSubmit(fromCategoryId);
|
||||
}
|
||||
onClose();
|
||||
}
|
||||
return (
|
||||
<Form onSubmit={onSubmitInner}>
|
||||
<View style={{ padding: 10 }}>
|
||||
<View style={{ marginBottom: 5 }}>
|
||||
<Trans>Cover from category:</Trans>
|
||||
</View>
|
||||
|
||||
<CategoryAutocomplete
|
||||
categoryGroups={filteredCategoryGroups}
|
||||
value={null}
|
||||
openOnFocus={true}
|
||||
onSelect={(id: string | undefined) => setFromCategoryId(id || null)}
|
||||
inputProps={{
|
||||
placeholder: t('(none)'),
|
||||
}}
|
||||
showHiddenCategories={false}
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
<View
|
||||
style={{
|
||||
alignItems: 'flex-end',
|
||||
marginTop: 10,
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="primary"
|
||||
type="submit"
|
||||
style={{
|
||||
fontSize: 12,
|
||||
paddingTop: 3,
|
||||
}}
|
||||
>
|
||||
<Trans>Transfer</Trans>
|
||||
</Button>
|
||||
</View>
|
||||
<View style={{ padding: 10 }}>
|
||||
<View style={{ marginBottom: 5 }}>
|
||||
<Trans>Cover from category:</Trans>
|
||||
</View>
|
||||
</Form>
|
||||
|
||||
<InitialFocus>
|
||||
{node => (
|
||||
<CategoryAutocomplete
|
||||
categoryGroups={filteredCategoryGroups}
|
||||
value={null}
|
||||
openOnFocus={true}
|
||||
onSelect={(id: string | undefined) => setFromCategoryId(id || null)}
|
||||
inputProps={{
|
||||
inputRef: node,
|
||||
onEnter: event => !event.defaultPrevented && submit(),
|
||||
placeholder: t('(none)'),
|
||||
}}
|
||||
showHiddenCategories={false}
|
||||
/>
|
||||
)}
|
||||
</InitialFocus>
|
||||
|
||||
<View
|
||||
style={{
|
||||
alignItems: 'flex-end',
|
||||
marginTop: 10,
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="primary"
|
||||
style={{
|
||||
fontSize: 12,
|
||||
paddingTop: 3,
|
||||
}}
|
||||
onPress={submit}
|
||||
>
|
||||
<Trans>Transfer</Trans>
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,7 +14,6 @@ import { evalArithmetic } from 'loot-core/src/shared/arithmetic';
|
||||
import * as monthUtils from 'loot-core/src/shared/months';
|
||||
import { integerToCurrency, amountToInteger } from 'loot-core/src/shared/util';
|
||||
|
||||
import { useFeatureFlag } from '../../../hooks/useFeatureFlag';
|
||||
import { useUndo } from '../../../hooks/useUndo';
|
||||
import { SvgCheveronDown } from '../../../icons/v1';
|
||||
import { styles, theme } from '../../../style';
|
||||
@@ -22,11 +21,7 @@ import { Button } from '../../common/Button2';
|
||||
import { Popover } from '../../common/Popover';
|
||||
import { Text } from '../../common/Text';
|
||||
import { View } from '../../common/View';
|
||||
import {
|
||||
type SheetResult,
|
||||
type Binding,
|
||||
type SheetFields,
|
||||
} from '../../spreadsheet';
|
||||
import { type Binding, type SheetFields } from '../../spreadsheet';
|
||||
import { CellValue, CellValueText } from '../../spreadsheet/CellValue';
|
||||
import { useSheetName } from '../../spreadsheet/useSheetName';
|
||||
import { useSheetValue } from '../../spreadsheet/useSheetValue';
|
||||
@@ -45,11 +40,8 @@ export function useEnvelopeSheetName<
|
||||
|
||||
export function useEnvelopeSheetValue<
|
||||
FieldName extends SheetFields<'envelope-budget'>,
|
||||
>(
|
||||
binding: Binding<'envelope-budget', FieldName>,
|
||||
onChange?: (result: SheetResult<'envelope-budget', FieldName>) => void,
|
||||
) {
|
||||
return useSheetValue(binding, onChange);
|
||||
>(binding: Binding<'envelope-budget', FieldName>) {
|
||||
return useSheetValue(binding);
|
||||
}
|
||||
|
||||
export const EnvelopeCellValue = <
|
||||
@@ -223,7 +215,6 @@ export const ExpenseCategoryMonth = memo(function ExpenseCategoryMonth({
|
||||
};
|
||||
|
||||
const { showUndoNotification } = useUndo();
|
||||
const contextMenusEnabled = useFeatureFlag('contextMenus');
|
||||
|
||||
return (
|
||||
<View
|
||||
@@ -247,12 +238,6 @@ export const ExpenseCategoryMonth = memo(function ExpenseCategoryMonth({
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
}}
|
||||
onContextMenu={e => {
|
||||
if (!contextMenusEnabled) return;
|
||||
if (editing) return;
|
||||
e.preventDefault();
|
||||
setBudgetMenuOpen(true);
|
||||
}}
|
||||
>
|
||||
{!editing && (
|
||||
<View
|
||||
@@ -289,7 +274,6 @@ export const ExpenseCategoryMonth = memo(function ExpenseCategoryMonth({
|
||||
isOpen={budgetMenuOpen}
|
||||
onOpenChange={() => setBudgetMenuOpen(false)}
|
||||
style={{ width: 200 }}
|
||||
isNonModal
|
||||
>
|
||||
<BudgetMenu
|
||||
onCopyLastMonthAverage={() => {
|
||||
@@ -406,11 +390,6 @@ export const ExpenseCategoryMonth = memo(function ExpenseCategoryMonth({
|
||||
<span
|
||||
ref={balanceMenuTriggerRef}
|
||||
onClick={() => setBalanceMenuOpen(true)}
|
||||
onContextMenu={e => {
|
||||
if (!contextMenusEnabled) return;
|
||||
e.preventDefault();
|
||||
setBalanceMenuOpen(true);
|
||||
}}
|
||||
>
|
||||
<BalanceWithCarryover
|
||||
carryover={envelopeBudget.catCarryover(category.id)}
|
||||
@@ -427,7 +406,6 @@ export const ExpenseCategoryMonth = memo(function ExpenseCategoryMonth({
|
||||
isOpen={balanceMenuOpen}
|
||||
onOpenChange={() => setBalanceMenuOpen(false)}
|
||||
style={{ width: 200 }}
|
||||
isNonModal
|
||||
>
|
||||
<BalanceMovementMenu
|
||||
categoryId={category.id}
|
||||
|
||||
@@ -1,48 +1,45 @@
|
||||
import React, { useState, useCallback, type FormEvent, useRef } from 'react';
|
||||
import { Form } from 'react-aria-components';
|
||||
import React, {
|
||||
useState,
|
||||
useContext,
|
||||
useEffect,
|
||||
type ChangeEvent,
|
||||
} from 'react';
|
||||
import { Trans } from 'react-i18next';
|
||||
|
||||
import { envelopeBudget } from 'loot-core/client/queries';
|
||||
import { useSpreadsheet } from 'loot-core/src/client/SpreadsheetProvider';
|
||||
import { evalArithmetic } from 'loot-core/src/shared/arithmetic';
|
||||
import { integerToCurrency, amountToInteger } from 'loot-core/src/shared/util';
|
||||
|
||||
import { Button } from '../../common/Button2';
|
||||
import { InitialFocus } from '../../common/InitialFocus';
|
||||
import { Input } from '../../common/Input';
|
||||
import { View } from '../../common/View';
|
||||
|
||||
import { useEnvelopeSheetValue } from './EnvelopeBudgetComponents';
|
||||
import { NamespaceContext } from '../../spreadsheet/NamespaceContext';
|
||||
|
||||
type HoldMenuProps = {
|
||||
onSubmit: (amount: number) => void;
|
||||
onClose: () => void;
|
||||
};
|
||||
export function HoldMenu({ onSubmit, onClose }: HoldMenuProps) {
|
||||
const [amount, setAmount] = useState<string>(
|
||||
integerToCurrency(
|
||||
useEnvelopeSheetValue(envelopeBudget.toBudget, result => {
|
||||
setAmount(integerToCurrency(result?.value ?? 0));
|
||||
}) ?? 0,
|
||||
),
|
||||
);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const spreadsheet = useSpreadsheet();
|
||||
const sheetName = useContext(NamespaceContext);
|
||||
|
||||
const onSubmitInner = useCallback(
|
||||
(e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
const [amount, setAmount] = useState<string | null>(null);
|
||||
|
||||
if (amount === '') {
|
||||
inputRef.current?.focus();
|
||||
return;
|
||||
}
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const node = await spreadsheet.get(sheetName, 'to-budget');
|
||||
setAmount(integerToCurrency(Math.max(node.value as number, 0)));
|
||||
})();
|
||||
}, []);
|
||||
|
||||
const parsedAmount = evalArithmetic(amount);
|
||||
if (parsedAmount) {
|
||||
onSubmit(amountToInteger(parsedAmount));
|
||||
}
|
||||
onClose();
|
||||
},
|
||||
[amount, onSubmit, onClose],
|
||||
);
|
||||
function submit(newAmount: string) {
|
||||
const parsedAmount = evalArithmetic(newAmount);
|
||||
if (parsedAmount) {
|
||||
onSubmit(amountToInteger(parsedAmount));
|
||||
}
|
||||
onClose();
|
||||
}
|
||||
|
||||
if (amount === null) {
|
||||
// See `TransferMenu` for more info about this
|
||||
@@ -50,37 +47,39 @@ export function HoldMenu({ onSubmit, onClose }: HoldMenuProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<Form onSubmit={onSubmitInner}>
|
||||
<View style={{ padding: 10 }}>
|
||||
<View style={{ marginBottom: 5 }}>
|
||||
<Trans>Hold this amount:</Trans>
|
||||
</View>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={amount}
|
||||
onChangeValue={(value: string) => setAmount(value)}
|
||||
autoFocus
|
||||
autoSelect
|
||||
/>
|
||||
<View
|
||||
style={{
|
||||
alignItems: 'flex-end',
|
||||
marginTop: 10,
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="primary"
|
||||
type="submit"
|
||||
style={{
|
||||
fontSize: 12,
|
||||
paddingTop: 3,
|
||||
paddingBottom: 3,
|
||||
}}
|
||||
>
|
||||
<Trans>Hold</Trans>
|
||||
</Button>
|
||||
</View>
|
||||
<View style={{ padding: 10 }}>
|
||||
<View style={{ marginBottom: 5 }}>
|
||||
<Trans>Hold this amount:</Trans>
|
||||
</View>
|
||||
</Form>
|
||||
<View>
|
||||
<InitialFocus>
|
||||
<Input
|
||||
value={amount}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) =>
|
||||
setAmount(e.target.value)
|
||||
}
|
||||
onEnter={() => submit(amount)}
|
||||
/>
|
||||
</InitialFocus>
|
||||
</View>
|
||||
<View
|
||||
style={{
|
||||
alignItems: 'flex-end',
|
||||
marginTop: 10,
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="primary"
|
||||
style={{
|
||||
fontSize: 12,
|
||||
paddingTop: 3,
|
||||
paddingBottom: 3,
|
||||
}}
|
||||
onPress={() => submit(amount)}
|
||||
>
|
||||
<Trans>Hold</Trans>
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import React, { type FormEvent, useCallback, useMemo, useState } from 'react';
|
||||
import { Form } from 'react-aria-components';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { Trans } from 'react-i18next';
|
||||
|
||||
import { evalArithmetic } from 'loot-core/src/shared/arithmetic';
|
||||
@@ -9,6 +8,7 @@ import { type CategoryEntity } from 'loot-core/types/models';
|
||||
import { useCategories } from '../../../hooks/useCategories';
|
||||
import { CategoryAutocomplete } from '../../autocomplete/CategoryAutocomplete';
|
||||
import { Button } from '../../common/Button2';
|
||||
import { InitialFocus } from '../../common/InitialFocus';
|
||||
import { Input } from '../../common/Input';
|
||||
import { View } from '../../common/View';
|
||||
import { addToBeBudgetedGroup, removeCategoriesFromGroups } from '../util';
|
||||
@@ -42,67 +42,65 @@ export function TransferMenu({
|
||||
}, [originalCategoryGroups, categoryId, showToBeBudgeted]);
|
||||
|
||||
const _initialAmount = integerToCurrency(Math.max(initialAmount, 0));
|
||||
const [amount, setAmount] = useState<string>(_initialAmount);
|
||||
const [amount, setAmount] = useState<string | null>(null);
|
||||
const [toCategoryId, setToCategoryId] = useState<string | null>(null);
|
||||
|
||||
const _onSubmit = useCallback(
|
||||
(e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
const _onSubmit = (newAmount: string | null, categoryId: string | null) => {
|
||||
const parsedAmount = evalArithmetic(newAmount || '');
|
||||
if (parsedAmount && categoryId) {
|
||||
onSubmit?.(amountToInteger(parsedAmount), categoryId);
|
||||
}
|
||||
|
||||
const parsedAmount = evalArithmetic(amount || '');
|
||||
if (parsedAmount && toCategoryId) {
|
||||
onSubmit?.(amountToInteger(parsedAmount), toCategoryId);
|
||||
}
|
||||
|
||||
onClose();
|
||||
},
|
||||
[amount, toCategoryId, onSubmit, onClose],
|
||||
);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Form onSubmit={_onSubmit}>
|
||||
<View style={{ padding: 10 }}>
|
||||
<View style={{ marginBottom: 5 }}>
|
||||
<Trans>Transfer this amount:</Trans>
|
||||
</View>
|
||||
<Input
|
||||
value={amount}
|
||||
onChangeValue={value => setAmount(value)}
|
||||
autoFocus
|
||||
autoSelect
|
||||
/>
|
||||
<View style={{ margin: '10px 0 5px 0' }}>To:</View>
|
||||
|
||||
<CategoryAutocomplete
|
||||
categoryGroups={filteredCategoryGroups}
|
||||
value={null}
|
||||
openOnFocus={true}
|
||||
onSelect={(id: string | undefined) => setToCategoryId(id || null)}
|
||||
inputProps={{
|
||||
placeholder: '(none)',
|
||||
}}
|
||||
showHiddenCategories={true}
|
||||
/>
|
||||
|
||||
<View
|
||||
style={{
|
||||
alignItems: 'flex-end',
|
||||
marginTop: 10,
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="primary"
|
||||
type="submit"
|
||||
style={{
|
||||
fontSize: 12,
|
||||
paddingTop: 3,
|
||||
paddingBottom: 3,
|
||||
}}
|
||||
>
|
||||
<Trans>Transfer</Trans>
|
||||
</Button>
|
||||
</View>
|
||||
<View style={{ padding: 10 }}>
|
||||
<View style={{ marginBottom: 5 }}>
|
||||
<Trans>Transfer this amount:</Trans>
|
||||
</View>
|
||||
</Form>
|
||||
<View>
|
||||
<InitialFocus>
|
||||
<Input
|
||||
defaultValue={_initialAmount}
|
||||
onUpdate={value => setAmount(value)}
|
||||
onEnter={() => _onSubmit(amount, toCategoryId)}
|
||||
/>
|
||||
</InitialFocus>
|
||||
</View>
|
||||
<View style={{ margin: '10px 0 5px 0' }}>To:</View>
|
||||
|
||||
<CategoryAutocomplete
|
||||
categoryGroups={filteredCategoryGroups}
|
||||
value={null}
|
||||
openOnFocus={true}
|
||||
onSelect={(id: string | undefined) => setToCategoryId(id || null)}
|
||||
inputProps={{
|
||||
onEnter: event =>
|
||||
!event.defaultPrevented && _onSubmit(amount, toCategoryId),
|
||||
placeholder: '(none)',
|
||||
}}
|
||||
showHiddenCategories={true}
|
||||
/>
|
||||
|
||||
<View
|
||||
style={{
|
||||
alignItems: 'flex-end',
|
||||
marginTop: 10,
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="primary"
|
||||
style={{
|
||||
fontSize: 12,
|
||||
paddingTop: 3,
|
||||
paddingBottom: 3,
|
||||
}}
|
||||
onPress={() => _onSubmit(amount, toCategoryId)}
|
||||
>
|
||||
<Trans>Transfer</Trans>
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
import React, {
|
||||
useRef,
|
||||
useState,
|
||||
type CSSProperties,
|
||||
useCallback,
|
||||
} from 'react';
|
||||
import React, { useRef, useState, type CSSProperties } from 'react';
|
||||
|
||||
import { envelopeBudget } from 'loot-core/src/client/queries';
|
||||
|
||||
import { useFeatureFlag } from '../../../../hooks/useFeatureFlag';
|
||||
import { Popover } from '../../../common/Popover';
|
||||
import { View } from '../../../common/View';
|
||||
import { CoverMenu } from '../CoverMenu';
|
||||
@@ -34,17 +28,8 @@ export function ToBudget({
|
||||
amountStyle,
|
||||
isCollapsed = false,
|
||||
}: ToBudgetProps) {
|
||||
const [menuOpen, _setMenuOpen] = useState<string | null>(null);
|
||||
const [menuOpen, setMenuOpen] = useState<string | null>(null);
|
||||
const triggerRef = useRef(null);
|
||||
|
||||
const ref = useRef<HTMLSpanElement>(null);
|
||||
const setMenuOpen = useCallback(
|
||||
(menu: string | null) => {
|
||||
if (menu) ref.current?.focus();
|
||||
_setMenuOpen(menu);
|
||||
},
|
||||
[ref],
|
||||
);
|
||||
const sheetValue = useEnvelopeSheetValue({
|
||||
name: envelopeBudget.toBudget,
|
||||
value: 0,
|
||||
@@ -56,7 +41,6 @@ export function ToBudget({
|
||||
);
|
||||
}
|
||||
const isMenuOpen = Boolean(menuOpen);
|
||||
const contextMenusEnabled = useFeatureFlag('contextMenus');
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -67,11 +51,6 @@ export function ToBudget({
|
||||
style={style}
|
||||
amountStyle={amountStyle}
|
||||
isTotalsListTooltipDisabled={!isCollapsed || isMenuOpen}
|
||||
onContextMenu={e => {
|
||||
if (!contextMenusEnabled) return;
|
||||
e.preventDefault();
|
||||
setMenuOpen('actions');
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
|
||||
@@ -81,52 +60,49 @@ export function ToBudget({
|
||||
isOpen={isMenuOpen}
|
||||
onOpenChange={() => setMenuOpen(null)}
|
||||
style={{ width: 200 }}
|
||||
isNonModal
|
||||
>
|
||||
<span tabIndex={-1} ref={ref}>
|
||||
{menuOpen === 'actions' && (
|
||||
<ToBudgetMenu
|
||||
onTransfer={() => setMenuOpen('transfer')}
|
||||
onCover={() => setMenuOpen('cover')}
|
||||
onHoldBuffer={() => setMenuOpen('buffer')}
|
||||
onResetHoldBuffer={() => {
|
||||
onBudgetAction(month, 'reset-hold');
|
||||
setMenuOpen(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{menuOpen === 'buffer' && (
|
||||
<HoldMenu
|
||||
onClose={() => setMenuOpen(null)}
|
||||
onSubmit={amount => {
|
||||
onBudgetAction(month, 'hold', { amount });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{menuOpen === 'transfer' && (
|
||||
<TransferMenu
|
||||
initialAmount={availableValue ?? undefined}
|
||||
onClose={() => setMenuOpen(null)}
|
||||
onSubmit={(amount, categoryId) => {
|
||||
onBudgetAction(month, 'transfer-available', {
|
||||
amount,
|
||||
category: categoryId,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{menuOpen === 'cover' && (
|
||||
<CoverMenu
|
||||
showToBeBudgeted={false}
|
||||
onClose={() => setMenuOpen(null)}
|
||||
onSubmit={categoryId => {
|
||||
onBudgetAction(month, 'cover-overbudgeted', {
|
||||
category: categoryId,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
{menuOpen === 'actions' && (
|
||||
<ToBudgetMenu
|
||||
onTransfer={() => setMenuOpen('transfer')}
|
||||
onCover={() => setMenuOpen('cover')}
|
||||
onHoldBuffer={() => setMenuOpen('buffer')}
|
||||
onResetHoldBuffer={() => {
|
||||
onBudgetAction(month, 'reset-hold');
|
||||
setMenuOpen(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{menuOpen === 'buffer' && (
|
||||
<HoldMenu
|
||||
onClose={() => setMenuOpen(null)}
|
||||
onSubmit={amount => {
|
||||
onBudgetAction(month, 'hold', { amount });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{menuOpen === 'transfer' && (
|
||||
<TransferMenu
|
||||
initialAmount={availableValue ?? undefined}
|
||||
onClose={() => setMenuOpen(null)}
|
||||
onSubmit={(amount, categoryId) => {
|
||||
onBudgetAction(month, 'transfer-available', {
|
||||
amount,
|
||||
category: categoryId,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{menuOpen === 'cover' && (
|
||||
<CoverMenu
|
||||
showToBeBudgeted={false}
|
||||
onClose={() => setMenuOpen(null)}
|
||||
onSubmit={categoryId => {
|
||||
onBudgetAction(month, 'cover-overbudgeted', {
|
||||
category: categoryId,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Popover>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { type CSSProperties, type MouseEventHandler } from 'react';
|
||||
import React, { type CSSProperties } from 'react';
|
||||
|
||||
import { css } from '@emotion/css';
|
||||
|
||||
@@ -22,7 +22,6 @@ type ToBudgetAmountProps = {
|
||||
style?: CSSProperties;
|
||||
amountStyle?: CSSProperties;
|
||||
onClick: () => void;
|
||||
onContextMenu?: MouseEventHandler;
|
||||
isTotalsListTooltipDisabled?: boolean;
|
||||
};
|
||||
|
||||
@@ -32,7 +31,6 @@ export function ToBudgetAmount({
|
||||
amountStyle,
|
||||
onClick,
|
||||
isTotalsListTooltipDisabled = false,
|
||||
onContextMenu,
|
||||
}: ToBudgetAmountProps) {
|
||||
const sheetName = useEnvelopeSheetName(envelopeBudget.toBudget);
|
||||
const sheetValue = useEnvelopeSheetValue({
|
||||
@@ -73,7 +71,6 @@ export function ToBudgetAmount({
|
||||
>
|
||||
<Block
|
||||
onClick={onClick}
|
||||
onContextMenu={onContextMenu}
|
||||
data-cellname={sheetName}
|
||||
className={css([
|
||||
styles.veryLargeText,
|
||||
|
||||
@@ -3,6 +3,15 @@ import React, { memo, useMemo, useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useDispatch } from 'react-redux';
|
||||
|
||||
import { useSpreadsheet } from 'loot-core/src/client/SpreadsheetProvider';
|
||||
import { send, listen } from 'loot-core/src/platform/client/fetch';
|
||||
import * as monthUtils from 'loot-core/src/shared/months';
|
||||
|
||||
import { useCategories } from '../../hooks/useCategories';
|
||||
import { useGlobalPref } from '../../hooks/useGlobalPref';
|
||||
import { useLocalPref } from '../../hooks/useLocalPref';
|
||||
import { useNavigate } from '../../hooks/useNavigate';
|
||||
import { useSyncedPref } from '../../hooks/useSyncedPref';
|
||||
import {
|
||||
addNotification,
|
||||
applyBudgetAction,
|
||||
@@ -16,16 +25,7 @@ import {
|
||||
pushModal,
|
||||
updateCategory,
|
||||
updateGroup,
|
||||
} from 'loot-core/src/client/actions';
|
||||
import { useSpreadsheet } from 'loot-core/src/client/SpreadsheetProvider';
|
||||
import { send, listen } from 'loot-core/src/platform/client/fetch';
|
||||
import * as monthUtils from 'loot-core/src/shared/months';
|
||||
|
||||
import { useCategories } from '../../hooks/useCategories';
|
||||
import { useGlobalPref } from '../../hooks/useGlobalPref';
|
||||
import { useLocalPref } from '../../hooks/useLocalPref';
|
||||
import { useNavigate } from '../../hooks/useNavigate';
|
||||
import { useSyncedPref } from '../../hooks/useSyncedPref';
|
||||
} from '../../state/actions';
|
||||
import { styles } from '../../style';
|
||||
import { View } from '../common/View';
|
||||
import { NamespaceContext } from '../spreadsheet/NamespaceContext';
|
||||
@@ -265,15 +265,6 @@ function BudgetInner(props: BudgetInnerProps) {
|
||||
}
|
||||
};
|
||||
|
||||
const onApplyBudgetTemplatesInGroup = async categories => {
|
||||
dispatch(
|
||||
applyBudgetAction(startMonth, 'apply-multiple-templates', {
|
||||
month: startMonth,
|
||||
categories,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const onBudgetAction = (month, type, args) => {
|
||||
dispatch(applyBudgetAction(month, type, args));
|
||||
};
|
||||
@@ -375,7 +366,6 @@ function BudgetInner(props: BudgetInnerProps) {
|
||||
onMonthSelect={onMonthSelect}
|
||||
onDeleteCategory={onDeleteCategory}
|
||||
onDeleteGroup={onDeleteGroup}
|
||||
onApplyBudgetTemplatesInGroup={onApplyBudgetTemplatesInGroup}
|
||||
onSaveCategory={onSaveCategory}
|
||||
onSaveGroup={onSaveGroup}
|
||||
onBudgetAction={onBudgetAction}
|
||||
|
||||
@@ -22,11 +22,7 @@ import { Button } from '../../common/Button2';
|
||||
import { Popover } from '../../common/Popover';
|
||||
import { Text } from '../../common/Text';
|
||||
import { View } from '../../common/View';
|
||||
import {
|
||||
type SheetResult,
|
||||
type Binding,
|
||||
type SheetFields,
|
||||
} from '../../spreadsheet';
|
||||
import { type Binding, type SheetFields } from '../../spreadsheet';
|
||||
import { CellValue, CellValueText } from '../../spreadsheet/CellValue';
|
||||
import { useSheetValue } from '../../spreadsheet/useSheetValue';
|
||||
import { Field, SheetCell, type SheetCellProps } from '../../table';
|
||||
@@ -40,9 +36,8 @@ export const useTrackingSheetValue = <
|
||||
FieldName extends SheetFields<'tracking-budget'>,
|
||||
>(
|
||||
binding: Binding<'tracking-budget', FieldName>,
|
||||
onChange?: (result: SheetResult<'tracking-budget', FieldName>) => void,
|
||||
) => {
|
||||
return useSheetValue(binding, onChange);
|
||||
return useSheetValue(binding);
|
||||
};
|
||||
|
||||
const TrackingCellValue = <FieldName extends SheetFields<'tracking-budget'>>(
|
||||
|
||||
@@ -60,7 +60,7 @@ export function separateGroups(categoryGroups: CategoryGroupEntity[]) {
|
||||
return [
|
||||
categoryGroups.filter(g => !g.is_income),
|
||||
categoryGroups.find(g => g.is_income),
|
||||
] as const;
|
||||
];
|
||||
}
|
||||
|
||||
export function makeAmountGrey(value: number | string): CSSProperties {
|
||||
|
||||
@@ -177,7 +177,7 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
className={
|
||||
typeof className === 'function'
|
||||
? renderProps =>
|
||||
`${defaultButtonClassName} ${className(renderProps) || ''}`
|
||||
`${defaultButtonClassName} ${className(renderProps)}`
|
||||
: `${defaultButtonClassName} ${className || ''}`
|
||||
}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import {
|
||||
type ReactElement,
|
||||
type Ref,
|
||||
cloneElement,
|
||||
useEffect,
|
||||
useRef,
|
||||
} from 'react';
|
||||
|
||||
type InitialFocusProps = {
|
||||
children:
|
||||
| ReactElement<{ inputRef: Ref<HTMLInputElement> }>
|
||||
| ((node: Ref<HTMLInputElement>) => ReactElement);
|
||||
};
|
||||
|
||||
export function InitialFocus({ children }: InitialFocusProps) {
|
||||
const node = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (node.current) {
|
||||
// This is needed to avoid a strange interaction with
|
||||
// `ScopeTab`, which doesn't allow it to be focused at first for
|
||||
// some reason. Need to look into it.
|
||||
setTimeout(() => {
|
||||
if (node.current) {
|
||||
node.current.focus();
|
||||
node.current.setSelectionRange(0, 10000);
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
}, []);
|
||||
|
||||
if (typeof children === 'function') {
|
||||
return children(node);
|
||||
}
|
||||
return cloneElement(children, { inputRef: node });
|
||||
}
|
||||
@@ -1,138 +1,110 @@
|
||||
import React, {
|
||||
type InputHTMLAttributes,
|
||||
type KeyboardEvent,
|
||||
type ComponentPropsWithRef,
|
||||
forwardRef,
|
||||
useEffect,
|
||||
type Ref,
|
||||
type CSSProperties,
|
||||
useRef,
|
||||
useMemo,
|
||||
} from 'react';
|
||||
import { Input as ReactAriaInput } from 'react-aria-components';
|
||||
|
||||
import { css, cx } from '@emotion/css';
|
||||
|
||||
import { useMergedRefs } from '../../hooks/useMergedRefs';
|
||||
import { useProperFocus } from '../../hooks/useProperFocus';
|
||||
import { styles, theme } from '../../style';
|
||||
|
||||
type InputProps = ComponentPropsWithRef<typeof ReactAriaInput> & {
|
||||
autoSelect?: boolean;
|
||||
export const defaultInputStyle = {
|
||||
outline: 0,
|
||||
backgroundColor: theme.tableBackground,
|
||||
color: theme.formInputText,
|
||||
margin: 0,
|
||||
padding: 5,
|
||||
borderRadius: 4,
|
||||
border: '1px solid ' + theme.formInputBorder,
|
||||
};
|
||||
|
||||
type InputProps = InputHTMLAttributes<HTMLInputElement> & {
|
||||
style?: CSSProperties;
|
||||
inputRef?: Ref<HTMLInputElement>;
|
||||
onEnter?: (event: KeyboardEvent<HTMLInputElement>) => void;
|
||||
onEscape?: (event: KeyboardEvent<HTMLInputElement>) => void;
|
||||
onChangeValue?: (newValue: string) => void;
|
||||
onUpdate?: (newValue: string) => void;
|
||||
focused?: boolean;
|
||||
};
|
||||
|
||||
export const Input = forwardRef<HTMLInputElement, InputProps>(
|
||||
(
|
||||
{
|
||||
autoSelect,
|
||||
className = '',
|
||||
onEnter,
|
||||
onEscape,
|
||||
onChangeValue,
|
||||
onUpdate,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const mergedRef = useMergedRefs<HTMLInputElement>(inputRef, ref);
|
||||
export function Input({
|
||||
style,
|
||||
inputRef,
|
||||
onEnter,
|
||||
onEscape,
|
||||
onChangeValue,
|
||||
onUpdate,
|
||||
focused,
|
||||
className,
|
||||
...nativeProps
|
||||
}: InputProps) {
|
||||
const ref = useRef<HTMLInputElement>(null);
|
||||
useProperFocus(ref, focused);
|
||||
|
||||
useEffect(() => {
|
||||
if (autoSelect) {
|
||||
// Select on mount does not work properly for inputs that are inside a dialog.
|
||||
// See https://github.com/facebook/react/issues/23301#issuecomment-1656908450
|
||||
// for the reason why we need to use setTimeout here.
|
||||
setTimeout(() => inputRef.current?.select());
|
||||
}
|
||||
}, [autoSelect]);
|
||||
const mergedRef = useMergedRefs<HTMLInputElement>(ref, inputRef);
|
||||
|
||||
const defaultInputClassName = useMemo(
|
||||
() =>
|
||||
return (
|
||||
<input
|
||||
ref={mergedRef}
|
||||
className={cx(
|
||||
css(
|
||||
defaultInputStyle,
|
||||
{
|
||||
outline: 0,
|
||||
backgroundColor: theme.tableBackground,
|
||||
color: theme.formInputText,
|
||||
margin: 0,
|
||||
padding: 5,
|
||||
borderRadius: 4,
|
||||
border: '1px solid ' + theme.formInputBorder,
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
flexShrink: 0,
|
||||
'&[data-focused]': {
|
||||
':focus': {
|
||||
border: '1px solid ' + theme.formInputBorderSelected,
|
||||
boxShadow: '0 1px 1px ' + theme.formInputShadowSelected,
|
||||
},
|
||||
'&::placeholder': { color: theme.formInputTextPlaceholder },
|
||||
'::placeholder': { color: theme.formInputTextPlaceholder },
|
||||
},
|
||||
styles.smallText,
|
||||
style,
|
||||
),
|
||||
[],
|
||||
);
|
||||
className,
|
||||
)}
|
||||
{...nativeProps}
|
||||
onKeyDown={e => {
|
||||
nativeProps.onKeyDown?.(e);
|
||||
|
||||
return (
|
||||
<ReactAriaInput
|
||||
ref={mergedRef}
|
||||
{...props}
|
||||
className={
|
||||
typeof className === 'function'
|
||||
? renderProps => cx(defaultInputClassName, className(renderProps))
|
||||
: cx(defaultInputClassName, className)
|
||||
if (e.key === 'Enter' && onEnter) {
|
||||
onEnter(e);
|
||||
}
|
||||
onKeyDown={e => {
|
||||
props.onKeyDown?.(e);
|
||||
|
||||
if (e.key === 'Enter' && onEnter) {
|
||||
onEnter(e);
|
||||
}
|
||||
|
||||
if (e.key === 'Escape' && onEscape) {
|
||||
onEscape(e);
|
||||
}
|
||||
}}
|
||||
onBlur={e => {
|
||||
onUpdate?.(e.target.value);
|
||||
props.onBlur?.(e);
|
||||
}}
|
||||
onChange={e => {
|
||||
onChangeValue?.(e.target.value);
|
||||
props.onChange?.(e);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Input.displayName = 'Input';
|
||||
|
||||
type BigInputProps = InputProps;
|
||||
|
||||
export const BigInput = forwardRef<HTMLInputElement, BigInputProps>(
|
||||
({ className, ...props }, ref) => {
|
||||
const defaultClassName = useMemo(
|
||||
() =>
|
||||
String(
|
||||
css({
|
||||
padding: 10,
|
||||
fontSize: 15,
|
||||
'&, &[data-focused]': { border: 'none', ...styles.shadow },
|
||||
}),
|
||||
),
|
||||
[],
|
||||
);
|
||||
return (
|
||||
<Input
|
||||
ref={ref}
|
||||
{...props}
|
||||
className={renderProps =>
|
||||
typeof className === 'function'
|
||||
? cx(defaultClassName, className(renderProps))
|
||||
: cx(defaultClassName, className)
|
||||
if (e.key === 'Escape' && onEscape) {
|
||||
onEscape(e);
|
||||
}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
}}
|
||||
onBlur={e => {
|
||||
onUpdate?.(e.target.value);
|
||||
nativeProps.onBlur?.(e);
|
||||
}}
|
||||
onChange={e => {
|
||||
onChangeValue?.(e.target.value);
|
||||
nativeProps.onChange?.(e);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
BigInput.displayName = 'BigInput';
|
||||
export function BigInput(props: InputProps) {
|
||||
return (
|
||||
<Input
|
||||
{...props}
|
||||
style={{
|
||||
padding: 10,
|
||||
fontSize: 15,
|
||||
border: 'none',
|
||||
...styles.shadow,
|
||||
':focus': { border: 'none', ...styles.shadow },
|
||||
...props.style,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,48 +1,74 @@
|
||||
import { type ComponentPropsWithRef, type ReactNode, forwardRef } from 'react';
|
||||
|
||||
import { css, cx } from '@emotion/css';
|
||||
import {
|
||||
useState,
|
||||
type ComponentProps,
|
||||
type ReactNode,
|
||||
type CSSProperties,
|
||||
} from 'react';
|
||||
|
||||
import { theme } from '../../style';
|
||||
|
||||
import { Input } from './Input';
|
||||
import { Input, defaultInputStyle } from './Input';
|
||||
import { View } from './View';
|
||||
|
||||
type InputWithContentProps = ComponentPropsWithRef<typeof Input> & {
|
||||
type InputWithContentProps = ComponentProps<typeof Input> & {
|
||||
leftContent?: ReactNode;
|
||||
rightContent?: ReactNode;
|
||||
containerClassName?: string;
|
||||
inputStyle?: CSSProperties;
|
||||
focusStyle?: CSSProperties;
|
||||
style?: CSSProperties;
|
||||
getStyle?: (focused: boolean) => CSSProperties;
|
||||
};
|
||||
export const InputWithContent = forwardRef<
|
||||
HTMLInputElement,
|
||||
InputWithContentProps
|
||||
>(({ leftContent, rightContent, containerClassName, ...props }, ref) => {
|
||||
export function InputWithContent({
|
||||
leftContent,
|
||||
rightContent,
|
||||
inputStyle,
|
||||
focusStyle,
|
||||
style,
|
||||
getStyle,
|
||||
...props
|
||||
}: InputWithContentProps) {
|
||||
const [focused, setFocused] = useState(props.focused ?? false);
|
||||
|
||||
return (
|
||||
<View
|
||||
className={cx(
|
||||
css({
|
||||
backgroundColor: theme.tableBackground,
|
||||
color: theme.formInputText,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
borderRadius: 4,
|
||||
'&:focus-within': {
|
||||
style={{
|
||||
...defaultInputStyle,
|
||||
padding: 0,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
...style,
|
||||
...(focused &&
|
||||
(focusStyle ?? {
|
||||
boxShadow: '0 0 0 1px ' + theme.formInputShadowSelected,
|
||||
},
|
||||
'& input, input[data-focused], input[data-hovered]': {
|
||||
})),
|
||||
...getStyle?.(focused),
|
||||
}}
|
||||
>
|
||||
{leftContent}
|
||||
<Input
|
||||
{...props}
|
||||
focused={focused}
|
||||
style={{
|
||||
width: '100%',
|
||||
...inputStyle,
|
||||
flex: 1,
|
||||
'&, &:focus, &:hover': {
|
||||
border: 0,
|
||||
backgroundColor: 'transparent',
|
||||
boxShadow: 'none',
|
||||
color: 'inherit',
|
||||
},
|
||||
}),
|
||||
containerClassName,
|
||||
)}
|
||||
>
|
||||
{leftContent}
|
||||
<Input ref={ref} {...props} />
|
||||
}}
|
||||
onFocus={e => {
|
||||
setFocused(true);
|
||||
props.onFocus?.(e);
|
||||
}}
|
||||
onBlur={e => {
|
||||
setFocused(false);
|
||||
props.onBlur?.(e);
|
||||
}}
|
||||
/>
|
||||
{rightContent}
|
||||
</View>
|
||||
);
|
||||
});
|
||||
|
||||
InputWithContent.displayName = 'InputWithContent';
|
||||
}
|
||||
|
||||