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