Skip to content

Commit bc88d13

Browse files
authored
Allow re-ordering investment accounts (#31)
1 parent 0ba1fd1 commit bc88d13

File tree

7 files changed

+356
-25
lines changed

7 files changed

+356
-25
lines changed

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,14 @@
11
# Changelog
22

3+
## [0.3.0] - 2025-12-20
4+
5+
### Added
6+
7+
- Add drag-and-drop reordering for investment accounts in Settings ([#31]) (George Dietrich)
8+
9+
[0.3.0]: https://github.com/blacksmoke16/rebalancer/releases/tag/v0.3.0
10+
[#31]: https://github.com/Blacksmoke16/rebalancer/pull/31
11+
312
## [0.2.0] - 2025-11-03
413

514
### Changed

package-lock.json

Lines changed: 59 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "rebalancer",
33
"private": true,
4-
"version": "0.2.0",
4+
"version": "0.3.0",
55
"type": "module",
66
"license": "MIT",
77
"scripts": {
@@ -18,6 +18,9 @@
1818
"formatter:fix": "prettier --write ."
1919
},
2020
"dependencies": {
21+
"@dnd-kit/core": "^6.3.1",
22+
"@dnd-kit/sortable": "^10.0.0",
23+
"@dnd-kit/utilities": "^3.2.2",
2124
"@mantine/core": "^8.3",
2225
"@mantine/form": "^8.3",
2326
"@mantine/hooks": "^8.3",
@@ -28,7 +31,7 @@
2831
},
2932
"devDependencies": {
3033
"@eslint/js": "^9.39.0",
31-
"@playwright/test": "^1.56.1",
34+
"@playwright/test": "^1.57.0",
3235
"@testing-library/jest-dom": "^6.9.0",
3336
"@testing-library/react": "^16.3.0",
3437
"@testing-library/user-event": "^14.6.1",

src/components/AccountsSettings.module.css

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,14 @@
66
margin-top: var(--mantine-spacing-md);
77
}
88

9+
.dragHandle {
10+
cursor: grab;
11+
}
12+
13+
.dragHandle:active {
14+
cursor: grabbing;
15+
}
16+
917
.actionButtons {
1018
margin-top: var(--mantine-spacing-md);
1119
}

src/components/AccountsSettings.tsx

Lines changed: 134 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,129 @@
1-
import { Button, Group, Paper, Title, Text, Stack } from "@mantine/core";
1+
import {
2+
Button,
3+
Group,
4+
Paper,
5+
Title,
6+
Text,
7+
Stack,
8+
ActionIcon,
9+
} from "@mantine/core";
210
import { memo } from "react";
11+
import {
12+
DndContext,
13+
closestCenter,
14+
KeyboardSensor,
15+
PointerSensor,
16+
useSensor,
17+
useSensors,
18+
DragEndEvent,
19+
} from "@dnd-kit/core";
20+
import {
21+
SortableContext,
22+
sortableKeyboardCoordinates,
23+
useSortable,
24+
verticalListSortingStrategy,
25+
} from "@dnd-kit/sortable";
26+
import { CSS } from "@dnd-kit/utilities";
27+
import { IconGripVertical } from "@tabler/icons-react";
328
import { Account } from "../types";
429
import { useAccountForm } from "../hooks/useAccountForm";
530
import { AccountNameInput, DeleteButton, FieldGroup } from "./ui/FormFields";
631
import classes from "./AccountsSettings.module.css";
732

33+
interface SortableAccountItemProps {
34+
account: Account;
35+
index: number;
36+
inputProps: ReturnType<typeof Object>;
37+
onRemove: () => void;
38+
}
39+
40+
function SortableAccountItem({
41+
account,
42+
index,
43+
inputProps,
44+
onRemove,
45+
}: SortableAccountItemProps) {
46+
const {
47+
attributes,
48+
listeners,
49+
setNodeRef,
50+
transform,
51+
transition,
52+
isDragging,
53+
} = useSortable({ id: account.key });
54+
55+
const style = {
56+
transform: CSS.Transform.toString(transform),
57+
transition,
58+
opacity: isDragging ? 0.5 : 1,
59+
};
60+
61+
return (
62+
<div ref={setNodeRef} style={style} className={classes.accountItem}>
63+
<FieldGroup align="flex-end">
64+
<ActionIcon
65+
{...attributes}
66+
{...listeners}
67+
variant="subtle"
68+
color="gray"
69+
className={classes.dragHandle}
70+
aria-label={`Drag to reorder ${account.name || "account"}`}
71+
>
72+
<IconGripVertical size={18} />
73+
</ActionIcon>
74+
<AccountNameInput
75+
{...inputProps}
76+
placeholder={`Account ${index + 1} name (e.g., 401k, Roth IRA)`}
77+
/>
78+
<DeleteButton
79+
onClick={onRemove}
80+
aria-label={`Delete account ${account.name || "account"}`}
81+
/>
82+
</FieldGroup>
83+
</div>
84+
);
85+
}
86+
887
interface AccountsSettingsProps {
988
accounts: Account[];
1089
onAccountsChange: (accounts: Account[]) => void;
1190
}
1291

1392
export const AccountsSettings = memo<AccountsSettingsProps>(
1493
function AccountsSettings({ accounts, onAccountsChange }) {
15-
const { form, handleSubmit, addAccount, removeAccount, isLoading } =
16-
useAccountForm({ accounts, onAccountsChange });
94+
const {
95+
form,
96+
handleSubmit,
97+
addAccount,
98+
removeAccount,
99+
reorderAccounts,
100+
isLoading,
101+
} = useAccountForm({ accounts, onAccountsChange });
102+
103+
const sensors = useSensors(
104+
useSensor(PointerSensor, {
105+
activationConstraint: {
106+
distance: 5,
107+
},
108+
}),
109+
useSensor(KeyboardSensor, {
110+
coordinateGetter: sortableKeyboardCoordinates,
111+
}),
112+
);
113+
114+
function handleDragEnd(event: DragEndEvent) {
115+
const { active, over } = event;
116+
117+
if (over && active.id !== over.id) {
118+
const oldIndex = form.values.accounts.findIndex(
119+
(a) => a.key === active.id,
120+
);
121+
const newIndex = form.values.accounts.findIndex(
122+
(a) => a.key === over.id,
123+
);
124+
reorderAccounts(oldIndex, newIndex);
125+
}
126+
}
17127

18128
return (
19129
<Paper shadow="sm" withBorder p="xl" className={classes.container}>
@@ -28,24 +138,28 @@ export const AccountsSettings = memo<AccountsSettingsProps>(
28138

29139
<form onSubmit={form.onSubmit(handleSubmit)}>
30140
<Stack gap="md">
31-
{form.values.accounts.map((account, idx) => (
32-
<FieldGroup
33-
key={account.key}
34-
align="flex-end"
35-
className={classes.accountItem}
141+
<DndContext
142+
sensors={sensors}
143+
collisionDetection={closestCenter}
144+
onDragEnd={handleDragEnd}
145+
>
146+
<SortableContext
147+
items={form.values.accounts.map((a) => a.key)}
148+
strategy={verticalListSortingStrategy}
36149
>
37-
<AccountNameInput
38-
{...form.getInputProps(`accounts.${idx}.name`)}
39-
placeholder={`Account ${idx + 1} name (e.g., 401k, Roth IRA)`}
40-
/>
41-
<DeleteButton
42-
onClick={() => {
43-
removeAccount(idx);
44-
}}
45-
aria-label={`Delete account ${account.name || "account"}`}
46-
/>
47-
</FieldGroup>
48-
))}
150+
{form.values.accounts.map((account, idx) => (
151+
<SortableAccountItem
152+
key={account.key}
153+
account={account}
154+
index={idx}
155+
inputProps={form.getInputProps(`accounts.${idx}.name`)}
156+
onRemove={() => {
157+
removeAccount(idx);
158+
}}
159+
/>
160+
))}
161+
</SortableContext>
162+
</DndContext>
49163

50164
<Group
51165
className={classes.actionButtons}

src/hooks/useAccountForm.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,11 +67,19 @@ export function useAccountForm({
6767
[form],
6868
);
6969

70+
const reorderAccounts = useCallback(
71+
(from: number, to: number) => {
72+
form.reorderListItem("accounts", { from, to });
73+
},
74+
[form],
75+
);
76+
7077
return {
7178
form,
7279
handleSubmit,
7380
addAccount,
7481
removeAccount,
82+
reorderAccounts,
7583
isLoading,
7684
};
7785
}

0 commit comments

Comments
 (0)