From f95cfbf82c935d3be5f8c510f02c567d63ed3342 Mon Sep 17 00:00:00 2001 From: Stephen Brown II Date: Sat, 14 Mar 2026 09:35:45 -0400 Subject: [PATCH] Add drag&drop reordering for transactions within the same day (#6653) * feat(desktop-client): add transaction drag-and-drop reordering Add the ability to manually reorder transactions within a date group using drag-and-drop in the TransactionsTable. This is useful for correcting the order of transactions that occurred on the same day. Prevent transaction reordering from falling back to the end of the day list when dropping before a split parent. Parent-level moves now target parent rows only, so regular transactions can be inserted between split parents on the same date. Key changes: Frontend: - Migrate drag-and-drop from react-dnd to react-aria hooks - Add transaction row drag handles and drop indicators - Implement onReorder handler in TransactionList - Restrict reordering to same-date transactions only - Disable drag-drop on aggregate views (categories, etc.) Backend: - Add transaction-move handler with validation - Implement moveTransaction in db layer with midpoint/shove algorithm - Add TRANSACTION_SORT_INCREMENT constant for consistent spacing - Handle split transaction subtransactions automatically - Inherit parent sort_order in child transactions during import Refactoring: - Remove useDragRef hook (replaced by react-aria) - Remove DndProvider wrapper from App.tsx - Update budget and sidebar components for new drag-drop API - Fix sort.tsx @ts-strict-ignore by adding proper types configure allowReorder for TransactionTable consumers - Account.tsx: Enable reordering for single-account views, add sort_order as tiebreaker for stable ordering when sorted by other columns - Calendar.tsx: Disable reordering in calendar report view (read-only context) * void promises --- .../src/components/accounts/Account.tsx | 11 + .../components/reports/reports/Calendar.tsx | 1 + .../desktop-client/src/components/sort.tsx | 13 +- .../transactions/TransactionList.tsx | 127 +- .../transactions/TransactionsTable.test.tsx | 2 +- .../transactions/TransactionsTable.tsx | 1399 ++++++++++------- .../desktop-client/src/hooks/useDragDrop.tsx | 515 ++++++ .../loot-core/src/server/accounts/sync.ts | 11 +- packages/loot-core/src/server/db/index.ts | 143 +- packages/loot-core/src/server/db/sort.ts | 145 +- .../loot-core/src/server/transactions/app.ts | 33 + .../__snapshots__/parse-file.test.ts.snap | 102 +- packages/loot-core/src/shared/transactions.ts | 4 +- upcoming-release-notes/6653.md | 6 + 14 files changed, 1906 insertions(+), 606 deletions(-) create mode 100644 packages/desktop-client/src/hooks/useDragDrop.tsx create mode 100644 upcoming-release-notes/6653.md diff --git a/packages/desktop-client/src/components/accounts/Account.tsx b/packages/desktop-client/src/components/accounts/Account.tsx index 9a175663b8..327b5b8c97 100644 --- a/packages/desktop-client/src/components/accounts/Account.tsx +++ b/packages/desktop-client/src/components/accounts/Account.tsx @@ -1661,6 +1661,11 @@ class AccountInternal extends PureComponent< } maybeSortByPreviousField(this, sortPrevField, sortPrevAscDesc); + + // Always add sort_order as a final tiebreaker to maintain stable ordering + // when transactions have the same values in the sorted column(s) + this.currentQuery = this.currentQuery.orderBy({ sort_order: sortAscDesc }); + this.updateQuery(this.currentQuery, isFiltered); }; @@ -1861,6 +1866,12 @@ class AccountInternal extends PureComponent< accountId === 'onbudget' || accountId === 'uncategorized' } + allowReorder={ + !!accountId && + accountId !== 'offbudget' && + accountId !== 'onbudget' && + accountId !== 'uncategorized' + } isAdding={this.state.isAdding} isNew={this.isNew} isMatched={this.isMatched} diff --git a/packages/desktop-client/src/components/reports/reports/Calendar.tsx b/packages/desktop-client/src/components/reports/reports/Calendar.tsx index c5c7f91405..54f6a624a3 100644 --- a/packages/desktop-client/src/components/reports/reports/Calendar.tsx +++ b/packages/desktop-client/src/components/reports/reports/Calendar.tsx @@ -697,6 +697,7 @@ function CalendarInner({ widget, parameters }: CalendarInnerProps) { onMakeAsNonSplitTransactions={() => {}} showSelection={false} allowSplitTransaction={false} + allowReorder={false} /> ) : ( diff --git a/packages/desktop-client/src/components/sort.tsx b/packages/desktop-client/src/components/sort.tsx index 517f317036..c1fd83122c 100644 --- a/packages/desktop-client/src/components/sort.tsx +++ b/packages/desktop-client/src/components/sort.tsx @@ -6,6 +6,7 @@ import React, { useRef, useState, } from 'react'; +import type { DropPosition as AriaDropPosition } from 'react-aria'; import { useDrag, useDrop } from 'react-dnd'; import { theme } from '@actual-app/components/theme'; @@ -150,7 +151,9 @@ type ItemPosition = 'first' | 'last' | null; export const DropHighlightPosContext = createContext(null); type DropHighlightProps = { - pos: DropPosition; + // Supports legacy ('top'/'bottom') and react-aria ('before'/'after'/'on') positions + // 'on' is not used in our UI but is included for type compatibility + pos: DropPosition | AriaDropPosition | null; offset?: { top?: number; bottom?: number; @@ -159,15 +162,17 @@ type DropHighlightProps = { export function DropHighlight({ pos, offset }: DropHighlightProps) { const itemPos = useContext(DropHighlightPosContext); - if (pos == null) { + // 'on' position is not supported for highlight (used for dropping onto items, not between) + if (pos == null || pos === 'on') { return null; } const topOffset = (itemPos === 'first' ? 2 : 0) + (offset?.top || 0); const bottomOffset = (itemPos === 'last' ? 2 : 0) + (offset?.bottom || 0); - const posStyle = - pos === 'top' ? { top: -2 + topOffset } : { bottom: -1 + bottomOffset }; + // Support both legacy ('top'/'bottom') and aria ('before'/'after') position names + const isTop = pos === 'top' || pos === 'before'; + const posStyle = isTop ? { top: topOffset } : { bottom: bottomOffset }; return ( { + // Don't support reorder while sorted by non-date field or filtered + if ((sortField && sortField !== 'date') || isFiltered) { + return; + } + + if (id === targetId) { + return; + } + + // Find the transaction being dragged to determine if it's a child + const draggedTrans = allTransactions.find(t => t.id === id); + if (!draggedTrans) { + return; + } + + // Child transaction reordering: siblings only + if (draggedTrans.is_child && draggedTrans.parent_id) { + const siblings = allTransactions.filter( + t => t.parent_id === draggedTrans.parent_id && !isPreviewId(t.id), + ); + + const targetTransIdx = siblings.findIndex(t => t.id === targetId); + if (targetTransIdx === -1) { + return; // Target is not a sibling + } + + // Convert dropPos to API targetId for child reordering + // API places transaction AFTER targetId; null means move to top of siblings + let apiTargetId: string | null; + if (dropPos === 'after') { + apiTargetId = targetId; + } else { + const aboveIdx = targetTransIdx - 1; + apiTargetId = aboveIdx >= 0 ? siblings[aboveIdx].id : null; + } + + await send('transaction-move', { + id, + accountId: draggedTrans.account, + targetId: apiTargetId, + }); + onRefetch(); + return; + } + + // Build a reorderable list that excludes child and preview/scheduled transactions + const reorderable = allTransactions.filter( + t => !t.is_child && !isPreviewId(t.id), + ); + + const transIdx = reorderable.findIndex(t => t.id === id); + const targetTransIdx = reorderable.findIndex(t => t.id === targetId); + + if (transIdx === -1 || targetTransIdx === -1) { + return; + } + + const trans = reorderable[transIdx]; + const targetTrans = reorderable[targetTransIdx]; + const isAscending = sortField === 'date' && ascDesc === 'asc'; + + // Validate drop position: same date or at a date boundary + let isValidDrop = targetTrans.date === trans.date; + if (!isValidDrop) { + const neighborIdx = + dropPos === 'before' ? targetTransIdx - 1 : targetTransIdx + 1; + const neighborTrans = + neighborIdx >= 0 && neighborIdx < reorderable.length + ? reorderable[neighborIdx] + : null; + isValidDrop = isValidBoundaryDrop( + dropPos, + targetTrans.date, + trans.date, + neighborTrans?.date ?? null, + isAscending, + ); + } + + if (!isValidDrop) { + return; + } + + // Convert dropPos to API targetId + // API places transaction AFTER targetId; null means move to top + let apiTargetId: string | null; + if (dropPos === 'after') { + // Prevent inserting immediately after a split parent + if (targetTrans.is_parent) { + return; + } + apiTargetId = targetTrans.date === trans.date ? targetId : null; + } else { + const aboveIdx = targetTransIdx - 1; + const aboveTrans = aboveIdx >= 0 ? reorderable[aboveIdx] : null; + // For parent-level reordering, always anchor to parent transactions. + // Using a child id here makes the backend miss the target and append. + if (aboveTrans?.is_parent) { + apiTargetId = aboveTrans.date === trans.date ? aboveTrans.id : null; + } else { + apiTargetId = + aboveTrans && aboveTrans.date === trans.date ? aboveTrans.id : null; + } + } + + await send('transaction-move', { + id, + accountId: trans.account, + targetId: apiTargetId, + }); + onRefetch(); + }, + [sortField, ascDesc, isFiltered, allTransactions, onRefetch], + ); + return ( { ?.name : 'Categorize', ); - if (transaction.amount <= 0) { + if (transaction.amount < 0) { expect(queryField(container, 'debit', 'div', idx).textContent).toBe( integerToCurrency(-transaction.amount), ); diff --git a/packages/desktop-client/src/components/transactions/TransactionsTable.tsx b/packages/desktop-client/src/components/transactions/TransactionsTable.tsx index 66314aff1a..ddb7ca390f 100644 --- a/packages/desktop-client/src/components/transactions/TransactionsTable.tsx +++ b/packages/desktop-client/src/components/transactions/TransactionsTable.tsx @@ -17,6 +17,8 @@ import type { Ref, RefObject, } from 'react'; +import { DragPreview } from 'react-aria'; +import type { DragPreviewRenderer } from 'react-aria'; import { useHotkeys } from 'react-hotkeys-hook'; import { Trans, useTranslation } from 'react-i18next'; @@ -124,6 +126,17 @@ import { DisplayPayeeProvider, useDisplayPayee, } from '@desktop-client/hooks/useDisplayPayee'; +import { + DropHighlight, + isValidBoundaryDrop, + useDrag, + useDrop, +} from '@desktop-client/hooks/useDragDrop'; +import type { + DropPosition, + OnDragChangeCallback, + OnDropCallback, +} from '@desktop-client/hooks/useDragDrop'; import { useLocalPref } from '@desktop-client/hooks/useLocalPref'; import { useMergedRefs } from '@desktop-client/hooks/useMergedRefs'; import { usePrevious } from '@desktop-client/hooks/usePrevious'; @@ -882,6 +895,18 @@ type TransactionProps = { showSelection?: boolean; allowSplitTransaction?: boolean; showHiddenCategories?: boolean; + // Drag and drop props + canDrag?: boolean; + draggedDate?: string | null; + draggedId?: TransactionEntity['id'] | null; + draggedParentId?: TransactionEntity['parent_id'] | null; + siblingCount?: number; + prevRowDate?: string | null; + nextRowDate?: string | null; + sortField?: string; + ascDesc?: 'asc' | 'desc'; + onDragChange?: OnDragChangeCallback; + onDrop?: OnDropCallback; }; const Transaction = memo(function Transaction({ @@ -929,6 +954,17 @@ const Transaction = memo(function Transaction({ showSelection, allowSplitTransaction, showHiddenCategories, + canDrag = false, + draggedDate, + draggedId, + draggedParentId, + siblingCount = 0, + prevRowDate, + nextRowDate, + sortField, + ascDesc, + onDragChange, + onDrop, }: TransactionProps) { const { t } = useTranslation(); @@ -1144,569 +1180,746 @@ const Transaction = memo(function Transaction({ const { setMenuOpen, menuOpen, handleContextMenu, position } = useContextMenu(); - return ( - - setMenuOpen(false)} - {...position} - style={{ width: 200, margin: 1 }} - isNonModal - > - allTransactions?.find(t => t.id === id)} - onDelete={ids => onBatchDelete?.(ids)} - onDuplicate={ids => onBatchDuplicate?.(ids)} - onLinkSchedule={ids => onBatchLinkSchedule?.(ids)} - onUnlinkSchedule={ids => onBatchUnlinkSchedule?.(ids)} - onCreateRule={ids => onCreateRule?.(ids)} - onScheduleAction={(name, ids) => onScheduleAction?.(name, ids)} - onMakeAsNonSplitTransactions={ids => - onMakeAsNonSplitTransactions?.(ids) - } - closeMenu={() => setMenuOpen(false)} - /> - + // Drag and drop support + const isChildTransaction = transaction.is_child; + const parentId = transaction.parent_id; + // Disable drag if this is the only transaction on its date (nothing to reorder with) + // For child transactions, disable if there's only one sibling (nothing to reorder with) + const isOnlyTransactionOnDate = isChildTransaction + ? siblingCount <= 1 + : prevRowDate !== transaction.date && nextRowDate !== transaction.date; + const previewRef = useRef(null); + const { dragRef, dragProps } = useDrag({ + item: originalTransaction, + type: 'transaction', + canDrag: canDrag && !isPreview && !isOnlyTransactionOnDate, + onDragChange, + preview: previewRef, + }); - {splitError && listContainerRef?.current && ( + // Gate callbacks for non-reorderable rows (children/previews) to avoid invalid drop operations + // For child transactions, allow drops only from siblings (same parent) + const isSiblingDrag = isChildTransaction && draggedParentId === parentId; + const safeOnDrop: OnDropCallback | undefined = isPreview + ? undefined + : isChildTransaction + ? isSiblingDrag + ? onDrop + : undefined + : onDrop; + + const { dropRef, dropProps, dropPos } = useDrop({ + types: 'transaction', + id: transaction.id, + onDrop: safeOnDrop, + }); + + // Merge refs: drag on row, drop on outer view + const rowRef = useMergedRefs(triggerRef, dragRef); + + // Check if this row is a valid drop target for the currently dragged transaction + const isValidDropTarget = useMemo(() => { + // Non-droppable row types + if (isPreview) return false; + + // When dragging a child transaction, only siblings are valid targets + if (draggedParentId) { + // Only allow drops between siblings (same parent) + if (!isChildTransaction || draggedParentId !== parentId) return false; + return dropPos != null; + } + + // Child transactions are not valid drop targets for parent transactions + if (isChildTransaction) return false; + + // Parent transaction drop logic (existing behavior) + if (!draggedDate) return false; + // Only allow drops when sorted by date (or no sort active) + if (sortField && sortField !== 'date') return false; + // Prevent inserting between a split parent and its children + if (isParent && dropPos === 'after') return false; + // Same date is always a valid drop target + if (transaction.date === draggedDate) return true; + // Boundary drops require a valid drop position + if (!dropPos) return false; + + const isAscending = sortField === 'date' && ascDesc === 'asc'; + const neighborDate = dropPos === 'before' ? prevRowDate : nextRowDate; + return isValidBoundaryDrop( + dropPos, + transaction.date, + draggedDate, + neighborDate ?? null, + isAscending, + ); + }, [ + draggedDate, + draggedParentId, + parentId, + isChildTransaction, + isPreview, + sortField, + isParent, + dropPos, + transaction.date, + ascDesc, + prevRowDate, + nextRowDate, + ]); + + // Dim this row if it (or its parent) is being dragged + const isBeingDragged = + draggedId != null && + (draggedId === transaction.id || draggedId === transaction.parent_id); + + // Show drop highlight only for valid targets that aren't the dragged row + const showDropHighlight = Boolean( + dropPos && isValidDropTarget && !isBeingDragged, + ); + + return ( + + + setMenuOpen(false)} + {...position} + style={{ width: 200, margin: 1 }} isNonModal - style={{ - maxWidth: 500, - minWidth: 375, - padding: 5, - maxHeight: '38px !important', - }} - shouldFlip={false} - placement="bottom end" - UNSTABLE_portalContainer={listContainerRef.current} > - {splitError} - - )} - - {isChild && ( - + + // reformat value so since we might have kept decimals + value ? amountToCurrency(currencyToAmount(value) || 0) : '' + } + valueStyle={valueStyle} + textAlign="right" + title={credit} + onExpose={name => !isPreview && onEdit(id, name)} + style={{ + ...(isParent && { fontStyle: 'italic' }), + ...styles.tnum, + ...amountStyle, + }} + inputProps={{ + value: credit, + onUpdate: onUpdate.bind(null, 'credit'), + 'data-1p-ignore': true, + }} + privacyFilter={{ + activationFilters: [!isTemporaryId(transaction.id)], + }} + /> + + {showBalance && ( + + )} + + {showCleared && ( + + )} + + + + + {() => ( + + + {date ? formatDate(parseISO(date), dateFormat) : ''} + + + {payee?.name || importedPayee || No payee} + + + {isParent ? ( + Split ({{ count: subtransactions?.length ?? 0 }}) + ) : categoryId ? ( + (getCategoriesById(categoryGroups)[categoryId]?.name ?? '') + ) : ( + '' + )} + + + {integerToCurrency(amount)} + + + )} + + ); }); @@ -2033,6 +2246,13 @@ type TransactionTableInnerProps = { onSort: (field: string, ascDesc: 'asc' | 'desc') => void; showHiddenCategories?: boolean; + // Drag and drop props + canDrag?: boolean; + draggedId?: TransactionEntity['id'] | null; + draggedParentId?: TransactionEntity['parent_id'] | null; + draggedDate?: string | null; + onDragChange?: OnDragChangeCallback; + onDrop?: OnDropCallback; }; function TransactionTableInner({ @@ -2151,6 +2371,36 @@ function TransactionTableInner({ (trans.is_parent ? trans.id : trans.parent_id) || '' ]?.filter(t => t.amount === 0); + // Get sibling count for child transactions (used for drag/drop) + const siblingCount = + trans.is_child && trans.parent_id + ? (props.transactionsByParent[trans.parent_id]?.length ?? 0) + : 0; + + // Compute adjacent row dates for boundary drop detection + // Use transactionsToRender (filtered list) to match rendered row indices + // Skip non-reorderable rows (child/preview) when finding neighbors + const findPrevReorderableDate = (): string | null => { + for (let i = index - 1; i >= 0; i--) { + const row = transactionsToRender[i]; + if (row && !row.is_child && !isPreviewId(row.id)) { + return row.date ?? null; + } + } + return null; + }; + const findNextReorderableDate = (): string | null => { + for (let i = index + 1; i < transactionsToRender.length; i++) { + const row = transactionsToRender[i]; + if (row && !row.is_child && !isPreviewId(row.id)) { + return row.date ?? null; + } + } + return null; + }; + const prevRowDate = findPrevReorderableDate(); + const nextRowDate = findNextReorderableDate(); + return ( ); }; @@ -2339,6 +2600,7 @@ export type TransactionTableProps = { isAdding: boolean; isNew: (id: TransactionEntity['id']) => boolean; isMatched: (id: TransactionEntity['id']) => boolean; + isFiltered?: boolean; dateFormat: string | undefined; hideFraction: boolean; renderEmpty: ReactNode | (() => ReactNode); @@ -2359,6 +2621,11 @@ export type TransactionTableProps = { onSort: (field: string, ascDesc: 'asc' | 'desc') => void; sortField: string; ascDesc: 'asc' | 'desc'; + onReorder?: ( + id: string, + dropPos: DropPosition, + targetId: string, + ) => Promise | void; onBatchDelete: (ids: TransactionEntity['id'][]) => void; onBatchDuplicate: (ids: TransactionEntity['id'][]) => void; onBatchLinkSchedule: (ids: TransactionEntity['id'][]) => void; @@ -2391,6 +2658,54 @@ export const TransactionTable = forwardRef( const splitsExpandedDispatch = splitsExpanded.dispatch; const prevSplitsExpanded = useRef(null); + // Drag state for transaction reordering + const [draggedId, setDraggedId] = useState( + null, + ); + const [draggedDate, setDraggedDate] = useState(null); + const [draggedParentId, setDraggedParentId] = useState< + TransactionEntity['parent_id'] | null + >(null); + + // Dragging is enabled when: + // - No sort is active (sortField is empty) OR sorted by date + // - Not filtered (isFiltered is false) + // - onReorder callback is provided + const canDrag = useMemo( + () => + (!props.sortField || props.sortField === 'date') && + !props.isFiltered && + props.onReorder != null, + [props.sortField, props.isFiltered, props.onReorder], + ); + + const onDragChange = useCallback>( + drag => { + if (drag.state === 'start-preview') { + // Set dragged item info immediately when drag preview starts + setDraggedId(drag.item?.id ?? null); + setDraggedDate(drag.item?.date ?? null); + setDraggedParentId(drag.item?.parent_id ?? null); + } else if (drag.state === 'end') { + setDraggedId(null); + setDraggedDate(null); + setDraggedParentId(null); + } + }, + [], + ); + + const { onReorder } = props; + const onDrop = useCallback( + (id, dropPos, targetId) => { + if (id === targetId) { + return; + } + void onReorder?.(id, dropPos, targetId); + }, + [onReorder], + ); + const tableRef = useRef>(null); const listContainerRef = useRef( null, @@ -3040,6 +3355,12 @@ export const TransactionTable = forwardRef( showSelection={props.showSelection} allowSplitTransaction={props.allowSplitTransaction} showHiddenCategories={showHiddenCategories} + canDrag={canDrag} + draggedId={draggedId} + draggedParentId={draggedParentId} + draggedDate={draggedDate} + onDragChange={onDragChange} + onDrop={onDrop} /> diff --git a/packages/desktop-client/src/hooks/useDragDrop.tsx b/packages/desktop-client/src/hooks/useDragDrop.tsx new file mode 100644 index 0000000000..54bc359484 --- /dev/null +++ b/packages/desktop-client/src/hooks/useDragDrop.tsx @@ -0,0 +1,515 @@ +import { + createContext, + useContext, + useEffect, + useLayoutEffect, + useRef, + useState, +} from 'react'; +import type { Context, RefObject } from 'react'; +import { + useDrag as useReactAriaDrag, + useDrop as useReactAriaDrop, +} from 'react-aria'; +import type { + DragPreviewRenderer, + DropPosition, + TextDropItem, +} from 'react-aria'; + +import { theme } from '@actual-app/components/theme'; +import { View } from '@actual-app/components/view'; + +// =========================================================================== +// Migration Notes +// =========================================================================== +// +// This module provides low-level drag-and-drop hooks based on react-aria. +// These hooks are used for components that haven't yet migrated to +// react-aria-components (ListBox, GridList, Table). +// +// When components are migrated to use react-aria-components, they should +// use the higher-level `useDragAndDrop` hook from react-aria-components +// instead of these low-level hooks. +// +// Migration path for each feature: +// +// | Current | react-aria-components equivalent | +// |----------------------------|-------------------------------------------| +// | useDrag + useDrop hooks | useDragAndDrop hook | +// | preview ref (useDrag) | renderDragPreview option | +// | DropHighlight component | renderDropIndicator + | +// | onDrop callback | onReorder / onMove / onInsert callbacks | +// | onLongHover callback | onDropActivate option (built-in) | +// | DragState (start-preview) | onDragStart / onDragEnd callbacks | +// | DropHighlightPosContext | Not needed (DropIndicator handles this) | +// +// =========================================================================== +// Components Already Migrated (using useDragAndDrop + GridList/ListBox) +// =========================================================================== +// +// Mobile: +// - ExpenseGroupList.tsx → GridList + useDragAndDrop +// - ExpenseCategoryList.tsx → GridList + useDragAndDrop +// - AccountsPage.tsx (AccountList) → ListBox + useDragAndDrop +// +// =========================================================================== +// Components Still Using react-dnd (To Migrate) +// =========================================================================== +// +// Desktop Budget (currently uses react-dnd, should migrate to GridList): +// - ExpenseCategory.tsx / SidebarCategory.tsx → react-dnd useDraggable/useDroppable +// - ExpenseGroup.tsx / SidebarGroup.tsx → react-dnd useDraggable/useDroppable +// - IncomeCategory.tsx → react-dnd useDraggable/useDroppable +// - BudgetCategories.tsx → Parent component with DndProvider +// +// Desktop Sidebar (currently uses react-dnd, should migrate to ListBox): +// - Account.tsx → react-dnd useDraggable/useDroppable +// - Accounts.tsx → Parent component with DndProvider +// +// =========================================================================== +// Components Using This Module (react-aria based) +// =========================================================================== +// +// Transactions: +// - TransactionsTable.tsx → Uses useDrag/useDrop from this module +// - TransactionList.tsx → Uses isValidBoundaryDrop for validation +// +// These hooks wrap react-aria's low-level useDrag/useDrop and add: +// - Two-phase drag start (preview then start) for UI coordination +// - Manual drop position calculation based on cursor position +// - Long hover detection via onDropActivate +// +// =========================================================================== + +// ============================================================ +// Types +// ============================================================ + +// Re-exported from react-aria for convenience +export type { DropPosition }; + +/** + * Check if a drop at a date boundary is valid. + * A boundary drop is valid when the adjacent transaction has the same date as the dragged transaction. + * + * MIGRATION: This validation logic is specific to transaction reordering and + * should be moved into the `onReorder` callback when migrating to useDragAndDrop. + * The callback receives the dragged keys and drop target, from which you can + * access the transaction data and perform this validation before applying changes. + * + * @param dropPos - The drop position relative to the target ('before' or 'after') + * @param targetDate - The date of the transaction being dropped onto + * @param draggedDate - The date of the transaction being dragged + * @param neighborDate - The date of the adjacent transaction in the drop direction, or null if none + * @param isAscending - Whether the list is sorted in ascending order (oldest first) + * @returns True if the drop is valid at this boundary position + */ +export function isValidBoundaryDrop( + dropPos: DropPosition, + targetDate: string, + draggedDate: string, + neighborDate: string | null, + isAscending: boolean, +): boolean { + if (!neighborDate || neighborDate !== draggedDate) { + return false; + } + + if (isAscending) { + // Ascending: older at top, newer at bottom + // Valid: drop 'before' later-dated, or 'after' earlier-dated + return ( + (dropPos === 'before' && targetDate > draggedDate) || + (dropPos === 'after' && targetDate < draggedDate) + ); + } + // Descending (default): newer at top, older at bottom + // Valid: drop 'before' earlier-dated, or 'after' later-dated + return ( + (dropPos === 'before' && targetDate < draggedDate) || + (dropPos === 'after' && targetDate > draggedDate) + ); +} + +/** + * Represents the current state of a drag operation. + * + * MIGRATION: When using useDragAndDrop from react-aria-components, + * use onDragStart/onDragEnd callbacks instead. The 'start-preview' + * state is used to show a preview before the actual drag starts, + * which can be replaced by renderDragPreview option. + */ +export type DragState = { + state: 'start-preview' | 'start' | 'end'; + type?: string; + item?: T; + preview?: boolean; +}; + +export type OnDragChangeCallback = ( + drag: DragState, +) => Promise | void; + +/** + * MIGRATION: When using useDragAndDrop, use onReorder callback instead. + * The signature changes to: (e: { keys: Set, target: DropTarget }) => void + */ +export type OnDropCallback = ( + id: string, + dropPos: DropPosition, + targetId: string, +) => Promise | void; + +/** + * MIGRATION: Use onDropActivate option in useDragAndDrop instead. + * react-aria calls this automatically after hovering for ~500ms. + */ +export type OnLongHoverCallback = () => Promise | void; + +// ============================================================ +// useDrag +// ============================================================ + +/** + * MIGRATION: When migrating to useDragAndDrop from react-aria-components: + * - Remove `preview` ref - use `renderDragPreview` option instead + * - Remove `onDragChange` - use `onDragStart`/`onDragEnd` options instead + * - Remove `canDrag` - use `isDisabled` on the collection component + * - The `type` becomes part of `getItems` return value + */ +export type UseDragArgs = { + item?: T; + type: string; + canDrag: boolean; + onDragChange?: OnDragChangeCallback; + /** MIGRATION: Replace with renderDragPreview option in useDragAndDrop */ + preview?: RefObject; +}; + +/** + * Low-level drag hook for custom components. + * + * MIGRATION: Replace with useDragAndDrop from react-aria-components + * when the parent component uses ListBox, GridList, or Table. + */ +export function useDrag({ + item, + type, + canDrag, + onDragChange, + preview, +}: UseDragArgs) { + const _onDragChange = useRef(onDragChange); + const dragRef = useRef(null); + const dragStartTimeoutRef = useRef | null>( + null, + ); + + useLayoutEffect(() => { + _onDragChange.current = onDragChange; + }); + + useEffect(() => { + return () => { + if (dragStartTimeoutRef.current) { + clearTimeout(dragStartTimeoutRef.current); + dragStartTimeoutRef.current = null; + } + }; + }, []); + + const { dragProps, isDragging } = useReactAriaDrag({ + isDisabled: !canDrag, + preview, + getItems() { + return [ + { + [type]: JSON.stringify(item), + 'text/plain': item?.id ?? '', + }, + ]; + }, + onDragStart() { + void _onDragChange.current?.({ state: 'start-preview', type, item }); + // Clear any pending timeout before scheduling a new one + if (dragStartTimeoutRef.current) { + clearTimeout(dragStartTimeoutRef.current); + } + // MIGRATION: This two-phase start (preview then start) pattern + // is used to first show the drag preview, then update UI state. + // With useDragAndDrop, use onDragStart for immediate feedback + // and renderDragPreview for the preview element. + dragStartTimeoutRef.current = setTimeout(() => { + void _onDragChange.current?.({ state: 'start' }); + dragStartTimeoutRef.current = null; + }, 0); + }, + onDragEnd() { + // Clear pending timeout to prevent stale 'start' callback after 'end' + if (dragStartTimeoutRef.current) { + clearTimeout(dragStartTimeoutRef.current); + dragStartTimeoutRef.current = null; + } + void _onDragChange.current?.({ state: 'end', type, item }); + }, + }); + + return { dragRef, dragProps, isDragging }; +} + +// ============================================================ +// useDrop +// ============================================================ + +/** + * MIGRATION: When migrating to useDragAndDrop from react-aria-components: + * - Remove `types` - use `acceptedDragTypes` option instead + * - Remove `id` - the collection handles item identification + * - Replace `onDrop` with `onReorder`/`onMove`/`onInsert` options + * - Replace `onLongHover` with `onDropActivate` option + */ +export type UseDropArgs = { + types: string | string[]; + id: string; + onDrop?: OnDropCallback; + /** MIGRATION: Use onDropActivate option in useDragAndDrop instead */ + onLongHover?: OnLongHoverCallback; +}; + +/** + * Low-level drop hook for custom components. + * + * MIGRATION: Replace with useDragAndDrop from react-aria-components + * when the parent component uses ListBox, GridList, or Table. + * The drop position calculation and cursor tracking will be handled + * automatically by the collection component. + */ +export function useDrop({ + types, + id, + onDrop, + onLongHover, +}: UseDropArgs) { + const dropRef = useRef(null); + const [dropPos, setDropPos] = useState(null); + // Track if cursor is actually within this element's bounds (not just drag preview overlap) + const [isCursorOver, setIsCursorOver] = useState(false); + const lastClientY = useRef(null); + // Track if react-aria thinks we're a drop target (needed to attach dragover listener) + const [isDropActive, setIsDropActive] = useState(false); + + // Reset state when cursor leaves + useEffect(() => { + if (!isCursorOver) { + setDropPos(null); + lastClientY.current = null; + } + }, [isCursorOver]); + + const acceptedTypes = Array.isArray(types) ? types : [types]; + + const { dropProps, isDropTarget } = useReactAriaDrop({ + ref: dropRef as RefObject, + getDropOperation(dragTypes) { + // Check if any of our accepted types are in the drag types + const hasAcceptedType = acceptedTypes.some(t => dragTypes.has(t)); + return hasAcceptedType ? 'move' : 'cancel'; + }, + onDropEnter() { + // Mark that a drag is active over this element (enables dragover listener) + setIsDropActive(true); + }, + onDropActivate() { + // MIGRATION: This is already using react-aria's built-in long hover. + // With useDragAndDrop, use the onDropActivate option directly. + void onLongHover?.(); + }, + onDropExit() { + setIsDropActive(false); + setIsCursorOver(false); + }, + async onDrop(e) { + if (!onDrop) return; + if (!dropRef.current) return; + + // Find the first matching type + const matchingType = acceptedTypes.find(t => + e.items.some( + (item): item is TextDropItem => + item.kind === 'text' && item.types.has(t), + ), + ); + + if (!matchingType) return; + + const textItem = e.items.find( + (item): item is TextDropItem => + item.kind === 'text' && item.types.has(matchingType), + ); + + if (!textItem) return; + + const data = await textItem.getText(matchingType); + if (typeof data !== 'string' || !data.trim()) return; + + let parsed: T; + try { + parsed = JSON.parse(data) as T; + } catch { + // Ignore malformed payloads + return; + } + + // Validate payload shape: must be an object with a non-empty string id + if ( + !parsed || + typeof parsed !== 'object' || + typeof parsed.id !== 'string' || + !parsed.id + ) { + return; + } + + // MIGRATION: With useDragAndDrop, the drop position is provided + // in the event object (e.target.dropPosition) - no manual + // calculation needed. + const rect = dropRef.current.getBoundingClientRect(); + const hoverMiddleY = (rect.bottom - rect.top) / 2; + const hoverClientY = + lastClientY.current != null + ? lastClientY.current - rect.top + : hoverMiddleY + 1; + const pos: DropPosition = + hoverClientY < hoverMiddleY ? 'before' : 'after'; + + void onDrop(parsed.id, pos, id); + }, + }); + + // MIGRATION: This manual dragover tracking is not needed with + // useDragAndDrop - the collection component handles it automatically. + useEffect(() => { + if (!isDropActive) return; + + const handleDragOver = (e: DragEvent) => { + if (!dropRef.current) return; + + const rect = dropRef.current.getBoundingClientRect(); + const { clientX, clientY } = e; + + // Check if cursor is actually within this element's bounds + const cursorInBounds = + clientX >= rect.left && + clientX <= rect.right && + clientY >= rect.top && + clientY <= rect.bottom; + + if (!cursorInBounds) { + setIsCursorOver(false); + return; + } + + setIsCursorOver(true); + lastClientY.current = clientY; + + const hoverMiddleY = (rect.bottom - rect.top) / 2; + const hoverClientY = clientY - rect.top; + const newPos: DropPosition = + hoverClientY < hoverMiddleY ? 'before' : 'after'; + + setDropPos(newPos); + }; + + const element = dropRef.current; + element?.addEventListener('dragover', handleDragOver); + + return () => { + element?.removeEventListener('dragover', handleDragOver); + }; + }, [isDropActive, dropRef]); + + return { + dropRef, + dropProps, + dropPos: isCursorOver ? dropPos : null, + isDropTarget, + }; +} + +// ============================================================ +// DropHighlight +// ============================================================ + +/** + * MIGRATION: Replace with renderDropIndicator option and + * component from react-aria-components. See ExpenseGroupList.tsx for example: + * + * ```tsx + * renderDropIndicator: target => ( + * + * ) + * ``` + */ +type ItemPosition = 'first' | 'last' | null; + +/** + * MIGRATION: Not needed with useDragAndDrop - DropIndicator handles + * positioning automatically based on the collection structure. + */ +export const DropHighlightPosContext: Context = + createContext(null); + +type DropHighlightProps = { + pos: DropPosition | null; + offset?: { + top?: number; + bottom?: number; + }; +}; + +/** + * Visual indicator showing where a dragged item will be dropped. + * + * MIGRATION: Replace with from react-aria-components + * when using useDragAndDrop. The DropIndicator component automatically + * positions itself between items based on the drop target. + */ +export function DropHighlight({ pos, offset }: DropHighlightProps) { + const itemPos = useContext(DropHighlightPosContext); + + // 'on' position is not supported for highlight (used for dropping onto items, not between) + if (pos == null || pos === 'on') { + return null; + } + + const topOffset = (itemPos === 'first' ? 2 : 0) + (offset?.top ?? 0); + const bottomOffset = (itemPos === 'last' ? 2 : 0) + (offset?.bottom ?? 0); + + const posStyle = + pos === 'before' ? { top: topOffset } : { bottom: bottomOffset }; + + return ( + + ); +} diff --git a/packages/loot-core/src/server/accounts/sync.ts b/packages/loot-core/src/server/accounts/sync.ts index e986c6fedb..1638a22ec7 100644 --- a/packages/loot-core/src/server/accounts/sync.ts +++ b/packages/loot-core/src/server/accounts/sync.ts @@ -22,6 +22,7 @@ import type { } from '../../types/models'; import { aqlQuery } from '../aql'; import * as db from '../db'; +import { TRANSACTION_SORT_INCREMENT } from '../db/sort'; import { runMutator } from '../mutators'; import { post } from '../post'; import { getServer } from '../server-config'; @@ -633,7 +634,7 @@ export async function reconcileTransactions( // Maintain the sort order of the server const now = Date.now(); added.forEach((t, index) => { - t.sort_order ??= now - index; + t.sort_order ??= now - index * TRANSACTION_SORT_INCREMENT; }); if (!isPreview) { @@ -897,6 +898,14 @@ export async function addTransactions( await createNewPayees(payeesToCreate, added); + // Assign decreasing sort_order values to preserve import file order. + // Transactions are displayed in sort_order DESC order, so first transaction + // in the file should have the highest sort_order. + const now = Date.now(); + added.forEach((t, index) => { + t.sort_order ??= now - index * TRANSACTION_SORT_INCREMENT; + }); + let newTransactions; if (runTransfers || learnCategories) { const res = await batchUpdateTransactions({ diff --git a/packages/loot-core/src/server/db/index.ts b/packages/loot-core/src/server/db/index.ts index 1bfabb4da3..d4c36b024c 100644 --- a/packages/loot-core/src/server/db/index.ts +++ b/packages/loot-core/src/server/db/index.ts @@ -33,7 +33,12 @@ import { } from '../models'; import { batchMessages, sendMessages } from '../sync'; -import { shoveSortOrders, SORT_INCREMENT } from './sort'; +import { + shoveSortOrders, + shoveSortOrdersDescending, + SORT_INCREMENT, + TRANSACTION_SORT_INCREMENT, +} from './sort'; import type { DbAccount, DbBank, @@ -810,6 +815,142 @@ export async function deleteTransaction(transaction) { return delete_('transactions', transaction.id); } +/** + * Move a transaction to a new position within the same date. + * Uses the same midpoint/shove algorithm as category reordering. + * + * @param id - The ID of the transaction to move + * @param accountId - The account the transaction belongs to + * @param targetId - The ID of the transaction to place AFTER, or null to place at top + */ +export async function moveTransaction( + id: string, + accountId: string, + targetId: string | null, +) { + await batchMessages(async () => { + const transaction = await getTransaction(id); + if (!transaction) { + throw new Error(`Transaction not found: ${id}`); + } + + // Validate that the transaction belongs to the specified account + if (transaction.account !== accountId) { + throw new Error( + `Transaction ${id} does not belong to account ${accountId}`, + ); + } + + // Convert date string (YYYY-MM-DD) to integer format (YYYYMMDD) for SQL query + const dateInt = parseInt(transaction.date.replace(/-/g, ''), 10); + + // Get transactions to reorder against. + // If this is a child transaction, scope to siblings with the same parent_id. + // Otherwise, get all parent transactions for the same date (excluding children). + // Query in DESC order to match UI display order. + const isChild = transaction.is_child && transaction.parent_id; + const transactions = await all<{ id: string; sort_order: number }>( + isChild + ? `SELECT vt.id, vt.sort_order + FROM v_transactions vt + WHERE vt.parent_id = ? + ORDER BY sort_order DESC, id` + : `SELECT vt.id, vt.sort_order + FROM v_transactions vt + WHERE vt.account = ? + AND vt.date = ? + AND vt.is_child = 0 + ORDER BY sort_order DESC, id`, + isChild ? [transaction.parent_id] : [accountId, dateInt], + ); + + // Calculate new sort_order using the descending shove algorithm + // - If targetId is null, place at TOP (highest sort_order) + // - Otherwise, place AFTER target (sort_order between target and next) + const { sort_order: newSortOrder, updates } = shoveSortOrdersDescending( + transactions, + targetId, + id, + TRANSACTION_SORT_INCREMENT, + ); + + // Apply updates to shuffle other transactions if needed + for (const info of updates) { + await update('transactions', info); + // Only move subtransactions for parent transactions + if (!isChild) { + await moveSubtransactions(info.id, info.sort_order, accountId, dateInt); + } + } + + // Update the moved transaction + await update('transactions', { id, sort_order: newSortOrder }); + // Only move subtransactions for parent transactions + if (!isChild) { + await moveSubtransactions(id, newSortOrder, accountId, dateInt); + } + }); +} + +/** + * Update sort_order of child/subtransactions to maintain relative ordering. + * Children are distributed evenly between the parent's sort_order and the + * next sibling's sort_order to avoid collisions. + * + * This ensures split transaction children move with their parent. + * + * @param parentId - The ID of the parent transaction + * @param parentSortOrder - The sort_order of the parent transaction + * @param accountId - The account ID to scope the next sibling lookup + * @param txnDate - The transaction date (as integer YYYYMMDD) to scope the next sibling lookup + */ +async function moveSubtransactions( + parentId: string, + parentSortOrder: number, + accountId: string, + txnDate: number, +) { + const subtransactions = await all<{ id: string }>( + 'SELECT id FROM v_transactions WHERE parent_id = ? ORDER BY sort_order DESC', + [parentId], + ); + + if (subtransactions.length === 0) { + return; + } + + // Find the next sibling's sort_order (transaction with lower sort_order that isn't a child) + // Scoped to the same account and date to avoid picking transactions from other contexts + const nextSibling = await first<{ sort_order: number }>( + `SELECT sort_order FROM v_transactions + WHERE parent_id IS NULL + AND is_child = 0 + AND sort_order < ? + AND account = ? + AND date = ? + ORDER BY sort_order DESC + LIMIT 1`, + [parentSortOrder, accountId, txnDate], + ); + + // Calculate the available gap for distributing children + // Use a sensible fallback if no next sibling exists + const nextSiblingSortOrder = + nextSibling?.sort_order ?? parentSortOrder - TRANSACTION_SORT_INCREMENT; + const gap = parentSortOrder - nextSiblingSortOrder; + + // Distribute children evenly within the gap + // Avoid rounding to prevent duplicate sort_order values in tight gaps + for (const [index, sub] of subtransactions.entries()) { + const newSortOrder = + parentSortOrder - ((index + 1) * gap) / (subtransactions.length + 1); + await update('transactions', { + id: sub.id, + sort_order: newSortOrder, + }); + } +} + function toSqlQueryParameters(params: unknown[]) { return params.map(() => '?').join(','); } diff --git a/packages/loot-core/src/server/db/sort.ts b/packages/loot-core/src/server/db/sort.ts index 28fa615d52..0c6c787bea 100644 --- a/packages/loot-core/src/server/db/sort.ts +++ b/packages/loot-core/src/server/db/sort.ts @@ -1,13 +1,21 @@ export const SORT_INCREMENT = 16384; -function midpoint(items: T[], to: number) { +// Smaller increment for transactions - allows more reordering within a date +// before needing to resequence. Used by moveTransaction(). +export const TRANSACTION_SORT_INCREMENT = 1024; + +function midpoint( + items: T[], + to: number, + sortIncrement: number = SORT_INCREMENT, +) { const below = items[to - 1]; const above = items[to]; if (!below) { return above.sort_order / 2; } else if (!above) { - return below.sort_order + SORT_INCREMENT; + return below.sort_order + sortIncrement; } else { return (below.sort_order + above.sort_order) / 2; } @@ -16,6 +24,7 @@ function midpoint(items: T[], to: number) { export function shoveSortOrders( items: T[], targetId: string | null = null, + sortIncrement: number = SORT_INCREMENT, ) { const to = items.findIndex(item => item.id === targetId); const target = items[to]; @@ -27,17 +36,17 @@ export function shoveSortOrders( let order; if (items.length > 0) { // Add a new increment to whatever is the latest sort order - order = items[items.length - 1].sort_order + SORT_INCREMENT; + order = items[items.length - 1].sort_order + sortIncrement; } else { // If no items exist, the default is to use the first increment - order = SORT_INCREMENT; + order = sortIncrement; } return { updates, sort_order: order }; } else { if (target.sort_order - (before ? before.sort_order : 0) <= 2) { let next = to; - let order = Math.floor(items[next].sort_order) + SORT_INCREMENT; + let order = Math.floor(items[next].sort_order) + sortIncrement; while (next < items.length) { // No need to update it if it's already greater than the current // order. This can happen because there may already be large @@ -49,10 +58,132 @@ export function shoveSortOrders( updates.push({ id: items[next].id, sort_order: order }); next++; - order += SORT_INCREMENT; + order += sortIncrement; } } - return { updates, sort_order: midpoint(items, to) }; + return { updates, sort_order: midpoint(items, to, sortIncrement) }; } } + +/** + * Midpoint calculation for descending-ordered lists. + * Places the new item between the target and the next item (lower sort_order). + * + * @param items - Array of items sorted by sort_order descending + * @param targetIdx - Index of the target item + * @param sortIncrement - The increment to use when no next item exists + * @param nextIdx - Optional index of the next item (to skip movingId) + */ +function midpointDescending( + items: T[], + targetIdx: number, + sortIncrement: number = SORT_INCREMENT, + nextIdx: number | null = null, +): number { + const target = items[targetIdx]; + const next = nextIdx === null ? items[targetIdx + 1] : items[nextIdx]; + + if (!next) { + // Target is at the bottom - place below it + return target.sort_order - sortIncrement; + } else { + // Use midpoint between target and next + return Math.round((target.sort_order + next.sort_order) / 2); + } +} + +/** + * Calculate sort_order for placing an item after a target in a descending-ordered list. + * Mirrors shoveSortOrders but for lists sorted by sort_order DESC (higher values first). + * + * Use this when: + * - Items are displayed with highest sort_order at top + * - You want to place an item AFTER the target (visually below it) + * - Items need to be shoved DOWN (decreased sort_order) to make room + * + * @param items - Array of items sorted by sort_order descending + * @param targetId - ID of the item to place after, or null to place at top + * @param movingId - ID of the item being moved (skipped when shoving) + * @param sortIncrement - The increment to use when shoving items + * @returns Object with sort_order for the new position and updates array for shoved items + */ +export function shoveSortOrdersDescending< + T extends { id: string; sort_order: number }, +>( + items: T[], + targetId: string | null = null, + movingId: string | null = null, + sortIncrement: number = SORT_INCREMENT, +): { + sort_order: number; + updates: Array<{ id: string; sort_order: number }>; +} { + const updates: Array<{ id: string; sort_order: number }> = []; + + // If no target is specified, place at the top (highest sort_order) + if (!targetId) { + let order; + if (items.length > 0) { + order = items[0].sort_order + sortIncrement; + } else { + order = sortIncrement; + } + return { updates, sort_order: order }; + } + + const targetIdx = items.findIndex(item => item.id === targetId); + + // Target not found, place at end (lowest sort_order) + if (targetIdx === -1) { + const order = + items.length > 0 + ? items[items.length - 1].sort_order - sortIncrement + : sortIncrement; + return { updates, sort_order: order }; + } + + const target = items[targetIdx]; + // Find the next item, skipping the moving item to avoid computing gap against itself + const nextIdx = items.findIndex( + (item, idx) => idx > targetIdx && (!movingId || item.id !== movingId), + ); + const next = nextIdx === -1 ? null : items[nextIdx]; + + // Check if there's room between target and next item + const gap = target.sort_order - (next ? next.sort_order : 0); + if (gap > 2) { + // There's room - use midpoint + // When nextIdx === -1, there's no next item (excluding the moving item), + // so place directly below target instead of calling midpointDescending + // which would incorrectly use items[targetIdx + 1] (the moving item itself) + const newSortOrder = + nextIdx === -1 + ? target.sort_order - sortIncrement + : midpointDescending(items, targetIdx, sortIncrement, nextIdx); + return { + updates, + sort_order: newSortOrder, + }; + } + + // Need to shove items down (decrease sort_order) to make room + let newOrder = target.sort_order - sortIncrement; + for (let i = targetIdx + 1; i < items.length; i++) { + // Skip the item being moved + if (movingId && items[i].id === movingId) continue; + + // Only update if we need to make room + if (items[i].sort_order >= newOrder) { + updates.push({ id: items[i].id, sort_order: newOrder }); + newOrder = newOrder - sortIncrement; + } else { + break; + } + } + + return { + updates, + sort_order: Math.round(target.sort_order - sortIncrement / 2), + }; +} diff --git a/packages/loot-core/src/server/transactions/app.ts b/packages/loot-core/src/server/transactions/app.ts index 96eeeb3e7c..90b4aaf47d 100644 --- a/packages/loot-core/src/server/transactions/app.ts +++ b/packages/loot-core/src/server/transactions/app.ts @@ -8,6 +8,7 @@ import type { } from '../../types/models'; import { createApp } from '../app'; import { aqlQuery } from '../aql'; +import * as db from '../db'; import { mutator } from '../mutators'; import { undoable } from '../undo'; @@ -23,6 +24,7 @@ export type TransactionHandlers = { 'transaction-add': typeof addTransaction; 'transaction-update': typeof updateTransaction; 'transaction-delete': typeof deleteTransaction; + 'transaction-move': typeof moveTransaction; 'transactions-parse-file': typeof parseTransactionsFile; 'transactions-export': typeof exportTransactions; 'transactions-export-query': typeof exportTransactionsQuery; @@ -64,6 +66,36 @@ async function deleteTransaction(transaction: Pick) { return {}; } +async function moveTransaction({ + id, + accountId, + targetId, +}: { + id: string; + accountId: string; + targetId: string | null; +}) { + // Fetch the transaction to validate it exists and verify account + const transaction = await db.getTransaction(id); + if (!transaction) { + throw new Error(`Transaction not found: ${id}`); + } + + // Validate that the provided accountId matches the transaction's actual account + // This prevents sort order calculations against the wrong account + if (transaction.account !== accountId) { + throw new Error( + `Account mismatch: transaction belongs to account ${transaction.account}, not ${accountId}`, + ); + } + + // Child transactions can be reordered within their parent's children + // The db.moveTransaction handles the sibling-scoped reordering for children + + await db.moveTransaction(id, accountId, targetId); + return {}; +} + async function parseTransactionsFile({ filepath, options, @@ -129,6 +161,7 @@ app.method('transactions-merge', mutator(undoable(mergeTransactions))); app.method('transaction-add', mutator(addTransaction)); app.method('transaction-update', mutator(updateTransaction)); app.method('transaction-delete', mutator(deleteTransaction)); +app.method('transaction-move', mutator(undoable(moveTransaction))); app.method('transactions-parse-file', mutator(parseTransactionsFile)); app.method('transactions-export', mutator(exportTransactions)); app.method('transactions-export-query', mutator(exportTransactionsQuery)); diff --git a/packages/loot-core/src/server/transactions/import/__snapshots__/parse-file.test.ts.snap b/packages/loot-core/src/server/transactions/import/__snapshots__/parse-file.test.ts.snap index c5ca6ce3cf..f8a7c916db 100644 --- a/packages/loot-core/src/server/transactions/import/__snapshots__/parse-file.test.ts.snap +++ b/packages/loot-core/src/server/transactions/import/__snapshots__/parse-file.test.ts.snap @@ -52,7 +52,7 @@ exports[`File import > CAMT.053 import works 1`] = ` "raw_synced_data": null, "reconciled": 0, "schedule": null, - "sort_order": 123456788, + "sort_order": 123455765, "starting_balance_flag": 0, "tombstone": 0, "transferred_id": null, @@ -78,7 +78,7 @@ exports[`File import > CAMT.053 import works 1`] = ` "raw_synced_data": null, "reconciled": 0, "schedule": null, - "sort_order": 123456787, + "sort_order": 123454741, "starting_balance_flag": 0, "tombstone": 0, "transferred_id": null, @@ -107,7 +107,7 @@ exports[`File import > CAMT.053 import works 1`] = ` "raw_synced_data": null, "reconciled": 0, "schedule": null, - "sort_order": 123456786, + "sort_order": 123453717, "starting_balance_flag": 0, "tombstone": 0, "transferred_id": null, @@ -133,7 +133,7 @@ exports[`File import > CAMT.053 import works 1`] = ` "raw_synced_data": null, "reconciled": 0, "schedule": null, - "sort_order": 123456785, + "sort_order": 123452693, "starting_balance_flag": 0, "tombstone": 0, "transferred_id": null, @@ -159,7 +159,7 @@ exports[`File import > CAMT.053 import works 1`] = ` "raw_synced_data": null, "reconciled": 0, "schedule": null, - "sort_order": 123456784, + "sort_order": 123451669, "starting_balance_flag": 0, "tombstone": 0, "transferred_id": null, @@ -185,7 +185,7 @@ exports[`File import > CAMT.053 import works 1`] = ` "raw_synced_data": null, "reconciled": 0, "schedule": null, - "sort_order": 123456783, + "sort_order": 123450645, "starting_balance_flag": 0, "tombstone": 0, "transferred_id": null, @@ -242,7 +242,7 @@ exports[`File import > handles html escaped plaintext 1`] = ` "raw_synced_data": null, "reconciled": 0, "schedule": null, - "sort_order": 123456788, + "sort_order": 123455765, "starting_balance_flag": 0, "tombstone": 0, "transferred_id": null, @@ -330,7 +330,7 @@ exports[`File import > import notes are respected when importing > transactions "raw_synced_data": null, "reconciled": 0, "schedule": null, - "sort_order": 123456788, + "sort_order": 123455765, "starting_balance_flag": 0, "tombstone": 0, "transferred_id": null, @@ -356,7 +356,7 @@ exports[`File import > import notes are respected when importing > transactions "raw_synced_data": null, "reconciled": 0, "schedule": null, - "sort_order": 123456787, + "sort_order": 123454741, "starting_balance_flag": 0, "tombstone": 0, "transferred_id": null, @@ -382,7 +382,7 @@ exports[`File import > import notes are respected when importing > transactions "raw_synced_data": null, "reconciled": 0, "schedule": null, - "sort_order": 123456786, + "sort_order": 123453717, "starting_balance_flag": 0, "tombstone": 0, "transferred_id": null, @@ -408,7 +408,7 @@ exports[`File import > import notes are respected when importing > transactions "raw_synced_data": null, "reconciled": 0, "schedule": null, - "sort_order": 123456785, + "sort_order": 123452693, "starting_balance_flag": 0, "tombstone": 0, "transferred_id": null, @@ -434,7 +434,7 @@ exports[`File import > import notes are respected when importing > transactions "raw_synced_data": null, "reconciled": 0, "schedule": null, - "sort_order": 123456784, + "sort_order": 123451669, "starting_balance_flag": 0, "tombstone": 0, "transferred_id": null, @@ -460,7 +460,7 @@ exports[`File import > import notes are respected when importing > transactions "raw_synced_data": null, "reconciled": 0, "schedule": null, - "sort_order": 123456783, + "sort_order": 123450645, "starting_balance_flag": 0, "tombstone": 0, "transferred_id": null, @@ -486,7 +486,7 @@ exports[`File import > import notes are respected when importing > transactions "raw_synced_data": null, "reconciled": 0, "schedule": null, - "sort_order": 123456782, + "sort_order": 123449621, "starting_balance_flag": 0, "tombstone": 0, "transferred_id": null, @@ -512,7 +512,7 @@ exports[`File import > import notes are respected when importing > transactions "raw_synced_data": null, "reconciled": 0, "schedule": null, - "sort_order": 123456781, + "sort_order": 123448597, "starting_balance_flag": 0, "tombstone": 0, "transferred_id": null, @@ -538,7 +538,7 @@ exports[`File import > import notes are respected when importing > transactions "raw_synced_data": null, "reconciled": 0, "schedule": null, - "sort_order": 123456780, + "sort_order": 123447573, "starting_balance_flag": 0, "tombstone": 0, "transferred_id": null, @@ -626,7 +626,7 @@ exports[`File import > ofx import works 1`] = ` "raw_synced_data": null, "reconciled": 0, "schedule": null, - "sort_order": 123456788, + "sort_order": 123455765, "starting_balance_flag": 0, "tombstone": 0, "transferred_id": null, @@ -652,7 +652,7 @@ exports[`File import > ofx import works 1`] = ` "raw_synced_data": null, "reconciled": 0, "schedule": null, - "sort_order": 123456787, + "sort_order": 123454741, "starting_balance_flag": 0, "tombstone": 0, "transferred_id": null, @@ -678,7 +678,7 @@ exports[`File import > ofx import works 1`] = ` "raw_synced_data": null, "reconciled": 0, "schedule": null, - "sort_order": 123456786, + "sort_order": 123453717, "starting_balance_flag": 0, "tombstone": 0, "transferred_id": null, @@ -704,7 +704,7 @@ exports[`File import > ofx import works 1`] = ` "raw_synced_data": null, "reconciled": 0, "schedule": null, - "sort_order": 123456785, + "sort_order": 123452693, "starting_balance_flag": 0, "tombstone": 0, "transferred_id": null, @@ -730,7 +730,7 @@ exports[`File import > ofx import works 1`] = ` "raw_synced_data": null, "reconciled": 0, "schedule": null, - "sort_order": 123456784, + "sort_order": 123451669, "starting_balance_flag": 0, "tombstone": 0, "transferred_id": null, @@ -756,7 +756,7 @@ exports[`File import > ofx import works 1`] = ` "raw_synced_data": null, "reconciled": 0, "schedule": null, - "sort_order": 123456783, + "sort_order": 123450645, "starting_balance_flag": 0, "tombstone": 0, "transferred_id": null, @@ -782,7 +782,7 @@ exports[`File import > ofx import works 1`] = ` "raw_synced_data": null, "reconciled": 0, "schedule": null, - "sort_order": 123456782, + "sort_order": 123449621, "starting_balance_flag": 0, "tombstone": 0, "transferred_id": null, @@ -808,7 +808,7 @@ exports[`File import > ofx import works 1`] = ` "raw_synced_data": null, "reconciled": 0, "schedule": null, - "sort_order": 123456781, + "sort_order": 123448597, "starting_balance_flag": 0, "tombstone": 0, "transferred_id": null, @@ -834,7 +834,7 @@ exports[`File import > ofx import works 1`] = ` "raw_synced_data": null, "reconciled": 0, "schedule": null, - "sort_order": 123456780, + "sort_order": 123447573, "starting_balance_flag": 0, "tombstone": 0, "transferred_id": null, @@ -891,7 +891,7 @@ exports[`File import > qfx import works 1`] = ` "raw_synced_data": null, "reconciled": 0, "schedule": null, - "sort_order": 123456788, + "sort_order": 123455765, "starting_balance_flag": 0, "tombstone": 0, "transferred_id": null, @@ -917,7 +917,7 @@ exports[`File import > qfx import works 1`] = ` "raw_synced_data": null, "reconciled": 0, "schedule": null, - "sort_order": 123456787, + "sort_order": 123454741, "starting_balance_flag": 0, "tombstone": 0, "transferred_id": null, @@ -943,7 +943,7 @@ exports[`File import > qfx import works 1`] = ` "raw_synced_data": null, "reconciled": 0, "schedule": null, - "sort_order": 123456786, + "sort_order": 123453717, "starting_balance_flag": 0, "tombstone": 0, "transferred_id": null, @@ -969,7 +969,7 @@ exports[`File import > qfx import works 1`] = ` "raw_synced_data": null, "reconciled": 0, "schedule": null, - "sort_order": 123456785, + "sort_order": 123452693, "starting_balance_flag": 0, "tombstone": 0, "transferred_id": null, @@ -995,7 +995,7 @@ exports[`File import > qfx import works 1`] = ` "raw_synced_data": null, "reconciled": 0, "schedule": null, - "sort_order": 123456784, + "sort_order": 123451669, "starting_balance_flag": 0, "tombstone": 0, "transferred_id": null, @@ -1021,7 +1021,7 @@ exports[`File import > qfx import works 1`] = ` "raw_synced_data": null, "reconciled": 0, "schedule": null, - "sort_order": 123456783, + "sort_order": 123450645, "starting_balance_flag": 0, "tombstone": 0, "transferred_id": null, @@ -1047,7 +1047,7 @@ exports[`File import > qfx import works 1`] = ` "raw_synced_data": null, "reconciled": 0, "schedule": null, - "sort_order": 123456782, + "sort_order": 123449621, "starting_balance_flag": 0, "tombstone": 0, "transferred_id": null, @@ -1073,7 +1073,7 @@ exports[`File import > qfx import works 1`] = ` "raw_synced_data": null, "reconciled": 0, "schedule": null, - "sort_order": 123456781, + "sort_order": 123448597, "starting_balance_flag": 0, "tombstone": 0, "transferred_id": null, @@ -1099,7 +1099,7 @@ exports[`File import > qfx import works 1`] = ` "raw_synced_data": null, "reconciled": 0, "schedule": null, - "sort_order": 123456780, + "sort_order": 123447573, "starting_balance_flag": 0, "tombstone": 0, "transferred_id": null, @@ -1156,7 +1156,7 @@ exports[`File import > qif import works 1`] = ` "raw_synced_data": null, "reconciled": 0, "schedule": null, - "sort_order": 123456788, + "sort_order": 123455765, "starting_balance_flag": 0, "tombstone": 0, "transferred_id": null, @@ -1182,7 +1182,7 @@ exports[`File import > qif import works 1`] = ` "raw_synced_data": null, "reconciled": 0, "schedule": null, - "sort_order": 123456787, + "sort_order": 123454741, "starting_balance_flag": 0, "tombstone": 0, "transferred_id": null, @@ -1208,7 +1208,7 @@ exports[`File import > qif import works 1`] = ` "raw_synced_data": null, "reconciled": 0, "schedule": null, - "sort_order": 123456786, + "sort_order": 123453717, "starting_balance_flag": 0, "tombstone": 0, "transferred_id": null, @@ -1234,7 +1234,7 @@ exports[`File import > qif import works 1`] = ` "raw_synced_data": null, "reconciled": 0, "schedule": null, - "sort_order": 123456785, + "sort_order": 123452693, "starting_balance_flag": 0, "tombstone": 0, "transferred_id": null, @@ -1260,7 +1260,7 @@ exports[`File import > qif import works 1`] = ` "raw_synced_data": null, "reconciled": 0, "schedule": null, - "sort_order": 123456784, + "sort_order": 123451669, "starting_balance_flag": 0, "tombstone": 0, "transferred_id": null, @@ -1286,7 +1286,7 @@ exports[`File import > qif import works 1`] = ` "raw_synced_data": null, "reconciled": 0, "schedule": null, - "sort_order": 123456783, + "sort_order": 123450645, "starting_balance_flag": 0, "tombstone": 0, "transferred_id": null, @@ -1312,7 +1312,7 @@ exports[`File import > qif import works 1`] = ` "raw_synced_data": null, "reconciled": 0, "schedule": null, - "sort_order": 123456782, + "sort_order": 123449621, "starting_balance_flag": 0, "tombstone": 0, "transferred_id": null, @@ -1338,7 +1338,7 @@ exports[`File import > qif import works 1`] = ` "raw_synced_data": null, "reconciled": 0, "schedule": null, - "sort_order": 123456781, + "sort_order": 123448597, "starting_balance_flag": 0, "tombstone": 0, "transferred_id": null, @@ -1364,7 +1364,7 @@ exports[`File import > qif import works 1`] = ` "raw_synced_data": null, "reconciled": 0, "schedule": null, - "sort_order": 123456780, + "sort_order": 123447573, "starting_balance_flag": 0, "tombstone": 0, "transferred_id": null, @@ -1390,7 +1390,7 @@ exports[`File import > qif import works 1`] = ` "raw_synced_data": null, "reconciled": 0, "schedule": null, - "sort_order": 123456779, + "sort_order": 123446549, "starting_balance_flag": 0, "tombstone": 0, "transferred_id": null, @@ -1416,7 +1416,7 @@ exports[`File import > qif import works 1`] = ` "raw_synced_data": null, "reconciled": 0, "schedule": null, - "sort_order": 123456778, + "sort_order": 123445525, "starting_balance_flag": 0, "tombstone": 0, "transferred_id": null, @@ -1442,7 +1442,7 @@ exports[`File import > qif import works 1`] = ` "raw_synced_data": null, "reconciled": 0, "schedule": null, - "sort_order": 123456777, + "sort_order": 123444501, "starting_balance_flag": 0, "tombstone": 0, "transferred_id": null, @@ -1468,7 +1468,7 @@ exports[`File import > qif import works 1`] = ` "raw_synced_data": null, "reconciled": 0, "schedule": null, - "sort_order": 123456776, + "sort_order": 123443477, "starting_balance_flag": 0, "tombstone": 0, "transferred_id": null, @@ -1494,7 +1494,7 @@ exports[`File import > qif import works 1`] = ` "raw_synced_data": null, "reconciled": 0, "schedule": null, - "sort_order": 123456775, + "sort_order": 123442453, "starting_balance_flag": 0, "tombstone": 0, "transferred_id": null, @@ -1520,7 +1520,7 @@ exports[`File import > qif import works 1`] = ` "raw_synced_data": null, "reconciled": 0, "schedule": null, - "sort_order": 123456774, + "sort_order": 123441429, "starting_balance_flag": 0, "tombstone": 0, "transferred_id": null, @@ -1546,7 +1546,7 @@ exports[`File import > qif import works 1`] = ` "raw_synced_data": null, "reconciled": 0, "schedule": null, - "sort_order": 123456773, + "sort_order": 123440405, "starting_balance_flag": 0, "tombstone": 0, "transferred_id": null, @@ -1572,7 +1572,7 @@ exports[`File import > qif import works 1`] = ` "raw_synced_data": null, "reconciled": 0, "schedule": null, - "sort_order": 123456772, + "sort_order": 123439381, "starting_balance_flag": 0, "tombstone": 0, "transferred_id": null, diff --git a/packages/loot-core/src/shared/transactions.ts b/packages/loot-core/src/shared/transactions.ts index b314cd6123..866ecee574 100644 --- a/packages/loot-core/src/shared/transactions.ts +++ b/packages/loot-core/src/shared/transactions.ts @@ -50,6 +50,8 @@ export function makeChild( parent.starting_balance_flag != null ? parent.starting_balance_flag : null, + sort_order: + 'sort_order' in data ? data.sort_order : (parent.sort_order ?? null), is_child: true, parent_id: parent.id, error: null, @@ -65,7 +67,7 @@ function makeNonChild( ...data, cleared: parent.cleared != null ? parent.cleared : null, reconciled: parent.reconciled != null ? parent.reconciled : null, - sort_order: parent.sort_order || null, + sort_order: parent.sort_order ?? null, starting_balance_flag: null, is_child: false, parent_id: null, diff --git a/upcoming-release-notes/6653.md b/upcoming-release-notes/6653.md new file mode 100644 index 0000000000..0e1471f65a --- /dev/null +++ b/upcoming-release-notes/6653.md @@ -0,0 +1,6 @@ +--- +category: Features +authors: [StephenBrown2] +--- + +Add drag&drop reordering for transactions within the same day