Skip to content

Commit

Permalink
Feat: Table view on large screens for transaction history (#56)
Browse files Browse the repository at this point in the history
  • Loading branch information
drishit96 authored Jan 7, 2024
1 parent e9a3be2 commit a85ba2b
Show file tree
Hide file tree
Showing 3 changed files with 291 additions and 17 deletions.
247 changes: 247 additions & 0 deletions app/components/TransactionsTable.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,247 @@
import type { Navigation, SubmitFunction, SubmitOptions } from "@remix-run/react";
import { Form, Link, useNavigation, useOutletContext, useSubmit } from "@remix-run/react";
import { Ripple } from "@rmwc/ripple";
import type { SortingState } from "@tanstack/react-table";
import {
createColumnHelper,
flexRender,
getCoreRowModel,
getSortedRowModel,
useReactTable,
} from "@tanstack/react-table";
import type { TransactionResponse } from "~/modules/transaction/transaction.schema";
import type { AppContext } from "~/root";
import { formatDate_YYYY_MM_DD } from "~/utils/date.utils";
import { formatNumber } from "~/utils/number.utils";
import EditIcon from "./icons/EditIcon";
import RepeatIcon from "./icons/RepeatIcon";
import TrashIcon from "./icons/TrashIcon";
import { firstLetterToUpperCase } from "~/utils/text.utils";
import { getTransactionColor } from "~/utils/colors.utils";
import { useMemo, useState } from "react";
import Decimal from "decimal.js";

const columnHelper = createColumnHelper<TransactionResponse>();

function getColumns(context: AppContext, navigation: Navigation, submit: SubmitFunction) {
return [
columnHelper.accessor("createdAt", {
header: () => "Date",
cell: (info) => (
<div className="text-end">{formatDate_YYYY_MM_DD(new Date(info.getValue()))}</div>
),
}),
columnHelper.accessor("type", {
header: () => "Type",
cell: (info) => <span>{firstLetterToUpperCase(info.getValue())}</span>,
}),
columnHelper.accessor("category", {
header: () => "Category",
cell: (info) => info.getValue(),
}),
columnHelper.accessor("amount", {
header: () => "Amount",
cell: (info) => {
const transactionType = info.row.original.type;
return (
<div
className={getTransactionColor(transactionType) + " text-end tabular-nums"}
>
{formatNumber(
info.getValue().toString(),
context.userPreferredLocale ?? context.locale
)}
</div>
);
},
sortingFn: (a, b) =>
new Decimal(a.getValue("amount"))
.sub(new Decimal(b.getValue("amount")))
.toNumber(),
}),
columnHelper.accessor("paymentMode", {
header: () => "Payment mode",
cell: (info) => info.getValue(),
}),
columnHelper.accessor("description", {
header: () => "Description",
cell: (info) => (
<div className="whitespace-nowrap overflow-hidden text-ellipsis">
{info.getValue()}
</div>
),
meta: {
type: "longText",
},
}),
columnHelper.display({
id: "actions",
header: () => "",
meta: {
type: "actions",
},
cell: ({ row }) => {
const isTransactionUpdateInProgress =
navigation.state === "submitting" &&
navigation.formMethod === "DELETE" &&
navigation.formData?.get("transactionId") === row.id;
return (
<span className="flex">
<Ripple unbounded>
<Link
data-test-id={"btn-edit"}
to={`/transaction/edit/${row.id}`}
className="flex justify-center items-center p-3"
title="Edit"
>
<EditIcon size={24} />
</Link>
</Ripple>
<Ripple unbounded>
<Link
data-test-id={"btn-make-this-recurring"}
to={`/transaction/recurring/new?amount=${row.getValue(
"amount"
)}&category=${encodeURIComponent(
row.getValue("category")
)}&type=${row.getValue("type")}&paymentMode=${row.getValue(
"paymentMode"
)}&description=${row.getValue("description")}`}
className="flex justify-center items-center p-3 border-l border-primary focus-border"
title="Make this recurring"
>
<RepeatIcon size={24} />
</Link>
</Ripple>

<Form
replace
method="DELETE"
className="flex cursor-pointer border-l border-primary"
>
<input type="hidden" name="transactionId" value={row.id} />
<Ripple unbounded>
<button
data-test-id={"btn-delete"}
className="flex w-full p-3 focus-border rounded-md"
type="submit"
disabled={isTransactionUpdateInProgress}
title="Delete"
onClick={(e) => {
e.preventDefault();
context.setDialogProps({
title: "Delete transaction?",
message:
"Once you delete this transaction, it cannot be recovered. Continue with deletion?",
showDialog: true,
positiveButton: "Delete",
onPositiveClick: () => {
const form = new FormData();
form.set("transactionId", row.id);
const submitOptions: SubmitOptions = {
method: "DELETE",
replace: true,
};
submit(form, submitOptions);
},
});
}}
>
<TrashIcon size={24} />
</button>
</Ripple>
</Form>
</span>
);
},
}),
];
}

export default function TransactionsTable({
transactions,
}: {
transactions: TransactionResponse[];
}) {
const context = useOutletContext<AppContext>();
const navigation = useNavigation();
const submit = useSubmit();
const columns = useMemo(() => getColumns(context, navigation, submit), []);
const [sorting, setSorting] = useState<SortingState>([]);
const table = useReactTable({
data: transactions,
columns: columns,
state: {
sorting,
},
onSortingChange: setSorting,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
getRowId: (row) => row.id,
});

return (
<div className="sticky p-2 w-full bg-background">
<table className="w-full">
<thead>
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id} className="border-b border-primary">
{headerGroup.headers.map((header, index) => (
<th
className={`p-2 ${
index === 0 ? "" : "border-l"
} border-t border-primary focus-border`}
key={header.id}
>
{header.isPlaceholder ? null : (
<div
{...{
className: header.column.getCanSort()
? "cursor-pointer select-none"
: "",
onClick: header.column.getToggleSortingHandler(),
}}
>
{flexRender(header.column.columnDef.header, header.getContext())}
{{
asc: " ▲",
desc: " ▼",
}[header.column.getIsSorted() as string] ?? null}
</div>
)}
</th>
))}
</tr>
))}
</thead>
<tbody>
{table.getRowModel().rows.map((row) => (
<tr key={row.id} className="border-b border-primary hover:bg-elevated-10">
{row.getVisibleCells().map((cell, index) => (
<td
className={`${
index === 0 ? "" : "border-l"
} border-primary focus-border ${
(cell.column.columnDef.meta as any)?.type === "actions" ? "" : "p-2"
} ${
(cell.column.columnDef.meta as any)?.type === "longText"
? "max-w-0 whitespace-nowrap overflow-hidden text-ellipsis"
: ""
}`}
key={row.id + cell.column.columnDef.header}
title={
(cell.column.columnDef.meta as any)?.type === "longText"
? row.original.description?.toString()
: ""
}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
}
54 changes: 37 additions & 17 deletions app/routes/transaction/history.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import { InlineSpacer } from "~/components/InlineSpacer";
import type { NewFilter } from "~/components/FilterBottomSheet";
import FilterBottomSheet from "~/components/FilterBottomSheet";
import { divide, formatToCurrency, sum } from "~/utils/number.utils";
import TransactionsTable from "~/components/TransactionsTable";

export const meta: MetaFunction = ({ matches }) => {
let rootModule = matches.find((match) => match.id === "root");
Expand Down Expand Up @@ -129,11 +130,26 @@ export default function TransactionHistory() {
const [expandedTransactionIndex, setExpandedTransactionIndex] = useState<
number | undefined
>(undefined);
const [isLargeScreen, setIsLargeScreen] = useState(false);

useEffect(() => {
context.showBackButton(true);
}, [context]);

const onWindowResize = () => {
if (!document.hidden) {
setIsLargeScreen(window.innerWidth >= 1180);
}
};

useEffect(() => {
setIsLargeScreen(window.innerWidth >= 1180);
window.addEventListener("resize", onWindowResize);
return () => {
window.removeEventListener("resize", onWindowResize);
};
}, []);

function setNewFilter(newFilter: NewFilter) {
const { selectedCategories, month, selectedTypes, selectedPaymentModes } = newFilter;

Expand Down Expand Up @@ -166,7 +182,7 @@ export default function TransactionHistory() {
<main className="pt-7 pb-12 pl-3 pr-3">
<h1 className="text-3xl text-center">Your transactions</h1>
<div className="flex flex-col justify-center items-center">
<div className="w-full md:w-3/4 lg:w-2/3 xl:w-1/2 mt-3">
<div className="w-full md:w-11/12 xl:w-10/12 mt-3">
<div className="flex bg-base sticky top-0 z-10">
<Form
className="flex flex-col items-center md:items-start w-8/12 lg:w-10/12"
Expand Down Expand Up @@ -277,22 +293,26 @@ export default function TransactionHistory() {
</div>
<Spacer />

<ul ref={listParent}>
{transactions.map((transaction, index) => {
return (
<li key={transaction.id}>
<Transaction
transaction={transaction}
navigation={navigation}
hideDivider={index == transactions.length - 1}
index={index}
expandedIndex={expandedTransactionIndex}
setExpandedIndex={setExpandedTransactionIndex}
/>
</li>
);
})}
</ul>
{isLargeScreen ? (
<TransactionsTable transactions={transactions} />
) : (
<ul ref={listParent}>
{transactions.map((transaction, index) => {
return (
<li key={transaction.id}>
<Transaction
transaction={transaction}
navigation={navigation}
hideDivider={index == transactions.length - 1}
index={index}
expandedIndex={expandedTransactionIndex}
setExpandedIndex={setExpandedTransactionIndex}
/>
</li>
);
})}
</ul>
)}
</div>
</div>
<Spacer size={4} />
Expand Down
7 changes: 7 additions & 0 deletions app/utils/text.utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,10 @@ export function base64ToBuffer(text: string) {
})
);
}

export function firstLetterToUpperCase(text: string) {
if (text == null) return "";
if (text.length === 0) return "";
if (text.length === 1) return text.toUpperCase();
return text.charAt(0).toUpperCase() + text.slice(1);
}

0 comments on commit a85ba2b

Please sign in to comment.