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