From 87ee246d593c82ef57a091b6caa2ba82adbb2f3e Mon Sep 17 00:00:00 2001 From: Matiss Janis Aboltins Date: Wed, 25 Sep 2024 22:57:52 +0100 Subject: [PATCH] :recycle: (typescript) migrated ManagePayees and LoadBackupModal files --- .../autocomplete/PayeeAutocomplete.tsx | 2 +- .../src/components/modals/LoadBackupModal.tsx | 60 ++- .../src/components/payees/ManagePayees.jsx | 355 ------------------ .../src/components/payees/ManagePayees.tsx | 290 ++++++++++++++ .../payees/ManagePayeesWithData.jsx | 4 +- .../src/components/payees/PayeeMenu.tsx | 2 +- .../src/components/payees/PayeeTable.tsx | 86 ++--- .../desktop-client/src/components/table.tsx | 2 +- .../src/hooks/useStableCallback.ts | 16 - packages/loot-core/src/shared/util.ts | 4 +- .../loot-core/src/types/models/payee.d.ts | 2 +- upcoming-release-notes/3507.md | 6 + 12 files changed, 364 insertions(+), 465 deletions(-) delete mode 100644 packages/desktop-client/src/components/payees/ManagePayees.jsx create mode 100644 packages/desktop-client/src/components/payees/ManagePayees.tsx delete mode 100644 packages/desktop-client/src/hooks/useStableCallback.ts create mode 100644 upcoming-release-notes/3507.md diff --git a/packages/desktop-client/src/components/autocomplete/PayeeAutocomplete.tsx b/packages/desktop-client/src/components/autocomplete/PayeeAutocomplete.tsx index 55b452ad5f7..4065e119676 100644 --- a/packages/desktop-client/src/components/autocomplete/PayeeAutocomplete.tsx +++ b/packages/desktop-client/src/components/autocomplete/PayeeAutocomplete.tsx @@ -309,7 +309,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.tsx b/packages/desktop-client/src/components/modals/LoadBackupModal.tsx index 2d2c867f4e3..b40b659d087 100644 --- a/packages/desktop-client/src/components/modals/LoadBackupModal.tsx +++ 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'; @@ -12,50 +13,31 @@ import { Modal, ModalCloseButton, ModalHeader } from '../common/Modal'; import { Text } from '../common/Text'; import { View } from '../common/View'; import { Row, Cell } from '../table'; -import { type Backup } from 'loot-core/server/backups'; type BackupTableProps = { backups: Backup[]; onSelect: (backupId: string) => void; }; -type BackupTableState = { - hoveredBackup: null | string; -}; - -class BackupTable extends Component { - state = { hoveredBackup: null }; - - onHover = (id: BackupTableState['hoveredBackup']) => { - 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' }} + > + + + ))} + + ); } type LoadBackupModalProps = { 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..0b9ca8bae27 --- /dev/null +++ b/packages/desktop-client/src/components/payees/ManagePayees.tsx @@ -0,0 +1,290 @@ +import { + useState, + useRef, + useMemo, + useCallback, + type ComponentProps, +} from 'react'; + +import { type CSSProperties } from 'glamor'; +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 EmptyMessageProps = { + text: string; + style: CSSProperties; +}; + +function EmptyMessage({ text, style }: EmptyMessageProps) { + return ( + + {text} + + ); +} + +type ManagePayeesProps = { + payees: PayeeEntity[]; + ruleCounts: ComponentProps['ruleCounts']; + orphanedPayees: PayeeEntity[]; + initialSelectedIds: string[]; + onBatchChange: (arg: { deleted?: unknown[]; updated?: unknown[] }) => 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: 'name' | 'favorite', value: unknown) => { + 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 ? ( + + ) : ( + + )} + + + + ); +}; 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/table.tsx b/packages/desktop-client/src/components/table.tsx index 60f38a0ee72..086d12c0c4f 100644 --- a/packages/desktop-client/src/components/table.tsx +++ b/packages/desktop-client/src/components/table.tsx @@ -1217,7 +1217,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/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/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`.