[refactor] Migrate Schedules Table to typescript (#1691)

This commit is contained in:
Mohamed Muhsin
2023-09-17 20:05:32 +02:00
committed by GitHub
parent 3496ac28f3
commit 2ee023ac22
11 changed files with 256 additions and 161 deletions

View File

@@ -37,7 +37,7 @@ type MenuProps = {
header?: ReactNode;
footer?: ReactNode;
items: Array<MenuItem | typeof Menu.line>;
onMenuSelect;
onMenuSelect: (itemName: MenuItem['name']) => void;
};
export default function Menu({

View File

@@ -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 (
<View>
@@ -49,23 +117,11 @@ function OverflowMenu({ schedule, status, onAction }) {
onClose={() => setOpen(false)}
>
<Menu
onMenuSelect={name => {
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()}
/>
</Tooltip>
)}
@@ -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 (
<Cell
@@ -129,38 +191,38 @@ export function SchedulesTable({
onSelect,
onAction,
tableStyle,
}) {
let dateFormat = useSelector(state => {
}: 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 (
<Row
height={ROW_HEIGHT}
inset={15}
onClick={() => onSelect(item.id)}
onClick={() => onSelect(schedule.id)}
style={{
cursor: 'pointer',
backgroundColor: theme.tableBackground,
@@ -204,33 +269,33 @@ export function SchedulesTable({
<Field width="flex" name="name">
<Text
style={
item.name == null
schedule.name == null
? { color: theme.buttonNormalDisabledText }
: null
}
title={item.name ? item.name : ''}
title={schedule.name ? schedule.name : ''}
>
{item.name ? item.name : 'None'}
{schedule.name ? schedule.name : 'None'}
</Text>
</Field>
<Field width="flex" name="payee">
<DisplayId type="payees" id={item._payee} />
<DisplayId type="payees" id={schedule._payee} />
</Field>
<Field width="flex" name="account">
<DisplayId type="accounts" id={item._account} />
<DisplayId type="accounts" id={schedule._account} />
</Field>
<Field width={110} name="date">
{item.next_date
? monthUtils.format(item.next_date, dateFormat)
{schedule.next_date
? monthUtilFormat(schedule.next_date, dateFormat)
: null}
</Field>
<Field width={120} name="status" style={{ alignItems: 'flex-start' }}>
<StatusBadge status={statuses.get(item.id)} />
<StatusBadge status={statuses.get(schedule.id)} />
</Field>
<ScheduleAmountCell amount={item._amount} op={item._amountOp} />
<ScheduleAmountCell amount={schedule._amount} op={schedule._amountOp} />
{!minimal && (
<Field width={80} style={{ textAlign: 'center' }}>
{item._date && item._date.frequency && (
{schedule._date && schedule._date.frequency && (
<Check style={{ width: 13, height: 13 }} />
)}
</Field>
@@ -238,8 +303,8 @@ export function SchedulesTable({
{!minimal && (
<Field width={40} name="actions">
<OverflowMenu
schedule={item}
status={statuses.get(item.id)}
schedule={schedule}
status={statuses.get(schedule.id)}
onAction={onAction}
/>
</Field>
@@ -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 (
<Row
height={ROW_HEIGHT}
@@ -274,7 +339,7 @@ export function SchedulesTable({
</Row>
);
}
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}

View File

@@ -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 (
<View
style={{
@@ -94,7 +87,6 @@ export function StatusBadge({ status, style }: StatusBadgeProps) {
flexDirection: 'row',
alignItems: 'center',
flexShrink: 0,
...style,
}}
>
<Icon

View File

@@ -11,7 +11,11 @@ import Search from '../common/Search';
import View from '../common/View';
import { Page } from '../Page';
import { SchedulesTable, ROW_HEIGHT } from './SchedulesTable';
import {
SchedulesTable,
ROW_HEIGHT,
type ScheduleItemAction,
} from './SchedulesTable';
export default function Schedules() {
const { pushModal } = useActions();
@@ -37,8 +41,7 @@ export default function Schedules() {
pushModal('schedules-discover');
}
// @todo: replace name: string with enum
async function onAction(name: string, id: ScheduleEntity['id']) {
async function onAction(name: ScheduleItemAction, id: ScheduleEntity['id']) {
switch (name) {
case 'post-transaction':
await send('schedule/post-transaction', { id });
@@ -88,9 +91,6 @@ export default function Schedules() {
onSelect={onEdit}
onAction={onAction}
style={{ backgroundColor: theme.tableBackground }}
// @todo: Remove following props after typing SchedulesTable
minimal={undefined}
tableStyle={undefined}
/>
</View>

View File

@@ -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 <Table {...props} navigator={navigator} />;
});
type TableItem = { id: number };
type TableItem = { id: number | string };
type TableProps = {
items: TableItem[];
type TableProps<T = TableItem> = {
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<typeof useTableNavigator>;
listRef?: unknown;
onScroll?: () => void;
version?: string;
allowPopupsEscape?: boolean;
@@ -882,7 +885,9 @@ type TableProps = {
saveScrollWidth?: (parent, child) => void;
};
export const Table = forwardRef<TableHandleRef, TableProps>(
export const Table: <T extends TableItem>(
props: TableProps<T> & { ref?: Ref<TableHandleRef> },
) => ReactElement = forwardRef<TableHandleRef, TableProps>(
(
{
items,

View File

@@ -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<AccountEntity[]>(null);
export function AccountsProvider({ children }) {
let data = useAccounts();
const data = useAccounts();
return <AccountsContext.Provider value={data} children={children} />;
}
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<AccountEntity['id'], AccountEntity>;
export function useCachedAccounts({ idKey }: { idKey?: boolean } = {}) {
const data = useContext(AccountsContext);
return idKey && data ? getAccountsById(data) : data;
}

View File

@@ -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<PayeeEntity[]>(null);
export function PayeesProvider({ children }) {
let data = usePayees();
const data = usePayees();
return <PayeesContext.Provider value={data} children={children} />;
}
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<PayeeEntity['id'], PayeeEntity>;
export function useCachedPayees({ idKey }: { idKey?: boolean } = {}) {
const data = useContext(PayeesContext);
return idKey && data ? getPayeesById(data) : data;
}

View File

@@ -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<typeof getStatus>;
export type ScheduleStatuses = Map<ScheduleEntity['id'], ScheduleStatusType>;
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<string, ReturnType<typeof getStatus>>;
statuses: ScheduleStatuses;
} | null;
export function useSchedules({ transform }: UseSchedulesArgs = {}) {
let [data, setData] = useState<UseSchedulesReturnType | null>(null);
export function useSchedules({
transform,
}: UseSchedulesArgs = {}): UseSchedulesReturnType {
const [data, setData] = useState<UseSchedulesReturnType>(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<string, ReturnType<typeof getStatus>>) =>
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 <SchedulesContext.Provider value={data} children={children} />;
}

View File

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

View File

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

View File

@@ -0,0 +1,6 @@
---
category: Maintenance
authors: [muhsinkamil]
---
Refactor SchedulesTable and its components to tsx.