From dc47c2d9b86071a12045321b0a1ec77a6a86407c Mon Sep 17 00:00:00 2001 From: Mohamed Muhsin <62111075+muhsinkamil@users.noreply.github.com> Date: Sun, 17 Sep 2023 20:05:32 +0200 Subject: [PATCH] [refactor] Migrate Schedules Table to typescript (#1691) --- .../src/components/common/Menu.tsx | 2 +- .../components/schedules/SchedulesTable.tsx | 187 ++++++++++++------ .../src/components/schedules/StatusBadge.tsx | 112 +++++------ .../src/components/schedules/index.tsx | 12 +- .../desktop-client/src/components/table.tsx | 17 +- .../src/client/data-hooks/accounts.tsx | 19 +- .../src/client/data-hooks/payees.tsx | 19 +- .../src/client/data-hooks/schedules.tsx | 21 +- packages/loot-core/src/shared/util.ts | 2 +- .../loot-core/src/types/models/schedule.d.ts | 20 +- upcoming-release-notes/1691.md | 6 + 11 files changed, 256 insertions(+), 161 deletions(-) create mode 100644 upcoming-release-notes/1691.md diff --git a/packages/desktop-client/src/components/common/Menu.tsx b/packages/desktop-client/src/components/common/Menu.tsx index 84ced272fbf..43aabf4dce9 100644 --- a/packages/desktop-client/src/components/common/Menu.tsx +++ b/packages/desktop-client/src/components/common/Menu.tsx @@ -37,7 +37,7 @@ type MenuProps = { header?: ReactNode; footer?: ReactNode; items: Array; - onMenuSelect; + onMenuSelect: (itemName: MenuItem['name']) => void; }; export default function Menu({ diff --git a/packages/desktop-client/src/components/schedules/SchedulesTable.tsx b/packages/desktop-client/src/components/schedules/SchedulesTable.tsx index e95348718ef..8bfb8062e2f 100644 --- a/packages/desktop-client/src/components/schedules/SchedulesTable.tsx +++ b/packages/desktop-client/src/components/schedules/SchedulesTable.tsx @@ -1,11 +1,16 @@ -import React, { useState, useMemo } from 'react'; +import React, { useState, useMemo, type CSSProperties } from 'react'; import { useSelector } from 'react-redux'; import { useCachedAccounts } from 'loot-core/src/client/data-hooks/accounts'; import { useCachedPayees } from 'loot-core/src/client/data-hooks/payees'; -import * as monthUtils from 'loot-core/src/shared/months'; +import { + type ScheduleStatusType, + type ScheduleStatuses, +} from 'loot-core/src/client/data-hooks/schedules'; +import { format as monthUtilFormat } from 'loot-core/src/shared/months'; import { getScheduledAmount } from 'loot-core/src/shared/schedules'; import { integerToCurrency } from 'loot-core/src/shared/util'; +import { type ScheduleEntity } from 'loot-core/src/types/models'; import DotsHorizontalTriple from '../../icons/v1/DotsHorizontalTriple'; import Check from '../../icons/v2/Check'; @@ -21,10 +26,73 @@ import DisplayId from '../util/DisplayId'; import { StatusBadge } from './StatusBadge'; -export let ROW_HEIGHT = 43; +type SchedulesTableProps = { + schedules: ScheduleEntity[]; + statuses: ScheduleStatuses; + filter: string; + allowCompleted: boolean; + onSelect: (id: ScheduleEntity['id']) => void; + onAction: (actionName: ScheduleItemAction, id: ScheduleEntity['id']) => void; + style: CSSProperties; + minimal?: boolean; + tableStyle?: CSSProperties; +}; -function OverflowMenu({ schedule, status, onAction }) { - let [open, setOpen] = useState(false); +type CompletedScheduleItem = { id: 'show-completed' }; +type SchedulesTableItem = ScheduleEntity | CompletedScheduleItem; + +export type ScheduleItemAction = + | 'post-transaction' + | 'skip' + | 'complete' + | 'restart' + | 'delete'; + +export const ROW_HEIGHT = 43; + +function OverflowMenu({ + schedule, + status, + onAction, +}: { + schedule: ScheduleEntity; + status: ScheduleStatusType; + onAction: SchedulesTableProps['onAction']; +}) { + const [open, setOpen] = useState(false); + + const getMenuItems = () => { + const menuItems: { name: ScheduleItemAction; text: string }[] = []; + + if (status === 'due') { + menuItems.push({ + name: 'post-transaction', + text: 'Post transaction', + }); + } + + if (status === 'completed') { + menuItems.push({ + name: 'restart', + text: 'Restart', + }); + } else { + menuItems.push( + { + name: 'skip', + text: 'Skip next date', + }, + { + name: 'complete', + text: 'Complete', + }, + ); + } + + menuItems.push({ name: 'delete', text: 'Delete' }); + + return menuItems; + }; return ( @@ -49,23 +117,11 @@ function OverflowMenu({ schedule, status, onAction }) { onClose={() => setOpen(false)} > { + onMenuSelect={(name: ScheduleItemAction) => { onAction(name, schedule.id); setOpen(false); }} - items={[ - status === 'due' && { - name: 'post-transaction', - text: 'Post transaction', - }, - ...(schedule.completed - ? [{ name: 'restart', text: 'Restart' }] - : [ - { name: 'skip', text: 'Skip next date' }, - { name: 'complete', text: 'Complete' }, - ]), - { name: 'delete', text: 'Delete' }, - ]} + items={getMenuItems()} /> )} @@ -73,10 +129,16 @@ function OverflowMenu({ schedule, status, onAction }) { ); } -export function ScheduleAmountCell({ amount, op }) { - let num = getScheduledAmount(amount); - let str = integerToCurrency(Math.abs(num || 0)); - let isApprox = op === 'isapprox' || op === 'isbetween'; +export function ScheduleAmountCell({ + amount, + op, +}: { + amount: ScheduleEntity['_amount']; + op: ScheduleEntity['_amountOp']; +}) { + const num = getScheduledAmount(amount); + const str = integerToCurrency(Math.abs(num || 0)); + const isApprox = op === 'isapprox' || op === 'isbetween'; return ( { +}: SchedulesTableProps) { + const dateFormat = useSelector(state => { return state.prefs.local.dateFormat || 'MM/dd/yyyy'; }); - let [showCompleted, setShowCompleted] = useState(false); + const [showCompleted, setShowCompleted] = useState(false); - let payees = useCachedPayees(); - let accounts = useCachedAccounts(); + const payees = useCachedPayees(); + const accounts = useCachedAccounts(); - let filteredSchedules = useMemo(() => { + const filteredSchedules = useMemo(() => { if (!filter) { return schedules; } - const filterIncludes = str => + const filterIncludes = (str: string) => str ? str.toLowerCase().includes(filter.toLowerCase()) || filter.toLowerCase().includes(str.toLowerCase()) : false; return schedules.filter(schedule => { - let payee = payees.find(p => schedule._payee === p.id); - let account = accounts.find(a => schedule._account === a.id); - let amount = getScheduledAmount(schedule._amount); - let amountStr = + const payee = payees.find(p => schedule._payee === p.id); + const account = accounts.find(a => schedule._account === a.id); + const amount = getScheduledAmount(schedule._amount); + const amountStr = (schedule._amountOp === 'isapprox' || schedule._amountOp === 'isbetween' ? '~' : '') + (amount > 0 ? '+' : '') + integerToCurrency(Math.abs(amount || 0)); - let dateStr = schedule.next_date - ? monthUtils.format(schedule.next_date, dateFormat) + const dateStr = schedule.next_date + ? monthUtilFormat(schedule.next_date, dateFormat) : null; return ( @@ -174,26 +236,29 @@ export function SchedulesTable({ }); }, [schedules, filter, statuses]); - let items = useMemo(() => { + const items: SchedulesTableItem[] = useMemo(() => { + const unCompletedSchedules = filteredSchedules.filter(s => !s.completed); + if (!allowCompleted) { - return filteredSchedules.filter(s => !s.completed); + return unCompletedSchedules; } if (showCompleted) { return filteredSchedules; } - let arr = filteredSchedules.filter(s => !s.completed); - if (filteredSchedules.find(s => s.completed)) { - arr.push({ type: 'show-completed' }); - } - return arr; + + const hasCompletedSchedule = filteredSchedules.find(s => s.completed); + + if (!hasCompletedSchedule) return unCompletedSchedules; + + return [...unCompletedSchedules, { id: 'show-completed' }]; }, [filteredSchedules, showCompleted, allowCompleted]); - function renderSchedule({ item }) { + function renderSchedule({ schedule }: { schedule: ScheduleEntity }) { return ( onSelect(item.id)} + onClick={() => onSelect(schedule.id)} style={{ cursor: 'pointer', backgroundColor: theme.tableBackground, @@ -204,33 +269,33 @@ export function SchedulesTable({ - {item.name ? item.name : 'None'} + {schedule.name ? schedule.name : 'None'} - + - + - {item.next_date - ? monthUtils.format(item.next_date, dateFormat) + {schedule.next_date + ? monthUtilFormat(schedule.next_date, dateFormat) : null} - + - + {!minimal && ( - {item._date && item._date.frequency && ( + {schedule._date && schedule._date.frequency && ( )} @@ -238,8 +303,8 @@ export function SchedulesTable({ {!minimal && ( @@ -248,8 +313,8 @@ export function SchedulesTable({ ); } - function renderItem({ item }) { - if (item.type === 'show-completed') { + function renderItem({ item }: { item: SchedulesTableItem }) { + if (item.id === 'show-completed') { return ( ); } - return renderSchedule({ item }); + return renderSchedule({ schedule: item as ScheduleEntity }); } return ( @@ -300,7 +365,7 @@ export function SchedulesTable({ backgroundColor="transparent" version="v2" style={{ flex: 1, backgroundColor: 'transparent', ...style }} - items={items} + items={items as ScheduleEntity[]} renderItem={renderItem} renderEmpty={filter ? 'No matching schedules' : 'No schedules'} allowPopupsEscape={items.length < 6} diff --git a/packages/desktop-client/src/components/schedules/StatusBadge.tsx b/packages/desktop-client/src/components/schedules/StatusBadge.tsx index ce52ff4a112..d0b7ae84cba 100644 --- a/packages/desktop-client/src/components/schedules/StatusBadge.tsx +++ b/packages/desktop-client/src/components/schedules/StatusBadge.tsx @@ -1,5 +1,6 @@ import React from 'react'; +import { type ScheduleStatusType } from 'loot-core/src/client/data-hooks/schedules'; import { titleFirst } from 'loot-core/src/shared/util'; import AlertTriangle from '../../icons/v2/AlertTriangle'; @@ -9,81 +10,73 @@ import CheckCircleHollow from '../../icons/v2/CheckCircleHollow'; import EditSkull1 from '../../icons/v2/EditSkull1'; import FavoriteStar from '../../icons/v2/FavoriteStar'; import ValidationCheck from '../../icons/v2/ValidationCheck'; -import { theme, type CSSProperties } from '../../style'; +import { theme } from '../../style'; import Text from '../common/Text'; import View from '../common/View'; -export function getStatusProps(status: Status) { - let color, backgroundColor, Icon; - +// Consists of Schedule Statuses + Transaction statuses +type StatusTypes = ScheduleStatusType | 'cleared' | 'pending'; +export function getStatusProps(status: StatusTypes) { switch (status) { case 'missed': - color = theme.altErrorText; - backgroundColor = theme.altErrorBackground; - Icon = EditSkull1; - break; + return { + color: theme.altErrorText, + backgroundColor: theme.altErrorBackground, + Icon: EditSkull1, + }; case 'due': - color = theme.altWarningText; - backgroundColor = theme.altWarningBackground; - Icon = AlertTriangle; - break; + return { + color: theme.altWarningText, + backgroundColor: theme.altWarningBackground, + Icon: AlertTriangle, + }; case 'upcoming': - color = theme.upcomingText; - backgroundColor = theme.upcomingBackground; - Icon = CalendarIcon; - break; + return { + color: theme.upcomingText, + backgroundColor: theme.upcomingBackground, + Icon: CalendarIcon, + }; case 'paid': - color = theme.alt2NoticeText; - backgroundColor = theme.altNoticeBackground; - Icon = ValidationCheck; - break; + return { + color: theme.alt2NoticeText, + backgroundColor: theme.altNoticeBackground, + Icon: ValidationCheck, + }; case 'completed': - color = theme.alt2TableText; - backgroundColor = theme.altTableBackground; - Icon = FavoriteStar; - break; + return { + color: theme.alt2TableText, + backgroundColor: theme.altTableBackground, + Icon: FavoriteStar, + }; case 'pending': - color = theme.alt3NoticeText; - backgroundColor = theme.alt2NoticeBackground; - Icon = CalendarIcon; - break; + return { + color: theme.alt3NoticeText, + backgroundColor: theme.alt2NoticeBackground, + Icon: CalendarIcon, + }; case 'scheduled': - color = theme.menuItemText; - backgroundColor = theme.altTableBackground; - Icon = CalendarIcon; - break; + return { + color: theme.menuItemText, + backgroundColor: theme.altTableBackground, + Icon: CalendarIcon, + }; case 'cleared': - color = theme.noticeText; - backgroundColor = theme.altTableBackground; - Icon = CheckCircle1; - break; + return { + color: theme.noticeText, + backgroundColor: theme.altTableBackground, + Icon: CheckCircle1, + }; default: - color = theme.buttonNormalDisabledText; - backgroundColor = theme.altTableBackground; - Icon = CheckCircleHollow; - break; + return { + color: theme.buttonNormalDisabledText, + backgroundColor: theme.altTableBackground, + Icon: CheckCircleHollow, + }; } - - return { color, backgroundColor, Icon }; } -type Status = - | 'missed' - | 'due' - | 'upcoming' - | 'paid' - | 'completed' - | 'pending' - | 'scheduled' - | 'cleared'; - -type StatusBadgeProps = { - status: Status; - style?: CSSProperties; -}; - -export function StatusBadge({ status, style }: StatusBadgeProps) { - let { color, backgroundColor, Icon } = getStatusProps(status); +export function StatusBadge({ status }: { status: ScheduleStatusType }) { + const { color, backgroundColor, Icon } = getStatusProps(status); return ( diff --git a/packages/desktop-client/src/components/table.tsx b/packages/desktop-client/src/components/table.tsx index 19aead8b566..7d0d2aca3cb 100644 --- a/packages/desktop-client/src/components/table.tsx +++ b/packages/desktop-client/src/components/table.tsx @@ -11,6 +11,8 @@ import React, { type ReactNode, type KeyboardEvent, type UIEvent, + type ReactElement, + type Ref, } from 'react'; import { useStore } from 'react-redux'; import AutoSizer from 'react-virtualized-auto-sizer'; @@ -833,7 +835,7 @@ let rowStyle: CSSProperties = { type TableHandleRef = { scrollTo: (id: number, alignment?: string) => void; scrollToTop: () => void; - getScrolledItem: () => number; + getScrolledItem: () => TableItem['id']; setRowAnimation: (flag) => void; edit(id: number, field, shouldScroll): void; anchor(): void; @@ -852,10 +854,10 @@ export const TableWithNavigator = forwardRef< return ; }); -type TableItem = { id: number }; +type TableItem = { id: number | string }; -type TableProps = { - items: TableItem[]; +type TableProps = { + items: T[]; count?: number; headers?: ReactNode | TableHeaderProps['headers']; contentHeader?: ReactNode; @@ -863,7 +865,7 @@ type TableProps = { rowHeight?: number; backgroundColor?: string; renderItem: (arg: { - item: TableItem; + item: T; editing: boolean; focusedField: unknown; onEdit: (id, field) => void; @@ -875,6 +877,7 @@ type TableProps = { loadMore?: () => void; style?: CSSProperties; navigator?: ReturnType; + listRef?: unknown; onScroll?: () => void; version?: string; allowPopupsEscape?: boolean; @@ -882,7 +885,9 @@ type TableProps = { saveScrollWidth?: (parent, child) => void; }; -export const Table = forwardRef( +export const Table: ( + props: TableProps & { ref?: Ref }, +) => ReactElement = forwardRef( ( { items, diff --git a/packages/loot-core/src/client/data-hooks/accounts.tsx b/packages/loot-core/src/client/data-hooks/accounts.tsx index 147e545c6f8..084060f3b87 100644 --- a/packages/loot-core/src/client/data-hooks/accounts.tsx +++ b/packages/loot-core/src/client/data-hooks/accounts.tsx @@ -1,26 +1,33 @@ import React, { createContext, useContext } from 'react'; +import { type AccountEntity } from '../../types/models'; import q from '../query-helpers'; import { useLiveQuery } from '../query-hooks'; import { getAccountsById } from '../reducers/queries'; -function useAccounts() { +function useAccounts(): AccountEntity[] { return useLiveQuery(() => q('accounts').select('*'), []); } -let AccountsContext = createContext(null); +const AccountsContext = createContext(null); export function AccountsProvider({ children }) { - let data = useAccounts(); + const data = useAccounts(); return ; } export function CachedAccounts({ children, idKey }) { - let data = useCachedAccounts({ idKey }); + const data = useCachedAccounts({ idKey }); return children(data); } -export function useCachedAccounts({ idKey }: { idKey? } = {}) { - let data = useContext(AccountsContext); +export function useCachedAccounts(): AccountEntity[]; +export function useCachedAccounts({ + idKey, +}: { + idKey: boolean; +}): Record; +export function useCachedAccounts({ idKey }: { idKey?: boolean } = {}) { + const data = useContext(AccountsContext); return idKey && data ? getAccountsById(data) : data; } diff --git a/packages/loot-core/src/client/data-hooks/payees.tsx b/packages/loot-core/src/client/data-hooks/payees.tsx index 2adba3bb71f..f5b1956aca4 100644 --- a/packages/loot-core/src/client/data-hooks/payees.tsx +++ b/packages/loot-core/src/client/data-hooks/payees.tsx @@ -1,26 +1,33 @@ import React, { createContext, useContext } from 'react'; +import { type PayeeEntity } from '../../types/models'; import q from '../query-helpers'; import { useLiveQuery } from '../query-hooks'; import { getPayeesById } from '../reducers/queries'; -function usePayees() { +function usePayees(): PayeeEntity[] { return useLiveQuery(() => q('payees').select('*'), []); } -let PayeesContext = createContext(null); +const PayeesContext = createContext(null); export function PayeesProvider({ children }) { - let data = usePayees(); + const data = usePayees(); return ; } export function CachedPayees({ children, idKey }) { - let data = useCachedPayees({ idKey }); + const data = useCachedPayees({ idKey }); return children(data); } -export function useCachedPayees({ idKey }: { idKey? } = {}) { - let data = useContext(PayeesContext); +export function useCachedPayees(): PayeeEntity[]; +export function useCachedPayees({ + idKey, +}: { + idKey: boolean; +}): Record; +export function useCachedPayees({ idKey }: { idKey?: boolean } = {}) { + const data = useContext(PayeesContext); return idKey && data ? getPayeesById(data) : data; } diff --git a/packages/loot-core/src/client/data-hooks/schedules.tsx b/packages/loot-core/src/client/data-hooks/schedules.tsx index 9131d3c96b1..a11bb1c7f0e 100644 --- a/packages/loot-core/src/client/data-hooks/schedules.tsx +++ b/packages/loot-core/src/client/data-hooks/schedules.tsx @@ -5,6 +5,9 @@ import { getStatus, getHasTransactionsQuery } from '../../shared/schedules'; import { type ScheduleEntity } from '../../types/models'; import q, { liveQuery } from '../query-helpers'; +export type ScheduleStatusType = ReturnType; +export type ScheduleStatuses = Map; + function loadStatuses(schedules: ScheduleEntity[], onData) { return liveQuery(getHasTransactionsQuery(schedules), onData, { mapper: data => { @@ -23,13 +26,15 @@ function loadStatuses(schedules: ScheduleEntity[], onData) { type UseSchedulesArgs = { transform?: (q: Query) => Query }; type UseSchedulesReturnType = { schedules: ScheduleEntity[]; - statuses: Record>; + statuses: ScheduleStatuses; } | null; -export function useSchedules({ transform }: UseSchedulesArgs = {}) { - let [data, setData] = useState(null); +export function useSchedules({ + transform, +}: UseSchedulesArgs = {}): UseSchedulesReturnType { + const [data, setData] = useState(null); useEffect(() => { - let query = q('schedules').select('*'); + const query = q('schedules').select('*'); let scheduleQuery, statusQuery; scheduleQuery = liveQuery( @@ -40,10 +45,8 @@ export function useSchedules({ transform }: UseSchedulesArgs = {}) { statusQuery.unsubscribe(); } - statusQuery = loadStatuses( - schedules, - (statuses: Record>) => - setData({ schedules, statuses }), + statusQuery = loadStatuses(schedules, (statuses: ScheduleStatuses) => + setData({ schedules, statuses }), ); } }, @@ -65,7 +68,7 @@ export function useSchedules({ transform }: UseSchedulesArgs = {}) { let SchedulesContext = createContext(null); export function SchedulesProvider({ transform, children }) { - let data = useSchedules({ transform }); + const data = useSchedules({ transform }); return ; } diff --git a/packages/loot-core/src/shared/util.ts b/packages/loot-core/src/shared/util.ts index 8c748d475e4..b4d64754853 100644 --- a/packages/loot-core/src/shared/util.ts +++ b/packages/loot-core/src/shared/util.ts @@ -179,7 +179,7 @@ export function fastSetMerge(set1, set2) { return finalSet; } -export function titleFirst(str) { +export function titleFirst(str: string) { return str[0].toUpperCase() + str.slice(1); } diff --git a/packages/loot-core/src/types/models/schedule.d.ts b/packages/loot-core/src/types/models/schedule.d.ts index f170b736a8f..5384eab2161 100644 --- a/packages/loot-core/src/types/models/schedule.d.ts +++ b/packages/loot-core/src/types/models/schedule.d.ts @@ -3,9 +3,9 @@ import type { PayeeEntity } from './payee'; import type { RuleEntity } from './rule'; export interface ScheduleEntity { - id?: string; + id: string; name?: string; - rule: RuleEntity; + rule: RuleEntity['id']; next_date: string; completed: boolean; posts_transaction: boolean; @@ -13,11 +13,21 @@ export interface ScheduleEntity { // These are special fields that are actually pulled from the // underlying rule - _payee: PayeeEntity; - _account: AccountEntity; + _payee: PayeeEntity['id']; + _account: AccountEntity['id']; _amount: unknown; _amountOp: string; - _date: unknown; + _date: { + interval: number; + patterns: { + value: number; + type: 'SU' | 'MO' | 'TU' | 'WE' | 'TH' | 'FR' | 'SA' | 'day'; + }[]; + skipWeekend: boolean; + start: string; + weekendSolveMode: 'before' | 'after'; + frequency: 'daily' | 'weekly' | 'monthly' | 'yearly'; + }; _conditions: unknown; _actions: unknown; } diff --git a/upcoming-release-notes/1691.md b/upcoming-release-notes/1691.md new file mode 100644 index 00000000000..3af55f81084 --- /dev/null +++ b/upcoming-release-notes/1691.md @@ -0,0 +1,6 @@ +--- +category: Maintenance +authors: [muhsinkamil] +--- + +Refactor SchedulesTable and its components to tsx.