TransactionLabels.tsx raw
1 import { PencilIcon, PlusIcon, TagIcon, XIcon } from "lucide-react";
2 import React from "react";
3 import { toast } from "sonner";
4 import { Badge } from "src/components/ui/badge";
5 import { Button } from "src/components/ui/button";
6 import { Input } from "src/components/ui/input";
7 import { request } from "src/utils/request";
8 import { useSWRConfig } from "swr";
9
10 type Props = {
11 id: number;
12 labels?: Record<string, string>;
13 transactionListKey: string;
14 };
15
16 type Row = { key: string; value: string };
17
18 function toRows(labels: Record<string, string> | undefined): Row[] {
19 if (!labels || Object.keys(labels).length === 0) {
20 return [{ key: "", value: "" }];
21 }
22
23 return Object.entries(labels).map(([key, value]) => ({ key, value }));
24 }
25
26 function rowsToLabels(rows: Row[]): Record<string, string> {
27 const result: Record<string, string> = {};
28 for (const row of rows) {
29 const key = row.key.trim();
30 const value = row.value.trim();
31 if (key && value) {
32 result[key] = value;
33 }
34 }
35
36 return result;
37 }
38
39 function TransactionLabels({
40 id,
41 labels: initialLabels,
42 transactionListKey,
43 }: Props) {
44 const { mutate } = useSWRConfig();
45 const [isEditing, setIsEditing] = React.useState(false);
46 const [loading, setLoading] = React.useState(false);
47 const [labels, setLabels] = React.useState<Record<string, string>>(
48 initialLabels ?? {}
49 );
50 const [rows, setRows] = React.useState<Row[]>(() => toRows(initialLabels));
51
52 React.useEffect(() => {
53 setLabels(initialLabels ?? {});
54 if (!isEditing) {
55 setRows(toRows(initialLabels));
56 }
57 }, [initialLabels, isEditing]);
58
59 const labelEntries = Object.entries(labels);
60
61 const updateRow = (index: number, patch: Partial<Row>) => {
62 setRows((prev) =>
63 prev.map((row, i) => (i === index ? { ...row, ...patch } : row))
64 );
65 };
66
67 const removeRow = (index: number) => {
68 setRows((prev) => prev.filter((_, i) => i !== index));
69 };
70
71 const addRow = () => {
72 setRows((prev) => [...prev, { key: "", value: "" }]);
73 };
74
75 const startEditing = () => {
76 setRows(toRows(labels));
77 setIsEditing(true);
78 };
79
80 const cancelEditing = () => {
81 setRows(toRows(labels));
82 setIsEditing(false);
83 };
84
85 const saveLabels = async () => {
86 const nextLabels = rowsToLabels(rows);
87 setLoading(true);
88
89 try {
90 await request(`/api/transactions/${id}/labels`, {
91 method: "PATCH",
92 headers: { "Content-Type": "application/json" },
93 body: JSON.stringify({ labels: nextLabels }),
94 });
95
96 setLabels(nextLabels);
97 setRows(toRows(nextLabels));
98 setIsEditing(false);
99
100 await mutate(transactionListKey);
101
102 toast("Labels saved");
103 } catch (error) {
104 console.error(error);
105 toast.error("Failed to save labels", {
106 description: String(error),
107 });
108 } finally {
109 setLoading(false);
110 }
111 };
112
113 return (
114 <div>
115 <div className="flex items-center justify-between">
116 <p>Labels</p>
117 {!isEditing && (
118 <Button
119 type="button"
120 variant="ghost"
121 size="sm"
122 onClick={startEditing}
123 >
124 {labelEntries.length > 0 ? (
125 <>
126 <PencilIcon className="size-3" />
127 Edit
128 </>
129 ) : (
130 <>
131 <TagIcon className="size-3" />
132 Add labels
133 </>
134 )}
135 </Button>
136 )}
137 </div>
138
139 {isEditing ? (
140 <div className="mt-2 flex flex-col gap-3">
141 {rows.map((row, index) => (
142 <div key={index} className="flex items-center gap-2">
143 <Input
144 placeholder="key"
145 value={row.key}
146 onChange={(e) => updateRow(index, { key: e.target.value })}
147 className="w-1/3"
148 />
149 <Input
150 placeholder="value"
151 value={row.value}
152 onChange={(e) => updateRow(index, { value: e.target.value })}
153 />
154 <Button
155 type="button"
156 variant="ghost"
157 size="icon"
158 onClick={() => removeRow(index)}
159 aria-label="Remove field"
160 >
161 <XIcon className="size-4" />
162 </Button>
163 </div>
164 ))}
165 <Button
166 type="button"
167 variant="outline"
168 size="sm"
169 onClick={addRow}
170 className="self-start"
171 >
172 <PlusIcon className="size-4" />
173 Add field
174 </Button>
175 <div className="flex justify-end gap-2">
176 <Button
177 type="button"
178 variant="outline"
179 onClick={cancelEditing}
180 disabled={loading}
181 >
182 Cancel
183 </Button>
184 <Button type="button" onClick={saveLabels} disabled={loading}>
185 Save
186 </Button>
187 </div>
188 </div>
189 ) : labelEntries.length > 0 ? (
190 <div className="mt-2 flex items-center gap-2 flex-wrap">
191 {labelEntries.map(([key, value]) => (
192 <Badge key={key} variant="secondary" className="font-normal">
193 <span className="text-muted-foreground">{key}:</span>
194 <span>{value}</span>
195 </Badge>
196 ))}
197 </div>
198 ) : (
199 <p className="mt-1 text-sm text-muted-foreground">No labels yet.</p>
200 )}
201 </div>
202 );
203 }
204
205 export default TransactionLabels;
206