mirror of
https://github.com/actualbudget/actual.git
synced 2026-03-09 11:42:54 -05:00
Compare commits
7 Commits
copilot/su
...
react-aria
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d2bf2e9cc9 | ||
|
|
7e1cc49478 | ||
|
|
e6a49b1d99 | ||
|
|
db7d890e79 | ||
|
|
46977b59ca | ||
|
|
b3d0348493 | ||
|
|
b78f1fd575 |
@@ -4,7 +4,6 @@ import { SvgPencil1 } from '../icons/v2';
|
||||
import { theme } from '../style';
|
||||
|
||||
import { Button } from './common/Button2';
|
||||
import { InitialFocus } from './common/InitialFocus';
|
||||
import { Input } from './common/Input';
|
||||
import { View } from './common/View';
|
||||
|
||||
@@ -33,24 +32,24 @@ export function EditablePageHeaderTitle({
|
||||
|
||||
if (isEditing) {
|
||||
return (
|
||||
<InitialFocus>
|
||||
<Input
|
||||
defaultValue={title}
|
||||
onEnter={e => onSaveValue(e.currentTarget.value)}
|
||||
onBlur={e => onSaveValue(e.target.value)}
|
||||
onEscape={() => setIsEditing(false)}
|
||||
style={{
|
||||
fontSize: 25,
|
||||
fontWeight: 500,
|
||||
marginTop: -3,
|
||||
marginBottom: -3,
|
||||
marginLeft: -6,
|
||||
paddingTop: 2,
|
||||
paddingBottom: 2,
|
||||
width: Math.max(20, title.length) + 'ch',
|
||||
}}
|
||||
/>
|
||||
</InitialFocus>
|
||||
<Input
|
||||
autoFocus
|
||||
autoSelect
|
||||
defaultValue={title}
|
||||
onEnter={e => onSaveValue(e.currentTarget.value)}
|
||||
onBlur={e => onSaveValue(e.target.value)}
|
||||
onEscape={() => setIsEditing(false)}
|
||||
style={{
|
||||
fontSize: 25,
|
||||
fontWeight: 500,
|
||||
marginTop: -3,
|
||||
marginBottom: -3,
|
||||
marginLeft: -6,
|
||||
paddingTop: 2,
|
||||
paddingBottom: 2,
|
||||
width: Math.max(20, title.length) + 'ch',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -294,7 +294,7 @@ export function ManageRules({
|
||||
<Search
|
||||
placeholder="Filter rules..."
|
||||
value={filter}
|
||||
onChange={onSearchChange}
|
||||
onChangeValue={onSearchChange}
|
||||
/>
|
||||
</View>
|
||||
<View style={{ flex: 1 }}>
|
||||
|
||||
@@ -30,7 +30,6 @@ import {
|
||||
import { theme, styles } from '../../style';
|
||||
import { AnimatedRefresh } from '../AnimatedRefresh';
|
||||
import { Button } from '../common/Button2';
|
||||
import { InitialFocus } from '../common/InitialFocus';
|
||||
import { Input } from '../common/Input';
|
||||
import { Menu } from '../common/Menu';
|
||||
import { MenuButton } from '../common/MenuButton';
|
||||
@@ -345,8 +344,8 @@ export function AccountHeader({
|
||||
<Search
|
||||
placeholder={t('Search')}
|
||||
value={search}
|
||||
onChange={onSearch}
|
||||
inputRef={searchInput}
|
||||
onChangeValue={onSearch}
|
||||
ref={searchInput}
|
||||
/>
|
||||
{workingHard ? (
|
||||
<View>
|
||||
@@ -574,24 +573,24 @@ function AccountNameField({
|
||||
if (editingName) {
|
||||
return (
|
||||
<Fragment>
|
||||
<InitialFocus>
|
||||
<Input
|
||||
defaultValue={accountName}
|
||||
onEnter={e => onSaveName(e.currentTarget.value)}
|
||||
onBlur={e => onSaveName(e.target.value)}
|
||||
onEscape={() => onExposeName(false)}
|
||||
style={{
|
||||
fontSize: 25,
|
||||
fontWeight: 500,
|
||||
marginTop: -3,
|
||||
marginBottom: -4,
|
||||
marginLeft: -6,
|
||||
paddingTop: 2,
|
||||
paddingBottom: 2,
|
||||
width: Math.max(20, accountName.length) + 'ch',
|
||||
}}
|
||||
/>
|
||||
</InitialFocus>
|
||||
<Input
|
||||
autoFocus
|
||||
autoSelect
|
||||
defaultValue={accountName}
|
||||
onEnter={e => onSaveName(e.target.value)}
|
||||
onBlur={e => onSaveName(e.target.value)}
|
||||
onEscape={() => onExposeName(false)}
|
||||
style={{
|
||||
fontSize: 25,
|
||||
fontWeight: 500,
|
||||
marginTop: -3,
|
||||
marginBottom: -4,
|
||||
marginLeft: -6,
|
||||
paddingTop: 2,
|
||||
paddingBottom: 2,
|
||||
width: Math.max(20, accountName.length) + 'ch',
|
||||
}}
|
||||
/>
|
||||
{saveNameError && (
|
||||
<View style={{ color: theme.warningText }}>{saveNameError}</View>
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { type FormEvent, useCallback, useRef, useState } from 'react';
|
||||
import { Form } from 'react-aria-components';
|
||||
import { Trans } from 'react-i18next';
|
||||
|
||||
import * as queries from 'loot-core/src/client/queries';
|
||||
@@ -9,7 +10,6 @@ import { type AccountEntity } from 'loot-core/types/models';
|
||||
import { SvgCheckCircle1 } from '../../icons/v2';
|
||||
import { styles, theme } from '../../style';
|
||||
import { Button } from '../common/Button2';
|
||||
import { InitialFocus } from '../common/InitialFocus';
|
||||
import { Input } from '../common/Input';
|
||||
import { Text } from '../common/Text';
|
||||
import { View } from '../common/View';
|
||||
@@ -124,43 +124,49 @@ export function ReconcileMenu({
|
||||
});
|
||||
const format = useFormat();
|
||||
const [inputValue, setInputValue] = useState<string | null>(null);
|
||||
const [inputFocused, setInputFocused] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
function onSubmit() {
|
||||
if (inputValue === '') {
|
||||
setInputFocused(true);
|
||||
return;
|
||||
}
|
||||
const onSubmit = useCallback(
|
||||
(e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
|
||||
const amount =
|
||||
inputValue != null ? currencyToInteger(inputValue) : clearedBalance;
|
||||
if (inputValue === '') {
|
||||
inputRef.current?.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
onReconcile(amount);
|
||||
onClose();
|
||||
}
|
||||
const amount =
|
||||
inputValue != null ? currencyToInteger(inputValue) : clearedBalance;
|
||||
|
||||
onReconcile(amount);
|
||||
onClose();
|
||||
},
|
||||
[clearedBalance, inputValue, onClose, onReconcile],
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={{ padding: '5px 8px' }}>
|
||||
<Text>
|
||||
<Trans>
|
||||
Enter the current balance of your bank account that you want to
|
||||
reconcile with:
|
||||
</Trans>
|
||||
</Text>
|
||||
{clearedBalance != null && (
|
||||
<InitialFocus>
|
||||
<Form onSubmit={onSubmit}>
|
||||
<View style={{ padding: '5px 8px' }}>
|
||||
<Text>
|
||||
<Trans>
|
||||
Enter the current balance of your bank account that you want to
|
||||
reconcile with:
|
||||
</Trans>
|
||||
</Text>
|
||||
{clearedBalance != null && (
|
||||
<Input
|
||||
ref={inputRef}
|
||||
defaultValue={format(clearedBalance, 'financial')}
|
||||
onChangeValue={setInputValue}
|
||||
style={{ margin: '7px 0' }}
|
||||
focused={inputFocused}
|
||||
onEnter={onSubmit}
|
||||
autoFocus
|
||||
autoSelect
|
||||
/>
|
||||
</InitialFocus>
|
||||
)}
|
||||
<Button variant="primary" onPress={onSubmit}>
|
||||
<Trans>Reconcile</Trans>
|
||||
</Button>
|
||||
</View>
|
||||
)}
|
||||
<Button variant="primary" type="submit">
|
||||
<Trans>Reconcile</Trans>
|
||||
</Button>
|
||||
</View>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -170,7 +170,7 @@ type AccountItemProps = {
|
||||
|
||||
function AccountItem({
|
||||
item,
|
||||
className,
|
||||
className = '',
|
||||
highlighted,
|
||||
embedded,
|
||||
...props
|
||||
|
||||
@@ -4,11 +4,12 @@ import React, {
|
||||
useRef,
|
||||
useEffect,
|
||||
useMemo,
|
||||
type ComponentProps,
|
||||
type HTMLProps,
|
||||
type ReactNode,
|
||||
type KeyboardEvent,
|
||||
type ChangeEvent,
|
||||
type ComponentPropsWithRef,
|
||||
type ComponentPropsWithoutRef,
|
||||
} from 'react';
|
||||
|
||||
import { css, cx } from '@emotion/css';
|
||||
@@ -25,18 +26,16 @@ import { View } from '../common/View';
|
||||
import { useResponsive } from '../responsive/ResponsiveProvider';
|
||||
|
||||
type CommonAutocompleteProps<T extends Item> = {
|
||||
focused?: boolean;
|
||||
autoFocus?: boolean;
|
||||
embedded?: boolean;
|
||||
containerProps?: HTMLProps<HTMLDivElement>;
|
||||
labelProps?: { id?: string };
|
||||
inputProps?: Omit<ComponentProps<typeof Input>, 'onChange'> & {
|
||||
onChange?: (value: string) => void;
|
||||
};
|
||||
inputProps?: ComponentPropsWithRef<typeof Input>;
|
||||
suggestions?: T[];
|
||||
renderInput?: (props: ComponentProps<typeof Input>) => ReactNode;
|
||||
renderInput?: (props: ComponentPropsWithRef<typeof Input>) => ReactNode;
|
||||
renderItems?: (
|
||||
items: T[],
|
||||
getItemProps: (arg: { item: T }) => ComponentProps<typeof View>,
|
||||
getItemProps: (arg: { item: T }) => ComponentPropsWithRef<typeof View>,
|
||||
idx: number,
|
||||
value?: string,
|
||||
) => ReactNode;
|
||||
@@ -138,14 +137,14 @@ function fireUpdate<T extends Item>(
|
||||
onUpdate?.(selected, value);
|
||||
}
|
||||
|
||||
function defaultRenderInput(props: ComponentProps<typeof Input>) {
|
||||
function defaultRenderInput(props: ComponentPropsWithRef<typeof Input>) {
|
||||
// data-1p-ignore disables 1Password autofill behaviour
|
||||
return <Input data-1p-ignore {...props} />;
|
||||
}
|
||||
|
||||
function defaultRenderItems<T extends Item>(
|
||||
items: T[],
|
||||
getItemProps: (arg: { item: T }) => ComponentProps<typeof View>,
|
||||
getItemProps: (arg: { item: T }) => ComponentPropsWithRef<typeof View>,
|
||||
highlightedIndex: number,
|
||||
) {
|
||||
return (
|
||||
@@ -210,7 +209,7 @@ type SingleAutocompleteProps<T extends Item> = CommonAutocompleteProps<T> & {
|
||||
};
|
||||
|
||||
function SingleAutocomplete<T extends Item>({
|
||||
focused,
|
||||
autoFocus,
|
||||
embedded = false,
|
||||
containerProps,
|
||||
labelProps = {},
|
||||
@@ -450,8 +449,8 @@ function SingleAutocomplete<T extends Item>({
|
||||
<View ref={triggerRef} style={{ flexShrink: 0 }}>
|
||||
{renderInput(
|
||||
getInputProps({
|
||||
focused,
|
||||
...inputProps,
|
||||
autoFocus,
|
||||
onFocus: e => {
|
||||
inputProps.onFocus?.(e);
|
||||
|
||||
@@ -550,8 +549,9 @@ function SingleAutocomplete<T extends Item>({
|
||||
}
|
||||
},
|
||||
onChange: (e: ChangeEvent<HTMLInputElement>) => {
|
||||
const { onChange } = inputProps || {};
|
||||
onChange?.(e.target.value);
|
||||
const { onChangeValue, onChange } = inputProps || {};
|
||||
onChangeValue?.(e.target.value);
|
||||
onChange?.(e);
|
||||
},
|
||||
}),
|
||||
)}
|
||||
@@ -641,7 +641,6 @@ function MultiAutocomplete<T extends Item>({
|
||||
clearOnBlur = true,
|
||||
...props
|
||||
}: MultiAutocompleteProps<T>) {
|
||||
const [focused, setFocused] = useState(false);
|
||||
const selectedItemIds = selectedItems.map(getItemId);
|
||||
|
||||
function onRemoveItem(id: T['id']) {
|
||||
@@ -658,7 +657,7 @@ function MultiAutocomplete<T extends Item>({
|
||||
|
||||
function onKeyDown(
|
||||
e: KeyboardEvent<HTMLInputElement>,
|
||||
prevOnKeyDown?: ComponentProps<typeof Input>['onKeyDown'],
|
||||
prevOnKeyDown?: ComponentPropsWithoutRef<typeof Input>['onKeyDown'],
|
||||
) {
|
||||
if (e.key === 'Backspace' && e.currentTarget.value === '') {
|
||||
onRemoveItem(selectedItemIds[selectedItems.length - 1]);
|
||||
@@ -682,7 +681,7 @@ function MultiAutocomplete<T extends Item>({
|
||||
strict={strict}
|
||||
renderInput={inputProps => (
|
||||
<View
|
||||
style={{
|
||||
className={`${css({
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
flexDirection: 'row',
|
||||
@@ -690,11 +689,11 @@ function MultiAutocomplete<T extends Item>({
|
||||
backgroundColor: theme.tableBackground,
|
||||
borderRadius: 4,
|
||||
border: '1px solid ' + theme.formInputBorder,
|
||||
...(focused && {
|
||||
'&:focus-within': {
|
||||
border: '1px solid ' + theme.formInputBorderSelected,
|
||||
boxShadow: '0 1px 1px ' + theme.formInputShadowSelected,
|
||||
}),
|
||||
}}
|
||||
},
|
||||
})} ${inputProps.className || ''}`}
|
||||
>
|
||||
{selectedItems.map((item, idx) => {
|
||||
item = findItem(strict, suggestions, item);
|
||||
@@ -711,21 +710,14 @@ function MultiAutocomplete<T extends Item>({
|
||||
<Input
|
||||
{...inputProps}
|
||||
onKeyDown={e => onKeyDown(e, inputProps.onKeyDown)}
|
||||
onFocus={e => {
|
||||
setFocused(true);
|
||||
inputProps.onFocus(e);
|
||||
}}
|
||||
onBlur={e => {
|
||||
setFocused(false);
|
||||
inputProps.onBlur(e);
|
||||
}}
|
||||
style={{
|
||||
flex: 1,
|
||||
minWidth: 30,
|
||||
border: 0,
|
||||
':focus': { border: 0, boxShadow: 'none' },
|
||||
...inputProps.style,
|
||||
}}
|
||||
className={String(
|
||||
css({
|
||||
flex: 1,
|
||||
minWidth: 30,
|
||||
border: 0,
|
||||
'&[data-focused]': { border: 0, boxShadow: 'none' },
|
||||
}),
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
@@ -761,8 +753,8 @@ export function AutocompleteFooter({
|
||||
}
|
||||
|
||||
type AutocompleteProps<T extends Item> =
|
||||
| ComponentProps<typeof SingleAutocomplete<T>>
|
||||
| ComponentProps<typeof MultiAutocomplete<T>>;
|
||||
| ComponentPropsWithoutRef<typeof SingleAutocomplete<T>>
|
||||
| ComponentPropsWithoutRef<typeof MultiAutocomplete<T>>;
|
||||
|
||||
export function Autocomplete<T extends Item>({
|
||||
...props
|
||||
|
||||
@@ -365,7 +365,7 @@ type CategoryItemProps = {
|
||||
|
||||
function CategoryItem({
|
||||
item,
|
||||
className,
|
||||
className = '',
|
||||
style,
|
||||
highlighted,
|
||||
embedded,
|
||||
|
||||
@@ -341,8 +341,6 @@ export function PayeeAutocomplete({
|
||||
}
|
||||
}
|
||||
|
||||
const [payeeFieldFocused, setPayeeFieldFocused] = useState(false);
|
||||
|
||||
return (
|
||||
<Autocomplete
|
||||
key={focusTransferPayees ? 'transfers' : 'all'}
|
||||
@@ -360,16 +358,11 @@ export function PayeeAutocomplete({
|
||||
}
|
||||
return item.name;
|
||||
}}
|
||||
focused={payeeFieldFocused}
|
||||
inputProps={{
|
||||
...inputProps,
|
||||
autoCapitalize: 'words',
|
||||
onBlur: () => {
|
||||
setRawPayee('');
|
||||
setPayeeFieldFocused(false);
|
||||
},
|
||||
onFocus: () => setPayeeFieldFocused(true),
|
||||
onChange: setRawPayee,
|
||||
onBlur: () => setRawPayee(''),
|
||||
onChangeValue: setRawPayee,
|
||||
}}
|
||||
onUpdate={(id, inputValue) => onUpdate?.(id, makeNew(id, inputValue))}
|
||||
onSelect={handleSelect}
|
||||
@@ -556,7 +549,7 @@ type PayeeItemProps = {
|
||||
|
||||
function PayeeItem({
|
||||
item,
|
||||
className,
|
||||
className = '',
|
||||
highlighted,
|
||||
embedded,
|
||||
...props
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import React, { type FormEvent, useCallback, useMemo, useState } from 'react';
|
||||
import { Form } from 'react-aria-components';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
|
||||
import { type CategoryEntity } from 'loot-core/src/types/models';
|
||||
@@ -6,7 +7,6 @@ import { type CategoryEntity } from 'loot-core/src/types/models';
|
||||
import { useCategories } from '../../../hooks/useCategories';
|
||||
import { CategoryAutocomplete } from '../../autocomplete/CategoryAutocomplete';
|
||||
import { Button } from '../../common/Button2';
|
||||
import { InitialFocus } from '../../common/InitialFocus';
|
||||
import { View } from '../../common/View';
|
||||
import { addToBeBudgetedGroup, removeCategoriesFromGroups } from '../util';
|
||||
|
||||
@@ -39,52 +39,55 @@ export function CoverMenu({
|
||||
: categoryGroups;
|
||||
}, [categoryId, showToBeBudgeted, originalCategoryGroups]);
|
||||
|
||||
function submit() {
|
||||
if (fromCategoryId) {
|
||||
onSubmit(fromCategoryId);
|
||||
}
|
||||
onClose();
|
||||
}
|
||||
const onSubmitInner = useCallback(
|
||||
(e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (fromCategoryId) {
|
||||
onSubmit(fromCategoryId);
|
||||
}
|
||||
onClose();
|
||||
},
|
||||
[fromCategoryId, onSubmit, onClose],
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={{ padding: 10 }}>
|
||||
<View style={{ marginBottom: 5 }}>
|
||||
<Trans>Cover from category:</Trans>
|
||||
</View>
|
||||
<Form onSubmit={onSubmitInner}>
|
||||
<View style={{ padding: 10 }}>
|
||||
<View style={{ marginBottom: 5 }}>
|
||||
<Trans>Cover from category:</Trans>
|
||||
</View>
|
||||
|
||||
<InitialFocus>
|
||||
{node => (
|
||||
<CategoryAutocomplete
|
||||
categoryGroups={filteredCategoryGroups}
|
||||
value={null}
|
||||
openOnFocus={true}
|
||||
onSelect={(id: string | undefined) => setFromCategoryId(id || null)}
|
||||
inputProps={{
|
||||
inputRef: node,
|
||||
onEnter: event => !event.defaultPrevented && submit(),
|
||||
placeholder: t('(none)'),
|
||||
}}
|
||||
showHiddenCategories={false}
|
||||
/>
|
||||
)}
|
||||
</InitialFocus>
|
||||
|
||||
<View
|
||||
style={{
|
||||
alignItems: 'flex-end',
|
||||
marginTop: 10,
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="primary"
|
||||
style={{
|
||||
fontSize: 12,
|
||||
paddingTop: 3,
|
||||
<CategoryAutocomplete
|
||||
categoryGroups={filteredCategoryGroups}
|
||||
value={null}
|
||||
openOnFocus={true}
|
||||
onSelect={(id: string | undefined) => setFromCategoryId(id || null)}
|
||||
inputProps={{
|
||||
placeholder: t('(none)'),
|
||||
}}
|
||||
showHiddenCategories={false}
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
<View
|
||||
style={{
|
||||
alignItems: 'flex-end',
|
||||
marginTop: 10,
|
||||
}}
|
||||
onPress={submit}
|
||||
>
|
||||
<Trans>Transfer</Trans>
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
type="submit"
|
||||
style={{
|
||||
fontSize: 12,
|
||||
paddingTop: 3,
|
||||
}}
|
||||
>
|
||||
<Trans>Transfer</Trans>
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -22,7 +22,11 @@ import { Button } from '../../common/Button2';
|
||||
import { Popover } from '../../common/Popover';
|
||||
import { Text } from '../../common/Text';
|
||||
import { View } from '../../common/View';
|
||||
import { type Binding, type SheetFields } from '../../spreadsheet';
|
||||
import {
|
||||
type SheetResult,
|
||||
type Binding,
|
||||
type SheetFields,
|
||||
} from '../../spreadsheet';
|
||||
import { CellValue, CellValueText } from '../../spreadsheet/CellValue';
|
||||
import { useSheetName } from '../../spreadsheet/useSheetName';
|
||||
import { useSheetValue } from '../../spreadsheet/useSheetValue';
|
||||
@@ -41,8 +45,11 @@ export function useEnvelopeSheetName<
|
||||
|
||||
export function useEnvelopeSheetValue<
|
||||
FieldName extends SheetFields<'envelope-budget'>,
|
||||
>(binding: Binding<'envelope-budget', FieldName>) {
|
||||
return useSheetValue(binding);
|
||||
>(
|
||||
binding: Binding<'envelope-budget', FieldName>,
|
||||
onChange?: (result: SheetResult<'envelope-budget', FieldName>) => void,
|
||||
) {
|
||||
return useSheetValue(binding, onChange);
|
||||
}
|
||||
|
||||
export const EnvelopeCellValue = <
|
||||
|
||||
@@ -1,45 +1,48 @@
|
||||
import React, {
|
||||
useState,
|
||||
useContext,
|
||||
useEffect,
|
||||
type ChangeEvent,
|
||||
} from 'react';
|
||||
import React, { useState, useCallback, type FormEvent, useRef } from 'react';
|
||||
import { Form } from 'react-aria-components';
|
||||
import { Trans } from 'react-i18next';
|
||||
|
||||
import { useSpreadsheet } from 'loot-core/src/client/SpreadsheetProvider';
|
||||
import { envelopeBudget } from 'loot-core/client/queries';
|
||||
import { evalArithmetic } from 'loot-core/src/shared/arithmetic';
|
||||
import { integerToCurrency, amountToInteger } from 'loot-core/src/shared/util';
|
||||
|
||||
import { Button } from '../../common/Button2';
|
||||
import { InitialFocus } from '../../common/InitialFocus';
|
||||
import { Input } from '../../common/Input';
|
||||
import { View } from '../../common/View';
|
||||
import { NamespaceContext } from '../../spreadsheet/NamespaceContext';
|
||||
|
||||
import { useEnvelopeSheetValue } from './EnvelopeBudgetComponents';
|
||||
|
||||
type HoldMenuProps = {
|
||||
onSubmit: (amount: number) => void;
|
||||
onClose: () => void;
|
||||
};
|
||||
export function HoldMenu({ onSubmit, onClose }: HoldMenuProps) {
|
||||
const spreadsheet = useSpreadsheet();
|
||||
const sheetName = useContext(NamespaceContext);
|
||||
const [amount, setAmount] = useState<string>(
|
||||
integerToCurrency(
|
||||
useEnvelopeSheetValue(envelopeBudget.toBudget, result => {
|
||||
setAmount(integerToCurrency(result?.value ?? 0));
|
||||
}) ?? 0,
|
||||
),
|
||||
);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [amount, setAmount] = useState<string | null>(null);
|
||||
const onSubmitInner = useCallback(
|
||||
(e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const node = await spreadsheet.get(sheetName, 'to-budget');
|
||||
setAmount(integerToCurrency(Math.max(node.value as number, 0)));
|
||||
})();
|
||||
}, []);
|
||||
if (amount === '') {
|
||||
inputRef.current?.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
function submit(newAmount: string) {
|
||||
const parsedAmount = evalArithmetic(newAmount);
|
||||
if (parsedAmount) {
|
||||
onSubmit(amountToInteger(parsedAmount));
|
||||
}
|
||||
onClose();
|
||||
}
|
||||
const parsedAmount = evalArithmetic(amount);
|
||||
if (parsedAmount) {
|
||||
onSubmit(amountToInteger(parsedAmount));
|
||||
}
|
||||
onClose();
|
||||
},
|
||||
[amount, onSubmit, onClose],
|
||||
);
|
||||
|
||||
if (amount === null) {
|
||||
// See `TransferMenu` for more info about this
|
||||
@@ -47,39 +50,37 @@ export function HoldMenu({ onSubmit, onClose }: HoldMenuProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={{ padding: 10 }}>
|
||||
<View style={{ marginBottom: 5 }}>
|
||||
<Trans>Hold this amount:</Trans>
|
||||
</View>
|
||||
<View>
|
||||
<InitialFocus>
|
||||
<Input
|
||||
value={amount}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) =>
|
||||
setAmount(e.target.value)
|
||||
}
|
||||
onEnter={() => submit(amount)}
|
||||
/>
|
||||
</InitialFocus>
|
||||
</View>
|
||||
<View
|
||||
style={{
|
||||
alignItems: 'flex-end',
|
||||
marginTop: 10,
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="primary"
|
||||
<Form onSubmit={onSubmitInner}>
|
||||
<View style={{ padding: 10 }}>
|
||||
<View style={{ marginBottom: 5 }}>
|
||||
<Trans>Hold this amount:</Trans>
|
||||
</View>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={amount}
|
||||
onChangeValue={(value: string) => setAmount(value)}
|
||||
autoFocus
|
||||
autoSelect
|
||||
/>
|
||||
<View
|
||||
style={{
|
||||
fontSize: 12,
|
||||
paddingTop: 3,
|
||||
paddingBottom: 3,
|
||||
alignItems: 'flex-end',
|
||||
marginTop: 10,
|
||||
}}
|
||||
onPress={() => submit(amount)}
|
||||
>
|
||||
<Trans>Hold</Trans>
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
type="submit"
|
||||
style={{
|
||||
fontSize: 12,
|
||||
paddingTop: 3,
|
||||
paddingBottom: 3,
|
||||
}}
|
||||
>
|
||||
<Trans>Hold</Trans>
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import React, { type FormEvent, useCallback, useMemo, useState } from 'react';
|
||||
import { Form } from 'react-aria-components';
|
||||
import { Trans } from 'react-i18next';
|
||||
|
||||
import { evalArithmetic } from 'loot-core/src/shared/arithmetic';
|
||||
@@ -8,7 +9,6 @@ import { type CategoryEntity } from 'loot-core/types/models';
|
||||
import { useCategories } from '../../../hooks/useCategories';
|
||||
import { CategoryAutocomplete } from '../../autocomplete/CategoryAutocomplete';
|
||||
import { Button } from '../../common/Button2';
|
||||
import { InitialFocus } from '../../common/InitialFocus';
|
||||
import { Input } from '../../common/Input';
|
||||
import { View } from '../../common/View';
|
||||
import { addToBeBudgetedGroup, removeCategoriesFromGroups } from '../util';
|
||||
@@ -42,65 +42,67 @@ export function TransferMenu({
|
||||
}, [originalCategoryGroups, categoryId, showToBeBudgeted]);
|
||||
|
||||
const _initialAmount = integerToCurrency(Math.max(initialAmount, 0));
|
||||
const [amount, setAmount] = useState<string | null>(null);
|
||||
const [amount, setAmount] = useState<string>(_initialAmount);
|
||||
const [toCategoryId, setToCategoryId] = useState<string | null>(null);
|
||||
|
||||
const _onSubmit = (newAmount: string | null, categoryId: string | null) => {
|
||||
const parsedAmount = evalArithmetic(newAmount || '');
|
||||
if (parsedAmount && categoryId) {
|
||||
onSubmit?.(amountToInteger(parsedAmount), categoryId);
|
||||
}
|
||||
const _onSubmit = useCallback(
|
||||
(e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
|
||||
onClose();
|
||||
};
|
||||
const parsedAmount = evalArithmetic(amount || '');
|
||||
if (parsedAmount && toCategoryId) {
|
||||
onSubmit?.(amountToInteger(parsedAmount), toCategoryId);
|
||||
}
|
||||
|
||||
onClose();
|
||||
},
|
||||
[amount, toCategoryId, onSubmit, onClose],
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={{ padding: 10 }}>
|
||||
<View style={{ marginBottom: 5 }}>
|
||||
<Trans>Transfer this amount:</Trans>
|
||||
</View>
|
||||
<View>
|
||||
<InitialFocus>
|
||||
<Input
|
||||
defaultValue={_initialAmount}
|
||||
onUpdate={value => setAmount(value)}
|
||||
onEnter={() => _onSubmit(amount, toCategoryId)}
|
||||
/>
|
||||
</InitialFocus>
|
||||
</View>
|
||||
<View style={{ margin: '10px 0 5px 0' }}>To:</View>
|
||||
<Form onSubmit={_onSubmit}>
|
||||
<View style={{ padding: 10 }}>
|
||||
<View style={{ marginBottom: 5 }}>
|
||||
<Trans>Transfer this amount:</Trans>
|
||||
</View>
|
||||
<Input
|
||||
value={amount}
|
||||
onChangeValue={value => setAmount(value)}
|
||||
autoFocus
|
||||
autoSelect
|
||||
/>
|
||||
<View style={{ margin: '10px 0 5px 0' }}>To:</View>
|
||||
|
||||
<CategoryAutocomplete
|
||||
categoryGroups={filteredCategoryGroups}
|
||||
value={null}
|
||||
openOnFocus={true}
|
||||
onSelect={(id: string | undefined) => setToCategoryId(id || null)}
|
||||
inputProps={{
|
||||
onEnter: event =>
|
||||
!event.defaultPrevented && _onSubmit(amount, toCategoryId),
|
||||
placeholder: '(none)',
|
||||
}}
|
||||
showHiddenCategories={true}
|
||||
/>
|
||||
|
||||
<View
|
||||
style={{
|
||||
alignItems: 'flex-end',
|
||||
marginTop: 10,
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="primary"
|
||||
style={{
|
||||
fontSize: 12,
|
||||
paddingTop: 3,
|
||||
paddingBottom: 3,
|
||||
<CategoryAutocomplete
|
||||
categoryGroups={filteredCategoryGroups}
|
||||
value={null}
|
||||
openOnFocus={true}
|
||||
onSelect={(id: string | undefined) => setToCategoryId(id || null)}
|
||||
inputProps={{
|
||||
placeholder: '(none)',
|
||||
}}
|
||||
showHiddenCategories={true}
|
||||
/>
|
||||
|
||||
<View
|
||||
style={{
|
||||
alignItems: 'flex-end',
|
||||
marginTop: 10,
|
||||
}}
|
||||
onPress={() => _onSubmit(amount, toCategoryId)}
|
||||
>
|
||||
<Trans>Transfer</Trans>
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
type="submit"
|
||||
style={{
|
||||
fontSize: 12,
|
||||
paddingTop: 3,
|
||||
paddingBottom: 3,
|
||||
}}
|
||||
>
|
||||
<Trans>Transfer</Trans>
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -22,7 +22,11 @@ import { Button } from '../../common/Button2';
|
||||
import { Popover } from '../../common/Popover';
|
||||
import { Text } from '../../common/Text';
|
||||
import { View } from '../../common/View';
|
||||
import { type Binding, type SheetFields } from '../../spreadsheet';
|
||||
import {
|
||||
type SheetResult,
|
||||
type Binding,
|
||||
type SheetFields,
|
||||
} from '../../spreadsheet';
|
||||
import { CellValue, CellValueText } from '../../spreadsheet/CellValue';
|
||||
import { useSheetValue } from '../../spreadsheet/useSheetValue';
|
||||
import { Field, SheetCell, type SheetCellProps } from '../../table';
|
||||
@@ -36,8 +40,9 @@ export const useTrackingSheetValue = <
|
||||
FieldName extends SheetFields<'tracking-budget'>,
|
||||
>(
|
||||
binding: Binding<'tracking-budget', FieldName>,
|
||||
onChange?: (result: SheetResult<'tracking-budget', FieldName>) => void,
|
||||
) => {
|
||||
return useSheetValue(binding);
|
||||
return useSheetValue(binding, onChange);
|
||||
};
|
||||
|
||||
const TrackingCellValue = <FieldName extends SheetFields<'tracking-budget'>>(
|
||||
|
||||
@@ -177,7 +177,7 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
className={
|
||||
typeof className === 'function'
|
||||
? renderProps =>
|
||||
`${defaultButtonClassName} ${className(renderProps)}`
|
||||
`${defaultButtonClassName} ${className(renderProps) || ''}`
|
||||
: `${defaultButtonClassName} ${className || ''}`
|
||||
}
|
||||
>
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
import {
|
||||
type ReactElement,
|
||||
type Ref,
|
||||
cloneElement,
|
||||
useEffect,
|
||||
useRef,
|
||||
} from 'react';
|
||||
|
||||
type InitialFocusProps = {
|
||||
children:
|
||||
| ReactElement<{ inputRef: Ref<HTMLInputElement> }>
|
||||
| ((node: Ref<HTMLInputElement>) => ReactElement);
|
||||
};
|
||||
|
||||
export function InitialFocus({ children }: InitialFocusProps) {
|
||||
const node = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (node.current) {
|
||||
// This is needed to avoid a strange interaction with
|
||||
// `ScopeTab`, which doesn't allow it to be focused at first for
|
||||
// some reason. Need to look into it.
|
||||
setTimeout(() => {
|
||||
if (node.current) {
|
||||
node.current.focus();
|
||||
node.current.setSelectionRange(0, 10000);
|
||||
}
|
||||
}, 0);
|
||||
}
|
||||
}, []);
|
||||
|
||||
if (typeof children === 'function') {
|
||||
return children(node);
|
||||
}
|
||||
return cloneElement(children, { inputRef: node });
|
||||
}
|
||||
@@ -1,110 +1,138 @@
|
||||
import React, {
|
||||
type InputHTMLAttributes,
|
||||
type KeyboardEvent,
|
||||
type Ref,
|
||||
type CSSProperties,
|
||||
type ComponentPropsWithRef,
|
||||
forwardRef,
|
||||
useEffect,
|
||||
useRef,
|
||||
useMemo,
|
||||
} from 'react';
|
||||
import { Input as ReactAriaInput } from 'react-aria-components';
|
||||
|
||||
import { css, cx } from '@emotion/css';
|
||||
|
||||
import { useMergedRefs } from '../../hooks/useMergedRefs';
|
||||
import { useProperFocus } from '../../hooks/useProperFocus';
|
||||
import { styles, theme } from '../../style';
|
||||
|
||||
export const defaultInputStyle = {
|
||||
outline: 0,
|
||||
backgroundColor: theme.tableBackground,
|
||||
color: theme.formInputText,
|
||||
margin: 0,
|
||||
padding: 5,
|
||||
borderRadius: 4,
|
||||
border: '1px solid ' + theme.formInputBorder,
|
||||
};
|
||||
|
||||
type InputProps = InputHTMLAttributes<HTMLInputElement> & {
|
||||
style?: CSSProperties;
|
||||
inputRef?: Ref<HTMLInputElement>;
|
||||
type InputProps = ComponentPropsWithRef<typeof ReactAriaInput> & {
|
||||
autoSelect?: boolean;
|
||||
onEnter?: (event: KeyboardEvent<HTMLInputElement>) => void;
|
||||
onEscape?: (event: KeyboardEvent<HTMLInputElement>) => void;
|
||||
onChangeValue?: (newValue: string) => void;
|
||||
onUpdate?: (newValue: string) => void;
|
||||
focused?: boolean;
|
||||
};
|
||||
|
||||
export function Input({
|
||||
style,
|
||||
inputRef,
|
||||
onEnter,
|
||||
onEscape,
|
||||
onChangeValue,
|
||||
onUpdate,
|
||||
focused,
|
||||
className,
|
||||
...nativeProps
|
||||
}: InputProps) {
|
||||
const ref = useRef<HTMLInputElement>(null);
|
||||
useProperFocus(ref, focused);
|
||||
export const Input = forwardRef<HTMLInputElement, InputProps>(
|
||||
(
|
||||
{
|
||||
autoSelect,
|
||||
className = '',
|
||||
onEnter,
|
||||
onEscape,
|
||||
onChangeValue,
|
||||
onUpdate,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const mergedRef = useMergedRefs<HTMLInputElement>(inputRef, ref);
|
||||
|
||||
const mergedRef = useMergedRefs<HTMLInputElement>(ref, inputRef);
|
||||
useEffect(() => {
|
||||
if (autoSelect) {
|
||||
// Select on mount does not work properly for inputs that are inside a dialog.
|
||||
// See https://github.com/facebook/react/issues/23301#issuecomment-1656908450
|
||||
// for the reason why we need to use setTimeout here.
|
||||
setTimeout(() => inputRef.current?.select());
|
||||
}
|
||||
}, [autoSelect]);
|
||||
|
||||
return (
|
||||
<input
|
||||
ref={mergedRef}
|
||||
className={cx(
|
||||
const defaultInputClassName = useMemo(
|
||||
() =>
|
||||
css(
|
||||
defaultInputStyle,
|
||||
{
|
||||
outline: 0,
|
||||
backgroundColor: theme.tableBackground,
|
||||
color: theme.formInputText,
|
||||
margin: 0,
|
||||
padding: 5,
|
||||
borderRadius: 4,
|
||||
border: '1px solid ' + theme.formInputBorder,
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
flexShrink: 0,
|
||||
':focus': {
|
||||
'&[data-focused]': {
|
||||
border: '1px solid ' + theme.formInputBorderSelected,
|
||||
boxShadow: '0 1px 1px ' + theme.formInputShadowSelected,
|
||||
},
|
||||
'::placeholder': { color: theme.formInputTextPlaceholder },
|
||||
'&::placeholder': { color: theme.formInputTextPlaceholder },
|
||||
},
|
||||
styles.smallText,
|
||||
style,
|
||||
),
|
||||
className,
|
||||
)}
|
||||
{...nativeProps}
|
||||
onKeyDown={e => {
|
||||
nativeProps.onKeyDown?.(e);
|
||||
[],
|
||||
);
|
||||
|
||||
if (e.key === 'Enter' && onEnter) {
|
||||
onEnter(e);
|
||||
return (
|
||||
<ReactAriaInput
|
||||
ref={mergedRef}
|
||||
{...props}
|
||||
className={
|
||||
typeof className === 'function'
|
||||
? renderProps => cx(defaultInputClassName, className(renderProps))
|
||||
: cx(defaultInputClassName, className)
|
||||
}
|
||||
onKeyDown={e => {
|
||||
props.onKeyDown?.(e);
|
||||
|
||||
if (e.key === 'Escape' && onEscape) {
|
||||
onEscape(e);
|
||||
if (e.key === 'Enter' && onEnter) {
|
||||
onEnter(e);
|
||||
}
|
||||
|
||||
if (e.key === 'Escape' && onEscape) {
|
||||
onEscape(e);
|
||||
}
|
||||
}}
|
||||
onBlur={e => {
|
||||
onUpdate?.(e.target.value);
|
||||
props.onBlur?.(e);
|
||||
}}
|
||||
onChange={e => {
|
||||
onChangeValue?.(e.target.value);
|
||||
props.onChange?.(e);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Input.displayName = 'Input';
|
||||
|
||||
type BigInputProps = InputProps;
|
||||
|
||||
export const BigInput = forwardRef<HTMLInputElement, BigInputProps>(
|
||||
({ className, ...props }, ref) => {
|
||||
const defaultClassName = useMemo(
|
||||
() =>
|
||||
String(
|
||||
css({
|
||||
padding: 10,
|
||||
fontSize: 15,
|
||||
'&, &[data-focused]': { border: 'none', ...styles.shadow },
|
||||
}),
|
||||
),
|
||||
[],
|
||||
);
|
||||
return (
|
||||
<Input
|
||||
ref={ref}
|
||||
{...props}
|
||||
className={renderProps =>
|
||||
typeof className === 'function'
|
||||
? cx(defaultClassName, className(renderProps))
|
||||
: cx(defaultClassName, className)
|
||||
}
|
||||
}}
|
||||
onBlur={e => {
|
||||
onUpdate?.(e.target.value);
|
||||
nativeProps.onBlur?.(e);
|
||||
}}
|
||||
onChange={e => {
|
||||
onChangeValue?.(e.target.value);
|
||||
nativeProps.onChange?.(e);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export function BigInput(props: InputProps) {
|
||||
return (
|
||||
<Input
|
||||
{...props}
|
||||
style={{
|
||||
padding: 10,
|
||||
fontSize: 15,
|
||||
border: 'none',
|
||||
...styles.shadow,
|
||||
':focus': { border: 'none', ...styles.shadow },
|
||||
...props.style,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
BigInput.displayName = 'BigInput';
|
||||
|
||||
@@ -1,74 +1,48 @@
|
||||
import {
|
||||
useState,
|
||||
type ComponentProps,
|
||||
type ReactNode,
|
||||
type CSSProperties,
|
||||
} from 'react';
|
||||
import { type ComponentPropsWithRef, type ReactNode, forwardRef } from 'react';
|
||||
|
||||
import { css, cx } from '@emotion/css';
|
||||
|
||||
import { theme } from '../../style';
|
||||
|
||||
import { Input, defaultInputStyle } from './Input';
|
||||
import { Input } from './Input';
|
||||
import { View } from './View';
|
||||
|
||||
type InputWithContentProps = ComponentProps<typeof Input> & {
|
||||
type InputWithContentProps = ComponentPropsWithRef<typeof Input> & {
|
||||
leftContent?: ReactNode;
|
||||
rightContent?: ReactNode;
|
||||
inputStyle?: CSSProperties;
|
||||
focusStyle?: CSSProperties;
|
||||
style?: CSSProperties;
|
||||
getStyle?: (focused: boolean) => CSSProperties;
|
||||
containerClassName?: string;
|
||||
};
|
||||
export function InputWithContent({
|
||||
leftContent,
|
||||
rightContent,
|
||||
inputStyle,
|
||||
focusStyle,
|
||||
style,
|
||||
getStyle,
|
||||
...props
|
||||
}: InputWithContentProps) {
|
||||
const [focused, setFocused] = useState(props.focused ?? false);
|
||||
|
||||
export const InputWithContent = forwardRef<
|
||||
HTMLInputElement,
|
||||
InputWithContentProps
|
||||
>(({ leftContent, rightContent, containerClassName, ...props }, ref) => {
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
...defaultInputStyle,
|
||||
padding: 0,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
...style,
|
||||
...(focused &&
|
||||
(focusStyle ?? {
|
||||
className={cx(
|
||||
css({
|
||||
backgroundColor: theme.tableBackground,
|
||||
color: theme.formInputText,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
borderRadius: 4,
|
||||
'&:focus-within': {
|
||||
boxShadow: '0 0 0 1px ' + theme.formInputShadowSelected,
|
||||
})),
|
||||
...getStyle?.(focused),
|
||||
}}
|
||||
>
|
||||
{leftContent}
|
||||
<Input
|
||||
{...props}
|
||||
focused={focused}
|
||||
style={{
|
||||
width: '100%',
|
||||
...inputStyle,
|
||||
flex: 1,
|
||||
'&, &:focus, &:hover': {
|
||||
},
|
||||
'& input, input[data-focused], input[data-hovered]': {
|
||||
border: 0,
|
||||
backgroundColor: 'transparent',
|
||||
boxShadow: 'none',
|
||||
color: 'inherit',
|
||||
},
|
||||
}}
|
||||
onFocus={e => {
|
||||
setFocused(true);
|
||||
props.onFocus?.(e);
|
||||
}}
|
||||
onBlur={e => {
|
||||
setFocused(false);
|
||||
props.onBlur?.(e);
|
||||
}}
|
||||
/>
|
||||
}),
|
||||
containerClassName,
|
||||
)}
|
||||
>
|
||||
{leftContent}
|
||||
<Input ref={ref} {...props} />
|
||||
{rightContent}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
InputWithContent.displayName = 'InputWithContent';
|
||||
|
||||
@@ -8,6 +8,7 @@ import React, {
|
||||
type ComponentPropsWithRef,
|
||||
type CSSProperties,
|
||||
} from 'react';
|
||||
import { FocusScope } from 'react-aria';
|
||||
import {
|
||||
ModalOverlay as ReactAriaModalOverlay,
|
||||
Modal as ReactAriaModal,
|
||||
@@ -22,9 +23,9 @@ import { useModalState } from '../../hooks/useModalState';
|
||||
import { AnimatedLoading } from '../../icons/AnimatedLoading';
|
||||
import { SvgLogo } from '../../icons/logo';
|
||||
import { SvgDelete } from '../../icons/v0';
|
||||
import { useResponsive } from '../../ResponsiveProvider';
|
||||
import { styles, theme } from '../../style';
|
||||
import { tokens } from '../../tokens';
|
||||
import { useResponsive } from '../responsive/ResponsiveProvider';
|
||||
|
||||
import { Button } from './Button2';
|
||||
import { Input } from './Input';
|
||||
@@ -132,9 +133,11 @@ export const Modal = ({
|
||||
}}
|
||||
>
|
||||
<View style={{ paddingTop: 0, flex: 1, flexShrink: 0 }}>
|
||||
{typeof children === 'function'
|
||||
? children(modalProps)
|
||||
: children}
|
||||
<FocusScope autoFocus restoreFocus>
|
||||
{typeof children === 'function'
|
||||
? children(modalProps)
|
||||
: children}
|
||||
</FocusScope>
|
||||
</View>
|
||||
{isLoading && (
|
||||
<View
|
||||
@@ -402,14 +405,15 @@ export function ModalTitle({
|
||||
|
||||
return isEditing ? (
|
||||
<Input
|
||||
inputRef={inputRef}
|
||||
ref={inputRef}
|
||||
style={{
|
||||
fontSize: 25,
|
||||
fontWeight: 700,
|
||||
textAlign: 'center',
|
||||
...style,
|
||||
}}
|
||||
focused={isEditing}
|
||||
autoFocus={isEditing}
|
||||
autoSelect={isEditing}
|
||||
defaultValue={title}
|
||||
onUpdate={_onTitleUpdate}
|
||||
onKeyDown={e => {
|
||||
|
||||
@@ -1,92 +1,89 @@
|
||||
import { type Ref } from 'react';
|
||||
import { type ComponentPropsWithRef, forwardRef, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { css } from '@emotion/css';
|
||||
|
||||
import { SvgRemove, SvgSearchAlternate } from '../../icons/v2';
|
||||
import { theme } from '../../style';
|
||||
|
||||
import { Button } from './Button2';
|
||||
import { type Input } from './Input';
|
||||
import { InputWithContent } from './InputWithContent';
|
||||
import { View } from './View';
|
||||
|
||||
type SearchProps = {
|
||||
inputRef?: Ref<HTMLInputElement>;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder: string;
|
||||
type SearchProps = ComponentPropsWithRef<typeof Input> & {
|
||||
isInModal?: boolean;
|
||||
width?: number;
|
||||
};
|
||||
|
||||
export function Search({
|
||||
inputRef,
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
isInModal = false,
|
||||
width = 250,
|
||||
}: SearchProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<InputWithContent
|
||||
inputRef={inputRef}
|
||||
style={{
|
||||
width,
|
||||
flex: '',
|
||||
borderColor: isInModal ? undefined : 'transparent',
|
||||
backgroundColor: isInModal ? undefined : theme.formInputBackground,
|
||||
}}
|
||||
focusStyle={
|
||||
isInModal
|
||||
? undefined
|
||||
: {
|
||||
boxShadow: '0 0 0 1px ' + theme.formInputShadowSelected,
|
||||
backgroundColor: theme.formInputBackgroundSelected,
|
||||
}
|
||||
}
|
||||
leftContent={
|
||||
<SvgSearchAlternate
|
||||
style={{
|
||||
width: 13,
|
||||
height: 13,
|
||||
flexShrink: 0,
|
||||
color: value ? theme.menuItemTextSelected : 'inherit',
|
||||
margin: 5,
|
||||
marginRight: 0,
|
||||
}}
|
||||
/>
|
||||
}
|
||||
rightContent={
|
||||
value && (
|
||||
<View title={t('Clear search term')}>
|
||||
<Button
|
||||
variant="bare"
|
||||
style={{ padding: 8 }}
|
||||
onPress={() => onChange('')}
|
||||
>
|
||||
<SvgRemove style={{ width: 8, height: 8 }} />
|
||||
</Button>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
inputStyle={{
|
||||
'::placeholder': {
|
||||
color: theme.formInputTextPlaceholder,
|
||||
transition: 'color .25s',
|
||||
},
|
||||
':focus': isInModal
|
||||
? {}
|
||||
: {
|
||||
'::placeholder': {
|
||||
color: theme.formInputTextPlaceholderSelected,
|
||||
export const Search = forwardRef<HTMLInputElement, SearchProps>(
|
||||
({ value, onChangeValue, isInModal = false, width = 250, ...props }, ref) => {
|
||||
const { t } = useTranslation();
|
||||
const defaultClassName = useMemo(
|
||||
() =>
|
||||
css({
|
||||
width,
|
||||
// flex: '',
|
||||
borderColor: isInModal ? undefined : 'transparent',
|
||||
backgroundColor: isInModal ? undefined : theme.formInputBackground,
|
||||
'&:focus-within': isInModal
|
||||
? {}
|
||||
: {
|
||||
boxShadow: '0 0 0 1px ' + theme.formInputShadowSelected,
|
||||
backgroundColor: theme.formInputBackgroundSelected,
|
||||
},
|
||||
'& input': {
|
||||
flex: 1,
|
||||
'::placeholder': {
|
||||
color: theme.formInputTextPlaceholder,
|
||||
transition: 'color .25s',
|
||||
},
|
||||
}}
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Escape') onChange('');
|
||||
}}
|
||||
onChangeValue={value => onChange(value)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
'[data-focused]': isInModal
|
||||
? {}
|
||||
: {
|
||||
'::placeholder': {
|
||||
color: theme.formInputTextPlaceholderSelected,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
[isInModal, width],
|
||||
);
|
||||
|
||||
return (
|
||||
<InputWithContent
|
||||
ref={ref}
|
||||
containerClassName={defaultClassName}
|
||||
leftContent={
|
||||
<SvgSearchAlternate
|
||||
style={{
|
||||
width: 13,
|
||||
height: 13,
|
||||
flexShrink: 0,
|
||||
color: value ? theme.menuItemTextSelected : 'inherit',
|
||||
margin: '5px 0 5px 5px',
|
||||
}}
|
||||
/>
|
||||
}
|
||||
rightContent={
|
||||
value && (
|
||||
<View title={t('Clear search term')}>
|
||||
<Button
|
||||
variant="bare"
|
||||
style={{ padding: 8 }}
|
||||
onPress={() => onChangeValue?.('')}
|
||||
>
|
||||
<SvgRemove style={{ width: 8, height: 8 }} />
|
||||
</Button>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
value={value}
|
||||
onEscape={() => onChangeValue?.('')}
|
||||
onChangeValue={onChangeValue}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Search.displayName = 'Search';
|
||||
|
||||
@@ -207,7 +207,7 @@ function ConfigureField({
|
||||
>
|
||||
{type !== 'boolean' && (
|
||||
<GenericInput
|
||||
inputRef={inputRef}
|
||||
ref={inputRef}
|
||||
field={field}
|
||||
subfield={subfield}
|
||||
type={
|
||||
|
||||
@@ -56,7 +56,7 @@ export function NameFilter({
|
||||
/>
|
||||
<Input
|
||||
id="name-field"
|
||||
inputRef={inputRef}
|
||||
ref={inputRef}
|
||||
defaultValue={name || ''}
|
||||
onChangeValue={setName}
|
||||
/>
|
||||
|
||||
@@ -53,7 +53,7 @@ export const InputField = forwardRef<HTMLInputElement, InputFieldProps>(
|
||||
({ disabled, style, onUpdate, ...props }, ref) => {
|
||||
return (
|
||||
<Input
|
||||
inputRef={ref}
|
||||
ref={ref}
|
||||
autoCorrect="false"
|
||||
autoCapitalize="none"
|
||||
disabled={disabled}
|
||||
|
||||
@@ -340,7 +340,8 @@ const ChildTransactionEdit = forwardRef(
|
||||
editingField &&
|
||||
editingField !== getFieldName(transaction.id, 'amount')
|
||||
}
|
||||
focused={amountFocused}
|
||||
autoFocus={amountFocused}
|
||||
autoSelect={amountFocused}
|
||||
value={amountToInteger(transaction.amount)}
|
||||
zeroSign={amountSign}
|
||||
style={{ marginRight: 8 }}
|
||||
|
||||
@@ -69,7 +69,7 @@ export function AccountAutocompleteModal({
|
||||
)}
|
||||
<View style={{ flex: 1 }}>
|
||||
<AccountAutocomplete
|
||||
focused={true}
|
||||
autoFocus={true}
|
||||
embedded={true}
|
||||
closeOnBlur={false}
|
||||
onClose={close}
|
||||
|
||||
@@ -78,7 +78,7 @@ export function CategoryAutocompleteModal({
|
||||
value={month ? monthUtils.sheetForMonth(month) : ''}
|
||||
>
|
||||
<CategoryAutocomplete
|
||||
focused={true}
|
||||
autoFocus={true}
|
||||
embedded={true}
|
||||
closeOnBlur={false}
|
||||
showSplitOption={false}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// @ts-strict-ignore
|
||||
import React, { useState } from 'react';
|
||||
import React, { type FormEvent, useCallback, useState } from 'react';
|
||||
import { Form } from 'react-aria-components';
|
||||
|
||||
import { useCategories } from '../../hooks/useCategories';
|
||||
import { theme } from '../../style';
|
||||
@@ -27,7 +28,7 @@ export function ConfirmCategoryDeleteModal({
|
||||
const group = categoryGroups.find(g => g.id === groupId);
|
||||
const category = categories.find(c => c.id === categoryId);
|
||||
|
||||
const renderError = (error: string) => {
|
||||
const renderError = useCallback((error: string) => {
|
||||
let msg: string;
|
||||
|
||||
switch (error) {
|
||||
@@ -48,10 +49,24 @@ export function ConfirmCategoryDeleteModal({
|
||||
{msg}
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const isIncome = !!(category || group).is_income;
|
||||
|
||||
const onSubmit = useCallback(
|
||||
(e: FormEvent<HTMLFormElement>, { close }: { close: () => void }) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!transferCategory) {
|
||||
setError('required-transfer');
|
||||
} else {
|
||||
onDelete(transferCategory);
|
||||
close();
|
||||
}
|
||||
},
|
||||
[onDelete, transferCategory],
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
name="confirm-category-delete"
|
||||
@@ -63,82 +78,75 @@ export function ConfirmCategoryDeleteModal({
|
||||
title="Confirm Delete"
|
||||
rightContent={<ModalCloseButton onPress={close} />}
|
||||
/>
|
||||
<View style={{ lineHeight: 1.5 }}>
|
||||
{group ? (
|
||||
<Block>
|
||||
Categories in the group <strong>{group.name}</strong> are used
|
||||
by existing transactions
|
||||
{!isIncome &&
|
||||
' or it has a positive leftover balance currently'}
|
||||
. <strong>Are you sure you want to delete it?</strong> If so,
|
||||
you must select another category to transfer existing
|
||||
transactions and balance to.
|
||||
</Block>
|
||||
) : (
|
||||
<Block>
|
||||
<strong>{category.name}</strong> is used by existing
|
||||
transactions
|
||||
{!isIncome &&
|
||||
' or it has a positive leftover balance currently'}
|
||||
. <strong>Are you sure you want to delete it?</strong> If so,
|
||||
you must select another category to transfer existing
|
||||
transactions and balance to.
|
||||
</Block>
|
||||
)}
|
||||
<Form onSubmit={e => onSubmit(e, { close })}>
|
||||
<View style={{ lineHeight: 1.5 }}>
|
||||
{group ? (
|
||||
<Block>
|
||||
Categories in the group <strong>{group.name}</strong> are used
|
||||
by existing transactions
|
||||
{!isIncome &&
|
||||
' or it has a positive leftover balance currently'}
|
||||
. <strong>Are you sure you want to delete it?</strong> If so,
|
||||
you must select another category to transfer existing
|
||||
transactions and balance to.
|
||||
</Block>
|
||||
) : (
|
||||
<Block>
|
||||
<strong>{category.name}</strong> is used by existing
|
||||
transactions
|
||||
{!isIncome &&
|
||||
' or it has a positive leftover balance currently'}
|
||||
. <strong>Are you sure you want to delete it?</strong> If so,
|
||||
you must select another category to transfer existing
|
||||
transactions and balance to.
|
||||
</Block>
|
||||
)}
|
||||
|
||||
{error && renderError(error)}
|
||||
{error && renderError(error)}
|
||||
|
||||
<View
|
||||
style={{
|
||||
marginTop: 20,
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'flex-start',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Text>Transfer to:</Text>
|
||||
|
||||
<View style={{ flex: 1, marginLeft: 10, marginRight: 30 }}>
|
||||
<CategoryAutocomplete
|
||||
categoryGroups={
|
||||
group
|
||||
? categoryGroups.filter(
|
||||
g => g.id !== group.id && !!g.is_income === isIncome,
|
||||
)
|
||||
: categoryGroups
|
||||
.filter(g => !!g.is_income === isIncome)
|
||||
.map(g => ({
|
||||
...g,
|
||||
categories: g.categories.filter(
|
||||
c => c.id !== category.id,
|
||||
),
|
||||
}))
|
||||
}
|
||||
value={transferCategory}
|
||||
focused={true}
|
||||
inputProps={{
|
||||
placeholder: 'Select category...',
|
||||
}}
|
||||
onSelect={category => setTransferCategory(category)}
|
||||
showHiddenCategories={true}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<Button
|
||||
variant="primary"
|
||||
onPress={() => {
|
||||
if (!transferCategory) {
|
||||
setError('required-transfer');
|
||||
} else {
|
||||
onDelete(transferCategory);
|
||||
close();
|
||||
}
|
||||
<View
|
||||
style={{
|
||||
marginTop: 20,
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'flex-start',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
<Text>Transfer to:</Text>
|
||||
|
||||
<View style={{ flex: 1, marginLeft: 10, marginRight: 30 }}>
|
||||
<CategoryAutocomplete
|
||||
categoryGroups={
|
||||
group
|
||||
? categoryGroups.filter(
|
||||
g =>
|
||||
g.id !== group.id && !!g.is_income === isIncome,
|
||||
)
|
||||
: categoryGroups
|
||||
.filter(g => !!g.is_income === isIncome)
|
||||
.map(g => ({
|
||||
...g,
|
||||
categories: g.categories.filter(
|
||||
c => c.id !== category.id,
|
||||
),
|
||||
}))
|
||||
}
|
||||
value={transferCategory}
|
||||
autoFocus={true}
|
||||
inputProps={{
|
||||
placeholder: 'Select category...',
|
||||
}}
|
||||
onSelect={category => setTransferCategory(category)}
|
||||
showHiddenCategories={true}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<Button variant="primary" type="submit">
|
||||
Delete
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Form>
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import React from 'react';
|
||||
import React, { type FormEvent, useCallback } from 'react';
|
||||
import { Form } from 'react-aria-components';
|
||||
|
||||
import { styles } from '../../style';
|
||||
import { Button } from '../common/Button2';
|
||||
import { InitialFocus } from '../common/InitialFocus';
|
||||
import { Modal, ModalCloseButton, ModalHeader } from '../common/Modal';
|
||||
import { Paragraph } from '../common/Paragraph';
|
||||
import { View } from '../common/View';
|
||||
@@ -24,6 +24,15 @@ export function ConfirmTransactionDeleteModal({
|
||||
}
|
||||
: {};
|
||||
|
||||
const onSubmit = useCallback(
|
||||
(e: FormEvent<HTMLFormElement>, { close }: { close: () => void }) => {
|
||||
e.preventDefault();
|
||||
onConfirm();
|
||||
close();
|
||||
},
|
||||
[onConfirm],
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal name="confirm-transaction-delete">
|
||||
{({ state: { close } }) => (
|
||||
@@ -32,37 +41,35 @@ export function ConfirmTransactionDeleteModal({
|
||||
title="Confirm Delete"
|
||||
rightContent={<ModalCloseButton onPress={close} />}
|
||||
/>
|
||||
<View style={{ lineHeight: 1.5 }}>
|
||||
<Paragraph>{message}</Paragraph>
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'flex-end',
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
<Form onSubmit={e => onSubmit(e, { close })}>
|
||||
<View style={{ lineHeight: 1.5 }}>
|
||||
<Paragraph>{message}</Paragraph>
|
||||
<View
|
||||
style={{
|
||||
marginRight: 10,
|
||||
...narrowButtonStyle,
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'flex-end',
|
||||
}}
|
||||
onPress={close}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<InitialFocus>
|
||||
<Button
|
||||
style={{
|
||||
marginRight: 10,
|
||||
...narrowButtonStyle,
|
||||
}}
|
||||
onPress={close}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
type="submit"
|
||||
style={narrowButtonStyle}
|
||||
onPress={() => {
|
||||
onConfirm();
|
||||
close();
|
||||
}}
|
||||
autoFocus
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</InitialFocus>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Form>
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
// @ts-strict-ignore
|
||||
import React from 'react';
|
||||
import React, { type FormEvent, useCallback } from 'react';
|
||||
import { Form } from 'react-aria-components';
|
||||
|
||||
import { Block } from '../common/Block';
|
||||
import { Button } from '../common/Button2';
|
||||
import { InitialFocus } from '../common/InitialFocus';
|
||||
import { Modal, ModalCloseButton, ModalHeader } from '../common/Modal';
|
||||
import { View } from '../common/View';
|
||||
|
||||
@@ -18,6 +18,16 @@ export function ConfirmTransactionEditModal({
|
||||
onConfirm,
|
||||
confirmReason,
|
||||
}: ConfirmTransactionEditProps) {
|
||||
const onSubmit = useCallback(
|
||||
(e: FormEvent<HTMLFormElement>, { close }: { close: () => void }) => {
|
||||
e.preventDefault();
|
||||
|
||||
close();
|
||||
onConfirm();
|
||||
},
|
||||
[onConfirm],
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
name="confirm-transaction-edit"
|
||||
@@ -29,81 +39,79 @@ export function ConfirmTransactionEditModal({
|
||||
title="Reconciled Transaction"
|
||||
rightContent={<ModalCloseButton onPress={close} />}
|
||||
/>
|
||||
<View style={{ lineHeight: 1.5 }}>
|
||||
{confirmReason === 'batchDeleteWithReconciled' ? (
|
||||
<Block>
|
||||
Deleting reconciled transactions may bring your reconciliation
|
||||
out of balance.
|
||||
</Block>
|
||||
) : confirmReason === 'batchEditWithReconciled' ? (
|
||||
<Block>
|
||||
Editing reconciled transactions may bring your reconciliation
|
||||
out of balance.
|
||||
</Block>
|
||||
) : confirmReason === 'batchDuplicateWithReconciled' ? (
|
||||
<Block>
|
||||
Duplicating reconciled transactions may bring your
|
||||
reconciliation out of balance.
|
||||
</Block>
|
||||
) : confirmReason === 'editReconciled' ? (
|
||||
<Block>
|
||||
Saving your changes to this reconciled transaction may bring
|
||||
your reconciliation out of balance.
|
||||
</Block>
|
||||
) : confirmReason === 'unlockReconciled' ? (
|
||||
<Block>
|
||||
Unlocking this transaction means you won‘t be warned about
|
||||
changes that can impact your reconciled balance. (Changes to
|
||||
amount, account, payee, etc).
|
||||
</Block>
|
||||
) : confirmReason === 'deleteReconciled' ? (
|
||||
<Block>
|
||||
Deleting this reconciled transaction may bring your
|
||||
reconciliation out of balance.
|
||||
</Block>
|
||||
) : (
|
||||
<Block>Are you sure you want to edit this transaction?</Block>
|
||||
)}
|
||||
<Form onSubmit={e => onSubmit(e, { close })}>
|
||||
<View style={{ lineHeight: 1.5 }}>
|
||||
{confirmReason === 'batchDeleteWithReconciled' ? (
|
||||
<Block>
|
||||
Deleting reconciled transactions may bring your reconciliation
|
||||
out of balance.
|
||||
</Block>
|
||||
) : confirmReason === 'batchEditWithReconciled' ? (
|
||||
<Block>
|
||||
Editing reconciled transactions may bring your reconciliation
|
||||
out of balance.
|
||||
</Block>
|
||||
) : confirmReason === 'batchDuplicateWithReconciled' ? (
|
||||
<Block>
|
||||
Duplicating reconciled transactions may bring your
|
||||
reconciliation out of balance.
|
||||
</Block>
|
||||
) : confirmReason === 'editReconciled' ? (
|
||||
<Block>
|
||||
Saving your changes to this reconciled transaction may bring
|
||||
your reconciliation out of balance.
|
||||
</Block>
|
||||
) : confirmReason === 'unlockReconciled' ? (
|
||||
<Block>
|
||||
Unlocking this transaction means you won‘t be warned about
|
||||
changes that can impact your reconciled balance. (Changes to
|
||||
amount, account, payee, etc).
|
||||
</Block>
|
||||
) : confirmReason === 'deleteReconciled' ? (
|
||||
<Block>
|
||||
Deleting this reconciled transaction may bring your
|
||||
reconciliation out of balance.
|
||||
</Block>
|
||||
) : (
|
||||
<Block>Are you sure you want to edit this transaction?</Block>
|
||||
)}
|
||||
|
||||
<View
|
||||
style={{
|
||||
marginTop: 20,
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'flex-start',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
marginTop: 20,
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'flex-end',
|
||||
justifyContent: 'flex-start',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
aria-label="Cancel"
|
||||
style={{ marginRight: 10 }}
|
||||
onPress={() => {
|
||||
close();
|
||||
onCancel();
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'flex-end',
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<InitialFocus>
|
||||
<Button
|
||||
aria-label="Cancel"
|
||||
style={{ marginRight: 10 }}
|
||||
onPress={() => {
|
||||
close();
|
||||
onCancel?.();
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
aria-label="Confirm"
|
||||
variant="primary"
|
||||
onPress={() => {
|
||||
close();
|
||||
onConfirm();
|
||||
}}
|
||||
type="submit"
|
||||
autoFocus
|
||||
>
|
||||
Confirm
|
||||
</Button>
|
||||
</InitialFocus>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Form>
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import React, { type FormEvent, useCallback } from 'react';
|
||||
import { Form } from 'react-aria-components';
|
||||
|
||||
import { Button } from '../common/Button2';
|
||||
import { InitialFocus } from '../common/InitialFocus';
|
||||
import { Modal, ModalCloseButton, ModalHeader } from '../common/Modal';
|
||||
import { Paragraph } from '../common/Paragraph';
|
||||
import { View } from '../common/View';
|
||||
@@ -15,6 +15,15 @@ export function ConfirmUnlinkAccountModal({
|
||||
accountName,
|
||||
onUnlink,
|
||||
}: ConfirmUnlinkAccountProps) {
|
||||
const onSubmit = useCallback(
|
||||
(e: FormEvent<HTMLFormElement>, { close }: { close: () => void }) => {
|
||||
e.preventDefault();
|
||||
|
||||
onUnlink();
|
||||
close();
|
||||
},
|
||||
[onUnlink],
|
||||
);
|
||||
return (
|
||||
<Modal
|
||||
name="confirm-unlink-account"
|
||||
@@ -26,38 +35,32 @@ export function ConfirmUnlinkAccountModal({
|
||||
title="Confirm Unlink"
|
||||
rightContent={<ModalCloseButton onPress={close} />}
|
||||
/>
|
||||
<View style={{ lineHeight: 1.5 }}>
|
||||
<Paragraph>
|
||||
Are you sure you want to unlink <strong>{accountName}</strong>?
|
||||
</Paragraph>
|
||||
<Form onSubmit={e => onSubmit(e, { close })}>
|
||||
<View style={{ lineHeight: 1.5 }}>
|
||||
<Paragraph>
|
||||
Are you sure you want to unlink <strong>{accountName}</strong>?
|
||||
</Paragraph>
|
||||
|
||||
<Paragraph>
|
||||
Transactions will no longer be synchronized with this account and
|
||||
must be manually entered.
|
||||
</Paragraph>
|
||||
<Paragraph>
|
||||
Transactions will no longer be synchronized with this account
|
||||
and must be manually entered.
|
||||
</Paragraph>
|
||||
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'flex-end',
|
||||
}}
|
||||
>
|
||||
<Button style={{ marginRight: 10 }} onPress={close}>
|
||||
Cancel
|
||||
</Button>
|
||||
<InitialFocus>
|
||||
<Button
|
||||
variant="primary"
|
||||
onPress={() => {
|
||||
onUnlink();
|
||||
close();
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'flex-end',
|
||||
}}
|
||||
>
|
||||
<Button style={{ marginRight: 10 }} onPress={close}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="primary" type="submit" autoFocus>
|
||||
Unlink
|
||||
</Button>
|
||||
</InitialFocus>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Form>
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
@@ -12,7 +12,6 @@ import { useSyncServerStatus } from '../../hooks/useSyncServerStatus';
|
||||
import { SvgDotsHorizontalTriple } from '../../icons/v1';
|
||||
import { theme } from '../../style';
|
||||
import { Button, ButtonWithLoading } from '../common/Button2';
|
||||
import { InitialFocus } from '../common/InitialFocus';
|
||||
import { Link } from '../common/Link';
|
||||
import { Menu } from '../common/Menu';
|
||||
import { Modal, ModalCloseButton, ModalHeader } from '../common/Modal';
|
||||
@@ -190,19 +189,18 @@ export function CreateAccountModal({ upgradingAccountId }: CreateAccountProps) {
|
||||
<View style={{ maxWidth: 500, gap: 30, color: theme.pageText }}>
|
||||
{upgradingAccountId == null && (
|
||||
<View style={{ gap: 10 }}>
|
||||
<InitialFocus>
|
||||
<Button
|
||||
variant="primary"
|
||||
style={{
|
||||
padding: '10px 0',
|
||||
fontSize: 15,
|
||||
fontWeight: 600,
|
||||
}}
|
||||
onPress={onCreateLocalAccount}
|
||||
>
|
||||
Create local account
|
||||
</Button>
|
||||
</InitialFocus>
|
||||
<Button
|
||||
variant="primary"
|
||||
style={{
|
||||
padding: '10px 0',
|
||||
fontSize: 15,
|
||||
fontWeight: 600,
|
||||
}}
|
||||
onPress={onCreateLocalAccount}
|
||||
autoFocus
|
||||
>
|
||||
Create local account
|
||||
</Button>
|
||||
<View style={{ lineHeight: '1.4em', fontSize: 15 }}>
|
||||
<Text>
|
||||
<strong>Create a local account</strong> if you want to add
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// @ts-strict-ignore
|
||||
import React, { useState } from 'react';
|
||||
import React, { type FormEvent, useCallback, useState } from 'react';
|
||||
import { Form } from 'react-aria-components';
|
||||
import { useDispatch } from 'react-redux';
|
||||
|
||||
@@ -11,7 +11,6 @@ import { getCreateKeyError } from 'loot-core/src/shared/errors';
|
||||
|
||||
import { styles, theme } from '../../style';
|
||||
import { ButtonWithLoading } from '../common/Button2';
|
||||
import { InitialFocus } from '../common/InitialFocus';
|
||||
import { Input } from '../common/Input';
|
||||
import { Link } from '../common/Link';
|
||||
import {
|
||||
@@ -43,26 +42,37 @@ export function CreateEncryptionKeyModal({
|
||||
|
||||
const isRecreating = options.recreate;
|
||||
|
||||
async function onCreateKey(close: () => void) {
|
||||
if (password !== '' && !loading) {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const onCreateKey = useCallback(
|
||||
async ({ close }: { close: () => void }) => {
|
||||
if (password !== '' && !loading) {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const res = await send('key-make', { password });
|
||||
if (res.error) {
|
||||
setLoading(null);
|
||||
setError(getCreateKeyError(res.error));
|
||||
return;
|
||||
const res = await send('key-make', { password });
|
||||
if (res.error) {
|
||||
setLoading(null);
|
||||
setError(getCreateKeyError(res.error));
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(loadGlobalPrefs());
|
||||
dispatch(loadAllFiles());
|
||||
dispatch(sync());
|
||||
|
||||
setLoading(false);
|
||||
close();
|
||||
}
|
||||
},
|
||||
[password, loading, dispatch],
|
||||
);
|
||||
|
||||
dispatch(loadGlobalPrefs());
|
||||
dispatch(loadAllFiles());
|
||||
dispatch(sync());
|
||||
|
||||
setLoading(false);
|
||||
close();
|
||||
}
|
||||
}
|
||||
const onSubmit = useCallback(
|
||||
(e: FormEvent<HTMLFormElement>, { close }: { close: () => void }) => {
|
||||
e.preventDefault();
|
||||
onCreateKey({ close });
|
||||
},
|
||||
[onCreateKey],
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal name="create-encryption-key">
|
||||
@@ -72,89 +82,84 @@ export function CreateEncryptionKeyModal({
|
||||
title={isRecreating ? 'Generate new key' : 'Enable encryption'}
|
||||
rightContent={<ModalCloseButton onPress={close} />}
|
||||
/>
|
||||
<View
|
||||
style={{
|
||||
maxWidth: 600,
|
||||
overflowX: 'hidden',
|
||||
overflowY: 'auto',
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
{!isRecreating ? (
|
||||
<>
|
||||
<Paragraph style={{ marginTop: 5 }}>
|
||||
To enable end-to-end encryption, you need to create a key. We
|
||||
will generate a key based on a password and use it to encrypt
|
||||
from now on. <strong>This requires a sync reset</strong> and
|
||||
all other devices will have to revert to this version of your
|
||||
data.{' '}
|
||||
<Link
|
||||
variant="external"
|
||||
to="https://actualbudget.org/docs/getting-started/sync/#end-to-end-encryption"
|
||||
linkColor="purple"
|
||||
>
|
||||
Learn more
|
||||
</Link>
|
||||
</Paragraph>
|
||||
<Paragraph>
|
||||
<ul
|
||||
className={css({
|
||||
marginTop: 0,
|
||||
'& li': { marginBottom: 8 },
|
||||
})}
|
||||
>
|
||||
<li>
|
||||
<strong>Important:</strong> if you forget this password{' '}
|
||||
<em>and</em> you don’t have any local copies of your data,
|
||||
you will lose access to all your data. The data cannot be
|
||||
decrypted without the password.
|
||||
</li>
|
||||
<li>
|
||||
This key only applies to this file. You will need to
|
||||
generate a new key for each file you want to encrypt.
|
||||
</li>
|
||||
<li>
|
||||
If you’ve already downloaded your data on other devices,
|
||||
you will need to reset them. Actual will automatically
|
||||
take you through this process.
|
||||
</li>
|
||||
<li>
|
||||
It is recommended for the encryption password to be
|
||||
different than the log-in password in order to better
|
||||
protect your data.
|
||||
</li>
|
||||
</ul>
|
||||
</Paragraph>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Paragraph style={{ marginTop: 5 }}>
|
||||
This will generate a new key for encrypting your data.{' '}
|
||||
<strong>This requires a sync reset</strong> and all other
|
||||
devices will have to revert to this version of your data.
|
||||
Actual will take you through that process on those devices.{' '}
|
||||
<Link
|
||||
variant="external"
|
||||
to="https://actualbudget.org/docs/getting-started/sync/#end-to-end-encryption"
|
||||
linkColor="purple"
|
||||
>
|
||||
Learn more
|
||||
</Link>
|
||||
</Paragraph>
|
||||
<Paragraph>
|
||||
Key generation is randomized. The same password will create
|
||||
different keys, so this will change your key regardless of the
|
||||
password being different.
|
||||
</Paragraph>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
<Form
|
||||
onSubmit={e => {
|
||||
e.preventDefault();
|
||||
onCreateKey(close);
|
||||
}}
|
||||
>
|
||||
<Form onSubmit={e => onSubmit(e, { close })}>
|
||||
<View
|
||||
style={{
|
||||
maxWidth: 600,
|
||||
overflowX: 'hidden',
|
||||
overflowY: 'auto',
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
{!isRecreating ? (
|
||||
<>
|
||||
<Paragraph style={{ marginTop: 5 }}>
|
||||
To enable end-to-end encryption, you need to create a key.
|
||||
We will generate a key based on a password and use it to
|
||||
encrypt from now on.{' '}
|
||||
<strong>This requires a sync reset</strong> and all other
|
||||
devices will have to revert to this version of your data.{' '}
|
||||
<Link
|
||||
variant="external"
|
||||
to="https://actualbudget.org/docs/getting-started/sync/#end-to-end-encryption"
|
||||
linkColor="purple"
|
||||
>
|
||||
Learn more
|
||||
</Link>
|
||||
</Paragraph>
|
||||
<Paragraph>
|
||||
<ul
|
||||
className={css({
|
||||
marginTop: 0,
|
||||
'& li': { marginBottom: 8 },
|
||||
})}
|
||||
>
|
||||
<li>
|
||||
<strong>Important:</strong> if you forget this password{' '}
|
||||
<em>and</em> you don’t have any local copies of your
|
||||
data, you will lose access to all your data. The data
|
||||
cannot be decrypted without the password.
|
||||
</li>
|
||||
<li>
|
||||
This key only applies to this file. You will need to
|
||||
generate a new key for each file you want to encrypt.
|
||||
</li>
|
||||
<li>
|
||||
If you’ve already downloaded your data on other devices,
|
||||
you will need to reset them. Actual will automatically
|
||||
take you through this process.
|
||||
</li>
|
||||
<li>
|
||||
It is recommended for the encryption password to be
|
||||
different than the log-in password in order to better
|
||||
protect your data.
|
||||
</li>
|
||||
</ul>
|
||||
</Paragraph>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Paragraph style={{ marginTop: 5 }}>
|
||||
This will generate a new key for encrypting your data.{' '}
|
||||
<strong>This requires a sync reset</strong> and all other
|
||||
devices will have to revert to this version of your data.
|
||||
Actual will take you through that process on those devices.{' '}
|
||||
<Link
|
||||
variant="external"
|
||||
to="https://actualbudget.org/docs/getting-started/sync/#end-to-end-encryption"
|
||||
linkColor="purple"
|
||||
>
|
||||
Learn more
|
||||
</Link>
|
||||
</Paragraph>
|
||||
<Paragraph>
|
||||
Key generation is randomized. The same password will create
|
||||
different keys, so this will change your key regardless of
|
||||
the password being different.
|
||||
</Paragraph>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
<View style={{ alignItems: 'center' }}>
|
||||
<Text style={{ fontWeight: 600, marginBottom: 3 }}>Password</Text>
|
||||
|
||||
@@ -171,16 +176,16 @@ export function CreateEncryptionKeyModal({
|
||||
</View>
|
||||
)}
|
||||
|
||||
<InitialFocus>
|
||||
<Input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
style={{
|
||||
width: isNarrowWidth ? '100%' : '50%',
|
||||
height: isNarrowWidth ? styles.mobileMinHeight : undefined,
|
||||
}}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
/>
|
||||
</InitialFocus>
|
||||
<Input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
style={{
|
||||
width: isNarrowWidth ? '100%' : '50%',
|
||||
height: isNarrowWidth ? styles.mobileMinHeight : undefined,
|
||||
}}
|
||||
onChangeValue={value => setPassword(value)}
|
||||
autoFocus
|
||||
autoSelect
|
||||
/>
|
||||
<Text style={{ marginTop: 5 }}>
|
||||
<label style={{ userSelect: 'none' }}>
|
||||
<input
|
||||
|
||||
@@ -11,7 +11,6 @@ import { useNavigate } from '../../hooks/useNavigate';
|
||||
import { theme } from '../../style';
|
||||
import { Button } from '../common/Button2';
|
||||
import { FormError } from '../common/FormError';
|
||||
import { InitialFocus } from '../common/InitialFocus';
|
||||
import { InlineField } from '../common/InlineField';
|
||||
import { Input } from '../common/Input';
|
||||
import { Link } from '../common/Link';
|
||||
@@ -77,18 +76,18 @@ export function CreateLocalAccountModal() {
|
||||
<View>
|
||||
<Form onSubmit={onSubmit}>
|
||||
<InlineField label="Name" width="100%">
|
||||
<InitialFocus>
|
||||
<Input
|
||||
name="name"
|
||||
value={name}
|
||||
onChange={event => setName(event.target.value)}
|
||||
onBlur={event => {
|
||||
const name = event.target.value.trim();
|
||||
validateAndSetName(name);
|
||||
}}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
</InitialFocus>
|
||||
<Input
|
||||
name="name"
|
||||
value={name}
|
||||
onChangeValue={setName}
|
||||
onBlur={event => {
|
||||
const name = event.target.value.trim();
|
||||
validateAndSetName(name);
|
||||
}}
|
||||
style={{ flex: 1 }}
|
||||
autoFocus
|
||||
autoSelect
|
||||
/>
|
||||
</InlineField>
|
||||
{nameError && (
|
||||
<FormError style={{ marginLeft: 75, color: theme.warningText }}>
|
||||
@@ -155,7 +154,7 @@ export function CreateLocalAccountModal() {
|
||||
name="balance"
|
||||
inputMode="decimal"
|
||||
value={balance}
|
||||
onChange={event => setBalance(event.target.value)}
|
||||
onChangeValue={value => setBalance(value)}
|
||||
onBlur={event => {
|
||||
const balance = event.target.value.trim();
|
||||
setBalance(balance);
|
||||
|
||||
@@ -45,7 +45,7 @@ export function EditFieldModal({ name, onSubmit, onClose }) {
|
||||
const { isNarrowWidth } = useResponsive();
|
||||
let label, editor, minWidth;
|
||||
const inputStyle = {
|
||||
':focus': { boxShadow: 0 },
|
||||
'&[data-focused]': { boxShadow: 0 },
|
||||
...(isNarrowWidth && itemStyle),
|
||||
};
|
||||
|
||||
@@ -60,7 +60,7 @@ export function EditFieldModal({ name, onSubmit, onClose }) {
|
||||
<DateSelect
|
||||
value={formatDate(parseISO(today), dateFormat)}
|
||||
dateFormat={dateFormat}
|
||||
focused={true}
|
||||
autoFocus={true}
|
||||
embedded={true}
|
||||
onUpdate={() => {}}
|
||||
onSelect={date => {
|
||||
@@ -186,7 +186,6 @@ export function EditFieldModal({ name, onSubmit, onClose }) {
|
||||
<Input
|
||||
id="noteInput"
|
||||
autoFocus
|
||||
focused={true}
|
||||
onEnter={e => {
|
||||
onSelectNote(e.target.value, noteAmend);
|
||||
close();
|
||||
@@ -201,7 +200,7 @@ export function EditFieldModal({ name, onSubmit, onClose }) {
|
||||
label = 'Amount';
|
||||
editor = ({ close }) => (
|
||||
<Input
|
||||
focused={true}
|
||||
autoFocus
|
||||
onEnter={e => {
|
||||
onSelect(e.target.value);
|
||||
close();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// @ts-strict-ignore
|
||||
import React, { useState } from 'react';
|
||||
import React, { type FormEvent, useCallback, useState } from 'react';
|
||||
import { Form } from 'react-aria-components';
|
||||
|
||||
import { type FinanceModals } from 'loot-core/src/client/state-types/modals';
|
||||
@@ -8,7 +8,6 @@ import { getTestKeyError } from 'loot-core/src/shared/errors';
|
||||
|
||||
import { styles, theme } from '../../style';
|
||||
import { Button, ButtonWithLoading } from '../common/Button2';
|
||||
import { InitialFocus } from '../common/InitialFocus';
|
||||
import { Input } from '../common/Input';
|
||||
import { Link } from '../common/Link';
|
||||
import {
|
||||
@@ -37,25 +36,36 @@ export function FixEncryptionKeyModal({
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const { isNarrowWidth } = useResponsive();
|
||||
|
||||
async function onUpdateKey(close: () => void) {
|
||||
if (password !== '' && !loading) {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const onUpdateKey = useCallback(
|
||||
async ({ close }: { close: () => void }) => {
|
||||
if (password !== '' && !loading) {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const { error } = await send('key-test', {
|
||||
password,
|
||||
fileId: cloudFileId,
|
||||
});
|
||||
if (error) {
|
||||
setError(getTestKeyError(error));
|
||||
setLoading(false);
|
||||
return;
|
||||
const { error } = await send('key-test', {
|
||||
password,
|
||||
fileId: cloudFileId,
|
||||
});
|
||||
if (error) {
|
||||
setError(getTestKeyError(error));
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
onSuccess?.();
|
||||
close();
|
||||
}
|
||||
},
|
||||
[cloudFileId, loading, onSuccess, password],
|
||||
);
|
||||
|
||||
onSuccess?.();
|
||||
close();
|
||||
}
|
||||
}
|
||||
const onSubmit = useCallback(
|
||||
(e: FormEvent<HTMLFormElement>, { close }: { close: () => void }) => {
|
||||
e.preventDefault();
|
||||
onUpdateKey({ close });
|
||||
},
|
||||
[onUpdateKey],
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal name="fix-encryption-key">
|
||||
@@ -69,45 +79,40 @@ export function FixEncryptionKeyModal({
|
||||
}
|
||||
rightContent={<ModalCloseButton onPress={close} />}
|
||||
/>
|
||||
<View
|
||||
style={{
|
||||
maxWidth: 500,
|
||||
overflowX: 'hidden',
|
||||
overflowY: 'auto',
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
{hasExistingKey ? (
|
||||
<Paragraph>
|
||||
This file was encrypted with a different key than you are
|
||||
currently using. This probably means you changed your password.
|
||||
Enter your current password to update your key.{' '}
|
||||
<Link
|
||||
variant="external"
|
||||
to="https://actualbudget.org/docs/getting-started/sync/#end-to-end-encryption"
|
||||
>
|
||||
Learn more
|
||||
</Link>
|
||||
</Paragraph>
|
||||
) : (
|
||||
<Paragraph>
|
||||
We don’t have a key that encrypts or decrypts this file. Enter
|
||||
the password for this file to create the key for encryption.{' '}
|
||||
<Link
|
||||
variant="external"
|
||||
to="https://actualbudget.org/docs/getting-started/sync/#end-to-end-encryption"
|
||||
>
|
||||
Learn more
|
||||
</Link>
|
||||
</Paragraph>
|
||||
)}
|
||||
</View>
|
||||
<Form
|
||||
onSubmit={e => {
|
||||
e.preventDefault();
|
||||
onUpdateKey(close);
|
||||
}}
|
||||
>
|
||||
<Form onSubmit={e => onSubmit(e, { close })}>
|
||||
<View
|
||||
style={{
|
||||
maxWidth: 500,
|
||||
overflowX: 'hidden',
|
||||
overflowY: 'auto',
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
{hasExistingKey ? (
|
||||
<Paragraph>
|
||||
This file was encrypted with a different key than you are
|
||||
currently using. This probably means you changed your
|
||||
password. Enter your current password to update your key.{' '}
|
||||
<Link
|
||||
variant="external"
|
||||
to="https://actualbudget.org/docs/getting-started/sync/#end-to-end-encryption"
|
||||
>
|
||||
Learn more
|
||||
</Link>
|
||||
</Paragraph>
|
||||
) : (
|
||||
<Paragraph>
|
||||
We don’t have a key that encrypts or decrypts this file. Enter
|
||||
the password for this file to create the key for encryption.{' '}
|
||||
<Link
|
||||
variant="external"
|
||||
to="https://actualbudget.org/docs/getting-started/sync/#end-to-end-encryption"
|
||||
>
|
||||
Learn more
|
||||
</Link>
|
||||
</Paragraph>
|
||||
)}
|
||||
</View>
|
||||
<View
|
||||
style={{
|
||||
marginTop: 15,
|
||||
@@ -128,16 +133,16 @@ export function FixEncryptionKeyModal({
|
||||
{error}
|
||||
</View>
|
||||
)}
|
||||
<InitialFocus>
|
||||
<Input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
style={{
|
||||
width: isNarrowWidth ? '100%' : '50%',
|
||||
height: isNarrowWidth ? styles.mobileMinHeight : undefined,
|
||||
}}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
/>
|
||||
</InitialFocus>
|
||||
<Input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
style={{
|
||||
width: isNarrowWidth ? '100%' : '50%',
|
||||
height: isNarrowWidth ? styles.mobileMinHeight : undefined,
|
||||
}}
|
||||
onChangeValue={value => setPassword(value)}
|
||||
autoFocus
|
||||
autoSelect
|
||||
/>
|
||||
<Text style={{ marginTop: 5 }}>
|
||||
<label style={{ userSelect: 'none' }}>
|
||||
<input
|
||||
|
||||
@@ -183,7 +183,7 @@ export function GoCardlessExternalMsgModal({
|
||||
<FormField>
|
||||
<FormLabel title={t('Choose your bank:')} htmlFor="bank-field" />
|
||||
<Autocomplete
|
||||
focused
|
||||
autoFocus
|
||||
strict
|
||||
highlightFirst
|
||||
suggestions={bankOptions}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
// @ts-strict-ignore
|
||||
import React, { useState } from 'react';
|
||||
import React, { type FormEvent, useCallback, useState } from 'react';
|
||||
import { Form } from 'react-aria-components';
|
||||
|
||||
import { send } from 'loot-core/src/platform/client/fetch';
|
||||
|
||||
import { Error } from '../alerts';
|
||||
import { ButtonWithLoading } from '../common/Button2';
|
||||
import { InitialFocus } from '../common/InitialFocus';
|
||||
import { Input } from '../common/Input';
|
||||
import { Link } from '../common/Link';
|
||||
import {
|
||||
@@ -30,29 +30,34 @@ export const GoCardlessInitialiseModal = ({
|
||||
const [isValid, setIsValid] = useState(true);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const onSubmit = async (close: () => void) => {
|
||||
if (!secretId || !secretKey) {
|
||||
setIsValid(false);
|
||||
return;
|
||||
}
|
||||
const onSubmit = useCallback(
|
||||
async (e: FormEvent<HTMLFormElement>, { close }: { close: () => void }) => {
|
||||
e.preventDefault();
|
||||
|
||||
setIsLoading(true);
|
||||
if (!secretId || !secretKey) {
|
||||
setIsValid(false);
|
||||
return;
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
send('secret-set', {
|
||||
name: 'gocardless_secretId',
|
||||
value: secretId,
|
||||
}),
|
||||
send('secret-set', {
|
||||
name: 'gocardless_secretKey',
|
||||
value: secretKey,
|
||||
}),
|
||||
]);
|
||||
setIsLoading(true);
|
||||
|
||||
onSuccess();
|
||||
setIsLoading(false);
|
||||
close();
|
||||
};
|
||||
await Promise.all([
|
||||
send('secret-set', {
|
||||
name: 'gocardless_secretId',
|
||||
value: secretId,
|
||||
}),
|
||||
send('secret-set', {
|
||||
name: 'gocardless_secretKey',
|
||||
value: secretKey,
|
||||
}),
|
||||
]);
|
||||
|
||||
onSuccess();
|
||||
setIsLoading(false);
|
||||
close();
|
||||
},
|
||||
[onSuccess, secretId, secretKey],
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal name="gocardless-init" containerProps={{ style: { width: '30vw' } }}>
|
||||
@@ -62,24 +67,24 @@ export const GoCardlessInitialiseModal = ({
|
||||
title="Set-up GoCardless"
|
||||
rightContent={<ModalCloseButton onPress={close} />}
|
||||
/>
|
||||
<View style={{ display: 'flex', gap: 10 }}>
|
||||
<Text>
|
||||
In order to enable bank-sync via GoCardless (only for EU banks)
|
||||
you will need to create access credentials. This can be done by
|
||||
creating an account with{' '}
|
||||
<Link
|
||||
variant="external"
|
||||
to="https://actualbudget.org/docs/advanced/bank-sync/"
|
||||
linkColor="purple"
|
||||
>
|
||||
GoCardless
|
||||
</Link>
|
||||
.
|
||||
</Text>
|
||||
<Form onSubmit={e => onSubmit(e, { close })}>
|
||||
<View style={{ display: 'flex', gap: 10 }}>
|
||||
<Text>
|
||||
In order to enable bank-sync via GoCardless (only for EU banks)
|
||||
you will need to create access credentials. This can be done by
|
||||
creating an account with{' '}
|
||||
<Link
|
||||
variant="external"
|
||||
to="https://actualbudget.org/docs/advanced/bank-sync/"
|
||||
linkColor="purple"
|
||||
>
|
||||
GoCardless
|
||||
</Link>
|
||||
.
|
||||
</Text>
|
||||
|
||||
<FormField>
|
||||
<FormLabel title="Secret ID:" htmlFor="secret-id-field" />
|
||||
<InitialFocus>
|
||||
<FormField>
|
||||
<FormLabel title="Secret ID:" htmlFor="secret-id-field" />
|
||||
<Input
|
||||
id="secret-id-field"
|
||||
type="password"
|
||||
@@ -88,41 +93,41 @@ export const GoCardlessInitialiseModal = ({
|
||||
setSecretId(value);
|
||||
setIsValid(true);
|
||||
}}
|
||||
autoFocus
|
||||
autoSelect
|
||||
/>
|
||||
</InitialFocus>
|
||||
</FormField>
|
||||
</FormField>
|
||||
|
||||
<FormField>
|
||||
<FormLabel title="Secret Key:" htmlFor="secret-key-field" />
|
||||
<Input
|
||||
id="secret-key-field"
|
||||
type="password"
|
||||
value={secretKey}
|
||||
onChangeValue={value => {
|
||||
setSecretKey(value);
|
||||
setIsValid(true);
|
||||
}}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField>
|
||||
<FormLabel title="Secret Key:" htmlFor="secret-key-field" />
|
||||
<Input
|
||||
id="secret-key-field"
|
||||
type="password"
|
||||
value={secretKey}
|
||||
onChangeValue={value => {
|
||||
setSecretKey(value);
|
||||
setIsValid(true);
|
||||
}}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
{!isValid && (
|
||||
<Error>
|
||||
It is required to provide both the secret id and secret key.
|
||||
</Error>
|
||||
)}
|
||||
</View>
|
||||
{!isValid && (
|
||||
<Error>
|
||||
It is required to provide both the secret id and secret key.
|
||||
</Error>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<ModalButtons>
|
||||
<ButtonWithLoading
|
||||
variant="primary"
|
||||
isLoading={isLoading}
|
||||
onPress={() => {
|
||||
onSubmit(close);
|
||||
}}
|
||||
>
|
||||
Save and continue
|
||||
</ButtonWithLoading>
|
||||
</ModalButtons>
|
||||
<ModalButtons>
|
||||
<ButtonWithLoading
|
||||
variant="primary"
|
||||
type="submit"
|
||||
isLoading={isLoading}
|
||||
>
|
||||
Save and continue
|
||||
</ButtonWithLoading>
|
||||
</ModalButtons>
|
||||
</Form>
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { type FormEvent, useCallback, useState } from 'react';
|
||||
import { Form } from 'react-aria-components';
|
||||
|
||||
import { envelopeBudget } from 'loot-core/client/queries';
|
||||
|
||||
import { styles } from '../../style';
|
||||
import { useEnvelopeSheetValue } from '../budget/envelope/EnvelopeBudgetComponents';
|
||||
import { Button } from '../common/Button2';
|
||||
import { InitialFocus } from '../common/InitialFocus';
|
||||
import { Modal, ModalCloseButton, ModalHeader } from '../common/Modal';
|
||||
import { View } from '../common/View';
|
||||
import { FieldLabel } from '../mobile/MobileForms';
|
||||
@@ -20,11 +20,16 @@ export function HoldBufferModal({ onSubmit }: HoldBufferModalProps) {
|
||||
const available = useEnvelopeSheetValue(envelopeBudget.toBudget) ?? 0;
|
||||
const [amount, setAmount] = useState<number>(0);
|
||||
|
||||
const _onSubmit = (newAmount: number) => {
|
||||
if (newAmount) {
|
||||
onSubmit?.(newAmount);
|
||||
}
|
||||
};
|
||||
const _onSubmit = useCallback(
|
||||
(e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (amount) {
|
||||
onSubmit?.(amount);
|
||||
}
|
||||
},
|
||||
[amount, onSubmit],
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal name="hold-buffer">
|
||||
@@ -34,9 +39,9 @@ export function HoldBufferModal({ onSubmit }: HoldBufferModalProps) {
|
||||
title="Hold Buffer"
|
||||
rightContent={<ModalCloseButton onPress={close} />}
|
||||
/>
|
||||
<View>
|
||||
<FieldLabel title="Hold this amount:" />
|
||||
<InitialFocus>
|
||||
<Form onSubmit={_onSubmit}>
|
||||
<View>
|
||||
<FieldLabel title="Hold this amount:" />
|
||||
<AmountInput
|
||||
value={available}
|
||||
autoDecimals={true}
|
||||
@@ -48,32 +53,30 @@ export function HoldBufferModal({ onSubmit }: HoldBufferModalProps) {
|
||||
height: styles.mobileMinHeight,
|
||||
}}
|
||||
onUpdate={setAmount}
|
||||
onEnter={() => {
|
||||
_onSubmit(amount);
|
||||
close();
|
||||
}}
|
||||
autoFocus
|
||||
autoSelect
|
||||
/>
|
||||
</InitialFocus>
|
||||
</View>
|
||||
<View
|
||||
style={{
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
paddingTop: 10,
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="primary"
|
||||
</View>
|
||||
<View
|
||||
style={{
|
||||
height: styles.mobileMinHeight,
|
||||
marginLeft: styles.mobileEditingPadding,
|
||||
marginRight: styles.mobileEditingPadding,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
paddingTop: 10,
|
||||
}}
|
||||
onPress={() => _onSubmit(amount)}
|
||||
>
|
||||
Hold
|
||||
</Button>
|
||||
</View>
|
||||
<Button
|
||||
variant="primary"
|
||||
type="submit"
|
||||
style={{
|
||||
height: styles.mobileMinHeight,
|
||||
marginLeft: styles.mobileEditingPadding,
|
||||
marginRight: styles.mobileEditingPadding,
|
||||
}}
|
||||
>
|
||||
Hold
|
||||
</Button>
|
||||
</View>
|
||||
</Form>
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { Form } from 'react-aria-components';
|
||||
|
||||
import deepEqual from 'deep-equal';
|
||||
|
||||
@@ -539,7 +540,7 @@ export function ImportTransactionsModal({ options }) {
|
||||
setTransactions(newTransactions);
|
||||
}
|
||||
|
||||
async function onImport(close) {
|
||||
const onImport = useCallback(async () => {
|
||||
setLoadingState('importing');
|
||||
|
||||
const finalTransactions = [];
|
||||
@@ -664,8 +665,38 @@ export function ImportTransactionsModal({ options }) {
|
||||
if (onImported) {
|
||||
onImported(didChange);
|
||||
}
|
||||
close();
|
||||
}
|
||||
}, [
|
||||
accountId,
|
||||
categories.list,
|
||||
clearOnImport,
|
||||
delimiter,
|
||||
fallbackMissingPayeeToMemo,
|
||||
fieldMappings,
|
||||
filetype,
|
||||
flipAmount,
|
||||
getPayees,
|
||||
hasHeaderRow,
|
||||
importTransactions,
|
||||
inOutMode,
|
||||
multiplierAmount,
|
||||
onImported,
|
||||
outValue,
|
||||
parseDateFormat,
|
||||
reconcile,
|
||||
savePrefs,
|
||||
skipLines,
|
||||
splitMode,
|
||||
transactions,
|
||||
]);
|
||||
|
||||
const onSubmit = useCallback(
|
||||
(e, { close }) => {
|
||||
e.preventDefault();
|
||||
onImport({ close });
|
||||
close();
|
||||
},
|
||||
[onImport],
|
||||
);
|
||||
|
||||
const runImportPreview = useCallback(async () => {
|
||||
const transactionPreview = await getImportPreview(
|
||||
@@ -731,339 +762,338 @@ export function ImportTransactionsModal({ options }) {
|
||||
}
|
||||
rightContent={<ModalCloseButton onPress={close} />}
|
||||
/>
|
||||
{error && !error.parsed && (
|
||||
<View style={{ alignItems: 'center', marginBottom: 15 }}>
|
||||
<Text style={{ marginRight: 10, color: theme.errorText }}>
|
||||
<strong>Error:</strong> {error.message}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
{(!error || !error.parsed) && (
|
||||
<View
|
||||
style={{
|
||||
flex: 'unset',
|
||||
height: 300,
|
||||
border: '1px solid ' + theme.tableBorder,
|
||||
}}
|
||||
>
|
||||
<TableHeader headers={headers} />
|
||||
<Form onSubmit={e => onSubmit(e, { close })}>
|
||||
{error && !error.parsed && (
|
||||
<View style={{ alignItems: 'center', marginBottom: 15 }}>
|
||||
<Text style={{ marginRight: 10, color: theme.errorText }}>
|
||||
<strong>Error:</strong> {error.message}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
{(!error || !error.parsed) && (
|
||||
<View
|
||||
style={{
|
||||
flex: 'unset',
|
||||
height: 300,
|
||||
border: '1px solid ' + theme.tableBorder,
|
||||
}}
|
||||
>
|
||||
<TableHeader headers={headers} />
|
||||
|
||||
<TableWithNavigator
|
||||
items={transactions.filter(
|
||||
trans =>
|
||||
!trans.isMatchedTransaction ||
|
||||
(trans.isMatchedTransaction && reconcile),
|
||||
)}
|
||||
fields={['payee', 'category', 'amount']}
|
||||
style={{ backgroundColor: theme.tableHeaderBackground }}
|
||||
getItemKey={index => index}
|
||||
renderEmpty={() => {
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
textAlign: 'center',
|
||||
marginTop: 25,
|
||||
color: theme.tableHeaderText,
|
||||
fontStyle: 'italic',
|
||||
}}
|
||||
>
|
||||
No transactions found
|
||||
<TableWithNavigator
|
||||
items={transactions.filter(
|
||||
trans =>
|
||||
!trans.isMatchedTransaction ||
|
||||
(trans.isMatchedTransaction && reconcile),
|
||||
)}
|
||||
fields={['payee', 'category', 'amount']}
|
||||
style={{ backgroundColor: theme.tableHeaderBackground }}
|
||||
getItemKey={index => index}
|
||||
renderEmpty={() => {
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
textAlign: 'center',
|
||||
marginTop: 25,
|
||||
color: theme.tableHeaderText,
|
||||
fontStyle: 'italic',
|
||||
}}
|
||||
>
|
||||
No transactions found
|
||||
</View>
|
||||
);
|
||||
}}
|
||||
renderItem={({ key, style, item }) => (
|
||||
<View key={key} style={style}>
|
||||
<Transaction
|
||||
transaction={item}
|
||||
showParsed={filetype === 'csv' || filetype === 'qif'}
|
||||
parseDateFormat={parseDateFormat}
|
||||
dateFormat={dateFormat}
|
||||
fieldMappings={fieldMappings}
|
||||
splitMode={splitMode}
|
||||
inOutMode={inOutMode}
|
||||
outValue={outValue}
|
||||
flipAmount={flipAmount}
|
||||
multiplierAmount={multiplierAmount}
|
||||
categories={categories.list}
|
||||
onCheckTransaction={onCheckTransaction}
|
||||
reconcile={reconcile}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
{error && error.parsed && (
|
||||
<View
|
||||
style={{
|
||||
color: theme.errorText,
|
||||
alignItems: 'center',
|
||||
marginTop: 10,
|
||||
}}
|
||||
>
|
||||
<Text style={{ maxWidth: 450, marginBottom: 15 }}>
|
||||
<strong>Error:</strong> {error.message}
|
||||
</Text>
|
||||
{error.parsed && (
|
||||
<Button onPress={() => onNewFile()}>
|
||||
Select new file...
|
||||
</Button>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{filetype === 'csv' && (
|
||||
<View style={{ marginTop: 10 }}>
|
||||
<FieldMappings
|
||||
transactions={transactions}
|
||||
onChange={onUpdateFields}
|
||||
mappings={fieldMappings}
|
||||
splitMode={splitMode}
|
||||
inOutMode={inOutMode}
|
||||
hasHeaderRow={hasHeaderRow}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
{isOfxFile(filetype) && (
|
||||
<CheckboxOption
|
||||
id="form_fallback_missing_payee"
|
||||
checked={fallbackMissingPayeeToMemo}
|
||||
onChange={() => {
|
||||
setFallbackMissingPayeeToMemo(state => !state);
|
||||
parse(
|
||||
filename,
|
||||
getParseOptions('ofx', {
|
||||
fallbackMissingPayeeToMemo: !fallbackMissingPayeeToMemo,
|
||||
}),
|
||||
);
|
||||
}}
|
||||
renderItem={({ key, style, item }) => (
|
||||
<View key={key} style={style}>
|
||||
<Transaction
|
||||
transaction={item}
|
||||
showParsed={filetype === 'csv' || filetype === 'qif'}
|
||||
parseDateFormat={parseDateFormat}
|
||||
dateFormat={dateFormat}
|
||||
fieldMappings={fieldMappings}
|
||||
splitMode={splitMode}
|
||||
inOutMode={inOutMode}
|
||||
outValue={outValue}
|
||||
flipAmount={flipAmount}
|
||||
multiplierAmount={multiplierAmount}
|
||||
categories={categories.list}
|
||||
onCheckTransaction={onCheckTransaction}
|
||||
reconcile={reconcile}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
{error && error.parsed && (
|
||||
<View
|
||||
style={{
|
||||
color: theme.errorText,
|
||||
alignItems: 'center',
|
||||
marginTop: 10,
|
||||
}}
|
||||
>
|
||||
<Text style={{ maxWidth: 450, marginBottom: 15 }}>
|
||||
<strong>Error:</strong> {error.message}
|
||||
</Text>
|
||||
{error.parsed && (
|
||||
<Button onPress={() => onNewFile()}>Select new file...</Button>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{filetype === 'csv' && (
|
||||
<View style={{ marginTop: 10 }}>
|
||||
<FieldMappings
|
||||
transactions={transactions}
|
||||
onChange={onUpdateFields}
|
||||
mappings={fieldMappings}
|
||||
splitMode={splitMode}
|
||||
inOutMode={inOutMode}
|
||||
hasHeaderRow={hasHeaderRow}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{isOfxFile(filetype) && (
|
||||
<CheckboxOption
|
||||
id="form_fallback_missing_payee"
|
||||
checked={fallbackMissingPayeeToMemo}
|
||||
onChange={() => {
|
||||
setFallbackMissingPayeeToMemo(state => !state);
|
||||
parse(
|
||||
filename,
|
||||
getParseOptions('ofx', {
|
||||
fallbackMissingPayeeToMemo: !fallbackMissingPayeeToMemo,
|
||||
}),
|
||||
);
|
||||
}}
|
||||
>
|
||||
Use Memo as a fallback for empty Payees
|
||||
</CheckboxOption>
|
||||
)}
|
||||
{(isOfxFile(filetype) || isCamtFile(filetype)) && (
|
||||
<CheckboxOption
|
||||
id="form_dont_reconcile"
|
||||
checked={reconcile}
|
||||
onChange={() => {
|
||||
setReconcile(!reconcile);
|
||||
}}
|
||||
>
|
||||
Merge with existing transactions
|
||||
</CheckboxOption>
|
||||
)}
|
||||
|
||||
{/*Import Options */}
|
||||
{(filetype === 'qif' || filetype === 'csv') && (
|
||||
<View style={{ marginTop: 10 }}>
|
||||
<Stack
|
||||
direction="row"
|
||||
align="flex-start"
|
||||
spacing={1}
|
||||
style={{ marginTop: 5 }}
|
||||
>
|
||||
{/*Date Format */}
|
||||
<View>
|
||||
{(filetype === 'qif' || filetype === 'csv') && (
|
||||
<DateFormatSelect
|
||||
transactions={transactions}
|
||||
fieldMappings={fieldMappings}
|
||||
parseDateFormat={parseDateFormat}
|
||||
onChange={value => {
|
||||
setParseDateFormat(value);
|
||||
runImportPreview();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
Use Memo as a fallback for empty Payees
|
||||
</CheckboxOption>
|
||||
)}
|
||||
{(isOfxFile(filetype) || isCamtFile(filetype)) && (
|
||||
<CheckboxOption
|
||||
id="form_dont_reconcile"
|
||||
checked={reconcile}
|
||||
onChange={() => {
|
||||
setReconcile(!reconcile);
|
||||
}}
|
||||
>
|
||||
Merge with existing transactions
|
||||
</CheckboxOption>
|
||||
)}
|
||||
|
||||
{/* CSV Options */}
|
||||
{filetype === 'csv' && (
|
||||
<View style={{ marginLeft: 10, gap: 5 }}>
|
||||
<SectionLabel title="CSV OPTIONS" />
|
||||
<label
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
gap: 5,
|
||||
alignItems: 'baseline',
|
||||
}}
|
||||
>
|
||||
Delimiter:
|
||||
<Select
|
||||
options={[
|
||||
[',', ','],
|
||||
[';', ';'],
|
||||
['|', '|'],
|
||||
['\t', 'tab'],
|
||||
]}
|
||||
value={delimiter}
|
||||
{/*Import Options */}
|
||||
{(filetype === 'qif' || filetype === 'csv') && (
|
||||
<View style={{ marginTop: 10 }}>
|
||||
<Stack
|
||||
direction="row"
|
||||
align="flex-start"
|
||||
spacing={1}
|
||||
style={{ marginTop: 5 }}
|
||||
>
|
||||
{/*Date Format */}
|
||||
<View>
|
||||
{(filetype === 'qif' || filetype === 'csv') && (
|
||||
<DateFormatSelect
|
||||
transactions={transactions}
|
||||
fieldMappings={fieldMappings}
|
||||
parseDateFormat={parseDateFormat}
|
||||
onChange={value => {
|
||||
setDelimiter(value);
|
||||
parse(
|
||||
filename,
|
||||
getParseOptions('csv', {
|
||||
delimiter: value,
|
||||
hasHeaderRow,
|
||||
skipLines,
|
||||
}),
|
||||
);
|
||||
setParseDateFormat(value);
|
||||
runImportPreview();
|
||||
}}
|
||||
style={{ width: 50 }}
|
||||
/>
|
||||
</label>
|
||||
<label
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
gap: 5,
|
||||
alignItems: 'baseline',
|
||||
}}
|
||||
>
|
||||
Skip lines:
|
||||
<Input
|
||||
type="number"
|
||||
value={skipLines}
|
||||
min="0"
|
||||
onChangeValue={value => {
|
||||
setSkipLines(+value);
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* CSV Options */}
|
||||
{filetype === 'csv' && (
|
||||
<View style={{ marginLeft: 10, gap: 5 }}>
|
||||
<SectionLabel title="CSV OPTIONS" />
|
||||
<label
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
gap: 5,
|
||||
alignItems: 'baseline',
|
||||
}}
|
||||
>
|
||||
Delimiter:
|
||||
<Select
|
||||
options={[
|
||||
[',', ','],
|
||||
[';', ';'],
|
||||
['|', '|'],
|
||||
['\t', 'tab'],
|
||||
]}
|
||||
value={delimiter}
|
||||
onChange={value => {
|
||||
setDelimiter(value);
|
||||
parse(
|
||||
filename,
|
||||
getParseOptions('csv', {
|
||||
delimiter: value,
|
||||
hasHeaderRow,
|
||||
skipLines,
|
||||
}),
|
||||
);
|
||||
}}
|
||||
style={{ width: 50 }}
|
||||
/>
|
||||
</label>
|
||||
<label
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
gap: 5,
|
||||
alignItems: 'baseline',
|
||||
}}
|
||||
>
|
||||
Skip lines:
|
||||
<Input
|
||||
type="number"
|
||||
value={skipLines}
|
||||
min="0"
|
||||
onChangeValue={value => {
|
||||
setSkipLines(+value);
|
||||
parse(
|
||||
filename,
|
||||
getParseOptions('csv', {
|
||||
delimiter,
|
||||
hasHeaderRow,
|
||||
skipLines: +value,
|
||||
}),
|
||||
);
|
||||
}}
|
||||
style={{ width: 50 }}
|
||||
/>
|
||||
</label>
|
||||
<CheckboxOption
|
||||
id="form_has_header"
|
||||
checked={hasHeaderRow}
|
||||
onChange={() => {
|
||||
setHasHeaderRow(!hasHeaderRow);
|
||||
parse(
|
||||
filename,
|
||||
getParseOptions('csv', {
|
||||
delimiter,
|
||||
hasHeaderRow,
|
||||
skipLines: +value,
|
||||
hasHeaderRow: !hasHeaderRow,
|
||||
skipLines,
|
||||
}),
|
||||
);
|
||||
}}
|
||||
style={{ width: 50 }}
|
||||
/>
|
||||
</label>
|
||||
<CheckboxOption
|
||||
id="form_has_header"
|
||||
checked={hasHeaderRow}
|
||||
onChange={() => {
|
||||
setHasHeaderRow(!hasHeaderRow);
|
||||
parse(
|
||||
filename,
|
||||
getParseOptions('csv', {
|
||||
delimiter,
|
||||
hasHeaderRow: !hasHeaderRow,
|
||||
skipLines,
|
||||
}),
|
||||
);
|
||||
}}
|
||||
>
|
||||
File has header row
|
||||
</CheckboxOption>
|
||||
<CheckboxOption
|
||||
id="clear_on_import"
|
||||
checked={clearOnImport}
|
||||
onChange={() => {
|
||||
setClearOnImport(!clearOnImport);
|
||||
}}
|
||||
>
|
||||
Clear transactions on import
|
||||
</CheckboxOption>
|
||||
<CheckboxOption
|
||||
id="form_dont_reconcile"
|
||||
checked={reconcile}
|
||||
onChange={() => {
|
||||
setReconcile(!reconcile);
|
||||
}}
|
||||
>
|
||||
Merge with existing transactions
|
||||
</CheckboxOption>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={{ flex: 1 }} />
|
||||
|
||||
<View style={{ marginRight: 10, gap: 5 }}>
|
||||
<SectionLabel title="AMOUNT OPTIONS" />
|
||||
<CheckboxOption
|
||||
id="form_flip"
|
||||
checked={flipAmount}
|
||||
disabled={splitMode || inOutMode}
|
||||
onChange={() => {
|
||||
setFlipAmount(!flipAmount);
|
||||
runImportPreview();
|
||||
}}
|
||||
>
|
||||
Flip amount
|
||||
</CheckboxOption>
|
||||
{filetype === 'csv' && (
|
||||
<>
|
||||
>
|
||||
File has header row
|
||||
</CheckboxOption>
|
||||
<CheckboxOption
|
||||
id="form_split"
|
||||
checked={splitMode}
|
||||
disabled={inOutMode || flipAmount}
|
||||
id="clear_on_import"
|
||||
checked={clearOnImport}
|
||||
onChange={() => {
|
||||
onSplitMode();
|
||||
runImportPreview();
|
||||
setClearOnImport(!clearOnImport);
|
||||
}}
|
||||
>
|
||||
Split amount into separate inflow/outflow columns
|
||||
Clear transactions on import
|
||||
</CheckboxOption>
|
||||
<InOutOption
|
||||
inOutMode={inOutMode}
|
||||
outValue={outValue}
|
||||
disabled={splitMode || flipAmount}
|
||||
onToggle={() => {
|
||||
setInOutMode(!inOutMode);
|
||||
runImportPreview();
|
||||
<CheckboxOption
|
||||
id="form_dont_reconcile"
|
||||
checked={reconcile}
|
||||
onChange={() => {
|
||||
setReconcile(!reconcile);
|
||||
}}
|
||||
onChangeText={setOutValue}
|
||||
/>
|
||||
</>
|
||||
>
|
||||
Merge with existing transactions
|
||||
</CheckboxOption>
|
||||
</View>
|
||||
)}
|
||||
<MultiplierOption
|
||||
multiplierEnabled={multiplierEnabled}
|
||||
multiplierAmount={multiplierAmount}
|
||||
onToggle={() => {
|
||||
setMultiplierEnabled(!multiplierEnabled);
|
||||
setMultiplierAmount('');
|
||||
runImportPreview();
|
||||
}}
|
||||
onChangeAmount={onMultiplierChange}
|
||||
/>
|
||||
</View>
|
||||
</Stack>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={{ flexDirection: 'row', marginTop: 5 }}>
|
||||
{/*Submit Button */}
|
||||
<View
|
||||
style={{
|
||||
alignSelf: 'flex-end',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: '1em',
|
||||
}}
|
||||
>
|
||||
<ButtonWithLoading
|
||||
variant="primary"
|
||||
autoFocus
|
||||
isDisabled={
|
||||
transactions?.filter(
|
||||
trans => !trans.isMatchedTransaction && trans.selected,
|
||||
).length === 0
|
||||
}
|
||||
isLoading={loadingState === 'importing'}
|
||||
onPress={() => {
|
||||
onImport(close);
|
||||
<View style={{ flex: 1 }} />
|
||||
|
||||
<View style={{ marginRight: 10, gap: 5 }}>
|
||||
<SectionLabel title="AMOUNT OPTIONS" />
|
||||
<CheckboxOption
|
||||
id="form_flip"
|
||||
checked={flipAmount}
|
||||
disabled={splitMode || inOutMode}
|
||||
onChange={() => {
|
||||
setFlipAmount(!flipAmount);
|
||||
runImportPreview();
|
||||
}}
|
||||
>
|
||||
Flip amount
|
||||
</CheckboxOption>
|
||||
{filetype === 'csv' && (
|
||||
<>
|
||||
<CheckboxOption
|
||||
id="form_split"
|
||||
checked={splitMode}
|
||||
disabled={inOutMode || flipAmount}
|
||||
onChange={() => {
|
||||
onSplitMode();
|
||||
runImportPreview();
|
||||
}}
|
||||
>
|
||||
Split amount into separate inflow/outflow columns
|
||||
</CheckboxOption>
|
||||
<InOutOption
|
||||
inOutMode={inOutMode}
|
||||
outValue={outValue}
|
||||
disabled={splitMode || flipAmount}
|
||||
onToggle={() => {
|
||||
setInOutMode(!inOutMode);
|
||||
runImportPreview();
|
||||
}}
|
||||
onChangeText={setOutValue}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<MultiplierOption
|
||||
multiplierEnabled={multiplierEnabled}
|
||||
multiplierAmount={multiplierAmount}
|
||||
onToggle={() => {
|
||||
setMultiplierEnabled(!multiplierEnabled);
|
||||
setMultiplierAmount('');
|
||||
runImportPreview();
|
||||
}}
|
||||
onChangeAmount={onMultiplierChange}
|
||||
/>
|
||||
</View>
|
||||
</Stack>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={{ flexDirection: 'row', marginTop: 5 }}>
|
||||
{/*Submit Button */}
|
||||
<View
|
||||
style={{
|
||||
alignSelf: 'flex-end',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: '1em',
|
||||
}}
|
||||
>
|
||||
Import{' '}
|
||||
{
|
||||
transactions?.filter(
|
||||
trans => !trans.isMatchedTransaction && trans.selected,
|
||||
).length
|
||||
}{' '}
|
||||
transactions
|
||||
</ButtonWithLoading>
|
||||
<ButtonWithLoading
|
||||
variant="primary"
|
||||
type="submit"
|
||||
autoFocus
|
||||
isDisabled={
|
||||
transactions?.filter(trans => !trans.isMatchedTransaction)
|
||||
.length === 0
|
||||
}
|
||||
isLoading={loadingState === 'importing'}
|
||||
>
|
||||
Import{' '}
|
||||
{
|
||||
transactions?.filter(trans => !trans.isMatchedTransaction)
|
||||
.length
|
||||
}{' '}
|
||||
transactions
|
||||
</ButtonWithLoading>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Form>
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
@@ -66,7 +66,7 @@ export function PayeeAutocompleteModal({
|
||||
<PayeeAutocomplete
|
||||
payees={payees}
|
||||
accounts={accounts}
|
||||
focused={true}
|
||||
autoFocus={true}
|
||||
embedded={true}
|
||||
closeOnBlur={false}
|
||||
onClose={close}
|
||||
|
||||
@@ -219,7 +219,7 @@ function TableRow({
|
||||
>
|
||||
{focusedField === 'account' ? (
|
||||
<Autocomplete
|
||||
focused
|
||||
autoFocus
|
||||
strict
|
||||
highlightFirst
|
||||
suggestions={availableAccountOptions}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// @ts-strict-ignore
|
||||
import React, { useState } from 'react';
|
||||
import React, { type FormEvent, useCallback, useState } from 'react';
|
||||
import { Form } from 'react-aria-components';
|
||||
|
||||
import { send } from 'loot-core/src/platform/client/fetch';
|
||||
|
||||
@@ -28,23 +29,28 @@ export const SimpleFinInitialiseModal = ({
|
||||
const [isValid, setIsValid] = useState(true);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const onSubmit = async (close: () => void) => {
|
||||
if (!token) {
|
||||
setIsValid(false);
|
||||
return;
|
||||
}
|
||||
const onSubmit = useCallback(
|
||||
async (e: FormEvent<HTMLFormElement>, { close }: { close: () => void }) => {
|
||||
e.preventDefault();
|
||||
|
||||
setIsLoading(true);
|
||||
if (!token) {
|
||||
setIsValid(false);
|
||||
return;
|
||||
}
|
||||
|
||||
await send('secret-set', {
|
||||
name: 'simplefin_token',
|
||||
value: token,
|
||||
});
|
||||
setIsLoading(true);
|
||||
|
||||
onSuccess();
|
||||
setIsLoading(false);
|
||||
close();
|
||||
};
|
||||
await send('secret-set', {
|
||||
name: 'simplefin_token',
|
||||
value: token,
|
||||
});
|
||||
|
||||
onSuccess();
|
||||
setIsLoading(false);
|
||||
close();
|
||||
},
|
||||
[onSuccess, token],
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal name="simplefin-init" containerProps={{ style: { width: 300 } }}>
|
||||
@@ -54,49 +60,49 @@ export const SimpleFinInitialiseModal = ({
|
||||
title="Set-up SimpleFIN"
|
||||
rightContent={<ModalCloseButton onPress={close} />}
|
||||
/>
|
||||
<View style={{ display: 'flex', gap: 10 }}>
|
||||
<Text>
|
||||
In order to enable bank-sync via SimpleFIN (only for North
|
||||
American banks) you will need to create a token. This can be done
|
||||
by creating an account with{' '}
|
||||
<Link
|
||||
variant="external"
|
||||
to="https://bridge.simplefin.org/"
|
||||
linkColor="purple"
|
||||
<Form onSubmit={e => onSubmit(e, { close })}>
|
||||
<View style={{ display: 'flex', gap: 10 }}>
|
||||
<Text>
|
||||
In order to enable bank-sync via SimpleFIN (only for North
|
||||
American banks) you will need to create a token. This can be
|
||||
done by creating an account with{' '}
|
||||
<Link
|
||||
variant="external"
|
||||
to="https://bridge.simplefin.org/"
|
||||
linkColor="purple"
|
||||
>
|
||||
SimpleFIN
|
||||
</Link>
|
||||
.
|
||||
</Text>
|
||||
|
||||
<FormField>
|
||||
<FormLabel title="Token:" htmlFor="token-field" />
|
||||
<Input
|
||||
id="token-field"
|
||||
type="password"
|
||||
value={token}
|
||||
onChangeValue={value => {
|
||||
setToken(value);
|
||||
setIsValid(true);
|
||||
}}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
{!isValid && <Error>It is required to provide a token.</Error>}
|
||||
</View>
|
||||
|
||||
<ModalButtons>
|
||||
<ButtonWithLoading
|
||||
variant="primary"
|
||||
type="submit"
|
||||
autoFocus
|
||||
isLoading={isLoading}
|
||||
>
|
||||
SimpleFIN
|
||||
</Link>
|
||||
.
|
||||
</Text>
|
||||
|
||||
<FormField>
|
||||
<FormLabel title="Token:" htmlFor="token-field" />
|
||||
<Input
|
||||
id="token-field"
|
||||
type="password"
|
||||
value={token}
|
||||
onChangeValue={value => {
|
||||
setToken(value);
|
||||
setIsValid(true);
|
||||
}}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
{!isValid && <Error>It is required to provide a token.</Error>}
|
||||
</View>
|
||||
|
||||
<ModalButtons>
|
||||
<ButtonWithLoading
|
||||
variant="primary"
|
||||
autoFocus
|
||||
isLoading={isLoading}
|
||||
onPress={() => {
|
||||
onSubmit(close);
|
||||
}}
|
||||
>
|
||||
Save and continue
|
||||
</ButtonWithLoading>
|
||||
</ModalButtons>
|
||||
Save and continue
|
||||
</ButtonWithLoading>
|
||||
</ModalButtons>
|
||||
</Form>
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
@@ -4,13 +4,13 @@ import React, {
|
||||
type ComponentType,
|
||||
type ComponentPropsWithoutRef,
|
||||
type FormEvent,
|
||||
useCallback,
|
||||
} from 'react';
|
||||
import { Form } from 'react-aria-components';
|
||||
|
||||
import { styles } from '../../style';
|
||||
import { Button } from '../common/Button2';
|
||||
import { FormError } from '../common/FormError';
|
||||
import { InitialFocus } from '../common/InitialFocus';
|
||||
import { Modal, ModalCloseButton, type ModalHeader } from '../common/Modal';
|
||||
import { View } from '../common/View';
|
||||
import { InputField } from '../mobile/MobileForms';
|
||||
@@ -35,37 +35,36 @@ export function SingleInputModal({
|
||||
const [value, setValue] = useState('');
|
||||
const [errorMessage, setErrorMessage] = useState(null);
|
||||
|
||||
const _onSubmit = (e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
const _onSubmit = useCallback(
|
||||
(e: FormEvent<HTMLFormElement>, { close }: { close: () => void }) => {
|
||||
e.preventDefault();
|
||||
|
||||
const error = onValidate?.(value);
|
||||
if (error) {
|
||||
setErrorMessage(error);
|
||||
return;
|
||||
}
|
||||
const error = onValidate?.(value);
|
||||
if (error) {
|
||||
setErrorMessage(error);
|
||||
return;
|
||||
}
|
||||
|
||||
onSubmit?.(value);
|
||||
};
|
||||
onSubmit?.(value);
|
||||
close();
|
||||
},
|
||||
[onSubmit, onValidate, value],
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal name={name}>
|
||||
{({ state: { close } }) => (
|
||||
<>
|
||||
<Header rightContent={<ModalCloseButton onPress={close} />} />
|
||||
<Form
|
||||
onSubmit={e => {
|
||||
_onSubmit(e);
|
||||
close();
|
||||
}}
|
||||
>
|
||||
<Form onSubmit={e => _onSubmit(e, { close })}>
|
||||
<View>
|
||||
<InitialFocus>
|
||||
<InputField
|
||||
placeholder={inputPlaceholder}
|
||||
defaultValue={value}
|
||||
onChangeValue={setValue}
|
||||
/>
|
||||
</InitialFocus>
|
||||
<InputField
|
||||
placeholder={inputPlaceholder}
|
||||
value={value}
|
||||
onChangeValue={setValue}
|
||||
autoFocus
|
||||
autoSelect
|
||||
/>
|
||||
{errorMessage && (
|
||||
<FormError
|
||||
style={{
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import React, { type FormEvent, useCallback, useMemo, useState } from 'react';
|
||||
import { Form } from 'react-aria-components';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
import { useDispatch } from 'react-redux';
|
||||
|
||||
@@ -12,7 +13,6 @@ import {
|
||||
removeCategoriesFromGroups,
|
||||
} from '../budget/util';
|
||||
import { Button } from '../common/Button2';
|
||||
import { InitialFocus } from '../common/InitialFocus';
|
||||
import { Modal, ModalCloseButton, ModalHeader } from '../common/Modal';
|
||||
import { View } from '../common/View';
|
||||
import { FieldLabel, TapField } from '../mobile/MobileForms';
|
||||
@@ -57,7 +57,7 @@ export function TransferModal({
|
||||
const [toCategoryId, setToCategoryId] = useState<string | null>(null);
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const openCategoryModal = () => {
|
||||
const openCategoryModal = useCallback(() => {
|
||||
dispatch(
|
||||
pushModal('category-autocomplete', {
|
||||
categoryGroups,
|
||||
@@ -68,13 +68,24 @@ export function TransferModal({
|
||||
},
|
||||
}),
|
||||
);
|
||||
};
|
||||
}, [categoryGroups, month, dispatch]);
|
||||
|
||||
const _onSubmit = (newAmount: number, categoryId: string | null) => {
|
||||
if (newAmount && categoryId) {
|
||||
onSubmit?.(newAmount, categoryId);
|
||||
}
|
||||
};
|
||||
const _onSubmit = useCallback(
|
||||
(e: FormEvent<HTMLFormElement>, { close }: { close: () => void }) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!toCategoryId) {
|
||||
openCategoryModal();
|
||||
return;
|
||||
}
|
||||
|
||||
if (amount && toCategoryId) {
|
||||
onSubmit?.(amount, toCategoryId);
|
||||
}
|
||||
close();
|
||||
},
|
||||
[toCategoryId, amount, openCategoryModal, onSubmit],
|
||||
);
|
||||
|
||||
const toCategory = categories.find(c => c.id === toCategoryId);
|
||||
|
||||
@@ -86,10 +97,10 @@ export function TransferModal({
|
||||
title={title}
|
||||
rightContent={<ModalCloseButton onPress={close} />}
|
||||
/>
|
||||
<View>
|
||||
<Form onSubmit={e => _onSubmit(e, { close })}>
|
||||
<View>
|
||||
<FieldLabel title={t('Transfer this amount:')} />
|
||||
<InitialFocus>
|
||||
<View>
|
||||
<FieldLabel title={t('Transfer this amount:')} />
|
||||
<AmountInput
|
||||
value={initialAmount}
|
||||
autoDecimals={true}
|
||||
@@ -101,45 +112,39 @@ export function TransferModal({
|
||||
height: styles.mobileMinHeight,
|
||||
}}
|
||||
onUpdate={setAmount}
|
||||
onEnter={() => {
|
||||
if (!toCategoryId) {
|
||||
openCategoryModal();
|
||||
}
|
||||
}}
|
||||
autoFocus
|
||||
autoSelect
|
||||
/>
|
||||
</InitialFocus>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<FieldLabel title="To:" />
|
||||
<TapField
|
||||
tabIndex={0}
|
||||
value={toCategory?.name}
|
||||
onClick={openCategoryModal}
|
||||
/>
|
||||
<FieldLabel title="To:" />
|
||||
<TapField
|
||||
tabIndex={0}
|
||||
value={toCategory?.name}
|
||||
onClick={openCategoryModal}
|
||||
/>
|
||||
|
||||
<View
|
||||
style={{
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
paddingTop: 10,
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="primary"
|
||||
<View
|
||||
style={{
|
||||
height: styles.mobileMinHeight,
|
||||
marginLeft: styles.mobileEditingPadding,
|
||||
marginRight: styles.mobileEditingPadding,
|
||||
}}
|
||||
onPress={() => {
|
||||
_onSubmit(amount, toCategoryId);
|
||||
close();
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
paddingTop: 10,
|
||||
}}
|
||||
>
|
||||
<Trans>Transfer</Trans>
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
type="submit"
|
||||
style={{
|
||||
height: styles.mobileMinHeight,
|
||||
marginLeft: styles.mobileEditingPadding,
|
||||
marginRight: styles.mobileEditingPadding,
|
||||
}}
|
||||
>
|
||||
<Trans>Transfer</Trans>
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Form>
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
@@ -243,7 +243,7 @@ export const ManagePayees = ({
|
||||
<Search
|
||||
placeholder={t('Filter payees...')}
|
||||
value={filter}
|
||||
onChange={applyFilter}
|
||||
onChangeValue={applyFilter}
|
||||
/>
|
||||
</View>
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ import React from 'react';
|
||||
|
||||
import { styles } from '../../style';
|
||||
import { Block } from '../common/Block';
|
||||
import { InitialFocus } from '../common/InitialFocus';
|
||||
import { Input } from '../common/Input';
|
||||
|
||||
import { NON_DRAGGABLE_AREA_CLASS_NAME } from './constants';
|
||||
@@ -22,22 +21,22 @@ export const ReportCardName = ({
|
||||
}: ReportCardNameProps) => {
|
||||
if (isEditing) {
|
||||
return (
|
||||
<InitialFocus>
|
||||
<Input
|
||||
className={NON_DRAGGABLE_AREA_CLASS_NAME}
|
||||
defaultValue={name}
|
||||
onEnter={e => onChange(e.currentTarget.value)}
|
||||
onUpdate={onChange}
|
||||
onEscape={onClose}
|
||||
style={{
|
||||
...styles.mediumText,
|
||||
marginTop: -6,
|
||||
marginBottom: -1,
|
||||
marginLeft: -6,
|
||||
width: Math.max(20, name.length) + 'ch',
|
||||
}}
|
||||
/>
|
||||
</InitialFocus>
|
||||
<Input
|
||||
className={NON_DRAGGABLE_AREA_CLASS_NAME}
|
||||
defaultValue={name}
|
||||
onEnter={e => onChange(e.currentTarget.value)}
|
||||
onUpdate={onChange}
|
||||
onEscape={onClose}
|
||||
style={{
|
||||
...styles.mediumText,
|
||||
marginTop: -6,
|
||||
marginBottom: -1,
|
||||
marginLeft: -6,
|
||||
width: Math.max(20, name.length) + 'ch',
|
||||
}}
|
||||
autoFocus
|
||||
autoSelect
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ export function SaveReportChoose({ onApply }: SaveReportChooseProps) {
|
||||
<View style={{ flex: 1 }} />
|
||||
</View>
|
||||
<GenericInput
|
||||
inputRef={inputRef}
|
||||
ref={inputRef}
|
||||
field="report"
|
||||
subfield={null}
|
||||
type="saved"
|
||||
|
||||
@@ -69,7 +69,7 @@ export function SaveReportName({
|
||||
<Input
|
||||
value={name}
|
||||
id="name-field"
|
||||
inputRef={inputRef}
|
||||
ref={inputRef}
|
||||
onChangeValue={setName}
|
||||
style={{ marginTop: 10 }}
|
||||
/>
|
||||
|
||||
@@ -19,7 +19,6 @@ import { theme } from '../../style';
|
||||
import { AccountAutocomplete } from '../autocomplete/AccountAutocomplete';
|
||||
import { PayeeAutocomplete } from '../autocomplete/PayeeAutocomplete';
|
||||
import { Button } from '../common/Button2';
|
||||
import { InitialFocus } from '../common/InitialFocus';
|
||||
import { Modal, ModalCloseButton, ModalHeader } from '../common/Modal';
|
||||
import { Stack } from '../common/Stack';
|
||||
import { Text } from '../common/Text';
|
||||
@@ -470,17 +469,17 @@ export function ScheduleDetails({ id, transaction }) {
|
||||
<Stack direction="row" style={{ marginTop: 10 }}>
|
||||
<FormField style={{ flex: 1 }}>
|
||||
<FormLabel title={t('Schedule Name')} htmlFor="name-field" />
|
||||
<InitialFocus>
|
||||
<GenericInput
|
||||
field="string"
|
||||
type="string"
|
||||
value={state.fields.name}
|
||||
multi={false}
|
||||
onChange={e => {
|
||||
dispatch({ type: 'set-field', field: 'name', value: e });
|
||||
}}
|
||||
/>
|
||||
</InitialFocus>
|
||||
<GenericInput
|
||||
field="string"
|
||||
type="string"
|
||||
value={state.fields.name}
|
||||
multi={false}
|
||||
onChange={e => {
|
||||
dispatch({ type: 'set-field', field: 'name', value: e });
|
||||
}}
|
||||
autoFocus
|
||||
autoSelect
|
||||
/>
|
||||
</FormField>
|
||||
</Stack>
|
||||
<Stack direction="row" style={{ marginTop: 20 }}>
|
||||
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
|
||||
import { SvgAdd } from '../../icons/v0';
|
||||
import { Button } from '../common/Button2';
|
||||
import { InitialFocus } from '../common/InitialFocus';
|
||||
import { Modal, ModalCloseButton, ModalHeader } from '../common/Modal';
|
||||
import { Search } from '../common/Search';
|
||||
import { Text } from '../common/Text';
|
||||
@@ -96,16 +95,16 @@ export function ScheduleLink({
|
||||
{ count: ids?.length ?? 0 },
|
||||
)}
|
||||
</Text>
|
||||
<InitialFocus>
|
||||
<Search
|
||||
inputRef={searchInput}
|
||||
isInModal
|
||||
width={300}
|
||||
placeholder={t('Filter schedules…')}
|
||||
value={filter}
|
||||
onChange={setFilter}
|
||||
/>
|
||||
</InitialFocus>
|
||||
<Search
|
||||
ref={searchInput}
|
||||
isInModal
|
||||
width={300}
|
||||
placeholder={t('Filter schedules…')}
|
||||
value={filter}
|
||||
onChangeValue={setFilter}
|
||||
autoFocus
|
||||
autoSelect
|
||||
/>
|
||||
{ids.length === 1 && (
|
||||
<Button
|
||||
variant="primary"
|
||||
|
||||
@@ -95,7 +95,7 @@ export function Schedules() {
|
||||
<Search
|
||||
placeholder={t('Filter schedules…')}
|
||||
value={filter}
|
||||
onChange={setFilter}
|
||||
onChangeValue={setFilter}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -10,7 +10,6 @@ import React, {
|
||||
useState,
|
||||
type ComponentProps,
|
||||
type KeyboardEvent,
|
||||
type MutableRefObject,
|
||||
} from 'react';
|
||||
|
||||
import { css } from '@emotion/css';
|
||||
@@ -27,6 +26,7 @@ import {
|
||||
currentDate,
|
||||
} from 'loot-core/src/shared/months';
|
||||
|
||||
import { useMergedRefs } from '../../hooks/useMergedRefs';
|
||||
import { useSyncedPref } from '../../hooks/useSyncedPref';
|
||||
import { styles, theme } from '../../style';
|
||||
import { Input } from '../common/Input';
|
||||
@@ -185,232 +185,235 @@ type DateSelectProps = {
|
||||
isOpen?: boolean;
|
||||
embedded?: boolean;
|
||||
dateFormat: string;
|
||||
focused?: boolean;
|
||||
autoFocus?: boolean;
|
||||
autoSelect?: boolean;
|
||||
openOnFocus?: boolean;
|
||||
inputRef?: MutableRefObject<HTMLInputElement>;
|
||||
shouldSaveFromKey?: (e: KeyboardEvent<HTMLInputElement>) => boolean;
|
||||
clearOnBlur?: boolean;
|
||||
onUpdate?: (selectedDate: string) => void;
|
||||
onSelect: (selectedDate: string) => void;
|
||||
};
|
||||
|
||||
export function DateSelect({
|
||||
id,
|
||||
containerProps,
|
||||
inputProps,
|
||||
value: defaultValue,
|
||||
isOpen,
|
||||
embedded,
|
||||
dateFormat = 'yyyy-MM-dd',
|
||||
focused,
|
||||
openOnFocus = true,
|
||||
inputRef: originalInputRef,
|
||||
shouldSaveFromKey = defaultShouldSaveFromKey,
|
||||
clearOnBlur = true,
|
||||
onUpdate,
|
||||
onSelect,
|
||||
}: DateSelectProps) {
|
||||
const parsedDefaultValue = useMemo(() => {
|
||||
if (defaultValue) {
|
||||
const date = parseISO(defaultValue);
|
||||
if (isValid(date)) {
|
||||
return format(date, dateFormat);
|
||||
export const DateSelect = forwardRef<HTMLInputElement, DateSelectProps>(
|
||||
(
|
||||
{
|
||||
id,
|
||||
containerProps,
|
||||
inputProps,
|
||||
value: defaultValue,
|
||||
isOpen,
|
||||
embedded,
|
||||
dateFormat = 'yyyy-MM-dd',
|
||||
autoFocus,
|
||||
autoSelect,
|
||||
openOnFocus = true,
|
||||
shouldSaveFromKey = defaultShouldSaveFromKey,
|
||||
clearOnBlur = true,
|
||||
onUpdate,
|
||||
onSelect,
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const parsedDefaultValue = useMemo(() => {
|
||||
if (defaultValue) {
|
||||
const date = parseISO(defaultValue);
|
||||
if (isValid(date)) {
|
||||
return format(date, dateFormat);
|
||||
}
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}, [defaultValue, dateFormat]);
|
||||
return '';
|
||||
}, [defaultValue, dateFormat]);
|
||||
|
||||
const picker = useRef(null);
|
||||
const [value, setValue] = useState(parsedDefaultValue);
|
||||
const [open, setOpen] = useState(embedded || isOpen || false);
|
||||
const inputRef = useRef(null);
|
||||
const picker = useRef(null);
|
||||
const [value, setValue] = useState(parsedDefaultValue);
|
||||
const [open, setOpen] = useState(embedded || isOpen || false);
|
||||
const inputRef = useRef(null);
|
||||
const mergedRef = useMergedRefs(ref, inputRef);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (originalInputRef) {
|
||||
originalInputRef.current = inputRef.current;
|
||||
}
|
||||
}, []);
|
||||
// This is confusing, so let me explain: `selectedValue` should be
|
||||
// renamed to `currentValue`. It represents the current highlighted
|
||||
// value in the date select and always changes as the user moves
|
||||
// around. `userSelectedValue` represents the last value that the
|
||||
// user actually selected (with enter or click). Having both allows
|
||||
// us to make various UX decisions
|
||||
const [selectedValue, setSelectedValue] = useState(value);
|
||||
const userSelectedValue = useRef(selectedValue);
|
||||
|
||||
// This is confusing, so let me explain: `selectedValue` should be
|
||||
// renamed to `currentValue`. It represents the current highlighted
|
||||
// value in the date select and always changes as the user moves
|
||||
// around. `userSelectedValue` represents the last value that the
|
||||
// user actually selected (with enter or click). Having both allows
|
||||
// us to make various UX decisions
|
||||
const [selectedValue, setSelectedValue] = useState(value);
|
||||
const userSelectedValue = useRef(selectedValue);
|
||||
const [_firstDayOfWeekIdx] = useSyncedPref('firstDayOfWeekIdx');
|
||||
const firstDayOfWeekIdx = _firstDayOfWeekIdx || '0';
|
||||
|
||||
const [_firstDayOfWeekIdx] = useSyncedPref('firstDayOfWeekIdx');
|
||||
const firstDayOfWeekIdx = _firstDayOfWeekIdx || '0';
|
||||
useEffect(() => {
|
||||
userSelectedValue.current = value;
|
||||
}, [value]);
|
||||
|
||||
useEffect(() => {
|
||||
userSelectedValue.current = value;
|
||||
}, [value]);
|
||||
useEffect(() => setValue(parsedDefaultValue), [parsedDefaultValue]);
|
||||
|
||||
useEffect(() => setValue(parsedDefaultValue), [parsedDefaultValue]);
|
||||
|
||||
useEffect(() => {
|
||||
if (getDayMonthRegex(dateFormat).test(value)) {
|
||||
// Support only entering the month and day (4/5). This is complex
|
||||
// because of the various date formats - we need to derive
|
||||
// the right day/month format from it
|
||||
const test = parse(value, getDayMonthFormat(dateFormat), new Date());
|
||||
if (isValid(test)) {
|
||||
onUpdate?.(format(test, 'yyyy-MM-dd'));
|
||||
setSelectedValue(format(test, dateFormat));
|
||||
}
|
||||
} else if (getShortYearRegex(dateFormat).test(value)) {
|
||||
// Support entering the year as only two digits (4/5/19)
|
||||
const test = parse(value, getShortYearFormat(dateFormat), new Date());
|
||||
if (isValid(test)) {
|
||||
onUpdate?.(format(test, 'yyyy-MM-dd'));
|
||||
setSelectedValue(format(test, dateFormat));
|
||||
}
|
||||
} else {
|
||||
const test = parse(value, dateFormat, new Date());
|
||||
if (isValid(test)) {
|
||||
const date = format(test, 'yyyy-MM-dd');
|
||||
onUpdate?.(date);
|
||||
setSelectedValue(value);
|
||||
}
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
function onKeyDown(e: KeyboardEvent<HTMLInputElement>) {
|
||||
if (
|
||||
['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(e.key) &&
|
||||
!e.shiftKey &&
|
||||
!e.metaKey &&
|
||||
!e.altKey &&
|
||||
open
|
||||
) {
|
||||
picker.current.handleInputKeyDown(e);
|
||||
} else if (e.key === 'Escape') {
|
||||
setValue(parsedDefaultValue);
|
||||
setSelectedValue(parsedDefaultValue);
|
||||
|
||||
if (parsedDefaultValue === value) {
|
||||
if (open) {
|
||||
if (!embedded) {
|
||||
e.stopPropagation();
|
||||
}
|
||||
|
||||
setOpen(false);
|
||||
useEffect(() => {
|
||||
if (getDayMonthRegex(dateFormat).test(value)) {
|
||||
// Support only entering the month and day (4/5). This is complex
|
||||
// because of the various date formats - we need to derive
|
||||
// the right day/month format from it
|
||||
const test = parse(value, getDayMonthFormat(dateFormat), new Date());
|
||||
if (isValid(test)) {
|
||||
onUpdate?.(format(test, 'yyyy-MM-dd'));
|
||||
setSelectedValue(format(test, dateFormat));
|
||||
}
|
||||
} else if (getShortYearRegex(dateFormat).test(value)) {
|
||||
// Support entering the year as only two digits (4/5/19)
|
||||
const test = parse(value, getShortYearFormat(dateFormat), new Date());
|
||||
if (isValid(test)) {
|
||||
onUpdate?.(format(test, 'yyyy-MM-dd'));
|
||||
setSelectedValue(format(test, dateFormat));
|
||||
}
|
||||
} else {
|
||||
const test = parse(value, dateFormat, new Date());
|
||||
if (isValid(test)) {
|
||||
const date = format(test, 'yyyy-MM-dd');
|
||||
onUpdate?.(date);
|
||||
setSelectedValue(value);
|
||||
}
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
function onKeyDown(e: KeyboardEvent<HTMLInputElement>) {
|
||||
if (
|
||||
['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(e.key) &&
|
||||
!e.shiftKey &&
|
||||
!e.metaKey &&
|
||||
!e.altKey &&
|
||||
open
|
||||
) {
|
||||
picker.current.handleInputKeyDown(e);
|
||||
} else if (e.key === 'Escape') {
|
||||
setValue(parsedDefaultValue);
|
||||
setSelectedValue(parsedDefaultValue);
|
||||
|
||||
if (parsedDefaultValue === value) {
|
||||
if (open) {
|
||||
if (!embedded) {
|
||||
e.stopPropagation();
|
||||
}
|
||||
|
||||
setOpen(false);
|
||||
}
|
||||
} else {
|
||||
setOpen(true);
|
||||
onUpdate?.(defaultValue);
|
||||
}
|
||||
} else if (shouldSaveFromKey(e)) {
|
||||
setValue(selectedValue);
|
||||
setOpen(false);
|
||||
|
||||
const date = parse(selectedValue, dateFormat, new Date());
|
||||
onSelect(format(date, 'yyyy-MM-dd'));
|
||||
|
||||
if (open && e.key === 'Enter') {
|
||||
// This stops the event from propagating up
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
const { onKeyDown } = inputProps || {};
|
||||
onKeyDown?.(e);
|
||||
} else if (!open) {
|
||||
setOpen(true);
|
||||
onUpdate?.(defaultValue);
|
||||
}
|
||||
} else if (shouldSaveFromKey(e)) {
|
||||
setValue(selectedValue);
|
||||
setOpen(false);
|
||||
|
||||
const date = parse(selectedValue, dateFormat, new Date());
|
||||
onSelect(format(date, 'yyyy-MM-dd'));
|
||||
|
||||
if (open && e.key === 'Enter') {
|
||||
// This stops the event from propagating up
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
const { onKeyDown } = inputProps || {};
|
||||
onKeyDown?.(e);
|
||||
} else if (!open) {
|
||||
setOpen(true);
|
||||
if (inputRef.current) {
|
||||
inputRef.current.setSelectionRange(0, 10000);
|
||||
if (inputRef.current) {
|
||||
inputRef.current.setSelectionRange(0, 10000);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onChange(e) {
|
||||
setValue(e.target.value);
|
||||
}
|
||||
|
||||
const maybeWrapTooltip = content => {
|
||||
if (embedded) {
|
||||
return open ? content : null;
|
||||
function onChange(e) {
|
||||
setValue(e.target.value);
|
||||
}
|
||||
|
||||
const maybeWrapTooltip = content => {
|
||||
if (embedded) {
|
||||
return open ? content : null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover
|
||||
triggerRef={inputRef}
|
||||
placement="bottom start"
|
||||
offset={2}
|
||||
isOpen={open}
|
||||
isNonModal
|
||||
onOpenChange={() => setOpen(false)}
|
||||
style={{ ...styles.popover, minWidth: 225 }}
|
||||
data-testid="date-select-tooltip"
|
||||
>
|
||||
{content}
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover
|
||||
triggerRef={inputRef}
|
||||
placement="bottom start"
|
||||
offset={2}
|
||||
isOpen={open}
|
||||
isNonModal
|
||||
onOpenChange={() => setOpen(false)}
|
||||
style={{ ...styles.popover, minWidth: 225 }}
|
||||
data-testid="date-select-tooltip"
|
||||
>
|
||||
{content}
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
<View {...containerProps}>
|
||||
<Input
|
||||
id={id}
|
||||
{...inputProps}
|
||||
autoFocus={autoFocus}
|
||||
autoSelect={autoSelect}
|
||||
ref={mergedRef}
|
||||
value={value}
|
||||
onPointerUp={() => {
|
||||
if (!embedded) {
|
||||
setOpen(true);
|
||||
}
|
||||
}}
|
||||
onKeyDown={onKeyDown}
|
||||
onChange={onChange}
|
||||
onFocus={e => {
|
||||
if (!embedded && openOnFocus) {
|
||||
setOpen(true);
|
||||
}
|
||||
inputProps?.onFocus?.(e);
|
||||
}}
|
||||
onBlur={e => {
|
||||
if (!embedded) {
|
||||
setOpen(false);
|
||||
}
|
||||
inputProps?.onBlur?.(e);
|
||||
|
||||
return (
|
||||
<View {...containerProps}>
|
||||
<Input
|
||||
id={id}
|
||||
focused={focused}
|
||||
{...inputProps}
|
||||
inputRef={inputRef}
|
||||
value={value}
|
||||
onPointerUp={() => {
|
||||
if (!embedded) {
|
||||
setOpen(true);
|
||||
}
|
||||
}}
|
||||
onKeyDown={onKeyDown}
|
||||
onChange={onChange}
|
||||
onFocus={e => {
|
||||
if (!embedded && openOnFocus) {
|
||||
setOpen(true);
|
||||
}
|
||||
inputProps?.onFocus?.(e);
|
||||
}}
|
||||
onBlur={e => {
|
||||
if (!embedded) {
|
||||
setOpen(false);
|
||||
}
|
||||
inputProps?.onBlur?.(e);
|
||||
if (clearOnBlur) {
|
||||
// If value is empty, that drives what gets selected.
|
||||
// Otherwise the input is reset to whatever is already
|
||||
// selected
|
||||
if (value === '') {
|
||||
setSelectedValue(null);
|
||||
onSelect(null);
|
||||
} else {
|
||||
setValue(selectedValue || '');
|
||||
|
||||
if (clearOnBlur) {
|
||||
// If value is empty, that drives what gets selected.
|
||||
// Otherwise the input is reset to whatever is already
|
||||
// selected
|
||||
if (value === '') {
|
||||
setSelectedValue(null);
|
||||
onSelect(null);
|
||||
} else {
|
||||
setValue(selectedValue || '');
|
||||
|
||||
const date = parse(selectedValue, dateFormat, new Date());
|
||||
if (date instanceof Date && !isNaN(date.valueOf())) {
|
||||
onSelect(format(date, 'yyyy-MM-dd'));
|
||||
const date = parse(selectedValue, dateFormat, new Date());
|
||||
if (date instanceof Date && !isNaN(date.valueOf())) {
|
||||
onSelect(format(date, 'yyyy-MM-dd'));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{maybeWrapTooltip(
|
||||
<DatePicker
|
||||
ref={picker}
|
||||
value={selectedValue}
|
||||
firstDayOfWeekIdx={firstDayOfWeekIdx}
|
||||
dateFormat={dateFormat}
|
||||
onUpdate={date => {
|
||||
setSelectedValue(format(date, dateFormat));
|
||||
onUpdate?.(format(date, 'yyyy-MM-dd'));
|
||||
}}
|
||||
onSelect={date => {
|
||||
setValue(format(date, dateFormat));
|
||||
onSelect(format(date, 'yyyy-MM-dd'));
|
||||
setOpen(false);
|
||||
}}
|
||||
/>,
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
/>
|
||||
{maybeWrapTooltip(
|
||||
<DatePicker
|
||||
ref={picker}
|
||||
value={selectedValue}
|
||||
firstDayOfWeekIdx={firstDayOfWeekIdx}
|
||||
dateFormat={dateFormat}
|
||||
onUpdate={date => {
|
||||
setSelectedValue(format(date, dateFormat));
|
||||
onUpdate?.(format(date, 'yyyy-MM-dd'));
|
||||
}}
|
||||
onSelect={date => {
|
||||
setValue(format(date, dateFormat));
|
||||
onSelect(format(date, 'yyyy-MM-dd'));
|
||||
setOpen(false);
|
||||
}}
|
||||
/>,
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
DateSelect.displayName = 'DateSelect';
|
||||
|
||||
@@ -17,7 +17,6 @@ import { useDateFormat } from '../../hooks/useDateFormat';
|
||||
import { SvgAdd, SvgSubtract } from '../../icons/v0';
|
||||
import { theme } from '../../style';
|
||||
import { Button } from '../common/Button2';
|
||||
import { InitialFocus } from '../common/InitialFocus';
|
||||
import { Input } from '../common/Input';
|
||||
import { Menu } from '../common/Menu';
|
||||
import { Popover } from '../common/Popover';
|
||||
@@ -393,16 +392,16 @@ function RecurringScheduleTooltip({
|
||||
<>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 5 }}>
|
||||
<label htmlFor="start">From</label>
|
||||
<InitialFocus>
|
||||
<DateSelect
|
||||
id="start"
|
||||
inputProps={{ placeholder: 'Start Date' }}
|
||||
value={config.start}
|
||||
onSelect={value => updateField('start', value)}
|
||||
containerProps={{ style: { width: 100 } }}
|
||||
dateFormat={dateFormat}
|
||||
/>
|
||||
</InitialFocus>
|
||||
<DateSelect
|
||||
id="start"
|
||||
inputProps={{ placeholder: 'Start Date' }}
|
||||
value={config.start}
|
||||
onSelect={value => updateField('start', value)}
|
||||
containerProps={{ style: { width: 100 } }}
|
||||
dateFormat={dateFormat}
|
||||
autoFocus
|
||||
autoSelect
|
||||
/>
|
||||
<Select
|
||||
id="repeat_end_dropdown"
|
||||
options={[
|
||||
@@ -420,7 +419,7 @@ function RecurringScheduleTooltip({
|
||||
style={{ width: 40 }}
|
||||
type="number"
|
||||
min={1}
|
||||
onChange={e => updateField('endOccurrences', e.target.value)}
|
||||
onChangeValue={value => updateField('endOccurrences', value)}
|
||||
defaultValue={config.endOccurrences || 1}
|
||||
/>
|
||||
<Text>occurrence{config.endOccurrences === '1' ? '' : 's'}</Text>
|
||||
@@ -450,7 +449,7 @@ function RecurringScheduleTooltip({
|
||||
style={{ width: 40 }}
|
||||
type="number"
|
||||
min={1}
|
||||
onChange={e => updateField('interval', e.target.value)}
|
||||
onChangeValue={value => updateField('interval', value)}
|
||||
defaultValue={config.interval || 1}
|
||||
/>
|
||||
<Select
|
||||
|
||||
@@ -17,7 +17,6 @@ import { SvgExpandArrow } from '../../icons/v0';
|
||||
import { SvgAdd } from '../../icons/v1';
|
||||
import { styles, theme } from '../../style';
|
||||
import { Button } from '../common/Button2';
|
||||
import { InitialFocus } from '../common/InitialFocus';
|
||||
import { Input } from '../common/Input';
|
||||
import { Menu } from '../common/Menu';
|
||||
import { Popover } from '../common/Popover';
|
||||
@@ -195,25 +194,25 @@ function EditableBudgetName() {
|
||||
|
||||
if (editing) {
|
||||
return (
|
||||
<InitialFocus>
|
||||
<Input
|
||||
style={{
|
||||
width: 160,
|
||||
fontSize: 16,
|
||||
fontWeight: 500,
|
||||
}}
|
||||
defaultValue={budgetName}
|
||||
onEnter={async e => {
|
||||
const inputEl = e.target as HTMLInputElement;
|
||||
const newBudgetName = inputEl.value;
|
||||
if (newBudgetName.trim() !== '') {
|
||||
setBudgetNamePref(newBudgetName);
|
||||
setEditing(false);
|
||||
}
|
||||
}}
|
||||
onBlur={() => setEditing(false)}
|
||||
/>
|
||||
</InitialFocus>
|
||||
<Input
|
||||
style={{
|
||||
width: 160,
|
||||
fontSize: 16,
|
||||
fontWeight: 500,
|
||||
}}
|
||||
defaultValue={budgetName}
|
||||
onEnter={async e => {
|
||||
const inputEl = e.target as HTMLInputElement;
|
||||
const newBudgetName = inputEl.value;
|
||||
if (newBudgetName.trim() !== '') {
|
||||
setBudgetNamePref(newBudgetName);
|
||||
setEditing(false);
|
||||
}
|
||||
}}
|
||||
onBlur={() => setEditing(false)}
|
||||
autoFocus
|
||||
autoSelect
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -88,6 +88,16 @@ export type Binding<
|
||||
value?: Spreadsheets[SheetName][SheetFieldName];
|
||||
query?: Query;
|
||||
};
|
||||
|
||||
export type SheetResult<
|
||||
SheetName extends SheetNames,
|
||||
FieldName extends SheetFields<SheetName>,
|
||||
> = {
|
||||
name: string;
|
||||
value: Spreadsheets[SheetName][FieldName];
|
||||
query?: Query;
|
||||
};
|
||||
|
||||
export const parametrizedField =
|
||||
<SheetName extends SheetNames>() =>
|
||||
<SheetFieldName extends SheetFields<SheetName>>(field: SheetFieldName) =>
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
type SheetFields,
|
||||
type SheetNames,
|
||||
type Binding,
|
||||
type SheetResult,
|
||||
} from '.';
|
||||
|
||||
export function useSheetValue<
|
||||
@@ -17,7 +18,7 @@ export function useSheetValue<
|
||||
FieldName extends SheetFields<SheetName>,
|
||||
>(
|
||||
binding: Binding<SheetName, FieldName>,
|
||||
onChange?: (result) => void,
|
||||
onChange?: (result: SheetResult<SheetName, FieldName>) => void,
|
||||
): Spreadsheets[SheetName][FieldName] {
|
||||
const { sheetName, fullSheetName } = useSheetName(binding);
|
||||
|
||||
|
||||
@@ -737,7 +737,7 @@ function PayeeCell({
|
||||
}}
|
||||
showManagePayees={true}
|
||||
clearOnBlur={false}
|
||||
focused={true}
|
||||
autoFocus={true}
|
||||
onUpdate={(id, value) => onUpdate?.(value)}
|
||||
onSelect={onSave}
|
||||
onManagePayees={() => onManagePayees(payee?.id)}
|
||||
@@ -1273,7 +1273,7 @@ const Transaction = memo(function Transaction({
|
||||
accounts={accounts}
|
||||
shouldSaveFromKey={shouldSaveFromKey}
|
||||
clearOnBlur={false}
|
||||
focused={true}
|
||||
autoFocus={true}
|
||||
inputProps={{ onBlur, onKeyDown, style: inputStyle }}
|
||||
onUpdate={onUpdate}
|
||||
onSelect={onSave}
|
||||
@@ -1497,7 +1497,7 @@ const Transaction = memo(function Transaction({
|
||||
<CategoryAutocomplete
|
||||
categoryGroups={categoryGroups}
|
||||
value={categoryId}
|
||||
focused={true}
|
||||
autoFocus={true}
|
||||
clearOnBlur={false}
|
||||
showSplitOption={!isChild && !isParent}
|
||||
shouldSaveFromKey={shouldSaveFromKey}
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
// @ts-strict-ignore
|
||||
import React, {
|
||||
type Ref,
|
||||
useRef,
|
||||
useState,
|
||||
useEffect,
|
||||
type FocusEventHandler,
|
||||
type KeyboardEventHandler,
|
||||
type CSSProperties,
|
||||
forwardRef,
|
||||
} from 'react';
|
||||
|
||||
import { css } from '@emotion/css';
|
||||
|
||||
import { evalArithmetic } from 'loot-core/src/shared/arithmetic';
|
||||
import { amountToInteger, appendDecimals } from 'loot-core/src/shared/util';
|
||||
|
||||
@@ -23,7 +25,6 @@ import { useFormat } from '../spreadsheet/useFormat';
|
||||
|
||||
type AmountInputProps = {
|
||||
id?: string;
|
||||
inputRef?: Ref<HTMLInputElement>;
|
||||
value: number;
|
||||
zeroSign?: '-' | '+';
|
||||
onChangeValue?: (value: string) => void;
|
||||
@@ -33,125 +34,134 @@ type AmountInputProps = {
|
||||
onUpdate?: (amount: number) => void;
|
||||
style?: CSSProperties;
|
||||
inputStyle?: CSSProperties;
|
||||
focused?: boolean;
|
||||
autoFocus?: boolean;
|
||||
autoSelect?: boolean;
|
||||
disabled?: boolean;
|
||||
autoDecimals?: boolean;
|
||||
};
|
||||
|
||||
export function AmountInput({
|
||||
id,
|
||||
inputRef,
|
||||
value: initialValue,
|
||||
zeroSign = '-', // + or -
|
||||
onFocus,
|
||||
onBlur,
|
||||
onChangeValue,
|
||||
onUpdate,
|
||||
onEnter,
|
||||
style,
|
||||
inputStyle,
|
||||
focused,
|
||||
disabled = false,
|
||||
autoDecimals = false,
|
||||
}: AmountInputProps) {
|
||||
const format = useFormat();
|
||||
const [symbol, setSymbol] = useState<'+' | '-'>(
|
||||
initialValue === 0 ? zeroSign : initialValue > 0 ? '+' : '-',
|
||||
);
|
||||
export const AmountInput = forwardRef<HTMLInputElement, AmountInputProps>(
|
||||
(
|
||||
{
|
||||
id,
|
||||
value: initialValue,
|
||||
zeroSign = '-', // + or -
|
||||
onFocus,
|
||||
onBlur,
|
||||
onChangeValue,
|
||||
onUpdate,
|
||||
onEnter,
|
||||
style,
|
||||
inputStyle,
|
||||
autoFocus,
|
||||
autoSelect,
|
||||
disabled = false,
|
||||
autoDecimals = false,
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const format = useFormat();
|
||||
const [symbol, setSymbol] = useState<'+' | '-'>(
|
||||
initialValue === 0 ? zeroSign : initialValue > 0 ? '+' : '-',
|
||||
);
|
||||
|
||||
const initialValueAbsolute = format(Math.abs(initialValue || 0), 'financial');
|
||||
const [value, setValue] = useState(initialValueAbsolute);
|
||||
useEffect(() => setValue(initialValueAbsolute), [initialValueAbsolute]);
|
||||
const initialValueAbsolute = format(
|
||||
Math.abs(initialValue || 0),
|
||||
'financial',
|
||||
);
|
||||
const [value, setValue] = useState(initialValueAbsolute);
|
||||
useEffect(() => setValue(initialValueAbsolute), [initialValueAbsolute]);
|
||||
|
||||
const buttonRef = useRef();
|
||||
const ref = useRef<HTMLInputElement>(null);
|
||||
const mergedRef = useMergedRefs<HTMLInputElement>(inputRef, ref);
|
||||
const [hideFraction] = useSyncedPref('hideFraction');
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const mergedRef = useMergedRefs<HTMLInputElement>(inputRef, ref);
|
||||
const [hideFraction] = useSyncedPref('hideFraction');
|
||||
|
||||
useEffect(() => {
|
||||
if (focused) {
|
||||
ref.current?.focus();
|
||||
}
|
||||
}, [focused]);
|
||||
|
||||
function onSwitch() {
|
||||
const amount = getAmount();
|
||||
if (amount === 0) {
|
||||
setSymbol(symbol === '+' ? '-' : '+');
|
||||
}
|
||||
fireUpdate(amount * -1);
|
||||
}
|
||||
|
||||
function getAmount() {
|
||||
const signedValued = symbol === '-' ? symbol + value : value;
|
||||
return amountToInteger(evalArithmetic(signedValued));
|
||||
}
|
||||
|
||||
function onInputTextChange(val) {
|
||||
val = autoDecimals
|
||||
? appendDecimals(val, String(hideFraction) === 'true')
|
||||
: val;
|
||||
setValue(val ? val : '');
|
||||
onChangeValue?.(val);
|
||||
}
|
||||
|
||||
function fireUpdate(amount) {
|
||||
onUpdate?.(amount);
|
||||
if (amount > 0) {
|
||||
setSymbol('+');
|
||||
} else if (amount < 0) {
|
||||
setSymbol('-');
|
||||
}
|
||||
}
|
||||
|
||||
function onInputAmountBlur(e) {
|
||||
if (!ref.current?.contains(e.relatedTarget)) {
|
||||
function onSwitch() {
|
||||
const amount = getAmount();
|
||||
fireUpdate(amount);
|
||||
}
|
||||
onBlur?.(e);
|
||||
}
|
||||
|
||||
return (
|
||||
<InputWithContent
|
||||
id={id}
|
||||
inputRef={mergedRef}
|
||||
inputMode="decimal"
|
||||
leftContent={
|
||||
<Button
|
||||
variant="bare"
|
||||
isDisabled={disabled}
|
||||
aria-label={`Make ${symbol === '-' ? 'positive' : 'negative'}`}
|
||||
style={{ padding: '0 7px' }}
|
||||
onPress={onSwitch}
|
||||
ref={buttonRef}
|
||||
>
|
||||
{symbol === '-' && (
|
||||
<SvgSubtract style={{ width: 8, height: 8, color: 'inherit' }} />
|
||||
)}
|
||||
{symbol === '+' && (
|
||||
<SvgAdd style={{ width: 8, height: 8, color: 'inherit' }} />
|
||||
)}
|
||||
</Button>
|
||||
if (amount === 0) {
|
||||
setSymbol(symbol === '+' ? '-' : '+');
|
||||
}
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
focused={focused}
|
||||
style={{ flex: 1, alignItems: 'stretch', ...style }}
|
||||
inputStyle={inputStyle}
|
||||
onKeyUp={e => {
|
||||
if (e.key === 'Enter') {
|
||||
const amount = getAmount();
|
||||
fireUpdate(amount);
|
||||
fireUpdate(amount * -1);
|
||||
}
|
||||
|
||||
function getAmount() {
|
||||
const signedValued = symbol === '-' ? symbol + value : value;
|
||||
return amountToInteger(evalArithmetic(signedValued));
|
||||
}
|
||||
|
||||
function onInputTextChange(val) {
|
||||
val = autoDecimals
|
||||
? appendDecimals(val, String(hideFraction) === 'true')
|
||||
: val;
|
||||
setValue(val ? val : '');
|
||||
onChangeValue?.(val);
|
||||
}
|
||||
function fireUpdate(amount) {
|
||||
onUpdate?.(amount);
|
||||
if (amount > 0) {
|
||||
setSymbol('+');
|
||||
} else if (amount < 0) {
|
||||
setSymbol('-');
|
||||
}
|
||||
}
|
||||
|
||||
function onInputAmountBlur(e) {
|
||||
if (!inputRef.current?.contains(e.relatedTarget)) {
|
||||
const amount = getAmount();
|
||||
fireUpdate(amount);
|
||||
}
|
||||
onBlur?.(e);
|
||||
}
|
||||
|
||||
return (
|
||||
<InputWithContent
|
||||
id={id}
|
||||
ref={mergedRef}
|
||||
inputMode="decimal"
|
||||
leftContent={
|
||||
<Button
|
||||
variant="bare"
|
||||
isDisabled={disabled}
|
||||
aria-label={`Make ${symbol === '-' ? 'positive' : 'negative'}`}
|
||||
style={{ padding: '0 7px', flexShrink: 0 }}
|
||||
onPress={onSwitch}
|
||||
ref={buttonRef}
|
||||
>
|
||||
{symbol === '-' && (
|
||||
<SvgSubtract style={{ width: 8, height: 8, color: 'inherit' }} />
|
||||
)}
|
||||
{symbol === '+' && (
|
||||
<SvgAdd style={{ width: 8, height: 8, color: 'inherit' }} />
|
||||
)}
|
||||
</Button>
|
||||
}
|
||||
}}
|
||||
onChangeValue={onInputTextChange}
|
||||
onBlur={onInputAmountBlur}
|
||||
onFocus={onFocus}
|
||||
onEnter={onEnter}
|
||||
/>
|
||||
);
|
||||
}
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
autoFocus={autoFocus}
|
||||
autoSelect={autoSelect}
|
||||
containerClassName={css({
|
||||
flex: 1,
|
||||
alignItems: 'stretch',
|
||||
'& input': inputStyle,
|
||||
...style,
|
||||
})}
|
||||
onKeyUp={e => {
|
||||
if (e.key === 'Enter') {
|
||||
const amount = getAmount();
|
||||
fireUpdate(amount);
|
||||
}
|
||||
}}
|
||||
onChangeValue={onInputTextChange}
|
||||
onBlur={onInputAmountBlur}
|
||||
onFocus={onFocus}
|
||||
onEnter={onEnter}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
AmountInput.displayName = 'AmountInput';
|
||||
|
||||
export function BetweenAmountInput({ defaultValue, onChange }) {
|
||||
const [num1, setNum1] = useState(defaultValue.num1);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import React, { forwardRef } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import { useReports } from 'loot-core/client/data-hooks/reports';
|
||||
@@ -22,243 +22,277 @@ import { RecurringSchedulePicker } from '../select/RecurringSchedulePicker';
|
||||
import { AmountInput } from './AmountInput';
|
||||
import { PercentInput } from './PercentInput';
|
||||
|
||||
export function GenericInput({
|
||||
field,
|
||||
subfield,
|
||||
type,
|
||||
numberFormatType = undefined,
|
||||
multi,
|
||||
value,
|
||||
inputRef,
|
||||
style,
|
||||
onChange,
|
||||
}) {
|
||||
const { grouped: categoryGroups } = useCategories();
|
||||
const { data: savedReports } = useReports();
|
||||
const saved = useSelector(state => state.queries.saved);
|
||||
const dateFormat = useDateFormat() || 'MM/dd/yyyy';
|
||||
export const GenericInput = forwardRef(
|
||||
(
|
||||
{
|
||||
field,
|
||||
subfield,
|
||||
type,
|
||||
numberFormatType = undefined,
|
||||
multi,
|
||||
value,
|
||||
style,
|
||||
onChange,
|
||||
autoFocus,
|
||||
autoSelect,
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const { grouped: categoryGroups } = useCategories();
|
||||
const { data: savedReports } = useReports();
|
||||
const saved = useSelector(state => state.queries.saved);
|
||||
const dateFormat = useDateFormat() || 'MM/dd/yyyy';
|
||||
|
||||
const getNumberInputByFormatType = numberFormatType => {
|
||||
switch (numberFormatType) {
|
||||
case 'currency':
|
||||
return (
|
||||
<AmountInput
|
||||
inputRef={inputRef}
|
||||
value={amountToInteger(value)}
|
||||
onUpdate={v => onChange(integerToAmount(v))}
|
||||
/>
|
||||
);
|
||||
case 'percentage':
|
||||
return (
|
||||
<PercentInput
|
||||
inputRef={inputRef}
|
||||
value={value}
|
||||
onUpdatePercent={onChange}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<Input
|
||||
inputRef={inputRef}
|
||||
defaultValue={value || ''}
|
||||
placeholder="nothing"
|
||||
onEnter={e => onChange(e.target.value)}
|
||||
onBlur={e => onChange(e.target.value)}
|
||||
/>
|
||||
);
|
||||
const getNumberInputByFormatType = numberFormatType => {
|
||||
switch (numberFormatType) {
|
||||
case 'currency':
|
||||
return (
|
||||
<AmountInput
|
||||
ref={ref}
|
||||
value={amountToInteger(value)}
|
||||
onUpdate={v => onChange(integerToAmount(v))}
|
||||
autoFocus={autoFocus}
|
||||
autoSelect={autoSelect}
|
||||
/>
|
||||
);
|
||||
case 'percentage':
|
||||
return (
|
||||
<PercentInput
|
||||
ref={ref}
|
||||
value={value}
|
||||
onUpdatePercent={onChange}
|
||||
autoFocus={autoFocus}
|
||||
autoSelect={autoSelect}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<Input
|
||||
ref={ref}
|
||||
defaultValue={value || ''}
|
||||
placeholder="nothing"
|
||||
onEnter={e => onChange(e.target.value)}
|
||||
onBlur={e => onChange(e.target.value)}
|
||||
autoFocus={autoFocus}
|
||||
autoSelect={autoSelect}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// This makes the UI more resilient in case of faulty data
|
||||
if (multi && !Array.isArray(value)) {
|
||||
value = [];
|
||||
} else if (!multi && Array.isArray(value)) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// This makes the UI more resilient in case of faulty data
|
||||
if (multi && !Array.isArray(value)) {
|
||||
value = [];
|
||||
} else if (!multi && Array.isArray(value)) {
|
||||
return null;
|
||||
}
|
||||
const showPlaceholder = multi ? value.length === 0 : true;
|
||||
const autocompleteType = multi ? 'multi' : 'single';
|
||||
|
||||
const showPlaceholder = multi ? value.length === 0 : true;
|
||||
const autocompleteType = multi ? 'multi' : 'single';
|
||||
|
||||
let content;
|
||||
switch (type) {
|
||||
case 'id':
|
||||
switch (field) {
|
||||
case 'payee':
|
||||
content = (
|
||||
<PayeeAutocomplete
|
||||
type={autocompleteType}
|
||||
showMakeTransfer={false}
|
||||
openOnFocus={true}
|
||||
value={value}
|
||||
onSelect={onChange}
|
||||
inputProps={{
|
||||
inputRef,
|
||||
...(showPlaceholder ? { placeholder: 'nothing' } : null),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
|
||||
case 'account':
|
||||
content = (
|
||||
<AccountAutocomplete
|
||||
type={autocompleteType}
|
||||
value={value}
|
||||
openOnFocus={true}
|
||||
onSelect={onChange}
|
||||
inputProps={{
|
||||
inputRef,
|
||||
...(showPlaceholder ? { placeholder: 'nothing' } : null),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
|
||||
case 'category':
|
||||
content = (
|
||||
<CategoryAutocomplete
|
||||
type={autocompleteType}
|
||||
categoryGroups={categoryGroups}
|
||||
value={value}
|
||||
openOnFocus={true}
|
||||
onSelect={onChange}
|
||||
showHiddenCategories={false}
|
||||
inputProps={{
|
||||
inputRef,
|
||||
...(showPlaceholder ? { placeholder: 'nothing' } : null),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
|
||||
default:
|
||||
}
|
||||
break;
|
||||
|
||||
case 'saved':
|
||||
switch (field) {
|
||||
case 'saved':
|
||||
content = (
|
||||
<FilterAutocomplete
|
||||
type={autocompleteType}
|
||||
saved={saved}
|
||||
value={value}
|
||||
openOnFocus={true}
|
||||
onSelect={onChange}
|
||||
inputProps={{
|
||||
inputRef,
|
||||
...(showPlaceholder ? { placeholder: 'nothing' } : null),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case 'report':
|
||||
content = (
|
||||
<ReportAutocomplete
|
||||
type={autocompleteType}
|
||||
saved={savedReports}
|
||||
value={value}
|
||||
openOnFocus={true}
|
||||
onSelect={onChange}
|
||||
inputProps={{
|
||||
inputRef,
|
||||
...(showPlaceholder ? { placeholder: 'nothing' } : null),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
|
||||
default:
|
||||
}
|
||||
break;
|
||||
|
||||
case 'date':
|
||||
switch (subfield) {
|
||||
case 'month':
|
||||
content = (
|
||||
<Input
|
||||
inputRef={inputRef}
|
||||
defaultValue={value || ''}
|
||||
placeholder={getMonthYearFormat(dateFormat).toLowerCase()}
|
||||
onEnter={e => onChange(e.target.value)}
|
||||
onBlur={e => onChange(e.target.value)}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
|
||||
case 'year':
|
||||
content = (
|
||||
<Input
|
||||
inputRef={inputRef}
|
||||
defaultValue={value || ''}
|
||||
placeholder="yyyy"
|
||||
onEnter={e => onChange(e.target.value)}
|
||||
onBlur={e => onChange(e.target.value)}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
|
||||
default:
|
||||
if (value && value.frequency) {
|
||||
let content;
|
||||
switch (type) {
|
||||
case 'id':
|
||||
switch (field) {
|
||||
case 'payee':
|
||||
content = (
|
||||
<RecurringSchedulePicker
|
||||
<PayeeAutocomplete
|
||||
type={autocompleteType}
|
||||
showMakeTransfer={false}
|
||||
openOnFocus={true}
|
||||
value={value}
|
||||
buttonStyle={{ justifyContent: 'flex-start' }}
|
||||
onChange={onChange}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
content = (
|
||||
<DateSelect
|
||||
value={value}
|
||||
dateFormat={dateFormat}
|
||||
openOnFocus={false}
|
||||
inputRef={inputRef}
|
||||
inputProps={{ placeholder: dateFormat.toLowerCase() }}
|
||||
onSelect={onChange}
|
||||
inputProps={{
|
||||
ref,
|
||||
autoFocus,
|
||||
autoSelect,
|
||||
...(showPlaceholder ? { placeholder: 'nothing' } : null),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
break;
|
||||
break;
|
||||
|
||||
case 'boolean':
|
||||
content = (
|
||||
<Checkbox
|
||||
checked={value}
|
||||
value={value}
|
||||
onChange={() => onChange(!value)}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case 'account':
|
||||
content = (
|
||||
<AccountAutocomplete
|
||||
type={autocompleteType}
|
||||
value={value}
|
||||
openOnFocus={true}
|
||||
onSelect={onChange}
|
||||
inputProps={{
|
||||
ref,
|
||||
autoFocus,
|
||||
autoSelect,
|
||||
...(showPlaceholder ? { placeholder: 'nothing' } : null),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
|
||||
default:
|
||||
if (multi) {
|
||||
case 'category':
|
||||
content = (
|
||||
<CategoryAutocomplete
|
||||
type={autocompleteType}
|
||||
categoryGroups={categoryGroups}
|
||||
value={value}
|
||||
openOnFocus={true}
|
||||
onSelect={onChange}
|
||||
showHiddenCategories={false}
|
||||
inputProps={{
|
||||
ref,
|
||||
autoFocus,
|
||||
autoSelect,
|
||||
...(showPlaceholder ? { placeholder: 'nothing' } : null),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
|
||||
default:
|
||||
}
|
||||
break;
|
||||
|
||||
case 'saved':
|
||||
switch (field) {
|
||||
case 'saved':
|
||||
content = (
|
||||
<FilterAutocomplete
|
||||
type={autocompleteType}
|
||||
saved={saved}
|
||||
value={value}
|
||||
openOnFocus={true}
|
||||
onSelect={onChange}
|
||||
inputProps={{
|
||||
ref,
|
||||
autoFocus,
|
||||
autoSelect,
|
||||
...(showPlaceholder ? { placeholder: 'nothing' } : null),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case 'report':
|
||||
content = (
|
||||
<ReportAutocomplete
|
||||
type={autocompleteType}
|
||||
saved={savedReports}
|
||||
value={value}
|
||||
openOnFocus={true}
|
||||
onSelect={onChange}
|
||||
inputProps={{
|
||||
ref,
|
||||
autoFocus,
|
||||
autoSelect,
|
||||
...(showPlaceholder ? { placeholder: 'nothing' } : null),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
|
||||
default:
|
||||
}
|
||||
break;
|
||||
|
||||
case 'date':
|
||||
switch (subfield) {
|
||||
case 'month':
|
||||
content = (
|
||||
<Input
|
||||
ref={ref}
|
||||
defaultValue={value || ''}
|
||||
placeholder={getMonthYearFormat(dateFormat).toLowerCase()}
|
||||
onEnter={e => onChange(e.target.value)}
|
||||
onBlur={e => onChange(e.target.value)}
|
||||
autoFocus={autoFocus}
|
||||
autoSelect={autoSelect}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
|
||||
case 'year':
|
||||
content = (
|
||||
<Input
|
||||
ref={ref}
|
||||
defaultValue={value || ''}
|
||||
placeholder="yyyy"
|
||||
onEnter={e => onChange(e.target.value)}
|
||||
onBlur={e => onChange(e.target.value)}
|
||||
autoFocus={autoFocus}
|
||||
autoSelect={autoSelect}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
|
||||
default:
|
||||
if (value && value.frequency) {
|
||||
content = (
|
||||
<RecurringSchedulePicker
|
||||
value={value}
|
||||
buttonStyle={{ justifyContent: 'flex-start' }}
|
||||
onChange={onChange}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
content = (
|
||||
<DateSelect
|
||||
ref={ref}
|
||||
value={value}
|
||||
dateFormat={dateFormat}
|
||||
openOnFocus={false}
|
||||
inputProps={{
|
||||
autoFocus,
|
||||
autoSelect,
|
||||
placeholder: dateFormat.toLowerCase(),
|
||||
}}
|
||||
onSelect={onChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'boolean':
|
||||
content = (
|
||||
<Autocomplete
|
||||
type={autocompleteType}
|
||||
suggestions={[]}
|
||||
<Checkbox
|
||||
checked={value}
|
||||
value={value}
|
||||
inputProps={{ inputRef }}
|
||||
onSelect={onChange}
|
||||
onChange={() => onChange(!value)}
|
||||
/>
|
||||
);
|
||||
} else if (type === 'number') {
|
||||
content = getNumberInputByFormatType(numberFormatType);
|
||||
} else {
|
||||
content = (
|
||||
<Input
|
||||
inputRef={inputRef}
|
||||
defaultValue={value || ''}
|
||||
placeholder="nothing"
|
||||
onEnter={e => onChange(e.target.value)}
|
||||
onBlur={e => onChange(e.target.value)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
break;
|
||||
|
||||
return <View style={{ flex: 1, ...style }}>{content}</View>;
|
||||
}
|
||||
default:
|
||||
if (multi) {
|
||||
content = (
|
||||
<Autocomplete
|
||||
type={autocompleteType}
|
||||
suggestions={[]}
|
||||
value={value}
|
||||
inputProps={{ autoFocus, autoSelect, ref }}
|
||||
onSelect={onChange}
|
||||
/>
|
||||
);
|
||||
} else if (type === 'number') {
|
||||
content = getNumberInputByFormatType(numberFormatType);
|
||||
} else {
|
||||
content = (
|
||||
<Input
|
||||
ref={ref}
|
||||
defaultValue={value || ''}
|
||||
placeholder="nothing"
|
||||
onEnter={e => onChange(e.target.value)}
|
||||
onBlur={e => onChange(e.target.value)}
|
||||
autoFocus={autoFocus}
|
||||
autoSelect={autoSelect}
|
||||
/>
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
return <View style={{ flex: 1, ...style }}>{content}</View>;
|
||||
},
|
||||
);
|
||||
|
||||
GenericInput.displayName = 'GenericInput';
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import React, {
|
||||
type Ref,
|
||||
useRef,
|
||||
useState,
|
||||
useEffect,
|
||||
type FocusEventHandler,
|
||||
type FocusEvent,
|
||||
type CSSProperties,
|
||||
forwardRef,
|
||||
} from 'react';
|
||||
|
||||
import { evalArithmetic } from 'loot-core/src/shared/arithmetic';
|
||||
@@ -16,109 +16,113 @@ import { useFormat } from '../spreadsheet/useFormat';
|
||||
|
||||
type PercentInputProps = {
|
||||
id?: string;
|
||||
inputRef?: Ref<HTMLInputElement>;
|
||||
value?: number;
|
||||
onFocus?: FocusEventHandler<HTMLInputElement>;
|
||||
onBlur?: FocusEventHandler<HTMLInputElement>;
|
||||
onChangeValue?: (value: string) => void;
|
||||
onUpdatePercent?: (percent: number) => void;
|
||||
style?: CSSProperties;
|
||||
focused?: boolean;
|
||||
autoFocus?: boolean;
|
||||
autoSelect?: boolean;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
const clampToPercent = (value: number) => Math.max(Math.min(value, 100), 0);
|
||||
|
||||
export function PercentInput({
|
||||
id,
|
||||
inputRef,
|
||||
value: initialValue = 0,
|
||||
onFocus,
|
||||
onBlur,
|
||||
onChangeValue,
|
||||
onUpdatePercent,
|
||||
style,
|
||||
focused,
|
||||
disabled = false,
|
||||
}: PercentInputProps) {
|
||||
const format = useFormat();
|
||||
export const PercentInput = forwardRef<HTMLInputElement, PercentInputProps>(
|
||||
(
|
||||
{
|
||||
id,
|
||||
value: initialValue = 0,
|
||||
onFocus,
|
||||
onBlur,
|
||||
onChangeValue,
|
||||
onUpdatePercent,
|
||||
style,
|
||||
autoFocus,
|
||||
autoSelect,
|
||||
disabled = false,
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const format = useFormat();
|
||||
|
||||
const [value, setValue] = useState(() =>
|
||||
format(clampToPercent(initialValue), 'percentage'),
|
||||
);
|
||||
useEffect(() => {
|
||||
const clampedInitialValue = clampToPercent(initialValue);
|
||||
if (clampedInitialValue !== initialValue) {
|
||||
setValue(format(clampedInitialValue, 'percentage'));
|
||||
onUpdatePercent?.(clampedInitialValue);
|
||||
}
|
||||
}, [initialValue, onUpdatePercent, format]);
|
||||
const [value, setValue] = useState(() =>
|
||||
format(clampToPercent(initialValue), 'percentage'),
|
||||
);
|
||||
useEffect(() => {
|
||||
const clampedInitialValue = clampToPercent(initialValue);
|
||||
if (clampedInitialValue !== initialValue) {
|
||||
setValue(format(clampedInitialValue, 'percentage'));
|
||||
onUpdatePercent?.(clampedInitialValue);
|
||||
}
|
||||
}, [initialValue, onUpdatePercent, format]);
|
||||
|
||||
const ref = useRef<HTMLInputElement>(null);
|
||||
const mergedRef = useMergedRefs<HTMLInputElement>(inputRef, ref);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const mergedRef = useMergedRefs<HTMLInputElement>(inputRef, ref);
|
||||
|
||||
useEffect(() => {
|
||||
if (focused) {
|
||||
ref.current?.focus();
|
||||
}
|
||||
}, [focused]);
|
||||
function onSelectionChange() {
|
||||
if (!inputRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
function onSelectionChange() {
|
||||
if (!ref.current) {
|
||||
return;
|
||||
const selectionStart = inputRef.current.selectionStart;
|
||||
const selectionEnd = inputRef.current.selectionEnd;
|
||||
if (
|
||||
selectionStart === selectionEnd &&
|
||||
selectionStart !== null &&
|
||||
selectionStart >= inputRef.current.value.length
|
||||
) {
|
||||
inputRef.current.setSelectionRange(
|
||||
inputRef.current.value.length - 1,
|
||||
inputRef.current.value.length - 1,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const selectionStart = ref.current.selectionStart;
|
||||
const selectionEnd = ref.current.selectionEnd;
|
||||
if (
|
||||
selectionStart === selectionEnd &&
|
||||
selectionStart !== null &&
|
||||
selectionStart >= ref.current.value.length
|
||||
) {
|
||||
ref.current.setSelectionRange(
|
||||
ref.current.value.length - 1,
|
||||
ref.current.value.length - 1,
|
||||
function onInputTextChange(val: string) {
|
||||
const number = val.replace(/[^0-9.]/g, '');
|
||||
setValue(number ? format(number, 'percentage') : '');
|
||||
onChangeValue?.(number);
|
||||
}
|
||||
|
||||
function fireUpdate() {
|
||||
const clampedValue = clampToPercent(
|
||||
evalArithmetic(value.replace('%', '')),
|
||||
);
|
||||
onUpdatePercent?.(clampedValue);
|
||||
onInputTextChange(String(clampedValue));
|
||||
}
|
||||
}
|
||||
|
||||
function onInputTextChange(val: string) {
|
||||
const number = val.replace(/[^0-9.]/g, '');
|
||||
setValue(number ? format(number, 'percentage') : '');
|
||||
onChangeValue?.(number);
|
||||
}
|
||||
|
||||
function fireUpdate() {
|
||||
const clampedValue = clampToPercent(evalArithmetic(value.replace('%', '')));
|
||||
onUpdatePercent?.(clampedValue);
|
||||
onInputTextChange(String(clampedValue));
|
||||
}
|
||||
|
||||
function onInputAmountBlur(e: FocusEvent<HTMLInputElement>) {
|
||||
if (!ref.current?.contains(e.relatedTarget)) {
|
||||
fireUpdate();
|
||||
function onInputAmountBlur(e: FocusEvent<HTMLInputElement>) {
|
||||
if (!inputRef.current?.contains(e.relatedTarget)) {
|
||||
fireUpdate();
|
||||
}
|
||||
onBlur?.(e);
|
||||
}
|
||||
onBlur?.(e);
|
||||
}
|
||||
|
||||
return (
|
||||
<Input
|
||||
id={id}
|
||||
inputRef={mergedRef}
|
||||
inputMode="decimal"
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
focused={focused}
|
||||
style={{ flex: 1, alignItems: 'stretch', ...style }}
|
||||
onKeyUp={e => {
|
||||
if (e.key === 'Enter') {
|
||||
fireUpdate();
|
||||
}
|
||||
}}
|
||||
onChangeValue={onInputTextChange}
|
||||
onBlur={onInputAmountBlur}
|
||||
onFocus={onFocus}
|
||||
onSelect={onSelectionChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Input
|
||||
id={id}
|
||||
ref={mergedRef}
|
||||
inputMode="decimal"
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
autoFocus={autoFocus}
|
||||
autoSelect={autoSelect}
|
||||
style={{ flex: 1, alignItems: 'stretch', ...style }}
|
||||
onKeyUp={e => {
|
||||
if (e.key === 'Enter') {
|
||||
fireUpdate();
|
||||
}
|
||||
}}
|
||||
onChangeValue={onInputTextChange}
|
||||
onBlur={onInputAmountBlur}
|
||||
onFocus={onFocus}
|
||||
onSelect={onSelectionChange}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
PercentInput.displayName = 'PercentInput';
|
||||
|
||||
Reference in New Issue
Block a user