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:
Joel Jeremy Marquez
2023-08-17 13:21:29 -07:00
committed by GitHub
parent f8ce38f11e
commit 6fae79560e
28 changed files with 1006 additions and 683 deletions

View File

@@ -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 }]}

View File

@@ -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';

View File

@@ -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);

View File

@@ -6,7 +6,7 @@ import { Page } from './Page';
export function ManageRulesPage() {
return (
<Page title="Rules">
<ManageRules />
<ManageRules isModal={false} payeeId={null} />
</Page>
);
}

View File

@@ -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();
}
}

View File

@@ -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_ });
}

View 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_ });
}

View File

@@ -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();

View File

@@ -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';

View File

@@ -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>
);
}

View 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;

View 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;

View 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;

View File

@@ -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;

View File

@@ -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;

View 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;

View File

@@ -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;

View File

@@ -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;

View 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;

View File

@@ -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;

View File

@@ -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 };

View File

@@ -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 };

View File

@@ -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;
}

View File

@@ -14,6 +14,7 @@ declare global {
openFileDialog: (
opts: Parameters<import('electron').Dialog['showOpenDialogSync']>[0],
) => Promise<string[]>;
relaunch: () => void;
};
__navigate?: import('react-router').NavigateFunction;

View File

@@ -0,0 +1,6 @@
---
category: Maintenance
authors: [joel-jeremy]
---
Migration some components to typescript