diff --git a/packages/desktop-client/src/components/autocomplete/PayeeAutocomplete.test.tsx b/packages/desktop-client/src/components/autocomplete/PayeeAutocomplete.test.tsx index 0bc3f317b3a..e248b8cf2f7 100644 --- a/packages/desktop-client/src/components/autocomplete/PayeeAutocomplete.test.tsx +++ b/packages/desktop-client/src/components/autocomplete/PayeeAutocomplete.test.tsx @@ -38,7 +38,7 @@ function makePayee(name: string, options?: { favorite: boolean }): PayeeEntity { return { id: name.toLowerCase() + '-id', name, - favorite: options?.favorite ?? false, + favorite: options?.favorite ? 1 : 0, transfer_acct: undefined, }; } diff --git a/packages/desktop-client/src/components/autocomplete/PayeeAutocomplete.tsx b/packages/desktop-client/src/components/autocomplete/PayeeAutocomplete.tsx index ea4d4791394..e567804f658 100644 --- a/packages/desktop-client/src/components/autocomplete/PayeeAutocomplete.tsx +++ b/packages/desktop-client/src/components/autocomplete/PayeeAutocomplete.tsx @@ -316,7 +316,7 @@ export function PayeeAutocomplete({ return filteredSuggestions; } - return [{ id: 'new', favorite: false, name: '' }, ...filteredSuggestions]; + return [{ id: 'new', favorite: 0, name: '' }, ...filteredSuggestions]; }, [commonPayees, payees, focusTransferPayees, accounts, hasPayeeInput]); const dispatch = useDispatch(); diff --git a/packages/desktop-client/src/components/modals/LoadBackupModal.jsx b/packages/desktop-client/src/components/modals/LoadBackupModal.tsx similarity index 71% rename from packages/desktop-client/src/components/modals/LoadBackupModal.jsx rename to packages/desktop-client/src/components/modals/LoadBackupModal.tsx index 84c8f68ff56..b40b659d087 100644 --- a/packages/desktop-client/src/components/modals/LoadBackupModal.jsx +++ b/packages/desktop-client/src/components/modals/LoadBackupModal.tsx @@ -1,7 +1,8 @@ -import React, { Component, useState, useEffect } from 'react'; +import React, { useState, useEffect } from 'react'; import { useDispatch } from 'react-redux'; import { loadBackup, makeBackup } from 'loot-core/client/actions'; +import { type Backup } from 'loot-core/server/backups'; import { send, listen, unlisten } from 'loot-core/src/platform/client/fetch'; import { useMetadataPref } from '../../hooks/useMetadataPref'; @@ -13,48 +14,47 @@ import { Text } from '../common/Text'; import { View } from '../common/View'; import { Row, Cell } from '../table'; -class BackupTable extends Component { - state = { hoveredBackup: null }; +type BackupTableProps = { + backups: Backup[]; + onSelect: (backupId: string) => void; +}; - onHover = id => { - this.setState({ hoveredBackup: id }); - }; - - render() { - const { backups, onSelect } = this.props; - const { hoveredBackup } = this.state; - - return ( - this.onHover(null)} - > - {backups.map((backup, idx) => ( - this.onHover(backup.id)} - onClick={() => onSelect(backup.id)} - style={{ cursor: 'pointer' }} - > - - - ))} - - ); - } +function BackupTable({ backups, onSelect }: BackupTableProps) { + return ( + + {backups.map((backup, idx) => ( + onSelect(backup.id)} + style={{ cursor: 'pointer' }} + > + + + ))} + + ); } -export function LoadBackupModal({ budgetId, watchUpdates, backupDisabled }) { +type LoadBackupModalProps = { + budgetId: string; + watchUpdates: boolean; + backupDisabled: boolean; +}; + +export function LoadBackupModal({ + budgetId, + watchUpdates, + backupDisabled, +}: LoadBackupModalProps) { const dispatch = useDispatch(); - const [backups, setBackups] = useState([]); + const [backups, setBackups] = useState([]); const [prefsBudgetId] = useMetadataPref('id'); - const budgetIdToLoad = budgetId || prefsBudgetId; + const budgetIdToLoad = budgetId ?? prefsBudgetId; useEffect(() => { send('backups-get', { id: budgetIdToLoad }).then(setBackups); @@ -63,12 +63,16 @@ export function LoadBackupModal({ budgetId, watchUpdates, backupDisabled }) { useEffect(() => { if (watchUpdates) { listen('backups-updated', setBackups); - return () => unlisten('backups-updated', setBackups); + return () => unlisten('backups-updated'); } }, [watchUpdates]); - const latestBackup = backups.find(backup => backup.isLatest); - const previousBackups = backups.filter(backup => !backup.isLatest); + const latestBackup = backups.find(backup => + 'isLatest' in backup ? backup.isLatest : false, + ); + const previousBackups = backups.filter( + backup => !('isLatest' in backup ? backup.isLatest : false), + ); return ( diff --git a/packages/desktop-client/src/components/payees/ManagePayees.jsx b/packages/desktop-client/src/components/payees/ManagePayees.jsx deleted file mode 100644 index 2843bc644ba..00000000000 --- a/packages/desktop-client/src/components/payees/ManagePayees.jsx +++ /dev/null @@ -1,355 +0,0 @@ -import { - forwardRef, - useState, - useEffect, - useLayoutEffect, - useRef, - useMemo, - useCallback, - useImperativeHandle, -} from 'react'; - -import memoizeOne from 'memoize-one'; - -import { getNormalisedString } from 'loot-core/src/shared/normalisation'; -import { groupById } from 'loot-core/src/shared/util'; - -import { - useSelected, - SelectedProvider, - useSelectedDispatch, - useSelectedItems, -} from '../../hooks/useSelected'; -import { useStableCallback } from '../../hooks/useStableCallback'; -import { SvgExpandArrow } from '../../icons/v0'; -import { theme } from '../../style'; -import { Button } from '../common/Button2'; -import { Popover } from '../common/Popover'; -import { Search } from '../common/Search'; -import { View } from '../common/View'; -import { TableHeader, Cell, SelectCell, useTableNavigator } from '../table'; - -import { PayeeMenu } from './PayeeMenu'; -import { PayeeTable } from './PayeeTable'; - -const getPayeesById = memoizeOne(payees => groupById(payees)); - -function plural(count, singleText, pluralText) { - return count === 1 ? singleText : pluralText; -} - -function PayeeTableHeader() { - const borderColor = theme.tableborder; - const dispatchSelected = useSelectedDispatch(); - const selectedItems = useSelectedItems(); - - return ( - - - 0} - onSelect={e => - dispatchSelected({ type: 'select-all', isRangeSelect: e.shiftKey }) - } - /> - - - - ); -} - -function EmptyMessage({ text, style }) { - return ( - - {text} - - ); -} - -export const ManagePayees = forwardRef( - ( - { - payees, - ruleCounts, - orphanedPayees, - categoryGroups, - initialSelectedIds, - ruleActions, - onBatchChange, - onViewRules, - onCreateRule, - ...props - }, - ref, - ) => { - const [highlightedRows, setHighlightedRows] = useState(null); - const [filter, setFilter] = useState(''); - const table = useRef(null); - const scrollTo = useRef(null); - const triggerRef = useRef(null); - const resetAnimation = useRef(false); - const [orphanedOnly, setOrphanedOnly] = useState(false); - - const filteredPayees = useMemo(() => { - let filtered = payees; - if (filter) { - filtered = filtered.filter(p => - getNormalisedString(p.name).includes(getNormalisedString(filter)), - ); - } - if (orphanedOnly) { - filtered = filtered.filter(p => - orphanedPayees.map(o => o.id).includes(p.id), - ); - } - return filtered; - }, [payees, filter, orphanedOnly]); - - const selected = useSelected('payees', filteredPayees, initialSelectedIds); - - function applyFilter(f) { - if (filter !== f) { - table.current?.setRowAnimation(false); - setFilter(f); - resetAnimation.current = true; - } - } - - function _scrollTo(id) { - applyFilter(''); - scrollTo.current = id; - } - - useEffect(() => { - if (resetAnimation.current) { - // Very annoying, for some reason it's as if the table doesn't - // actually update its contents until the next tick or - // something? The table keeps being animated without this - setTimeout(() => { - table.current?.setRowAnimation(true); - }, 0); - resetAnimation.current = false; - } - }); - - useImperativeHandle(ref, () => ({ - selectRows: (ids, scroll) => { - tableNavigator.onEdit(null); - selected.dispatch({ type: 'select-all', ids }); - setHighlightedRows(null); - - if (scroll && ids.length > 0) { - _scrollTo(ids[0]); - } - }, - - highlightRow: id => { - tableNavigator.onEdit(null); - setHighlightedRows(new Set([id])); - _scrollTo(id); - }, - })); - - // `highlightedRows` should only ever be true once, and we - // immediately discard it. This triggers an animation. - useEffect(() => { - if (highlightedRows) { - setHighlightedRows(null); - } - }, [highlightedRows]); - - useLayoutEffect(() => { - if (scrollTo.current) { - table.current.scrollTo(scrollTo.current); - scrollTo.current = null; - } - }); - - const onUpdate = useStableCallback((id, name, value) => { - const payee = payees.find(p => p.id === id); - if (payee[name] !== value) { - onBatchChange({ updated: [{ id, [name]: value }] }); - } - }); - - const getSelectableIds = useCallback(() => { - return filteredPayees.filter(p => p.transfer_acct == null).map(p => p.id); - }, [filteredPayees]); - - function onDelete() { - onBatchChange({ deleted: [...selected.items].map(id => ({ id })) }); - selected.dispatch({ type: 'select-none' }); - } - - function onFavorite() { - const allFavorited = [...selected.items] - .map(id => payeesById[id].favorite) - .every(f => f === 1); - if (allFavorited) { - onBatchChange({ - updated: [...selected.items].map(id => ({ id, favorite: 0 })), - }); - } else { - onBatchChange({ - updated: [...selected.items].map(id => ({ id, favorite: 1 })), - }); - } - selected.dispatch({ type: 'select-none' }); - } - - async function onMerge() { - const ids = [...selected.items]; - await props.onMerge(ids); - - tableNavigator.onEdit(ids[0], 'name'); - selected.dispatch({ type: 'select-none' }); - _scrollTo(ids[0]); - } - - const buttonsDisabled = selected.items.size === 0; - - const tableNavigator = useTableNavigator(filteredPayees, item => - ['select', 'name', 'rule-count'].filter(name => { - switch (name) { - case 'select': - return item.transfer_acct == null; - default: - return true; - } - }), - ); - - const payeesById = getPayeesById(payees); - - const [menuOpen, setMenuOpen] = useState(false); - - return ( - - - - - - setMenuOpen(false)} - > - setMenuOpen(false)} - onDelete={onDelete} - onMerge={onMerge} - onFavorite={onFavorite} - /> - - - - {(orphanedOnly || - (orphanedPayees && orphanedPayees.length > 0)) && ( - - )} - - - - - - - - - {filteredPayees.length === 0 ? ( - - ) : ( - - )} - - - - ); - }, -); - -ManagePayees.displayName = 'ManagePayees'; diff --git a/packages/desktop-client/src/components/payees/ManagePayees.tsx b/packages/desktop-client/src/components/payees/ManagePayees.tsx new file mode 100644 index 00000000000..b9c37160d63 --- /dev/null +++ b/packages/desktop-client/src/components/payees/ManagePayees.tsx @@ -0,0 +1,284 @@ +import { + useState, + useRef, + useMemo, + useCallback, + type ComponentProps, +} from 'react'; + +import memoizeOne from 'memoize-one'; + +import { getNormalisedString } from 'loot-core/src/shared/normalisation'; +import { groupById } from 'loot-core/src/shared/util'; +import { type PayeeEntity } from 'loot-core/types/models'; + +import { + useSelected, + SelectedProvider, + useSelectedDispatch, + useSelectedItems, +} from '../../hooks/useSelected'; +import { SvgExpandArrow } from '../../icons/v0'; +import { theme } from '../../style'; +import { Button } from '../common/Button2'; +import { Popover } from '../common/Popover'; +import { Search } from '../common/Search'; +import { View } from '../common/View'; +import { TableHeader, Cell, SelectCell } from '../table'; + +import { PayeeMenu } from './PayeeMenu'; +import { PayeeTable } from './PayeeTable'; + +const getPayeesById = memoizeOne((payees: PayeeEntity[]) => groupById(payees)); + +function plural(count: number, singleText: string, pluralText: string) { + return count === 1 ? singleText : pluralText; +} + +function PayeeTableHeader() { + const dispatchSelected = useSelectedDispatch(); + const selectedItems = useSelectedItems(); + + return ( + + + 0} + onSelect={e => + dispatchSelected({ type: 'select-all', isRangeSelect: e.shiftKey }) + } + /> + + + + ); +} + +type ManagePayeesProps = { + payees: PayeeEntity[]; + ruleCounts: ComponentProps['ruleCounts']; + orphanedPayees: PayeeEntity[]; + initialSelectedIds: string[]; + onBatchChange: (arg: { + deleted?: Array<{ id: string }>; + updated?: Array<{ id: string; name?: string; favorite?: 0 | 1 }>; + }) => void; + onViewRules: ComponentProps['onViewRules']; + onCreateRule: ComponentProps['onCreateRule']; + onMerge: (ids: string[]) => Promise; +}; + +export const ManagePayees = ({ + payees, + ruleCounts, + orphanedPayees, + initialSelectedIds, + onBatchChange, + onViewRules, + onCreateRule, + ...props +}: ManagePayeesProps) => { + const [filter, setFilter] = useState(''); + const table = useRef(null); + const triggerRef = useRef(null); + const [orphanedOnly, setOrphanedOnly] = useState(false); + + const filteredPayees = useMemo(() => { + let filtered = payees; + if (filter) { + filtered = filtered.filter(p => + getNormalisedString(p.name).includes(getNormalisedString(filter)), + ); + } + if (orphanedOnly) { + filtered = filtered.filter(p => + orphanedPayees.map(o => o.id).includes(p.id), + ); + } + return filtered; + }, [payees, filter, orphanedOnly, orphanedPayees]); + + const selected = useSelected('payees', filteredPayees, initialSelectedIds); + + function applyFilter(f: string) { + if (filter !== f) { + setFilter(f); + } + } + + const onUpdate = useCallback( + ( + id: PayeeEntity['id'], + name: T, + value: PayeeEntity[T], + ) => { + const payee = payees.find(p => p.id === id); + if (payee && payee[name] !== value) { + onBatchChange({ updated: [{ id, [name]: value }] }); + } + }, + [payees, onBatchChange], + ); + + const getSelectableIds = useCallback(() => { + return Promise.resolve( + filteredPayees.filter(p => p.transfer_acct == null).map(p => p.id), + ); + }, [filteredPayees]); + + function onDelete() { + onBatchChange({ deleted: [...selected.items].map(id => ({ id })) }); + selected.dispatch({ type: 'select-none' }); + } + + function onFavorite() { + const allFavorited = [...selected.items] + .map(id => payeesById[id].favorite) + .every(f => f === 1); + if (allFavorited) { + onBatchChange({ + updated: [...selected.items].map(id => ({ id, favorite: 0 })), + }); + } else { + onBatchChange({ + updated: [...selected.items].map(id => ({ id, favorite: 1 })), + }); + } + selected.dispatch({ type: 'select-none' }); + } + + async function onMerge() { + const ids = [...selected.items]; + await props.onMerge(ids); + + selected.dispatch({ type: 'select-none' }); + } + + const buttonsDisabled = selected.items.size === 0; + + const payeesById = getPayeesById(payees); + + const [menuOpen, setMenuOpen] = useState(false); + + return ( + + + + + + setMenuOpen(false)} + > + setMenuOpen(false)} + onDelete={onDelete} + onMerge={onMerge} + onFavorite={onFavorite} + /> + + + + {(orphanedOnly || (orphanedPayees && orphanedPayees.length > 0)) && ( + + )} + + + + + + + + + {filteredPayees.length === 0 ? ( + + No payees + + ) : ( + + )} + + + + ); +}; diff --git a/packages/desktop-client/src/components/payees/ManagePayeesWithData.jsx b/packages/desktop-client/src/components/payees/ManagePayeesWithData.jsx index 55ea8bc78ae..423a41132d3 100644 --- a/packages/desktop-client/src/components/payees/ManagePayeesWithData.jsx +++ b/packages/desktop-client/src/components/payees/ManagePayeesWithData.jsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useRef } from 'react'; +import React, { useState, useEffect } from 'react'; import { useSelector } from 'react-redux'; import { send, listen } from 'loot-core/src/platform/client/fetch'; @@ -21,7 +21,6 @@ export function ManagePayeesWithData({ initialSelectedIds }) { const [payees, setPayees] = useState(initialPayees); const [ruleCounts, setRuleCounts] = useState({ value: new Map() }); const [orphans, setOrphans] = useState({ value: new Map() }); - const payeesRef = useRef(); async function refetchOrphanedPayees() { const orphs = await send('payees-get-orphaned'); @@ -120,7 +119,6 @@ export function ManagePayeesWithData({ initialSelectedIds }) { return ( ; onDelete: () => void; onMerge: () => Promise; - onFavorite: () => Promise; + onFavorite: () => void; onClose: () => void; }; diff --git a/packages/desktop-client/src/components/payees/PayeeTable.tsx b/packages/desktop-client/src/components/payees/PayeeTable.tsx index 3efbaaad8e2..08542439935 100644 --- a/packages/desktop-client/src/components/payees/PayeeTable.tsx +++ b/packages/desktop-client/src/components/payees/PayeeTable.tsx @@ -12,7 +12,7 @@ import { type PayeeEntity } from 'loot-core/src/types/models'; import { useSelectedItems } from '../../hooks/useSelected'; import { View } from '../common/View'; -import { Table, type TableNavigator } from '../table'; +import { Table } from '../table'; import { PayeeTableRow } from './PayeeTableRow'; @@ -23,7 +23,6 @@ type PayeeWithId = PayeeEntity & Required>; type PayeeTableProps = { payees: PayeeWithId[]; ruleCounts: Map; - navigator: TableNavigator; } & Pick< ComponentProps, 'onUpdate' | 'onViewRules' | 'onCreateRule' @@ -32,53 +31,46 @@ type PayeeTableProps = { export const PayeeTable = forwardRef< ComponentRef>, PayeeTableProps ->( - ( - { payees, ruleCounts, navigator, onUpdate, onViewRules, onCreateRule }, - ref, - ) => { - const [hovered, setHovered] = useState(null); - const selectedItems = useSelectedItems(); +>(({ payees, ruleCounts, onUpdate, onViewRules, onCreateRule }, ref) => { + const [hovered, setHovered] = useState(null); + const selectedItems = useSelectedItems(); - useLayoutEffect(() => { - const firstSelected = [...selectedItems][0] as string; - if (typeof ref !== 'function') { - ref.current.scrollTo(firstSelected, 'center'); - } - navigator.onEdit(firstSelected, 'select'); - }, []); + useLayoutEffect(() => { + const firstSelected = [...selectedItems][0] as string; + if (typeof ref !== 'function') { + ref.current.scrollTo(firstSelected, 'center'); + } + }, []); - const onHover = useCallback(id => { - setHovered(id); - }, []); + const onHover = useCallback(id => { + setHovered(id); + }, []); - return ( - setHovered(null)}> - { - return ( - - ); - }} - /> - - ); - }, -); + return ( + setHovered(null)}> +
{ + return ( + + ); + }} + /> + + ); +}); PayeeTable.displayName = 'PayeeTable'; diff --git a/packages/desktop-client/src/components/payees/PayeeTableRow.tsx b/packages/desktop-client/src/components/payees/PayeeTableRow.tsx index cd6fe5aeb2e..b716d04bc9d 100644 --- a/packages/desktop-client/src/components/payees/PayeeTableRow.tsx +++ b/packages/desktop-client/src/components/payees/PayeeTableRow.tsx @@ -71,10 +71,10 @@ type PayeeTableRowProps = { focusedField: string; onHover?: (id: PayeeEntity['id']) => void; onEdit: (id: PayeeEntity['id'], field: string) => void; - onUpdate: ( + onUpdate: ( id: PayeeEntity['id'], - field: EditablePayeeFields, - value: unknown, + field: T, + value: PayeeEntity[T], ) => void; onViewRules: (id: PayeeEntity['id']) => void; onCreateRule: (id: PayeeEntity['id']) => void; diff --git a/packages/desktop-client/src/components/table.tsx b/packages/desktop-client/src/components/table.tsx index 655c8f954e9..b2ee1bb96e9 100644 --- a/packages/desktop-client/src/components/table.tsx +++ b/packages/desktop-client/src/components/table.tsx @@ -1212,7 +1212,7 @@ export const Table = forwardRef( // @ts-expect-error fix me Table.displayName = 'Table'; -export type TableNavigator = { +type TableNavigator = { onEdit: (id: T['id'], field?: string) => void; editingId: T['id']; focusedField: string; diff --git a/packages/desktop-client/src/components/transactions/TransactionsTable.test.jsx b/packages/desktop-client/src/components/transactions/TransactionsTable.test.jsx index a9644f97704..d8d58265f83 100644 --- a/packages/desktop-client/src/components/transactions/TransactionsTable.test.jsx +++ b/packages/desktop-client/src/components/transactions/TransactionsTable.test.jsx @@ -40,20 +40,20 @@ const payees = [ { id: 'bob-id', name: 'Bob', - favorite: true, + favorite: 1, transfer_acct: null, category: null, }, { id: 'alice-id', name: 'Alice', - favorite: true, + favorite: 1, transfer_acct: null, category: null, }, { id: 'guy', - favorite: false, + favorite: 0, transfer_acct: null, category: null, name: 'This guy on the side of the road', diff --git a/packages/desktop-client/src/hooks/useStableCallback.ts b/packages/desktop-client/src/hooks/useStableCallback.ts deleted file mode 100644 index 9a09968750e..00000000000 --- a/packages/desktop-client/src/hooks/useStableCallback.ts +++ /dev/null @@ -1,16 +0,0 @@ -// @ts-strict-ignore -import { useRef, useLayoutEffect, useCallback } from 'react'; - -type UseStableCallbackArg = (...args: unknown[]) => unknown; - -export function useStableCallback(callback: UseStableCallbackArg) { - const callbackRef = useRef(); - const memoCallback = useCallback( - (...args) => callbackRef.current && callbackRef.current(...args), - [], - ); - useLayoutEffect(() => { - callbackRef.current = callback; - }); - return memoCallback; -} diff --git a/packages/loot-core/src/shared/util.ts b/packages/loot-core/src/shared/util.ts index 43dabd75372..04e33702c93 100644 --- a/packages/loot-core/src/shared/util.ts +++ b/packages/loot-core/src/shared/util.ts @@ -148,7 +148,9 @@ export function diffItems( return { added, updated, deleted }; } -export function groupById(data: T[]) { +export function groupById( + data: T[], +): Record { const res: { [key: string]: T } = {}; for (let i = 0; i < data.length; i++) { const item = data[i]; diff --git a/packages/loot-core/src/types/models/payee.d.ts b/packages/loot-core/src/types/models/payee.d.ts index f55f04aa49f..37e3f77bb0e 100644 --- a/packages/loot-core/src/types/models/payee.d.ts +++ b/packages/loot-core/src/types/models/payee.d.ts @@ -4,6 +4,6 @@ export interface PayeeEntity { id: string; name: string; transfer_acct?: AccountEntity['id']; - favorite?: boolean; + favorite?: 1 | 0; tombstone?: boolean; } diff --git a/packages/loot-core/src/types/server-events.d.ts b/packages/loot-core/src/types/server-events.d.ts index c53004a9cab..cd0dabf8514 100644 --- a/packages/loot-core/src/types/server-events.d.ts +++ b/packages/loot-core/src/types/server-events.d.ts @@ -1,7 +1,8 @@ +import { type Backup } from '../server/backups'; import { type UndoState } from '../server/undo'; export interface ServerEvents { - 'backups-updated': unknown; + 'backups-updated': Backup[]; 'cells-changed': Array<{ name }>; 'fallback-write-error': unknown; 'finish-import': unknown; diff --git a/upcoming-release-notes/3507.md b/upcoming-release-notes/3507.md new file mode 100644 index 00000000000..f19343f3440 --- /dev/null +++ b/upcoming-release-notes/3507.md @@ -0,0 +1,6 @@ +--- +category: Maintenance +authors: [MatissJanis] +--- + +TypeScript: migrated `ManagePayees` and `LoadBackupModal`.