diff --git a/packages/desktop-client/src/components/schedules/ScheduleEditForm.tsx b/packages/desktop-client/src/components/schedules/ScheduleEditForm.tsx new file mode 100644 index 0000000000..b3f76b049d --- /dev/null +++ b/packages/desktop-client/src/components/schedules/ScheduleEditForm.tsx @@ -0,0 +1,514 @@ +// @ts-strict-ignore +import React from 'react'; +import { Trans, useTranslation } from 'react-i18next'; + +import { Button } from '@actual-app/components/button'; +import { InitialFocus } from '@actual-app/components/initial-focus'; +import { SpaceBetween } from '@actual-app/components/space-between'; +import { Text } from '@actual-app/components/text'; +import { theme } from '@actual-app/components/theme'; +import { View } from '@actual-app/components/view'; + +import * as monthUtils from 'loot-core/shared/months'; +import { + type RecurConfig, + type ScheduleEntity, + type TransactionEntity, +} from 'loot-core/types/models'; + +import { AccountAutocomplete } from '@desktop-client/components/autocomplete/AccountAutocomplete'; +import { PayeeAutocomplete } from '@desktop-client/components/autocomplete/PayeeAutocomplete'; +import { + FormField, + FormLabel, + Checkbox, +} from '@desktop-client/components/forms'; +import { OpSelect } from '@desktop-client/components/rules/RuleEditor'; +import { DateSelect } from '@desktop-client/components/select/DateSelect'; +import { RecurringSchedulePicker } from '@desktop-client/components/select/RecurringSchedulePicker'; +import { SelectedItemsButton } from '@desktop-client/components/table'; +import { SimpleTransactionsTable } from '@desktop-client/components/transactions/SimpleTransactionsTable'; +import { + AmountInput, + BetweenAmountInput, +} from '@desktop-client/components/util/AmountInput'; +import { GenericInput } from '@desktop-client/components/util/GenericInput'; +import { useDateFormat } from '@desktop-client/hooks/useDateFormat'; +import { useLocale } from '@desktop-client/hooks/useLocale'; +import { + type Actions, + SelectedProvider, +} from '@desktop-client/hooks/useSelected'; + +export type ScheduleFormFields = { + payee: null | string; + account: null | string; + amount: null | number | { num1: number; num2: number }; + amountOp: null | string; + date: null | string | RecurConfig; + posts_transaction: boolean; + name: null | string; +}; + +type ScheduleEditFormDispatch = + | { + type: 'set-field'; + field: 'name' | 'account' | 'payee'; + value: string; + } + | { + type: 'set-field'; + field: 'amountOp'; + value: 'is' | 'isbetween' | 'isapprox'; + } + | { + type: 'set-field'; + field: 'amount'; + value: number | { num1: number; num2: number }; + } + | { + type: 'set-field'; + field: 'date'; + value: string | RecurConfig; + } + | { + type: 'set-field'; + field: 'posts_transaction'; + value: boolean; + } + | { + type: 'set-repeats'; + repeats: boolean; + }; + +type ScheduleEditFormProps = { + fields: ScheduleFormFields; + dispatch: (action: ScheduleEditFormDispatch) => void; + upcomingDates: null | string[]; + repeats: boolean; + schedule: Partial; + adding: boolean; + isCustom: boolean; + onEditRule: (ruleId: string) => void; + transactions: TransactionEntity[]; + transactionsMode: 'matched' | 'linked'; + error: null | string; + selectedInst: { items: Set; dispatch: (action: Actions) => void }; + onSwitchTransactions: (mode: 'linked' | 'matched') => void; + onLinkTransactions: (ids: string[], scheduleId?: string) => Promise; + onUnlinkTransactions: (ids: string[]) => Promise; + onSave: () => Promise; + onCancel: () => void; +}; + +export function ScheduleEditForm({ + fields, + dispatch, + upcomingDates, + repeats, + schedule, + adding, + isCustom, + onEditRule, + transactions, + transactionsMode, + error, + selectedInst, + onSwitchTransactions, + onLinkTransactions, + onUnlinkTransactions, + onSave, + onCancel, +}: ScheduleEditFormProps) { + const locale = useLocale(); + const { t } = useTranslation(); + const dateFormat = useDateFormat() || 'MM/dd/yyyy'; + + return ( + <> + + + + + { + dispatch({ type: 'set-field', field: 'name', value: e }); + }} + /> + + + + + + + + dispatch({ type: 'set-field', field: 'payee', value: id }) + } + /> + + + + + + dispatch({ type: 'set-field', field: 'account', value: id }) + } + /> + + + + + + { + switch (op) { + case 'is': + return t('is exactly'); + case 'isapprox': + return t('is approximately'); + case 'isbetween': + return t('is between'); + default: + throw new Error('Invalid op for select: ' + op); + } + }} + style={{ + padding: '0 10px', + color: theme.pageTextLight, + fontSize: 12, + }} + onChange={(_, op) => + dispatch({ + type: 'set-field', + field: 'amountOp', + value: op, + }) + } + /> + + {fields.amountOp === 'isbetween' ? ( + + dispatch({ + type: 'set-field', + field: 'amount', + value, + }) + } + /> + ) : ( + + dispatch({ + type: 'set-field', + field: 'amount', + value, + }) + } + /> + )} + + + + + + + + + + {repeats ? ( + + dispatch({ type: 'set-field', field: 'date', value }) + } + /> + ) : ( + + dispatch({ type: 'set-field', field: 'date', value: date }) + } + dateFormat={dateFormat} + /> + )} + + {upcomingDates && ( + + + Upcoming dates + + + {upcomingDates.map(date => ( + + {monthUtils.format(date, `${dateFormat} EEEE`, locale)} + + ))} + + + )} + + + + { + dispatch({ type: 'set-repeats', repeats: e.target.checked }); + }} + /> + + + + + + { + dispatch({ + type: 'set-field', + field: 'posts_transaction', + value: e.target.checked, + }); + }} + /> + + + + + + If checked, the schedule will automatically create transactions + for you in the specified account + + + + {!adding && schedule.rule && ( + + {isCustom && ( + + This schedule has custom conditions and actions + + )} + + + )} + + + + + + {adding ? ( + + + These transactions match this schedule: + + + + Select transactions to link on save + + + ) : ( + + {' '} + + + t('{{count}} transactions', { count })} + items={ + transactionsMode === 'linked' + ? [{ name: 'unlink', text: t('Unlink from schedule') }] + : [{ name: 'link', text: t('Link to schedule') }] + } + onSelect={(name, ids) => { + switch (name) { + case 'link': + onLinkTransactions(ids, schedule.id); + break; + case 'unlink': + onUnlinkTransactions(ids); + break; + default: + } + }} + /> + + )} + + + } + transactions={transactions} + fields={['date', 'payee', 'notes', 'amount']} + style={{ + border: '1px solid ' + theme.tableBorder, + borderRadius: 4, + overflow: 'hidden', + marginTop: 5, + maxHeight: 200, + }} + /> + + + + + {error && {error}} + + + + + ); +} + +type NoTransactionsMessageProps = { + error: string | null; + transactionsMode: 'matched' | 'linked'; +}; + +function NoTransactionsMessage(props: NoTransactionsMessageProps) { + const { t } = useTranslation(); + + return ( + + {props.error ? ( + + Could not search: {{ errorReason: props.error }} + + ) : props.transactionsMode === 'matched' ? ( + t('No matching transactions') + ) : ( + t('No linked transactions') + )} + + ); +} diff --git a/packages/desktop-client/src/components/schedules/ScheduleEditModal.tsx b/packages/desktop-client/src/components/schedules/ScheduleEditModal.tsx index affd3da711..53d1613c43 100644 --- a/packages/desktop-client/src/components/schedules/ScheduleEditModal.tsx +++ b/packages/desktop-client/src/components/schedules/ScheduleEditModal.tsx @@ -1,13 +1,7 @@ // @ts-strict-ignore import React, { useEffect, useReducer } from 'react'; -import { Trans, useTranslation } from 'react-i18next'; +import { useTranslation } from 'react-i18next'; -import { Button } from '@actual-app/components/button'; -import { InitialFocus } from '@actual-app/components/initial-focus'; -import { SpaceBetween } from '@actual-app/components/space-between'; -import { Text } from '@actual-app/components/text'; -import { theme } from '@actual-app/components/theme'; -import { View } from '@actual-app/components/view'; import { t } from 'i18next'; import { send, sendCatch } from 'loot-core/platform/client/fetch'; @@ -21,35 +15,15 @@ import { type RecurConfig, } from 'loot-core/types/models'; -import { AccountAutocomplete } from '@desktop-client/components/autocomplete/AccountAutocomplete'; -import { PayeeAutocomplete } from '@desktop-client/components/autocomplete/PayeeAutocomplete'; +import { ScheduleEditForm, type ScheduleFormFields } from './ScheduleEditForm'; + import { Modal, ModalCloseButton, ModalHeader, } from '@desktop-client/components/common/Modal'; -import { - FormField, - FormLabel, - Checkbox, -} from '@desktop-client/components/forms'; -import { OpSelect } from '@desktop-client/components/rules/RuleEditor'; -import { DateSelect } from '@desktop-client/components/select/DateSelect'; -import { RecurringSchedulePicker } from '@desktop-client/components/select/RecurringSchedulePicker'; -import { SelectedItemsButton } from '@desktop-client/components/table'; -import { SimpleTransactionsTable } from '@desktop-client/components/transactions/SimpleTransactionsTable'; -import { - AmountInput, - BetweenAmountInput, -} from '@desktop-client/components/util/AmountInput'; -import { GenericInput } from '@desktop-client/components/util/GenericInput'; -import { useDateFormat } from '@desktop-client/hooks/useDateFormat'; -import { useLocale } from '@desktop-client/hooks/useLocale'; import { usePayees } from '@desktop-client/hooks/usePayees'; -import { - useSelected, - SelectedProvider, -} from '@desktop-client/hooks/useSelected'; +import { useSelected } from '@desktop-client/hooks/useSelected'; import { type Modal as ModalType, pushModal, @@ -59,15 +33,7 @@ import { aqlQuery } from '@desktop-client/queries/aqlQuery'; import { liveQuery } from '@desktop-client/queries/liveQuery'; import { useDispatch } from '@desktop-client/redux'; -type Fields = { - payee: null | string; - account: null | string; - amount: null | number | { num1: number; num2: number }; - amountOp: null | string; - date: null | string | RecurConfig; - posts_transaction: boolean; - name: null | string; -}; +type Fields = ScheduleFormFields; function updateScheduleConditions( schedule: Partial, @@ -125,14 +91,12 @@ type ScheduleEditModalProps = Extract< >['options']; export function ScheduleEditModal({ id, transaction }: ScheduleEditModalProps) { - const locale = useLocale(); const { t } = useTranslation(); const adding = id == null; const fromTrans = transaction != null; const payees = getPayeesById(usePayees()); const globalDispatch = useDispatch(); - const dateFormat = useDateFormat() || 'MM/dd/yyyy'; const [state, dispatch] = useReducer( ( @@ -492,13 +456,17 @@ export function ScheduleEditModal({ id, transaction }: ScheduleEditModalProps) { transaction ? [transaction.id] : [], ); - async function onSave(close: () => void, schedule: Partial) { + async function onSave(close: () => void) { dispatch({ type: 'form-error', error: null }); + if (!state.schedule) { + return; + } + if (state.fields.name) { const { data: sameName } = await aqlQuery( q('schedules').filter({ name: state.fields.name }).select('id'), ); - if (sameName.length > 0 && sameName[0].id !== schedule.id) { + if (sameName.length > 0 && sameName[0].id !== state.schedule.id) { dispatch({ type: 'form-error', error: t('There is already a schedule with this name'), @@ -508,7 +476,7 @@ export function ScheduleEditModal({ id, transaction }: ScheduleEditModalProps) { } const { error, conditions } = updateScheduleConditions( - schedule, + state.schedule, state.fields, ); @@ -521,7 +489,7 @@ export function ScheduleEditModal({ id, transaction }: ScheduleEditModalProps) { adding ? 'schedule/create' : 'schedule/update', { schedule: { - id: schedule.id, + id: state.schedule.id, posts_transaction: state.fields.posts_transaction, name: state.fields.name, }, @@ -616,408 +584,27 @@ export function ScheduleEditModal({ id, transaction }: ScheduleEditModalProps) { } rightContent={} /> - - - - - { - dispatch({ type: 'set-field', field: 'name', value: e }); - }} - /> - - - - - - - - dispatch({ type: 'set-field', field: 'payee', value: id }) - } - /> - - - - - - dispatch({ type: 'set-field', field: 'account', value: id }) - } - /> - - - - - - { - switch (op) { - case 'is': - return t('is exactly'); - case 'isapprox': - return t('is approximately'); - case 'isbetween': - return t('is between'); - default: - throw new Error('Invalid op for select: ' + op); - } - }} - style={{ - padding: '0 10px', - color: theme.pageTextLight, - fontSize: 12, - }} - onChange={(_, op) => - dispatch({ - type: 'set-field', - field: 'amountOp', - value: op, - }) - } - /> - - {state.fields.amountOp === 'isbetween' ? ( - - dispatch({ - type: 'set-field', - field: 'amount', - value, - }) - } - /> - ) : ( - - dispatch({ - type: 'set-field', - field: 'amount', - value, - }) - } - /> - )} - - - - - - - - - - {repeats ? ( - - dispatch({ type: 'set-field', field: 'date', value }) - } - /> - ) : ( - - dispatch({ type: 'set-field', field: 'date', value: date }) - } - dateFormat={dateFormat} - /> - )} - - {state.upcomingDates && ( - - - Upcoming dates - - - {state.upcomingDates.map(date => ( - - {monthUtils.format(date, `${dateFormat} EEEE`, locale)} - - ))} - - - )} - - - - { - dispatch({ type: 'set-repeats', repeats: e.target.checked }); - }} - /> - - - - - - { - dispatch({ - type: 'set-field', - field: 'posts_transaction', - value: e.target.checked, - }); - }} - /> - - - - - - If checked, the schedule will automatically create - transactions for you in the specified account - - - - {!adding && schedule.rule && ( - - {state.isCustom && ( - - - This schedule has custom conditions and actions - - - )} - - - )} - - - - - - {adding ? ( - - - These transactions match this schedule: - - - - Select transactions to link on save - - - ) : ( - - {' '} - - - t('{{count}} transactions', { count })} - items={ - state.transactionsMode === 'linked' - ? [{ name: 'unlink', text: t('Unlink from schedule') }] - : [{ name: 'link', text: t('Link to schedule') }] - } - onSelect={(name, ids) => { - switch (name) { - case 'link': - onLinkTransactions(ids, schedule.id); - break; - case 'unlink': - onUnlinkTransactions(ids); - break; - default: - } - }} - /> - - )} - - - } - transactions={state.transactions} - fields={['date', 'payee', 'notes', 'amount']} - style={{ - border: '1px solid ' + theme.tableBorder, - borderRadius: 4, - overflow: 'hidden', - marginTop: 5, - maxHeight: 200, - }} - /> - - - - - {state.error && ( - {state.error} - )} - - - + onSave(close)} + onCancel={close} + /> )} ); } - -type NoTransactionsMessageProps = { - error: string | null; - transactionsMode: 'matched' | 'linked'; -}; - -function NoTransactionsMessage(props: NoTransactionsMessageProps) { - const { t } = useTranslation(); - - return ( - - {props.error ? ( - - Could not search: {{ errorReason: props.error }} - - ) : props.transactionsMode === 'matched' ? ( - t('No matching transactions') - ) : ( - t('No linked transactions') - )} - - ); -} diff --git a/upcoming-release-notes/6150.md b/upcoming-release-notes/6150.md new file mode 100644 index 0000000000..c3d71da638 --- /dev/null +++ b/upcoming-release-notes/6150.md @@ -0,0 +1,6 @@ +--- +category: Maintenance +authors: [MatissJanis] +--- + +Extract schedules form into a separate component