mirror of
https://github.com/actualbudget/actual.git
synced 2026-03-09 03:32:54 -05:00
Some typescript migration (#1532)
* Typescript migration * Release notes * Update error boundary * Breakup sidebar components * Account and Sidebar props * Remove button in Item component + exports cleanup * Put accountNameStyle to Account * Revert component ports (separated to another PR) * Export cleanup * Remove ErrorBoundary (separated to another PR) * Sidebar budgetName as ReactNode
This commit is contained in:
committed by
GitHub
parent
f8ce38f11e
commit
6fae79560e
@@ -1,17 +1,25 @@
|
||||
import React from 'react';
|
||||
import React, { type CSSProperties } from 'react';
|
||||
|
||||
import { css } from 'glamor';
|
||||
import { keyframes } from 'glamor';
|
||||
|
||||
import Refresh from '../icons/v1/Refresh';
|
||||
|
||||
import View from './common/View';
|
||||
|
||||
let spin = css.keyframes({
|
||||
let spin = keyframes({
|
||||
'0%': { transform: 'rotateZ(0deg)' },
|
||||
'100%': { transform: 'rotateZ(360deg)' },
|
||||
});
|
||||
|
||||
export default function AnimatedRefresh({ animating, iconStyle }) {
|
||||
type AnimatedRefreshProps = {
|
||||
animating: boolean;
|
||||
iconStyle?: CSSProperties;
|
||||
};
|
||||
|
||||
export default function AnimatedRefresh({
|
||||
animating,
|
||||
iconStyle,
|
||||
}: AnimatedRefreshProps) {
|
||||
return (
|
||||
<View
|
||||
style={[{ animation: animating ? `${spin} 1s infinite linear` : null }]}
|
||||
@@ -32,7 +32,6 @@ import { getIsOutdated, getLatestVersion } from '../util/versions';
|
||||
import BankSyncStatus from './BankSyncStatus';
|
||||
import { BudgetMonthCountProvider } from './budget/BudgetMonthCountContext';
|
||||
import View from './common/View';
|
||||
import FloatableSidebar, { SidebarProvider } from './FloatableSidebar';
|
||||
import GlobalKeys from './GlobalKeys';
|
||||
import { ManageRulesPage } from './ManageRulesPage';
|
||||
import Modals from './Modals';
|
||||
@@ -41,6 +40,7 @@ import { ManagePayeesPage } from './payees/ManagePayeesPage';
|
||||
import Reports from './reports';
|
||||
import { NarrowAlternate, WideComponent } from './responsive';
|
||||
import Settings from './settings';
|
||||
import FloatableSidebar, { SidebarProvider } from './sidebar';
|
||||
import Titlebar, { TitlebarProvider } from './Titlebar';
|
||||
import { TransactionEdit } from './transactions/MobileTransaction';
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, type CSSProperties } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import { useActions } from '../hooks/useActions';
|
||||
@@ -11,7 +11,16 @@ import View from './common/View';
|
||||
import { useServerURL } from './ServerContext';
|
||||
import { Tooltip } from './tooltips';
|
||||
|
||||
export default function LoggedInUser({ hideIfNoServer, style, color }) {
|
||||
type LoggedInUserProps = {
|
||||
hideIfNoServer?: boolean;
|
||||
style?: CSSProperties;
|
||||
color?: string;
|
||||
};
|
||||
export default function LoggedInUser({
|
||||
hideIfNoServer,
|
||||
style,
|
||||
color,
|
||||
}: LoggedInUserProps) {
|
||||
let userData = useSelector(state => state.user.data);
|
||||
let { getUserData, signOut, closeBudget } = useActions();
|
||||
let [loading, setLoading] = useState(true);
|
||||
@@ -6,7 +6,7 @@ import { Page } from './Page';
|
||||
export function ManageRulesPage() {
|
||||
return (
|
||||
<Page title="Rules">
|
||||
<ManageRules />
|
||||
<ManageRules isModal={false} payeeId={null} />
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
@@ -40,7 +40,7 @@ export default function MobileWebMessage() {
|
||||
let d = new Date();
|
||||
d.setTime(d.getTime() + 1000 * 60 * 5);
|
||||
document.cookie =
|
||||
'hideMobileMessage=true;path=/;expires=' + d.toGMTString();
|
||||
'hideMobileMessage=true;path=/;expires=' + d.toUTCString();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
export default function SyncRefresh({ onSync, children }) {
|
||||
let [syncing, setSyncing] = useState(false);
|
||||
|
||||
async function onSync_() {
|
||||
setSyncing(true);
|
||||
await onSync();
|
||||
setSyncing(false);
|
||||
}
|
||||
|
||||
return children({ refreshing: syncing, onRefresh: onSync_ });
|
||||
}
|
||||
21
packages/desktop-client/src/components/SyncRefresh.tsx
Normal file
21
packages/desktop-client/src/components/SyncRefresh.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { type ReactNode, useState } from 'react';
|
||||
|
||||
type ChildrenProps = {
|
||||
refreshing: boolean;
|
||||
onRefresh: () => Promise<void>;
|
||||
};
|
||||
type SyncRefreshProps = {
|
||||
onSync: () => Promise<void>;
|
||||
children: (props: ChildrenProps) => ReactNode;
|
||||
};
|
||||
export default function SyncRefresh({ onSync, children }: SyncRefreshProps) {
|
||||
let [syncing, setSyncing] = useState(false);
|
||||
|
||||
async function onSync_() {
|
||||
setSyncing(true);
|
||||
await onSync();
|
||||
setSyncing(false);
|
||||
}
|
||||
|
||||
return children({ refreshing: syncing, onRefresh: onSync_ });
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import React, {
|
||||
useEffect,
|
||||
useRef,
|
||||
useContext,
|
||||
type ReactNode,
|
||||
type CSSProperties,
|
||||
} from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { Routes, Route, useLocation, useNavigate } from 'react-router-dom';
|
||||
@@ -34,16 +36,19 @@ import ExternalLink from './common/ExternalLink';
|
||||
import Paragraph from './common/Paragraph';
|
||||
import Text from './common/Text';
|
||||
import View from './common/View';
|
||||
import { useSidebar } from './FloatableSidebar';
|
||||
import LoggedInUser from './LoggedInUser';
|
||||
import { useServerURL } from './ServerContext';
|
||||
import { useSidebar } from './sidebar';
|
||||
import useSheetValue from './spreadsheet/useSheetValue';
|
||||
import { ThemeSelector } from './ThemeSelector';
|
||||
import { Tooltip } from './tooltips';
|
||||
|
||||
export let TitlebarContext = createContext();
|
||||
export let TitlebarContext = createContext(null);
|
||||
|
||||
export function TitlebarProvider({ children }) {
|
||||
type TitlebarProviderProps = {
|
||||
children?: ReactNode;
|
||||
};
|
||||
export function TitlebarProvider({ children }: TitlebarProviderProps) {
|
||||
let listeners = useRef([]);
|
||||
|
||||
function sendEvent(msg) {
|
||||
@@ -101,7 +106,10 @@ function PrivacyButton() {
|
||||
);
|
||||
}
|
||||
|
||||
export function SyncButton({ style }) {
|
||||
type SyncButtonProps = {
|
||||
style?: CSSProperties;
|
||||
};
|
||||
export function SyncButton({ style }: SyncButtonProps) {
|
||||
let cloudFileId = useSelector(state => state.prefs.local.cloudFileId);
|
||||
let { sync } = useActions();
|
||||
|
||||
@@ -10,8 +10,8 @@ import Button from '../common/Button';
|
||||
import Select from '../common/Select';
|
||||
import Text from '../common/Text';
|
||||
import View from '../common/View';
|
||||
import { useSidebar } from '../FloatableSidebar';
|
||||
import { Checkbox } from '../forms';
|
||||
import { useSidebar } from '../sidebar';
|
||||
|
||||
import { Setting } from './UI';
|
||||
|
||||
|
||||
@@ -1,620 +0,0 @@
|
||||
import React, { useState, useMemo, useCallback, useEffect } from 'react';
|
||||
import { useLocation } from 'react-router';
|
||||
|
||||
import { css } from 'glamor';
|
||||
|
||||
import * as Platform from 'loot-core/src/client/platform';
|
||||
|
||||
import Add from '../icons/v1/Add';
|
||||
import CheveronDown from '../icons/v1/CheveronDown';
|
||||
import CheveronRight from '../icons/v1/CheveronRight';
|
||||
import Cog from '../icons/v1/Cog';
|
||||
import Pin from '../icons/v1/Pin';
|
||||
import Reports from '../icons/v1/Reports';
|
||||
import StoreFrontIcon from '../icons/v1/StoreFront';
|
||||
import TuningIcon from '../icons/v1/Tuning';
|
||||
import Wallet from '../icons/v1/Wallet';
|
||||
import ArrowButtonLeft1 from '../icons/v2/ArrowButtonLeft1';
|
||||
import CalendarIcon from '../icons/v2/Calendar';
|
||||
import { styles, colors } from '../style';
|
||||
|
||||
import AlignedText from './common/AlignedText';
|
||||
import AnchorLink from './common/AnchorLink';
|
||||
import Block from './common/Block';
|
||||
import Button from './common/Button';
|
||||
import View from './common/View';
|
||||
import { useSidebar } from './FloatableSidebar';
|
||||
import { useDraggable, useDroppable, DropHighlight } from './sort';
|
||||
import CellValue from './spreadsheet/CellValue';
|
||||
|
||||
export const SIDEBAR_WIDTH = 240;
|
||||
|
||||
const fontWeight = 600;
|
||||
|
||||
function ItemContent({
|
||||
style,
|
||||
to,
|
||||
onClick,
|
||||
activeStyle,
|
||||
forceActive,
|
||||
children,
|
||||
}) {
|
||||
return onClick ? (
|
||||
<View
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
style={[
|
||||
style,
|
||||
{
|
||||
touchAction: 'auto',
|
||||
userSelect: 'none',
|
||||
userDrag: 'none',
|
||||
cursor: 'pointer',
|
||||
...(forceActive ? activeStyle : {}),
|
||||
},
|
||||
]}
|
||||
onClick={onClick}
|
||||
>
|
||||
{children}
|
||||
</View>
|
||||
) : (
|
||||
<AnchorLink
|
||||
to={to}
|
||||
style={style}
|
||||
activeStyle={activeStyle}
|
||||
forceActive={forceActive}
|
||||
>
|
||||
{children}
|
||||
</AnchorLink>
|
||||
);
|
||||
}
|
||||
|
||||
function Item({
|
||||
children,
|
||||
Icon,
|
||||
title,
|
||||
style,
|
||||
indent = 0,
|
||||
to,
|
||||
onClick,
|
||||
button,
|
||||
forceHover = false,
|
||||
forceActive = false,
|
||||
}) {
|
||||
const hoverStyle = {
|
||||
backgroundColor: colors.n2,
|
||||
};
|
||||
const activeStyle = {
|
||||
borderLeft: '4px solid ' + colors.p8,
|
||||
paddingLeft: 19 + indent - 4,
|
||||
color: colors.p8,
|
||||
};
|
||||
const linkStyle = [
|
||||
{
|
||||
...styles.mediumText,
|
||||
paddingTop: 9,
|
||||
paddingBottom: 9,
|
||||
paddingLeft: 19 + indent,
|
||||
paddingRight: 10,
|
||||
textDecoration: 'none',
|
||||
color: colors.n9,
|
||||
...(forceHover ? hoverStyle : {}),
|
||||
},
|
||||
{ ':hover': hoverStyle },
|
||||
];
|
||||
|
||||
const content = (
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
height: 20,
|
||||
}}
|
||||
>
|
||||
<Icon width={15} height={15} />
|
||||
<Block style={{ marginLeft: 8 }}>{title}</Block>
|
||||
<View style={{ flex: 1 }} />
|
||||
{button}
|
||||
</View>
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={[{ flexShrink: 0 }, style]}>
|
||||
<ItemContent
|
||||
style={linkStyle}
|
||||
to={to}
|
||||
onClick={onClick}
|
||||
activeStyle={activeStyle}
|
||||
forceActive={forceActive}
|
||||
>
|
||||
{content}
|
||||
</ItemContent>
|
||||
{children ? <View style={{ marginTop: 5 }}>{children}</View> : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function SecondaryItem({ Icon, title, style, to, onClick, bold, indent = 0 }) {
|
||||
const hoverStyle = {
|
||||
backgroundColor: colors.n2,
|
||||
};
|
||||
const activeStyle = {
|
||||
borderLeft: '4px solid ' + colors.p8,
|
||||
paddingLeft: 14 - 4 + indent,
|
||||
color: colors.p8,
|
||||
fontWeight: bold ? fontWeight : null,
|
||||
};
|
||||
const linkStyle = [
|
||||
accountNameStyle,
|
||||
{
|
||||
color: colors.n9,
|
||||
paddingLeft: 14 + indent,
|
||||
fontWeight: bold ? fontWeight : null,
|
||||
},
|
||||
{ ':hover': hoverStyle },
|
||||
];
|
||||
|
||||
const content = (
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
height: 16,
|
||||
}}
|
||||
>
|
||||
{Icon && <Icon width={12} height={12} />}
|
||||
<Block style={{ marginLeft: Icon ? 8 : 0, color: 'inherit' }}>
|
||||
{title}
|
||||
</Block>
|
||||
</View>
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={[{ flexShrink: 0 }, style]}>
|
||||
<ItemContent
|
||||
style={linkStyle}
|
||||
to={to}
|
||||
onClick={onClick}
|
||||
activeStyle={activeStyle}
|
||||
>
|
||||
{content}
|
||||
</ItemContent>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
let accountNameStyle = [
|
||||
{
|
||||
marginTop: -2,
|
||||
marginBottom: 2,
|
||||
paddingTop: 4,
|
||||
paddingBottom: 4,
|
||||
paddingRight: 15,
|
||||
paddingLeft: 10,
|
||||
textDecoration: 'none',
|
||||
color: colors.n9,
|
||||
},
|
||||
{ ':hover': { backgroundColor: colors.n2 } },
|
||||
styles.smallText,
|
||||
];
|
||||
|
||||
function Account({
|
||||
name,
|
||||
account,
|
||||
connected,
|
||||
failed,
|
||||
updated,
|
||||
to,
|
||||
query,
|
||||
style,
|
||||
outerStyle,
|
||||
onDragChange,
|
||||
onDrop,
|
||||
}) {
|
||||
let type = account
|
||||
? account.closed
|
||||
? 'account-closed'
|
||||
: account.offbudget
|
||||
? 'account-offbudget'
|
||||
: 'account-onbudget'
|
||||
: 'title';
|
||||
|
||||
let { dragRef } = useDraggable({
|
||||
type,
|
||||
onDragChange,
|
||||
item: { id: account && account.id },
|
||||
canDrag: account != null,
|
||||
});
|
||||
|
||||
let { dropRef, dropPos } = useDroppable({
|
||||
types: account ? [type] : [],
|
||||
id: account && account.id,
|
||||
onDrop: onDrop,
|
||||
});
|
||||
|
||||
return (
|
||||
<View innerRef={dropRef} style={[{ flexShrink: 0 }, outerStyle]}>
|
||||
<View>
|
||||
<DropHighlight pos={dropPos} />
|
||||
<View innerRef={dragRef}>
|
||||
<AnchorLink
|
||||
to={to}
|
||||
style={[
|
||||
accountNameStyle,
|
||||
style,
|
||||
{ position: 'relative', borderLeft: '4px solid transparent' },
|
||||
updated && { fontWeight: 700 },
|
||||
]}
|
||||
activeStyle={{
|
||||
borderColor: colors.p8,
|
||||
color: colors.p8,
|
||||
// This is kind of a hack, but we don't ever want the account
|
||||
// that the user is looking at to be "bolded" which means it
|
||||
// has unread transactions. The system does mark is read and
|
||||
// unbolds it, but it still "flashes" bold so this just
|
||||
// ignores it if it's active
|
||||
fontWeight: (style && style.fontWeight) || 'normal',
|
||||
'& .dot': {
|
||||
backgroundColor: colors.p8,
|
||||
transform: 'translateX(-4.5px)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="dot"
|
||||
{...css({
|
||||
marginRight: 3,
|
||||
width: 5,
|
||||
height: 5,
|
||||
borderRadius: 5,
|
||||
backgroundColor: failed ? colors.r7 : colors.g5,
|
||||
marginLeft: 2,
|
||||
transition: 'transform .3s',
|
||||
opacity: connected ? 1 : 0,
|
||||
})}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<AlignedText
|
||||
left={name}
|
||||
right={
|
||||
<CellValue debug={true} binding={query} type="financial" />
|
||||
}
|
||||
/>
|
||||
</AnchorLink>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function Accounts({
|
||||
accounts,
|
||||
failedAccounts,
|
||||
updatedAccounts,
|
||||
getAccountPath,
|
||||
allAccountsPath,
|
||||
budgetedAccountPath,
|
||||
offBudgetAccountPath,
|
||||
getBalanceQuery,
|
||||
getAllAccountBalance,
|
||||
getOnBudgetBalance,
|
||||
getOffBudgetBalance,
|
||||
showClosedAccounts,
|
||||
onAddAccount,
|
||||
onToggleClosedAccounts,
|
||||
onReorder,
|
||||
}) {
|
||||
let [isDragging, setIsDragging] = useState(false);
|
||||
let offbudgetAccounts = useMemo(
|
||||
() =>
|
||||
accounts.filter(
|
||||
account => account.closed === 0 && account.offbudget === 1,
|
||||
),
|
||||
[accounts],
|
||||
);
|
||||
let budgetedAccounts = useMemo(
|
||||
() =>
|
||||
accounts.filter(
|
||||
account => account.closed === 0 && account.offbudget === 0,
|
||||
),
|
||||
[accounts],
|
||||
);
|
||||
let closedAccounts = useMemo(
|
||||
() => accounts.filter(account => account.closed === 1),
|
||||
[accounts],
|
||||
);
|
||||
|
||||
function onDragChange(drag) {
|
||||
setIsDragging(drag.state === 'start');
|
||||
}
|
||||
|
||||
let makeDropPadding = (i, length) => {
|
||||
if (i === 0) {
|
||||
return {
|
||||
paddingTop: isDragging ? 15 : 0,
|
||||
marginTop: isDragging ? -15 : 0,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<View>
|
||||
<Account
|
||||
name="All accounts"
|
||||
to={allAccountsPath}
|
||||
query={getAllAccountBalance()}
|
||||
style={{ fontWeight, marginTop: 15 }}
|
||||
/>
|
||||
|
||||
{budgetedAccounts.length > 0 && (
|
||||
<Account
|
||||
name="For budget"
|
||||
to={budgetedAccountPath}
|
||||
query={getOnBudgetBalance()}
|
||||
style={{ fontWeight, marginTop: 13 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{budgetedAccounts.map((account, i) => (
|
||||
<Account
|
||||
key={account.id}
|
||||
name={account.name}
|
||||
account={account}
|
||||
connected={!!account.bankId}
|
||||
failed={failedAccounts && failedAccounts.has(account.id)}
|
||||
updated={updatedAccounts && updatedAccounts.includes(account.id)}
|
||||
to={getAccountPath(account)}
|
||||
query={getBalanceQuery(account)}
|
||||
onDragChange={onDragChange}
|
||||
onDrop={onReorder}
|
||||
outerStyle={makeDropPadding(i, budgetedAccounts.length)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{offbudgetAccounts.length > 0 && (
|
||||
<Account
|
||||
name="Off budget"
|
||||
to={offBudgetAccountPath}
|
||||
query={getOffBudgetBalance()}
|
||||
style={{ fontWeight, marginTop: 13 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{offbudgetAccounts.map((account, i) => (
|
||||
<Account
|
||||
key={account.id}
|
||||
name={account.name}
|
||||
account={account}
|
||||
connected={!!account.bankId}
|
||||
failed={failedAccounts && failedAccounts.has(account.id)}
|
||||
updated={updatedAccounts && updatedAccounts.includes(account.id)}
|
||||
to={getAccountPath(account)}
|
||||
query={getBalanceQuery(account)}
|
||||
onDragChange={onDragChange}
|
||||
onDrop={onReorder}
|
||||
outerStyle={makeDropPadding(i, offbudgetAccounts.length)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{closedAccounts.length > 0 && (
|
||||
<SecondaryItem
|
||||
style={{ marginTop: 15 }}
|
||||
title={'Closed accounts' + (showClosedAccounts ? '' : '...')}
|
||||
onClick={onToggleClosedAccounts}
|
||||
bold
|
||||
/>
|
||||
)}
|
||||
|
||||
{showClosedAccounts &&
|
||||
closedAccounts.map((account, i) => (
|
||||
<Account
|
||||
key={account.id}
|
||||
name={account.name}
|
||||
account={account}
|
||||
to={getAccountPath(account)}
|
||||
query={getBalanceQuery(account)}
|
||||
onDragChange={onDragChange}
|
||||
onDrop={onReorder}
|
||||
/>
|
||||
))}
|
||||
|
||||
<SecondaryItem
|
||||
style={{
|
||||
marginTop: 15,
|
||||
marginBottom: 9,
|
||||
}}
|
||||
onClick={onAddAccount}
|
||||
Icon={Add}
|
||||
title="Add account"
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function ToggleButton({ style, isFloating, onFloat }) {
|
||||
return (
|
||||
<View className="float" style={[style, { flexShrink: 0 }]}>
|
||||
<Button type="bare" onClick={onFloat} color={colors.n5}>
|
||||
{isFloating ? (
|
||||
<Pin
|
||||
style={{
|
||||
margin: -2,
|
||||
width: 15,
|
||||
height: 15,
|
||||
transform: 'rotate(45deg)',
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<ArrowButtonLeft1 style={{ width: 13, height: 13 }} />
|
||||
)}
|
||||
</Button>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function Tools() {
|
||||
let [isOpen, setOpen] = useState(false);
|
||||
let onToggle = useCallback(() => setOpen(open => !open), []);
|
||||
let location = useLocation();
|
||||
|
||||
const isActive = ['/payees', '/rules', '/settings', '/tools'].some(route =>
|
||||
location.pathname.startsWith(route),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isActive) {
|
||||
setOpen(true);
|
||||
}
|
||||
}, [location.pathname]);
|
||||
|
||||
return (
|
||||
<View style={{ flexShrink: 0 }}>
|
||||
<Item
|
||||
title="More"
|
||||
Icon={isOpen ? CheveronDown : CheveronRight}
|
||||
onClick={onToggle}
|
||||
style={{ marginBottom: isOpen ? 8 : 0 }}
|
||||
forceActive={!isOpen && isActive}
|
||||
/>
|
||||
{isOpen && (
|
||||
<>
|
||||
<SecondaryItem
|
||||
title="Payees"
|
||||
Icon={StoreFrontIcon}
|
||||
to="/payees"
|
||||
indent={15}
|
||||
/>
|
||||
<SecondaryItem
|
||||
title="Rules"
|
||||
Icon={TuningIcon}
|
||||
to="/rules"
|
||||
indent={15}
|
||||
/>
|
||||
<SecondaryItem
|
||||
title="Settings"
|
||||
Icon={Cog}
|
||||
to="/settings"
|
||||
indent={15}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export function Sidebar({
|
||||
style,
|
||||
budgetName,
|
||||
accounts,
|
||||
failedAccounts,
|
||||
updatedAccounts,
|
||||
getBalanceQuery,
|
||||
getAllAccountBalance,
|
||||
getOnBudgetBalance,
|
||||
getOffBudgetBalance,
|
||||
showClosedAccounts,
|
||||
isFloating,
|
||||
onFloat,
|
||||
onAddAccount,
|
||||
onToggleClosedAccounts,
|
||||
onReorder,
|
||||
}) {
|
||||
let hasWindowButtons = !Platform.isBrowser && Platform.OS === 'mac';
|
||||
|
||||
const sidebar = useSidebar();
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
{
|
||||
width: SIDEBAR_WIDTH,
|
||||
color: colors.n9,
|
||||
backgroundColor: colors.n1,
|
||||
'& .float': {
|
||||
opacity: isFloating ? 1 : 0,
|
||||
transition: 'opacity .25s, width .25s',
|
||||
width: hasWindowButtons || isFloating ? null : 0,
|
||||
},
|
||||
'&:hover .float': {
|
||||
opacity: 1,
|
||||
width: hasWindowButtons ? null : 'auto',
|
||||
},
|
||||
},
|
||||
style,
|
||||
]}
|
||||
>
|
||||
<View
|
||||
style={[
|
||||
{
|
||||
paddingTop: 35,
|
||||
height: 30,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
margin: '0 8px 23px 20px',
|
||||
transition: 'padding .4s',
|
||||
},
|
||||
hasWindowButtons && {
|
||||
paddingTop: 20,
|
||||
justifyContent: 'flex-start',
|
||||
},
|
||||
]}
|
||||
>
|
||||
{budgetName}
|
||||
|
||||
<View style={{ flex: 1, flexDirection: 'row' }} />
|
||||
|
||||
{!sidebar.alwaysFloats && (
|
||||
<ToggleButton isFloating={isFloating} onFloat={onFloat} />
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={{ overflow: 'auto' }}>
|
||||
<Item title="Budget" Icon={Wallet} to="/budget" />
|
||||
<Item title="Reports" Icon={Reports} to="/reports" />
|
||||
|
||||
<Item title="Schedules" Icon={CalendarIcon} to="/schedules" />
|
||||
|
||||
<Tools />
|
||||
|
||||
<View
|
||||
style={{
|
||||
height: 1,
|
||||
backgroundColor: colors.n3,
|
||||
marginTop: 15,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
|
||||
<Accounts
|
||||
accounts={accounts}
|
||||
failedAccounts={failedAccounts}
|
||||
updatedAccounts={updatedAccounts}
|
||||
getAccountPath={account => `/accounts/${account.id}`}
|
||||
allAccountsPath="/accounts"
|
||||
budgetedAccountPath="/accounts/budgeted"
|
||||
offBudgetAccountPath="/accounts/offbudget"
|
||||
getBalanceQuery={getBalanceQuery}
|
||||
getAllAccountBalance={getAllAccountBalance}
|
||||
getOnBudgetBalance={getOnBudgetBalance}
|
||||
getOffBudgetBalance={getOffBudgetBalance}
|
||||
showClosedAccounts={showClosedAccounts}
|
||||
onAddAccount={onAddAccount}
|
||||
onToggleClosedAccounts={onToggleClosedAccounts}
|
||||
onReorder={onReorder}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
148
packages/desktop-client/src/components/sidebar/Account.tsx
Normal file
148
packages/desktop-client/src/components/sidebar/Account.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
import React, { type CSSProperties } from 'react';
|
||||
|
||||
import { css } from 'glamor';
|
||||
|
||||
import { type AccountEntity } from 'loot-core/src/types/models';
|
||||
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { styles, colors } from '../../style';
|
||||
import AlignedText from '../common/AlignedText';
|
||||
import AnchorLink from '../common/AnchorLink';
|
||||
import View from '../common/View';
|
||||
import {
|
||||
useDraggable,
|
||||
useDroppable,
|
||||
DropHighlight,
|
||||
type OnDragChangeCallback,
|
||||
type OnDropCallback,
|
||||
} from '../sort';
|
||||
import { type Binding } from '../spreadsheet';
|
||||
import CellValue from '../spreadsheet/CellValue';
|
||||
|
||||
const accountNameStyle = {
|
||||
marginTop: -2,
|
||||
marginBottom: 2,
|
||||
paddingTop: 4,
|
||||
paddingBottom: 4,
|
||||
paddingRight: 15,
|
||||
paddingLeft: 10,
|
||||
textDecoration: 'none',
|
||||
color: colors.n9,
|
||||
':hover': { backgroundColor: colors.n2 },
|
||||
...styles.smallText,
|
||||
};
|
||||
|
||||
type AccountProps = {
|
||||
name: string;
|
||||
to: string;
|
||||
query: Binding;
|
||||
account?: AccountEntity;
|
||||
connected?: boolean;
|
||||
failed?: boolean;
|
||||
updated?: boolean;
|
||||
style?: CSSProperties;
|
||||
outerStyle?: CSSProperties;
|
||||
onDragChange?: OnDragChangeCallback;
|
||||
onDrop?: OnDropCallback;
|
||||
};
|
||||
|
||||
function Account({
|
||||
name,
|
||||
account,
|
||||
connected,
|
||||
failed,
|
||||
updated,
|
||||
to,
|
||||
query,
|
||||
style,
|
||||
outerStyle,
|
||||
onDragChange,
|
||||
onDrop,
|
||||
}: AccountProps) {
|
||||
let type = account
|
||||
? account.closed
|
||||
? 'account-closed'
|
||||
: account.offbudget
|
||||
? 'account-offbudget'
|
||||
: 'account-onbudget'
|
||||
: 'title';
|
||||
|
||||
let { dragRef } = useDraggable({
|
||||
type,
|
||||
onDragChange,
|
||||
item: { id: account && account.id },
|
||||
canDrag: account != null,
|
||||
});
|
||||
|
||||
let { dropRef, dropPos } = useDroppable({
|
||||
types: account ? [type] : [],
|
||||
id: account && account.id,
|
||||
onDrop: onDrop,
|
||||
});
|
||||
|
||||
return (
|
||||
<View innerRef={dropRef} style={[{ flexShrink: 0 }, outerStyle]}>
|
||||
<View>
|
||||
<DropHighlight pos={dropPos} />
|
||||
<View innerRef={dragRef}>
|
||||
<AnchorLink
|
||||
to={to}
|
||||
style={[
|
||||
accountNameStyle,
|
||||
style,
|
||||
{ position: 'relative', borderLeft: '4px solid transparent' },
|
||||
updated && { fontWeight: 700 },
|
||||
]}
|
||||
activeStyle={{
|
||||
borderColor: colors.p8,
|
||||
color: colors.p8,
|
||||
// This is kind of a hack, but we don't ever want the account
|
||||
// that the user is looking at to be "bolded" which means it
|
||||
// has unread transactions. The system does mark is read and
|
||||
// unbolds it, but it still "flashes" bold so this just
|
||||
// ignores it if it's active
|
||||
fontWeight: (style && style.fontWeight) || 'normal',
|
||||
'& .dot': {
|
||||
backgroundColor: colors.p8,
|
||||
transform: 'translateX(-4.5px)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="dot"
|
||||
{...css({
|
||||
marginRight: 3,
|
||||
width: 5,
|
||||
height: 5,
|
||||
borderRadius: 5,
|
||||
backgroundColor: failed ? colors.r7 : colors.g5,
|
||||
marginLeft: 2,
|
||||
transition: 'transform .3s',
|
||||
opacity: connected ? 1 : 0,
|
||||
})}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<AlignedText
|
||||
left={name}
|
||||
right={<CellValue binding={query} type="financial" />}
|
||||
/>
|
||||
</AnchorLink>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export { accountNameStyle };
|
||||
export default Account;
|
||||
184
packages/desktop-client/src/components/sidebar/Accounts.tsx
Normal file
184
packages/desktop-client/src/components/sidebar/Accounts.tsx
Normal file
@@ -0,0 +1,184 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
|
||||
import { type AccountEntity } from 'loot-core/src/types/models';
|
||||
|
||||
import Add from '../../icons/v1/Add';
|
||||
import View from '../common/View';
|
||||
import { type OnDropCallback } from '../sort';
|
||||
import { type Binding } from '../spreadsheet';
|
||||
|
||||
import Account from './Account';
|
||||
import SecondaryItem from './SecondaryItem';
|
||||
|
||||
const fontWeight = 600;
|
||||
|
||||
type AccountsProps = {
|
||||
accounts: AccountEntity[];
|
||||
failedAccounts: Map<
|
||||
string,
|
||||
{
|
||||
type: string;
|
||||
code: string;
|
||||
}
|
||||
>;
|
||||
updatedAccounts: string[];
|
||||
getAccountPath: (account: AccountEntity) => string;
|
||||
allAccountsPath: string;
|
||||
budgetedAccountPath: string;
|
||||
offBudgetAccountPath: string;
|
||||
getBalanceQuery: (account: AccountEntity) => Binding;
|
||||
getAllAccountBalance: () => Binding;
|
||||
getOnBudgetBalance: () => Binding;
|
||||
getOffBudgetBalance: () => Binding;
|
||||
showClosedAccounts: boolean;
|
||||
onAddAccount: () => void;
|
||||
onToggleClosedAccounts: () => void;
|
||||
onReorder: OnDropCallback;
|
||||
};
|
||||
|
||||
function Accounts({
|
||||
accounts,
|
||||
failedAccounts,
|
||||
updatedAccounts,
|
||||
getAccountPath,
|
||||
allAccountsPath,
|
||||
budgetedAccountPath,
|
||||
offBudgetAccountPath,
|
||||
getBalanceQuery,
|
||||
getAllAccountBalance,
|
||||
getOnBudgetBalance,
|
||||
getOffBudgetBalance,
|
||||
showClosedAccounts,
|
||||
onAddAccount,
|
||||
onToggleClosedAccounts,
|
||||
onReorder,
|
||||
}: AccountsProps) {
|
||||
let [isDragging, setIsDragging] = useState(false);
|
||||
let offbudgetAccounts = useMemo(
|
||||
() =>
|
||||
accounts.filter(
|
||||
account => account.closed === 0 && account.offbudget === 1,
|
||||
),
|
||||
[accounts],
|
||||
);
|
||||
let budgetedAccounts = useMemo(
|
||||
() =>
|
||||
accounts.filter(
|
||||
account => account.closed === 0 && account.offbudget === 0,
|
||||
),
|
||||
[accounts],
|
||||
);
|
||||
let closedAccounts = useMemo(
|
||||
() => accounts.filter(account => account.closed === 1),
|
||||
[accounts],
|
||||
);
|
||||
|
||||
function onDragChange(drag) {
|
||||
setIsDragging(drag.state === 'start');
|
||||
}
|
||||
|
||||
let makeDropPadding = (i, length) => {
|
||||
if (i === 0) {
|
||||
return {
|
||||
paddingTop: isDragging ? 15 : 0,
|
||||
marginTop: isDragging ? -15 : 0,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<View>
|
||||
<Account
|
||||
name="All accounts"
|
||||
to={allAccountsPath}
|
||||
query={getAllAccountBalance()}
|
||||
style={{ fontWeight, marginTop: 15 }}
|
||||
/>
|
||||
|
||||
{budgetedAccounts.length > 0 && (
|
||||
<Account
|
||||
name="For budget"
|
||||
to={budgetedAccountPath}
|
||||
query={getOnBudgetBalance()}
|
||||
style={{ fontWeight, marginTop: 13 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{budgetedAccounts.map((account, i) => (
|
||||
<Account
|
||||
key={account.id}
|
||||
name={account.name}
|
||||
account={account}
|
||||
connected={!!account.bank}
|
||||
failed={failedAccounts && failedAccounts.has(account.id)}
|
||||
updated={updatedAccounts && updatedAccounts.includes(account.id)}
|
||||
to={getAccountPath(account)}
|
||||
query={getBalanceQuery(account)}
|
||||
onDragChange={onDragChange}
|
||||
onDrop={onReorder}
|
||||
outerStyle={makeDropPadding(i, budgetedAccounts.length)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{offbudgetAccounts.length > 0 && (
|
||||
<Account
|
||||
name="Off budget"
|
||||
to={offBudgetAccountPath}
|
||||
query={getOffBudgetBalance()}
|
||||
style={{ fontWeight, marginTop: 13 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{offbudgetAccounts.map((account, i) => (
|
||||
<Account
|
||||
key={account.id}
|
||||
name={account.name}
|
||||
account={account}
|
||||
connected={!!account.bank}
|
||||
failed={failedAccounts && failedAccounts.has(account.id)}
|
||||
updated={updatedAccounts && updatedAccounts.includes(account.id)}
|
||||
to={getAccountPath(account)}
|
||||
query={getBalanceQuery(account)}
|
||||
onDragChange={onDragChange}
|
||||
onDrop={onReorder}
|
||||
outerStyle={makeDropPadding(i, offbudgetAccounts.length)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{closedAccounts.length > 0 && (
|
||||
<SecondaryItem
|
||||
style={{ marginTop: 15 }}
|
||||
title={'Closed accounts' + (showClosedAccounts ? '' : '...')}
|
||||
onClick={onToggleClosedAccounts}
|
||||
bold
|
||||
/>
|
||||
)}
|
||||
|
||||
{showClosedAccounts &&
|
||||
closedAccounts.map((account, i) => (
|
||||
<Account
|
||||
key={account.id}
|
||||
name={account.name}
|
||||
account={account}
|
||||
to={getAccountPath(account)}
|
||||
query={getBalanceQuery(account)}
|
||||
onDragChange={onDragChange}
|
||||
onDrop={onReorder}
|
||||
/>
|
||||
))}
|
||||
|
||||
<SecondaryItem
|
||||
style={{
|
||||
marginTop: 15,
|
||||
marginBottom: 9,
|
||||
}}
|
||||
onClick={onAddAccount}
|
||||
Icon={Add}
|
||||
title="Add account"
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export default Accounts;
|
||||
91
packages/desktop-client/src/components/sidebar/Item.tsx
Normal file
91
packages/desktop-client/src/components/sidebar/Item.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import React, {
|
||||
type CSSProperties,
|
||||
type ComponentType,
|
||||
type MouseEventHandler,
|
||||
type ReactNode,
|
||||
type SVGProps,
|
||||
} from 'react';
|
||||
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { styles, colors } from '../../style';
|
||||
import Block from '../common/Block';
|
||||
import View from '../common/View';
|
||||
|
||||
import ItemContent from './ItemContent';
|
||||
|
||||
type ItemProps = {
|
||||
title: string;
|
||||
Icon: ComponentType<SVGProps<SVGElement>>;
|
||||
to?: string;
|
||||
children?: ReactNode;
|
||||
style?: CSSProperties;
|
||||
indent?: number;
|
||||
onClick?: MouseEventHandler<HTMLDivElement>;
|
||||
forceHover?: boolean;
|
||||
forceActive?: boolean;
|
||||
};
|
||||
|
||||
function Item({
|
||||
children,
|
||||
Icon,
|
||||
title,
|
||||
style,
|
||||
to,
|
||||
onClick,
|
||||
indent = 0,
|
||||
forceHover = false,
|
||||
forceActive = false,
|
||||
}: ItemProps) {
|
||||
const hoverStyle = {
|
||||
backgroundColor: colors.n2,
|
||||
};
|
||||
|
||||
const activeStyle = {
|
||||
borderLeft: '4px solid ' + colors.p8,
|
||||
paddingLeft: 19 + indent - 4,
|
||||
color: colors.p8,
|
||||
};
|
||||
|
||||
const linkStyle = {
|
||||
...styles.mediumText,
|
||||
paddingTop: 9,
|
||||
paddingBottom: 9,
|
||||
paddingLeft: 19 + indent,
|
||||
paddingRight: 10,
|
||||
textDecoration: 'none',
|
||||
color: colors.n9,
|
||||
...(forceHover ? hoverStyle : {}),
|
||||
':hover': hoverStyle,
|
||||
};
|
||||
|
||||
const content = (
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
height: 20,
|
||||
}}
|
||||
>
|
||||
<Icon width={15} height={15} />
|
||||
<Block style={{ marginLeft: 8 }}>{title}</Block>
|
||||
<View style={{ flex: 1 }} />
|
||||
</View>
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={[{ flexShrink: 0 }, style]}>
|
||||
<ItemContent
|
||||
style={linkStyle}
|
||||
to={to}
|
||||
onClick={onClick}
|
||||
activeStyle={activeStyle}
|
||||
forceActive={forceActive}
|
||||
>
|
||||
{content}
|
||||
</ItemContent>
|
||||
{children ? <View style={{ marginTop: 5 }}>{children}</View> : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export default Item;
|
||||
@@ -0,0 +1,52 @@
|
||||
import React, {
|
||||
type CSSProperties,
|
||||
type MouseEventHandler,
|
||||
type ReactNode,
|
||||
} from 'react';
|
||||
|
||||
import AnchorLink from '../common/AnchorLink';
|
||||
import View from '../common/View';
|
||||
|
||||
type ItemContentProps = {
|
||||
style: CSSProperties;
|
||||
to: string;
|
||||
onClick: MouseEventHandler<HTMLDivElement>;
|
||||
activeStyle: CSSProperties;
|
||||
children: ReactNode;
|
||||
forceActive?: boolean;
|
||||
};
|
||||
|
||||
function ItemContent({
|
||||
style,
|
||||
to,
|
||||
onClick,
|
||||
activeStyle,
|
||||
forceActive,
|
||||
children,
|
||||
}: ItemContentProps) {
|
||||
return onClick ? (
|
||||
<View
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
style={[
|
||||
style,
|
||||
{
|
||||
touchAction: 'auto',
|
||||
userSelect: 'none',
|
||||
userDrag: 'none',
|
||||
cursor: 'pointer',
|
||||
...(forceActive ? activeStyle : {}),
|
||||
},
|
||||
]}
|
||||
onClick={onClick}
|
||||
>
|
||||
{children}
|
||||
</View>
|
||||
) : (
|
||||
<AnchorLink to={to} style={style} activeStyle={activeStyle}>
|
||||
{children}
|
||||
</AnchorLink>
|
||||
);
|
||||
}
|
||||
|
||||
export default ItemContent;
|
||||
@@ -0,0 +1,83 @@
|
||||
import React, {
|
||||
type CSSProperties,
|
||||
type ComponentType,
|
||||
type MouseEventHandler,
|
||||
type SVGProps,
|
||||
} from 'react';
|
||||
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { colors } from '../../style';
|
||||
import Block from '../common/Block';
|
||||
import View from '../common/View';
|
||||
|
||||
import { accountNameStyle } from './Account';
|
||||
import ItemContent from './ItemContent';
|
||||
|
||||
const fontWeight = 600;
|
||||
|
||||
type SecondaryItemProps = {
|
||||
title: string;
|
||||
to?: string;
|
||||
Icon?: ComponentType<SVGProps<SVGElement>>;
|
||||
style?: CSSProperties;
|
||||
onClick?: MouseEventHandler<HTMLDivElement>;
|
||||
bold?: boolean;
|
||||
indent?: number;
|
||||
};
|
||||
|
||||
function SecondaryItem({
|
||||
Icon,
|
||||
title,
|
||||
style,
|
||||
to,
|
||||
onClick,
|
||||
bold,
|
||||
indent = 0,
|
||||
}: SecondaryItemProps) {
|
||||
const hoverStyle = {
|
||||
backgroundColor: colors.n2,
|
||||
};
|
||||
const activeStyle = {
|
||||
borderLeft: '4px solid ' + colors.p8,
|
||||
paddingLeft: 14 - 4 + indent,
|
||||
color: colors.p8,
|
||||
fontWeight: bold ? fontWeight : null,
|
||||
};
|
||||
const linkStyle = {
|
||||
...accountNameStyle,
|
||||
color: colors.n9,
|
||||
paddingLeft: 14 + indent,
|
||||
fontWeight: bold ? fontWeight : null,
|
||||
':hover': hoverStyle,
|
||||
};
|
||||
|
||||
const content = (
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
height: 16,
|
||||
}}
|
||||
>
|
||||
{Icon && <Icon width={12} height={12} />}
|
||||
<Block style={{ marginLeft: Icon ? 8 : 0, color: 'inherit' }}>
|
||||
{title}
|
||||
</Block>
|
||||
</View>
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={[{ flexShrink: 0 }, style]}>
|
||||
<ItemContent
|
||||
style={linkStyle}
|
||||
to={to}
|
||||
onClick={onClick}
|
||||
activeStyle={activeStyle}
|
||||
>
|
||||
{content}
|
||||
</ItemContent>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export default SecondaryItem;
|
||||
153
packages/desktop-client/src/components/sidebar/Sidebar.tsx
Normal file
153
packages/desktop-client/src/components/sidebar/Sidebar.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
import React, { type ReactNode, type CSSProperties } from 'react';
|
||||
|
||||
import * as Platform from 'loot-core/src/client/platform';
|
||||
import { type AccountEntity } from 'loot-core/src/types/models';
|
||||
|
||||
import Reports from '../../icons/v1/Reports';
|
||||
import Wallet from '../../icons/v1/Wallet';
|
||||
import CalendarIcon from '../../icons/v2/Calendar';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { colors } from '../../style';
|
||||
import View from '../common/View';
|
||||
import { type OnDropCallback } from '../sort';
|
||||
import { type Binding } from '../spreadsheet';
|
||||
|
||||
import Accounts from './Accounts';
|
||||
import Item from './Item';
|
||||
import ToggleButton from './ToggleButton';
|
||||
import Tools from './Tools';
|
||||
|
||||
import { useSidebar } from '.';
|
||||
|
||||
export const SIDEBAR_WIDTH = 240;
|
||||
|
||||
type SidebarProps = {
|
||||
style: CSSProperties;
|
||||
budgetName: ReactNode;
|
||||
accounts: AccountEntity[];
|
||||
failedAccounts: Map<
|
||||
string,
|
||||
{
|
||||
type: string;
|
||||
code: string;
|
||||
}
|
||||
>;
|
||||
updatedAccounts: string[];
|
||||
getBalanceQuery: (account: AccountEntity) => Binding;
|
||||
getAllAccountBalance: () => Binding;
|
||||
getOnBudgetBalance: () => Binding;
|
||||
getOffBudgetBalance: () => Binding;
|
||||
showClosedAccounts: boolean;
|
||||
isFloating: boolean;
|
||||
onFloat: () => void;
|
||||
onAddAccount: () => void;
|
||||
onToggleClosedAccounts: () => void;
|
||||
onReorder: OnDropCallback;
|
||||
};
|
||||
|
||||
function Sidebar({
|
||||
style,
|
||||
budgetName,
|
||||
accounts,
|
||||
failedAccounts,
|
||||
updatedAccounts,
|
||||
getBalanceQuery,
|
||||
getAllAccountBalance,
|
||||
getOnBudgetBalance,
|
||||
getOffBudgetBalance,
|
||||
showClosedAccounts,
|
||||
isFloating,
|
||||
onFloat,
|
||||
onAddAccount,
|
||||
onToggleClosedAccounts,
|
||||
onReorder,
|
||||
}: SidebarProps) {
|
||||
let hasWindowButtons = !Platform.isBrowser && Platform.OS === 'mac';
|
||||
|
||||
const sidebar = useSidebar();
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
{
|
||||
width: SIDEBAR_WIDTH,
|
||||
color: colors.n9,
|
||||
backgroundColor: colors.n1,
|
||||
'& .float': {
|
||||
opacity: isFloating ? 1 : 0,
|
||||
transition: 'opacity .25s, width .25s',
|
||||
width: hasWindowButtons || isFloating ? null : 0,
|
||||
},
|
||||
'&:hover .float': {
|
||||
opacity: 1,
|
||||
width: hasWindowButtons ? null : 'auto',
|
||||
},
|
||||
},
|
||||
style,
|
||||
]}
|
||||
>
|
||||
<View
|
||||
style={[
|
||||
{
|
||||
paddingTop: 35,
|
||||
height: 30,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
margin: '0 8px 23px 20px',
|
||||
transition: 'padding .4s',
|
||||
},
|
||||
hasWindowButtons && {
|
||||
paddingTop: 20,
|
||||
justifyContent: 'flex-start',
|
||||
},
|
||||
]}
|
||||
>
|
||||
{budgetName}
|
||||
|
||||
<View style={{ flex: 1, flexDirection: 'row' }} />
|
||||
|
||||
{!sidebar.alwaysFloats && (
|
||||
<ToggleButton isFloating={isFloating} onFloat={onFloat} />
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={{ overflow: 'auto' }}>
|
||||
<Item title="Budget" Icon={Wallet} to="/budget" />
|
||||
<Item title="Reports" Icon={Reports} to="/reports" />
|
||||
|
||||
<Item title="Schedules" Icon={CalendarIcon} to="/schedules" />
|
||||
|
||||
<Tools />
|
||||
|
||||
<View
|
||||
style={{
|
||||
height: 1,
|
||||
backgroundColor: colors.n3,
|
||||
marginTop: 15,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
|
||||
<Accounts
|
||||
accounts={accounts}
|
||||
failedAccounts={failedAccounts}
|
||||
updatedAccounts={updatedAccounts}
|
||||
getAccountPath={account => `/accounts/${account.id}`}
|
||||
allAccountsPath="/accounts"
|
||||
budgetedAccountPath="/accounts/budgeted"
|
||||
offBudgetAccountPath="/accounts/offbudget"
|
||||
getBalanceQuery={getBalanceQuery}
|
||||
getAllAccountBalance={getAllAccountBalance}
|
||||
getOnBudgetBalance={getOnBudgetBalance}
|
||||
getOffBudgetBalance={getOffBudgetBalance}
|
||||
showClosedAccounts={showClosedAccounts}
|
||||
onAddAccount={onAddAccount}
|
||||
onToggleClosedAccounts={onToggleClosedAccounts}
|
||||
onReorder={onReorder}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export default Sidebar;
|
||||
@@ -6,20 +6,26 @@ import { closeBudget } from 'loot-core/src/client/actions/budgets';
|
||||
import * as Platform from 'loot-core/src/client/platform';
|
||||
import * as queries from 'loot-core/src/client/queries';
|
||||
import { send } from 'loot-core/src/platform/client/fetch';
|
||||
import { type LocalPrefs } from 'loot-core/src/types/prefs';
|
||||
|
||||
import { useActions } from '../hooks/useActions';
|
||||
import ExpandArrow from '../icons/v0/ExpandArrow';
|
||||
import { styles, colors } from '../style';
|
||||
import { useActions } from '../../hooks/useActions';
|
||||
import ExpandArrow from '../../icons/v0/ExpandArrow';
|
||||
import { styles, theme } from '../../style';
|
||||
import Button from '../common/Button';
|
||||
import InitialFocus from '../common/InitialFocus';
|
||||
import Input from '../common/Input';
|
||||
import Menu from '../common/Menu';
|
||||
import Text from '../common/Text';
|
||||
import { Tooltip } from '../tooltips';
|
||||
|
||||
import Button from './common/Button';
|
||||
import InitialFocus from './common/InitialFocus';
|
||||
import Input from './common/Input';
|
||||
import Menu from './common/Menu';
|
||||
import Text from './common/Text';
|
||||
import { Sidebar } from './sidebar';
|
||||
import { Tooltip } from './tooltips';
|
||||
import Sidebar from './Sidebar';
|
||||
|
||||
function EditableBudgetName({ prefs, savePrefs }) {
|
||||
type EditableBudgetNameProps = {
|
||||
prefs: LocalPrefs;
|
||||
savePrefs: (prefs: Partial<LocalPrefs>) => Promise<void>;
|
||||
};
|
||||
|
||||
function EditableBudgetName({ prefs, savePrefs }: EditableBudgetNameProps) {
|
||||
let dispatch = useDispatch();
|
||||
let navigate = useNavigate();
|
||||
const [editing, setEditing] = useState(false);
|
||||
@@ -63,10 +69,11 @@ function EditableBudgetName({ prefs, savePrefs }) {
|
||||
}}
|
||||
defaultValue={prefs.budgetName}
|
||||
onEnter={async e => {
|
||||
const newBudgetName = e.target.value;
|
||||
const inputEl = e.target as HTMLInputElement;
|
||||
const newBudgetName = inputEl.value;
|
||||
if (newBudgetName.trim() !== '') {
|
||||
await savePrefs({
|
||||
budgetName: e.target.value,
|
||||
budgetName: inputEl.value,
|
||||
});
|
||||
setEditing(false);
|
||||
}
|
||||
@@ -79,7 +86,7 @@ function EditableBudgetName({ prefs, savePrefs }) {
|
||||
return (
|
||||
<Button
|
||||
type="bare"
|
||||
color={colors.n9}
|
||||
color={theme.buttonNormalBorder}
|
||||
style={{
|
||||
fontSize: 16,
|
||||
fontWeight: 500,
|
||||
@@ -106,7 +113,7 @@ function EditableBudgetName({ prefs, savePrefs }) {
|
||||
}
|
||||
}
|
||||
|
||||
export default function SidebarWithData() {
|
||||
function SidebarWithData() {
|
||||
let accounts = useSelector(state => state.queries.accounts);
|
||||
let failedAccounts = useSelector(state => state.account.failedAccounts);
|
||||
let updatedAccounts = useSelector(state => state.queries.updatedAccounts);
|
||||
@@ -131,8 +138,8 @@ export default function SidebarWithData() {
|
||||
|
||||
return (
|
||||
<Sidebar
|
||||
isFloating={floatingSidebar}
|
||||
budgetName={<EditableBudgetName prefs={prefs} savePrefs={savePrefs} />}
|
||||
isFloating={floatingSidebar}
|
||||
accounts={accounts}
|
||||
failedAccounts={failedAccounts}
|
||||
updatedAccounts={updatedAccounts}
|
||||
@@ -149,7 +156,12 @@ export default function SidebarWithData() {
|
||||
'ui.showClosedAccounts': !prefs['ui.showClosedAccounts'],
|
||||
})
|
||||
}
|
||||
style={[{ flex: 1 }, styles.darkScrollbar]}
|
||||
style={{
|
||||
flex: 1,
|
||||
...styles.darkScrollbar,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default SidebarWithData;
|
||||
@@ -0,0 +1,36 @@
|
||||
import React, { type CSSProperties, type MouseEventHandler } from 'react';
|
||||
|
||||
import Pin from '../../icons/v1/Pin';
|
||||
import ArrowButtonLeft1 from '../../icons/v2/ArrowButtonLeft1';
|
||||
import { theme } from '../../style';
|
||||
import Button from '../common/Button';
|
||||
import View from '../common/View';
|
||||
|
||||
type ToggleButtonProps = {
|
||||
isFloating: boolean;
|
||||
onFloat: MouseEventHandler<HTMLButtonElement>;
|
||||
style?: CSSProperties;
|
||||
};
|
||||
|
||||
function ToggleButton({ style, isFloating, onFloat }: ToggleButtonProps) {
|
||||
return (
|
||||
<View className="float" style={[style, { flexShrink: 0 }]}>
|
||||
<Button type="bare" onClick={onFloat} color={theme.buttonMenuBorder}>
|
||||
{isFloating ? (
|
||||
<Pin
|
||||
style={{
|
||||
margin: -2,
|
||||
width: 15,
|
||||
height: 15,
|
||||
transform: 'rotate(45deg)',
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<ArrowButtonLeft1 style={{ width: 13, height: 13 }} />
|
||||
)}
|
||||
</Button>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export default ToggleButton;
|
||||
64
packages/desktop-client/src/components/sidebar/Tools.tsx
Normal file
64
packages/desktop-client/src/components/sidebar/Tools.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import React, { useState, useCallback, useEffect } from 'react';
|
||||
import { useLocation } from 'react-router';
|
||||
|
||||
import CheveronDown from '../../icons/v1/CheveronDown';
|
||||
import CheveronRight from '../../icons/v1/CheveronRight';
|
||||
import Cog from '../../icons/v1/Cog';
|
||||
import StoreFrontIcon from '../../icons/v1/StoreFront';
|
||||
import TuningIcon from '../../icons/v1/Tuning';
|
||||
import View from '../common/View';
|
||||
|
||||
import Item from './Item';
|
||||
import SecondaryItem from './SecondaryItem';
|
||||
|
||||
function Tools() {
|
||||
let [isOpen, setOpen] = useState(false);
|
||||
let onToggle = useCallback(() => setOpen(open => !open), []);
|
||||
let location = useLocation();
|
||||
|
||||
const isActive = ['/payees', '/rules', '/settings', '/tools'].some(route =>
|
||||
location.pathname.startsWith(route),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isActive) {
|
||||
setOpen(true);
|
||||
}
|
||||
}, [location.pathname]);
|
||||
|
||||
return (
|
||||
<View style={{ flexShrink: 0 }}>
|
||||
<Item
|
||||
title="More"
|
||||
Icon={isOpen ? CheveronDown : CheveronRight}
|
||||
onClick={onToggle}
|
||||
style={{ marginBottom: isOpen ? 8 : 0 }}
|
||||
forceActive={!isOpen && isActive}
|
||||
/>
|
||||
{isOpen && (
|
||||
<>
|
||||
<SecondaryItem
|
||||
title="Payees"
|
||||
Icon={StoreFrontIcon}
|
||||
to="/payees"
|
||||
indent={15}
|
||||
/>
|
||||
<SecondaryItem
|
||||
title="Rules"
|
||||
Icon={TuningIcon}
|
||||
to="/rules"
|
||||
indent={15}
|
||||
/>
|
||||
<SecondaryItem
|
||||
title="Settings"
|
||||
Icon={Cog}
|
||||
to="/settings"
|
||||
indent={15}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export default Tools;
|
||||
@@ -1,15 +1,34 @@
|
||||
import React, { createContext, useState, useContext, useMemo } from 'react';
|
||||
import React, {
|
||||
createContext,
|
||||
useState,
|
||||
useContext,
|
||||
useMemo,
|
||||
type ReactNode,
|
||||
type Dispatch,
|
||||
type SetStateAction,
|
||||
} from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import { useResponsive } from '../ResponsiveProvider';
|
||||
import { useResponsive } from '../../ResponsiveProvider';
|
||||
import View from '../common/View';
|
||||
|
||||
import View from './common/View';
|
||||
import { SIDEBAR_WIDTH } from './sidebar';
|
||||
import { SIDEBAR_WIDTH } from './Sidebar';
|
||||
import SidebarWithData from './SidebarWithData';
|
||||
|
||||
const SidebarContext = createContext(null);
|
||||
type SidebarContextValue = {
|
||||
hidden: boolean;
|
||||
setHidden: Dispatch<SetStateAction<boolean>>;
|
||||
floating: boolean;
|
||||
alwaysFloats: boolean;
|
||||
};
|
||||
|
||||
export function SidebarProvider({ children }) {
|
||||
const SidebarContext = createContext<SidebarContextValue>(null);
|
||||
|
||||
type SidebarProviderProps = {
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
function SidebarProvider({ children }: SidebarProviderProps) {
|
||||
let floatingSidebar = useSelector(
|
||||
state => state.prefs.global.floatingSidebar,
|
||||
);
|
||||
@@ -27,7 +46,7 @@ export function SidebarProvider({ children }) {
|
||||
);
|
||||
}
|
||||
|
||||
export function useSidebar() {
|
||||
function useSidebar() {
|
||||
let { hidden, setHidden, floating, alwaysFloats } =
|
||||
useContext(SidebarContext);
|
||||
|
||||
@@ -37,7 +56,7 @@ export function useSidebar() {
|
||||
);
|
||||
}
|
||||
|
||||
export default function Sidebar() {
|
||||
function FloatableSidebar() {
|
||||
let floatingSidebar = useSelector(
|
||||
state => state.prefs.global.floatingSidebar,
|
||||
);
|
||||
@@ -84,3 +103,6 @@ export default function Sidebar() {
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export { SidebarProvider, useSidebar };
|
||||
export default FloatableSidebar;
|
||||
@@ -6,6 +6,10 @@ import React, {
|
||||
useMemo,
|
||||
useState,
|
||||
useContext,
|
||||
type RefCallback,
|
||||
type MutableRefObject,
|
||||
type Context,
|
||||
type Ref,
|
||||
} from 'react';
|
||||
import { useDrag, useDrop } from 'react-dnd';
|
||||
|
||||
@@ -13,7 +17,10 @@ import { theme } from '../style';
|
||||
|
||||
import View from './common/View';
|
||||
|
||||
function useMergedRefs(ref1, ref2) {
|
||||
function useMergedRefs<T>(
|
||||
ref1: RefCallback<T> | MutableRefObject<T>,
|
||||
ref2: RefCallback<T> | MutableRefObject<T>,
|
||||
): Ref<T> {
|
||||
return useMemo(() => {
|
||||
function ref(value) {
|
||||
[ref1, ref2].forEach(ref => {
|
||||
@@ -29,7 +36,26 @@ function useMergedRefs(ref1, ref2) {
|
||||
}, [ref1, ref2]);
|
||||
}
|
||||
|
||||
export function useDraggable({ item, type, canDrag, onDragChange }) {
|
||||
type DragState = {
|
||||
state: 'start-preview' | 'start' | 'end';
|
||||
type?: string;
|
||||
item?: unknown;
|
||||
};
|
||||
|
||||
export type OnDragChangeCallback = (drag: DragState) => Promise<void> | void;
|
||||
type UseDraggableArgs = {
|
||||
item: unknown;
|
||||
type: string;
|
||||
canDrag: boolean;
|
||||
onDragChange: OnDragChangeCallback;
|
||||
};
|
||||
|
||||
export function useDraggable({
|
||||
item,
|
||||
type,
|
||||
canDrag,
|
||||
onDragChange,
|
||||
}: UseDraggableArgs) {
|
||||
let _onDragChange = useRef(onDragChange);
|
||||
|
||||
const [, dragRef] = useDrag({
|
||||
@@ -60,10 +86,31 @@ export function useDraggable({ item, type, canDrag, onDragChange }) {
|
||||
|
||||
return { dragRef };
|
||||
}
|
||||
type DropPosition = 'top' | 'bottom';
|
||||
|
||||
export function useDroppable({ types, id, onDrop, onLongHover }) {
|
||||
export type OnDropCallback = (
|
||||
id: unknown,
|
||||
dropPos: DropPosition,
|
||||
targetId: unknown,
|
||||
) => Promise<void> | void;
|
||||
|
||||
type OnLongHoverCallback = () => Promise<void> | void;
|
||||
|
||||
type UseDroppableArgs = {
|
||||
types: string | string[];
|
||||
id: unknown;
|
||||
onDrop: OnDropCallback;
|
||||
onLongHover?: OnLongHoverCallback;
|
||||
};
|
||||
|
||||
export function useDroppable({
|
||||
types,
|
||||
id,
|
||||
onDrop,
|
||||
onLongHover,
|
||||
}: UseDroppableArgs) {
|
||||
let ref = useRef(null);
|
||||
let [dropPos, setDropPos] = useState(null);
|
||||
let [dropPos, setDropPos] = useState<DropPosition>(null);
|
||||
|
||||
let [{ isOver }, dropRef] = useDrop({
|
||||
accept: types,
|
||||
@@ -75,7 +122,7 @@ export function useDroppable({ types, id, onDrop, onLongHover }) {
|
||||
let hoverMiddleY = (hoverBoundingRect.bottom - hoverBoundingRect.top) / 2;
|
||||
let clientOffset = monitor.getClientOffset();
|
||||
let hoverClientY = clientOffset.y - hoverBoundingRect.top;
|
||||
let pos = hoverClientY < hoverMiddleY ? 'top' : 'bottom';
|
||||
let pos: DropPosition = hoverClientY < hoverMiddleY ? 'top' : 'bottom';
|
||||
|
||||
setDropPos(pos);
|
||||
},
|
||||
@@ -99,17 +146,26 @@ export function useDroppable({ types, id, onDrop, onLongHover }) {
|
||||
};
|
||||
}
|
||||
|
||||
export const DropHighlightPosContext = createContext(null);
|
||||
type ItemPosition = 'first' | 'last';
|
||||
export const DropHighlightPosContext: Context<ItemPosition> =
|
||||
createContext(null);
|
||||
|
||||
export function DropHighlight({ pos, offset = {} }) {
|
||||
type DropHighlightProps = {
|
||||
pos: 'top' | 'bottom';
|
||||
offset?: {
|
||||
top?: number;
|
||||
bottom?: number;
|
||||
};
|
||||
};
|
||||
export function DropHighlight({ pos, offset }: DropHighlightProps) {
|
||||
let itemPos = useContext(DropHighlightPosContext);
|
||||
|
||||
if (pos == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let topOffset = (itemPos === 'first' ? 2 : 0) + (offset.top || 0);
|
||||
let bottomOffset = (itemPos === 'last' ? 2 : 0) + (offset.bottom || 0);
|
||||
let topOffset = (itemPos === 'first' ? 2 : 0) + (offset?.top || 0);
|
||||
let bottomOffset = (itemPos === 'last' ? 2 : 0) + (offset?.bottom || 0);
|
||||
|
||||
let posStyle =
|
||||
pos === 'top' ? { top: -2 + topOffset } : { bottom: -1 + bottomOffset };
|
||||
@@ -1 +1,3 @@
|
||||
export type Binding = string | { name: string; value; query?: unknown };
|
||||
import { type Query } from 'loot-core/src/shared/query';
|
||||
|
||||
export type Binding = string | { name: string; value?; query?: Query };
|
||||
|
||||
@@ -11,6 +11,6 @@ export interface ServerEvents {
|
||||
'show-budgets': unknown;
|
||||
'start-import': unknown;
|
||||
'start-load': unknown;
|
||||
'sync-event': { type; subtype; meta; tables };
|
||||
'sync-event': { type; subtype; meta; tables; syncDisabled };
|
||||
'undo-event': unknown;
|
||||
}
|
||||
|
||||
1
packages/loot-core/typings/window.d.ts
vendored
1
packages/loot-core/typings/window.d.ts
vendored
@@ -14,6 +14,7 @@ declare global {
|
||||
openFileDialog: (
|
||||
opts: Parameters<import('electron').Dialog['showOpenDialogSync']>[0],
|
||||
) => Promise<string[]>;
|
||||
relaunch: () => void;
|
||||
};
|
||||
|
||||
__navigate?: import('react-router').NavigateFunction;
|
||||
|
||||
6
upcoming-release-notes/1532.md
Normal file
6
upcoming-release-notes/1532.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Maintenance
|
||||
authors: [joel-jeremy]
|
||||
---
|
||||
|
||||
Migration some components to typescript
|
||||
Reference in New Issue
Block a user