Building a Banking admin app using Blitzjs

Building a Banking admin app using Blitzjs
Author
Chidume Nnamdi
Related tags on daily.dev

🎯

The incredible thing about Blitz is that it comes with the basic stuff we will need when building a web app. Blitz.js generates for you a default client-side implementation that you customize along with backend code to support it. Let's see it in action!

In this post, we will build a banking admin app using the Blitz.js framework. This tutorial will help us understand the core concepts of the Blitz.js framework and learn how we can apply the concepts to build real-world applications.

The banking admin app will be able to:

  • Create accounts
  • View transactions in an account
  • Make credits
  • Make debits

Read on to see how all these can be done in a Blitz.js app.

Blitz.js

Blitz.js is a full-stack React framework built on Nextjs, it was inspired by Ruby on Rails.

The incredible thing about Blitz is that it comes with the basic stuff we will need when building a web app. Blitz.js generates for you a default client-side implementation that you customize along with backend code to support it.

Blitz.js uses Prisma ORM client for database connection, object modeling, and migrations.

API endpoints in Blitz.js are written in mutation and query resolvers. These resolvers are functions called from components, they are where data are added or retrieve from the database using the Prisma client.

With Blitz, all we have to do is scaffold the project and we are almost half-done.

Requirements

We need to have the following tools installed in our machine:

  • Node.js - Blitz.js is based on Node.js, so we will need to have Node.js installed in our machine. Go to https://nodejs.org/download page to download the binary for your machine.
  • NPM This is Node packager manager for the Node.js, it comes bundled with Node.js.
  • Yarn This is another Node package manager, it is faster than NPM. If you decide to use it, install it by running the command npm i -g yarn.
  • blitz This is the Blitz.js CLI tool, we will need it to scaffold Blitz.js projects, generate files and folders, run Blitz.js servers, and lots more. To install the Blitz.js tool run the command yarn add global blitz.

Building the bank admin app

First, we have to scaffold the Blitz.js project. Run the below command to do that:


blitz new bank-admin

The new sub-command tells Blitz that we want to create a new Blitz.js project. The bank-admin is the name of the folder where the project will reside. The Blitz tool will create the folder and create the files and folder of the project inside it.

The above command at some point would request for a Form library to choose, just hit Enter to choose the recommended one.


✔ Pick a form library (you can switch to something else later if you want) · React Final Form

After that it will generate the files:

Log of generated files
Log of generated files
Log of generated files
Log of generated files

Then it will install the dependencies:

Dependencies installed
Dependencies installed

and exit.

Move into the bank-admin folder: cd bank-admin.

We are set. Let's look at the routes our app will have.

Our bank admin app will have the below routes:

Routes:

/accounts: List all the accounts in the system. /accounts/:id: View an account. /accounts/new: This page is where new accounts are added to the system.

/transactions: This page is where all transactions carried out are listed. /transactions/:id: This page displays a specific transaction.

/transactions/new: This page is where a transaction is carried out.

Let's create the accounts pages:


blitz g all account

This command creates the files:

  • app/pages/accounts/[accountId].tsx
  • app/pages/accounts/[accountId]/edit.tsx
  • app/pages/accounts/index.tsx
  • app/pages/accounts/new.tsx
  • app/accounts/components/AccountForm.tsx
  • app/accounts/queries/getAccount.ts
  • app/accounts/queries/getAccounts.ts
  • app/accounts/mutations/createAccount.ts
  • app/accounts/mutations/deleteAccount.ts
  • app/accounts/mutations/updateAccount.ts

The command will create an Account model in the schema.prisma. The command will run migrations on the newly added model and it will prompt for a name for the migration, type added_account and hit Enter.

Creating Account model and running migrations
Creating Account model and running migrations

Let's go through the files:

  • app/pages/accounts/[accountId].tsx: This file will display the details of an account based on its accountId id.
  • app/pages/accounts/[accountId]/edit.tsx: This file is where we edit an account.
  • app/pages/accounts/index.tsx: This file is where all accounts in the system are displayed.
  • app/pages/accounts/new.tsx
  • app/accounts/components/AccountForm.tsx: We will remove this, we do not need it.
  • app/accounts/queries/getAccount.ts: This is a query resolver file. This file gets an account from the database.
  • app/accounts/queries/getAccounts.ts: This file is a query resolver file too, it returns all the accounts from the database.
  • app/accounts/mutations/createAccount.ts: This file is a mutation resolver, it is where new accounts are created in the database.
  • app/accounts/mutations/deleteAccount.ts: This is a mutation resolver, it is here that accounts are deleted from the database.
  • app/accounts/mutations/updateAccount.ts: This is a mutation resolver, it is here accounts are edited in the database.

Now Let's create the transaction model. Run the below command:


blitz g all transaction

This will create pages, models, mutations, and queries for the transaction model.

Generate all files for the Transaction model
Generate all files for the Transaction model

Let's go through the files:

  • app/pages/transactions/[transactionId].tsx: This is where a transaction in the system is displayed.
  • app/pages/transactions/[transactionId]/edit.tsx: We will not need this file, so we will remove. It should have been where we edit a transaction but in a banking system transactions are not edited so we do away with this.
  • app/pages/transactions/index.tsx: This file displays the list of transactions that has been carried out in our system.
  • app/pages/transactions/new.tsx: This file is where a new transaction is carried out.
  • app/transactions/components/TransactionForm.tsx: We don't need this, so we will remove it.
  • app/transactions/queries/getTransaction.ts: This is a query resolver that returns a transaction from the database.
  • app/transactions/queries/getTransactions.ts: This is a query resolver file that returns all the transactions in the database.
  • app/transactions/mutations/createTransaction.ts: This is a mutation resolver that creates a new transaction in the database.
  • app/transactions/mutations/deleteTransaction.ts: This is a mutation resolver file, it deletes a transaction from the database. We won't need it because we can't delete transactions from the system.
  • app/transactions/mutations/updateTransaction.ts: This is a mutation resolver file that modifies a transaction in the database. We won't need this file too because we can modify a transaction.

We will need some components:

  • Header: This will hold the header section of our app. It will display the name of our app on every page.
  • AccountCard: This component will render account details on the /accounts page.
  • TransactionCard: This component will display transaction details on the /transactions page.

We have listed and narrated each component, before we flesh them out let's redefine our Schema models. We have Account and Transaction models in the schema.prisma file. We will need to add more fields to the models:

Account


model Account {
  id        Int      @id @default(autoincrement())
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
  name      String
  balance   Int      @default(0)
}

We added name and balance fields. The name holds the name of the account holder, e.g "John Smith", "Nnamdi Chidume". The balance is the amount of money in dollars the account holder has in the account e.g 10,000, 2,000,000.

Transaction


model Transaction {
  id        Int      @id @default(autoincrement())
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
  sender    Int
  receiver  Int
  amount    Int
}

We added the fields: sender, receiver, and amount. The sender is the account sending the money, the receiver is the beneficiary, and the amount is the amount in dollars being sent.

Now we run the migrations:


blitz prisma migrate dev

This command will run migrations on the new fields we added to the models in our schema.prisma.

Let's start coding our components:

Header

Create a folder Header in app/core/components. Create the files: index.tsx and Header.module.css

Open the index.tsx and paste the below code:


import { header, headerName } from "./Header.module.css";

export default function Header() {
  return (
    <section className={header}>
      <div className={headerName}>BankSys</div>
    </section>
  );
}

This is simple, the UI renders BankSys. And here’s the styling:


.header {
  height: 54px;
  background-color: rgb(43 156 245);
  color: white;
  display: flex;
  align-items: center;
  padding: 10px;
  font-family: sans-serif;
  width: 100%;
  padding-left: 19%;
}

.headerName {
  font-size: 1.8em;
}

AccountCard

This component is a presentational component that renders an account object passed to it.

This component will reside in the app/accounts/components folder. So create an AccountCard folder in app/accounts/components and create the files in it: index.tsx and AccountCard.module.css.

Paste the below in the index.tsx file:


import styles from "./AccountCard.module.css";
import Link from "next/link";

export default function AccountCard({ account }) {
  const { id, name, balance, createdAt } = account;
  return (
    <Link href={`accounts/${id}`}>
      <div className={styles.account}>
        <div className={styles.accountdetails}>
          <div className={styles.accountname}>
            <h3>
              <span style={{ fontWeight: "100" }}>Account: </span>
              {name}
            </h3>
          </div>
          <div className={styles.accountbalance}>
            <span>
              <span style={{ fontWeight: "100" }}>Balance($): </span>
              {balance}
            </span>
          </div>
          <div className={styles.accountcreated_at}>
            <span>Created: {createdAt.toString()}</span>
          </div>
        </div>
      </div>
    </Link>
  );
}

See that we destructured account from the props. Also from the account, we destructured the properties from its model: id, name, balance, and createdAt. These fields are what we want the component to display. The UI does the job of displaying the fields.

Paste the styling code in the AccountCard.module.css:


.account {
  display: flex;
  border-bottom: 1px solid rgba(232, 232, 2321);
  padding-bottom: 12px;
  margin: 20px 0;
  cursor: pointer;
}

.accountdetails {
  display: flex;
  flex-direction: column;
}

.accountname h3 {
  margin-top: 0;
  margin-bottom: 5px;
}

.accountbalance {
  color: black;
  padding: 7px 0;
}

.accountcreated_at {
  font-weight: 100;
}

.accountcreated_at :nth-child(1) {
  padding-right: 14px;
}

.accountcreated_at :nth-child(2) {
  padding-right: 3px;
}

TransactionCard

This is the same as the AccountCard component above, it displays the details of a transaction. Create a TransactionCard folder in app/transactions/components, and inside the TransactionCard create index.tsx and TransactionCard.module.css.

Inside the index.tsx paste the below code:


import styles from "./TransactionCard.module.css";
import Link from "next/link";

export default function TransactionCard({ transaction }) {
  const { id, sender, receiver, amount, createdAt } = transaction;
  return (
    <Link href={`transactions/${id}`}>
      <div className={styles.transactionCard}>
        <div className={styles.transactionCardDetails}>
          <div className={styles.transactionCardName}>
            <h4>
              <span>Sender: </span>
              <span style={{ fontWeight: "bold" }}>{sender?.name}</span>
            </h4>
          </div>
          <div className={styles.transactionCardName}>
            <h4>
              <span>Receiver: </span>
              <span style={{ fontWeight: "bold" }}>{receiver?.name}</span>
            </h4>
          </div>
          <div className={styles.transactionCardName}>
            <h4>
              <span>Amount($): </span>
              <span style={{ fontWeight: "bold" }}>{amount}</span>
            </h4>
          </div>
          <div className={styles.transactionCardName}>
            <h4>
              <span>Created At: </span>
              <span style={{ fontWeight: "bold" }}>{createdAt.toString()}</span>
            </h4>
          </div>
        </div>
      </div>
    </Link>
  );
}

The code destructures the details of the transaction from the passed in transaction object. These details are then rendered in the UI.

For the styling paste the below CSS code in TransactionCard.module.css:


.transactionCard {
  display: flex;
  border-bottom: 1px solid darkgray;
  padding-bottom: 10px;
  margin: 19px 0px;
  cursor: pointer;
}

.transactionCardDetails {
  display: flex;
  flex-direction: column;
}

.transactionCardName h4 {
  font-weight: 300;
  margin: 5px 0;
  margin-top: 0;
}

Now we have completed the work on our presentational components. We can then proceed working on our Accounts queries and mutations. Blitz scaffolds code with authorization as default and pagination, we don't want them so let’s remove them. Open blitzjs/bank-admin/app/accounts/queries/getAccount.ts

blitzjs/bank-admin/app/accounts/queries/getAccount.ts

Rewrite the code here to below:


import { resolver, NotFoundError } from "blitz";
import db from "db";
import { z } from "zod";

const GetAccount = z.object({
  // This accepts type of undefined, but is required at runtime
  id: z.number().optional().refine(Boolean, "Required"),
});

export default resolver.pipe(resolver.zod(GetAccount), async ({ id }) => {
  // TODO: in multi-tenant app, you must add validation to ensure correct tenant
  const account = await db.account.findFirst({ where: { id } });

  if (!account) throw new NotFoundError();

  return account;
});

The GetAccount is a schema that defines the inputs of the query resolver. The function retrieves the account by calling db.account.findFirst({ where: { id } }).

The where condition is used to retrieve a specific account whose id is the same as the id we want. The result of the query is then returned.

There was a resolver.authorize() function call passed in as the second arg but we removed it because we don't want authorization. We will do the same with all queries and mutations in this project.

Let's rewrite the blitzjs/bank-admin/app/accounts/queries/getAccounts.ts:


import { NotFoundError, resolver } from "blitz";
import db, { Prisma } from "db";

export default resolver.pipe(async () => {
  // TODO: in multi-tenant app, you must add validation to ensure correct tenant
  return db.account.findMany();
});

This returns all accounts in the db, the .findMany() call does that.

app/accounts/mutations/createAccount.ts


import { resolver } from "blitz";
import db from "db";
import { z } from "zod";

const CreateAccount = z.object({
  name: z.string(),
  balance: z.number(),
});

export default resolver.pipe(resolver.zod(CreateAccount), async (input) => {
  // TODO: in multi-tenant app, you must add validation to ensure correct tenant
  const account = await db.account.create({ data: input });
  return account;
});

This creates a new entry in the account table. The db.account.create({ data: input }) does that, the details of the new account is held in the input param, and we see that it is passed to the db.account.create({ data: input }) call.

app/accounts/mutations/deleteAccount.ts


import { resolver } from "blitz";
import db from "db";
import { z } from "zod";

const DeleteAccount = z.object({
  id: z.number(),
});

export default resolver.pipe(resolver.zod(DeleteAccount), async ({ id }) => {
  // TODO: in multi-tenant app, you must add validation to ensure correct tenant
  const account = await db.account.deleteMany({ where: { id } });

  return account;
});

This code deletes an account from the database. It does it by calling the db.account.deleteMany({ where: { id } }) method. The where condition is passed to the deleteMany() to tell the database the account by its id to delete.

app/accounts/mutations/updateAccount.ts


import { resolver } from "blitz";
import db from "db";
import { z } from "zod";

const UpdateAccount = z.object({
  id: z.number(),
  name: z.string(),
});

export default resolver.pipe(
  resolver.zod(UpdateAccount),
  async ({ id, ...data }) => {
    // TODO: in multi-tenant app, you must add validation to ensure correct tenant
    const account = await db.account.update({ where: { id }, data });

    return account;
  }
);

This code updates an already existing account on the database. The db.account.update({ where: { id }, data }) method call did the trick. The where condition tells the database the account we want to update, and the data is the new details of the account.

Now, we are done with the account queries and mutations. Let's move over to the accounts pages.

app/pages/accounts/index.tsx

Blitz has its code already set up for us, but we want a custom UI. This page will fetch all accounts from the database and display them:


import { Head, useQuery, useRouter, BlitzPage, Routes } from "blitz";
import getAccounts from "app/accounts/queries/getAccounts";

import styles from "app/styles/Home.module.css";
import AccountCard from "app/accounts/components/AccountCard";

const AccountsPage: BlitzPage = () => {
  const [accounts] = useQuery(getAccounts, {});
  const router = useRouter();

  return (
    <div className={styles.container}>
      <Head>
        <title>Bank Admin</title>
        <link rel="icon" href="/favicon.ico" />
      </Head>

      <main className={styles.main}>
        <div className={styles.breadcrumb}>
          <div>
            <span style={{ margin: "1px" }}>
              <button onClick={() => router.push("/transactions/new")}>
                Transact
              </button>
            </span>
            <span style={{ margin: "1px" }}>
              <button onClick={() => router.push("/accounts/new")}>
                Add Account
              </button>
            </span>
            <span style={{ margin: "1px" }}>
              <button onClick={() => router.push(Routes.TransactionsPage())}>
                Go to Transactions
              </button>
            </span>
          </div>
        </div>

        <div className={styles.accountcontainer}>
          <div className={styles.youraccounts}>
            <h3>All Accounts</h3>
          </div>
          <div>
            {accounts?.map((account, i) => (
              <AccountCard key={i} account={account} />
            ))}
          </div>
        </div>
      </main>
    </div>
  );
};
export default AccountsPage;

Blitz exports useQuery and useMutation hooks. These are used to call our query and mutation resolver respectively. We used the useQuery hook to call the getAccounts query resolver. This returns all the accounts in the database, we stored this result in the accounts variable and use it to display the accounts. See that we used the .map method to render arrays in React, the AccountCard is passed each account to render each account.

app/pages/accounts/new.tsx

Paste the below code to app/pages/accounts/new.tsx:


import { Head, Link, useRouter, useMutation, BlitzPage, Routes } from "blitz";
import createAccount from "app/accounts/mutations/createAccount";
import styles from "app/styles/Home.module.css";
import { useState, useRef } from "react";

const NewAccountPage: BlitzPage = () => {
  const [disable, setDisable] = useState(false);
  const router = useRouter();
  const [createAccountMutation] = useMutation(createAccount);
  const formRef = useRef<string | undefined>();

  async function addAccount() {
    const { accountName, accountBalance } = formRef.current;

    const name = accountName.value;
    const balance = accountBalance.value;
    const account = await createAccountMutation({
      name,
      balance: parseInt(balance),
    });
    router.push(Routes.AccountsPage());
  }
  return (
    <div className={styles.container}>
      <Head>
        <title>Bank Admin</title>
        <link rel="icon" href="/favicon.ico" />
      </Head>

      <main className={styles.main}>
        <div className={styles.breadcrumb}>
          <div>
            <span style={{ margin: "1px" }}>
              <button onClick={() => router.push(Routes.AccountsPage())}>
                Go to Accounts
              </button>
            </span>
            <span style={{ margin: "1px" }}>
              <button onClick={() => router.push(Routes.TransactionsPage())}>
                Go to Transactions
              </button>
            </span>
          </div>
        </div>

        <div className={styles.accountcontainer}>
          <div className={styles.youraccounts}>
            <h3>Add New Account</h3>
          </div>
          <div>
            <form ref={formRef}>
              <div
                style={{ display: "flex", flexWrap: "wrap", marginTop: "9px" }}
              >
                <div className="inputField">
                  <div className="label">
                    <label>Name</label>
                  </div>
                  <div>
                    <input id="accountName" type="text" />
                  </div>
                </div>
                <div className="inputField">
                  <div className="label">
                    <label>Balance($):</label>
                  </div>
                  <div>
                    <input id="accountBalance" type="text" />
                  </div>
                </div>
                <div
                  style={{
                    display: "flex",
                    marginTop: "12px",
                    justifyContent: "flex-end",
                    width: "100%",
                  }}
                >
                  <span style={{ margin: "1px" }}>
                    <button
                      disabled={disable}
                      className="btn"
                      onClick={addAccount}
                    >
                      Add Account
                    </button>
                  </span>
                </div>
              </div>
            </form>
          </div>
        </div>
      </main>
    </div>
  );
};

export default NewAccountPage;

This code renders input boxes where the name of the account holder and its initial balance are typed in. The “Add Account” button when clicked calls the addAccount function. This function retrieves the values from the input boxes and calls the createAccountMutation function with the same values. The createAccountMutation is the function returned from calling the useMutation hook with createAccount. It calls the resolver function in the createAccount.ts file.

app/pages/accounts/[accountId].tsx


import {
  useRouter,
  useQuery,
  useParam,
  BlitzPage,
  useMutation,
  Routes,
} from "blitz";
import getAccount from "app/accounts/queries/getAccount";
import deleteAccount from "app/accounts/mutations/deleteAccount";
import TransactionCard from "app/transactions/components/TransactionCard";
import styles from "app/styles/AccountView.module.css";
import getTransactions from "app/transactions/queries/getTransactions";

export const ShowAccountPage: BlitzPage = () => {
  const router = useRouter();
  const accountId = useParam("accountId", "number");
  const [deleteAccountMutation] = useMutation(deleteAccount);
  const [account] = useQuery(getAccount, { id: accountId });

  const [transactions] = useQuery(getTransactions, {
    where: { OR: [{ sender: accountId }, { receiver: accountId }] },
  });

  async function deleteAccountFn() {
    if (confirm("Do you really want to delete this account?")) {
      await deleteAccountMutation({ id: account?.id });
      router.push(Routes.AccountsPage());
    }
  }

  return (
    <div className={styles.accountviewcontainer}>
      <div className={styles.accountviewmain}>
        <div style={{ width: "100%" }}>
          <div className={styles.accountviewname}>
            <h1>{account?.name}</h1>
          </div>
          <div className={styles.accountviewminidet}>
            <div>
              <span style={{ marginRight: "4px", color: "rgb(142 142 142)" }}>
                Balance($):
              </span>
              <span style={{ fontWeight: "600" }}>{account?.balance}</span>
            </div>
            <div style={{ padding: "14px 0" }}>
              <span style={{ margin: "1px" }}>
                <button
                  onClick={() => router.push(`/accounts/${account?.id}/edit`)}
                  className="btn"
                >
                  Edit
                </button>
              </span>
              <span style={{ margin: "1px" }}>
                <button onClick={deleteAccountFn} className="btn-danger">
                  Delete
                </button>
              </span>
            </div>
          </div>
          <div className={styles.accountviewtransactionscont}>
            <div className={styles.accountviewtransactions}>
              <h2>Transactions</h2>
            </div>
            <div className={styles.accountviewtransactionslist}>
              {!transactions || transactions?.length <= 0
                ? "No transactions yet."
                : transactions?.map((transaction, i) => (
                    <TransactionCard key={i} transaction={transaction} />
                  ))}
            </div>
          </div>
        </div>
      </div>
    </div>
  );
};

export default ShowAccountPage;

This component retrieves the value of the accountId param from the dynamic route URL using the useParam hook. The account id is passed as param to the useQuery(getAccount, { id: accountId }) method call. This will call the resolver function at getAccount.ts with the account id, this returns the account and sets it to the account variable. This account is used to display the account details on the UI. The transactions done by the account is retrieved too. This is done by calling:


const [transactions] = useQuery(getTransactions, {
  where: { OR: [{ sender: accountId }, { receiver: accountId }] },
});

We are telling the database to get us the transactions in which the sender is the account id or the receiver is the account id. In English, it is to return us the transactions of the account id where the id has sent money to another person or received money from another person.

The deleteAccountFn is called by the Delete button. The function calls the deleteAccountMutation function to remove the account from the database.

The details of the account and its transactions are displayed on the UI. The transactions array is rendered in the UI using the .map method, each transaction UI is handled by the TransactionCard component.

app/pages/accounts/[accountId]/edit.tsx

This page will have input boxes filled with the details of an account. The details can be modified and persist in the database.


import { useState, useRef } from "react";
import {
  Head,
  useRouter,
  useQuery,
  useMutation,
  useParam,
  BlitzPage,
  Routes,
} from "blitz";
import getAccount from "app/accounts/queries/getAccount";
import updateAccount from "app/accounts/mutations/updateAccount";
import styles from "app/styles/Home.module.css";

export const EditAccountPage: BlitzPage = () => {
  const [disable, setDisable] = useState(false);
  const formRef = useRef();

  const router = useRouter();
  const accountId = useParam("accountId", "number");
  const [account, { setQueryData }] = useQuery(
    getAccount,
    { id: accountId },
    {
      // This ensures the query never refreshes and overwrites the form data while the user is editing.
      staleTime: Infinity,
    }
  );
  const [updateAccountMutation] = useMutation(updateAccount);

  async function editAccount() {
    const { accountName, accountBalance } = formRef.current;

    const name = accountName.value;
    const balance = accountBalance.value;

    await updateAccountMutation({
      id: account?.id,
      name,
      balance,
    });
    router.push(Routes.ShowAccountPage({ accountId: account?.id }));
  }

  return (
    <div className={styles.container}>
      <Head>
        <title>Bank Admin</title>
        <link rel="icon" href="/favicon.ico" />
      </Head>

      <main className={styles.main}>
        <div className={styles.breadcrumb}>
          <div>
            <span style={{ margin: "1px" }}>
              <button onClick={() => router.push(Routes.AccountsPage())}>
                Go to Accounts
              </button>
            </span>
            <span style={{ margin: "1px" }}>
              <button onClick={() => router.push(Routes.TransactionsPage())}>
                Transact
              </button>
            </span>
          </div>
        </div>

        <div className={styles.accountcontainer}>
          <div className={styles.youraccounts}>
            <h3>Edit Account</h3>
          </div>
          <div>
            <form ref={formRef}>
              <div
                style={{ display: "flex", flexWrap: "wrap", marginTop: "9px" }}
              >
                <div className="inputField">
                  <div className="label">
                    <label>Name</label>
                  </div>
                  <div>
                    <input
                      id="accountName"
                      type="text"
                      defaultValue={account?.name}
                    />
                  </div>
                </div>
                <div className="inputField">
                  <div className="label">
                    <label>Balance($):</label>
                  </div>
                  <div>
                    <input
                      id="accountBalance"
                      type="text"
                      defaultValue={account?.balance}
                    />
                  </div>
                </div>
                <div
                  style={{
                    display: "flex",
                    marginTop: "12px",
                    justifyContent: "flex-end",
                    width: "100%",
                  }}
                >
                  <span style={{ margin: "1px" }}>
                    <button
                      disabled={disable}
                      className="btn"
                      onClick={editAccount}
                    >
                      Save Account
                    </button>
                  </span>
                </div>
              </div>
            </form>
          </div>
        </div>
      </main>
    </div>
  );
};

export default EditAccountPage;

The id of the account accountId is gotten from the URL using the useParam hook. The getAccount query is called via the useQuery hook, it returns the details of the account id. The values of the input boxes are filled with the details of the account object.

We can then edit the values in the input boxes. Clicking the “Save Account” button runs the editAccount function, the function gathers the values from the input boxes and calls the updateAccountMutation method passing the gathered values to it as params. The id of the account is passed also, this enables the database to ascertain the account to edit. The updateAccountMutation method calls the function in updateAccount.ts to modify the account with the details sent to it. We are done with the Account model, let's move over to Transaction.

Let's start with the mutations. First, we look at createTransaction.

app/transactions/mutations/createTransaction.ts

This file is where we carry out the core function. The credit and debit are made here. Let's see the code:


import { resolver } from "blitz";
import db from "db";

export default resolver.pipe(async (input: any) => {
  // TODO: in multi-tenant app, you must add validation to ensure correct tenant
  const senderDetails = await db.account.findFirst({
    where: { id: input?.sender },
  });
  await db.account.update({
    where: { id: senderDetails?.id },
    data: { balance: senderDetails.balance - input?.amount },
  });

  const receiverDetails = await db.account.findFirst({
    where: { id: input?.receiver },
  });
  await db.account.update({
    where: { id: receiverDetails?.id },
    data: { balance: receiverDetails.balance + input?.amount },
  });

  const transaction = await db.transaction.create({ data: input });

  return transaction;
});

The input will expect the sender's and receiver's id and the amount.

First, we retrieved the sender account details using the sender id. Then, we subtracted the amount from the balance in the sender's account.

Next, we retrieved the receiver account details and added the amount to the receiver's balance. Finally, we added the transaction to the transaction table.

That is it. When transferring money from one account to another account. The amount to send is debited from the sender's balance, and the amount is added to the beneficiary's balance.

So we have completed it, let's look at the queries. Let's go to getTransaction.

app/transactions/queries/getTransaction.ts

This query simply retrieves a transaction using its id and returns it.


import { resolver, NotFoundError } from "blitz";
import db from "db";
import { z } from "zod";

const GetTransaction = z.object({
  // This accepts type of undefined, but is required at runtime
  id: z.number().optional().refine(Boolean, "Required"),
});

export default resolver.pipe(resolver.zod(GetTransaction), async ({ id }) => {
  // TODO: in multi-tenant app, you must add validation to ensure correct tenant
  const transaction = await db.transaction.findFirst({ where: { id } });

  if (!transaction) throw new NotFoundError();

  return transaction;
});

It uses the where condition arg in the db.transaction.findFirst({ where: { id } }) call to get the specific transaction with the id and return the transaction object.

app/transactions/queries/getTransactions.ts


import { resolver } from "blitz";
import db from "db";

export default resolver.pipe(async ({ where = undefined }) => {
  // TODO: in multi-tenant app, you must add validation to ensure correct tenant
  return await db.transaction.findMany({ where });
});

This simply returns all the transactions in the database. We passed in where condition for some cases where we might return some transactions. For example, in our accounts/[accountId] page we need to fetch transactions that the account holder has made. So the where helps us, in this case, to fetch the transactions without creating a separate query for it.

app/pages/transactions/index.tsx

This page fetches all transactions from the database and displays them:


import { Suspense } from "react";
import { Head, Link, useQuery, useRouter, BlitzPage, Routes } from "blitz";
import getTransactions from "app/transactions/queries/getTransactions";
import TransactionCard from "app/transactions/components/TransactionCard";
import styles from "app/styles/Home.module.css";
import getAccount from "app/accounts/queries/getAccount";

export const TransactionsList = () => {
  var [transactions] = useQuery(getTransactions, {});
  transactions = transactions.map((transaction) => {
    const [sender] = useQuery(getAccount, { id: transaction?.sender });
    const [receiver] = useQuery(getAccount, { id: transaction?.receiver });
    return { ...transaction, sender, receiver };
  });

  return (
    <>
      {transactions?.map((transaction, i) => (
        <TransactionCard transaction={transaction} key={i} />
      ))}
    </>
  );
};

const TransactionsPage: BlitzPage = () => {
  const router = useRouter();

  return (
    <div className={styles.container}>
      <Head>
        <title>Bank Admin</title>
        <link rel="icon" href="/favicon.ico" />
      </Head>

      <main className={styles.main}>
        <div className={styles.breadcrumb}>
          <div>
            <span style={{ margin: "1px" }}>
              <button onClick={() => router.push("/transactions/new")}>
                Transact
              </button>
            </span>
            <span style={{ margin: "1px" }}>
              <button onClick={() => router.push("/accounts/new")}>
                Add Account
              </button>
            </span>
            <span style={{ margin: "1px" }}>
              <button onClick={() => router.push(Routes.AccountsPage())}>
                Go to Accounts
              </button>
            </span>
          </div>
        </div>

        <div className={styles.accountcontainer}>
          <div className={styles.youraccounts}>
            <h3>All Transactions</h3>
          </div>
          <div>
            <Suspense fallback={<div>Loading...</div>}>
              <TransactionsList />
            </Suspense>
          </div>
        </div>
      </main>
    </div>
  );
};

export default TransactionsPage;

It uses the useQuery hook to call the getTransactions query. The result is stored in transactions variable. Since the sender and receiver fields in a transaction just holds ids to the account object, we map through the transactions array and populate them with their account objects.

Then, we render the transactions using the .map method; each transaction is rendered by the TransactionCard component.

app/pages/transactions/[transactionId].tsx

This page fetches the transaction object from the transaction id.


import { Suspense } from "react";
import {
  useRouter,
  useQuery,
  useParam,
  BlitzPage,
  useMutation,
  Routes,
} from "blitz";
import getTransaction from "app/transactions/queries/getTransaction";
import styles from "app/styles/AccountView.module.css";
import getAccount from "app/accounts/queries/getAccount";

export const Transaction = () => {
  const router = useRouter();
  const transactionId = useParam("transactionId", "number");
  const [transaction] = useQuery(getTransaction, { id: transactionId });
  const [sender] = useQuery(getAccount, { id: transaction?.sender });
  const [receiver] = useQuery(getAccount, { id: transaction?.receiver });

  return (
    <div className={styles.accountviewcontainer}>
      <div className={styles.accountviewmain}>
        <div style={{ width: "100%" }}>
          <div className={styles.accountviewname}>
            <h1>Transaction ID: {transaction?.id}</h1>
          </div>
          <div className={styles.accountviewminidet}>
            <div>
              <h2 style={{ marginRight: "4px" }}>
                <span style={{ color: "grey" }}>Sender:</span> {sender?.name}
              </h2>
            </div>
            <div>
              <h2 style={{ marginRight: "4px" }}>
                <span style={{ color: "grey" }}>Receiver:</span>{" "}
                {receiver?.name}
              </h2>
            </div>
            <div>
              <h2 style={{ fontWeight: "600" }}>
                <span style={{ color: "grey" }}>Amount($):</span>{" "}
                {transaction?.amount}
              </h2>
            </div>
          </div>
        </div>
      </div>
    </div>
  );
};

const ShowTransactionPage: BlitzPage = () => {
  return (
    <Suspense fallback={<div>Loading...</div>}>
      <Transaction />
    </Suspense>
  );
};

export default ShowTransactionPage;

It uses the useParam hook to extract the value of the transactionId from the URL. This id is passed to useQuery(getTransaction, { id: transactionId }) to get the transaction details. Next, we also fetched the sender and receiver account details from the sender and receiver fields in the transaction object. Finally, the transaction details are rendered in the UI.

app/pages/transactions/new.tsx

Let's see the code:


import {
  Link,
  invoke,
  Head,
  useRouter,
  useMutation,
  useQuery,
  BlitzPage,
  Routes,
} from "blitz";
import Layout from "app/core/layouts/Layout";
import createTransaction from "app/transactions/mutations/createTransaction";
import {
  TransactionForm,
  FORM_ERROR,
} from "app/transactions/components/TransactionForm";
import styles from "app/styles/Home.module.css";
import { useRef, useState } from "react";
import getAccount from "app/accounts/queries/getAccount";

const NewTransactionPage: BlitzPage = () => {
  const [disable, setDisable] = useState(false);
  const [sender, setSender] = useState();
  const [fetchingSender, setFetchingSender] = useState(false);
  const [receiver, setReceiver] = useState();
  const [fetchingReceiver, setFetchingReceiver] = useState(false);

  const router = useRouter();
  const [createTransactionMutation] = useMutation(createTransaction);
  const formRef = useRef();

  async function transact() {
    const sender = formRef.current.sender.value;
    const receiver = formRef.current.receiver.value;
    const amount = formRef.current.amount.value;
    const newTransact = await createTransactionMutation({
      sender: parseInt(sender),
      receiver: parseInt(receiver),
      amount: parseInt(amount),
    });
    router.push(Routes.TransactionsPage());
  }

  async function fetchAccountSender(e) {
    const value = e.target.value;
    setSender(undefined);
    setFetchingSender(true);
    if (value.length == 0) {
      setSender(undefined);
    } else {
      const account = await invoke(getAccount, { id: parseInt(value) });
      setSender(account);
    }
    setFetchingSender(false);
  }

  async function fetchAccountReceiver(e) {
    const value = e.target.value;
    setReceiver(undefined);
    setFetchingReceiver(true);
    if (value.length == 0) {
      setReceiver(undefined);
    } else {
      const account = await invoke(getAccount, { id: parseInt(value) });
      setReceiver(account);
    }
    setFetchingReceiver(false);
  }

  return (
    <div className={styles.container}>
      <Head>
        <title>Bank Admin</title>
        <link rel="icon" href="/favicon.ico" />
      </Head>

      <main className={styles.main}>
        <div className={styles.breadcrumb}>
          <div>
            <span style={{ margin: "1px" }}>
              <button onClick={() => router.push(Routes.AccountsPage())}>
                Go to Accounts
              </button>
            </span>
            <span style={{ margin: "1px" }}>
              <button onClick={() => router.push(Routes.TransactionsPage())}>
                Go to Transactions
              </button>
            </span>
          </div>
        </div>

        <div className={styles.accountcontainer}>
          <div className={styles.youraccounts}>
            <h3>Transact</h3>
          </div>
          <div>
            <form ref={formRef}>
              <div style={{ display: "flex", flexWrap: "wrap" }}>
                <div className="inputField">
                  <div className="label">
                    <label>Sender</label>
                  </div>
                  <div>
                    <input
                      name="sender"
                      type="text"
                      onChange={fetchAccountSender}
                      placeholder="Type in account id"
                    />
                    {fetchingSender ? (
                      <span style={{ fontSize: "11px" }}>
                        Fetching account name...
                      </span>
                    ) : null}
                    {sender && (
                      <span style={{ fontSize: "11px" }}>
                        Sender: {sender?.name}
                      </span>
                    )}
                  </div>
                </div>
                <div className="inputField">
                  <div className="label">
                    <label>Receiver</label>
                  </div>
                  <div>
                    <input
                      name="receiver"
                      type="text"
                      onChange={fetchAccountReceiver}
                      placeholder="Type in account id"
                    />
                    {fetchingReceiver ? (
                      <span style={{ fontSize: "11px" }}>
                        Fetching account name...
                      </span>
                    ) : null}
                    {receiver && (
                      <span style={{ fontSize: "11px" }}>
                        Receiver: {receiver?.name}
                      </span>
                    )}
                  </div>
                </div>
                <div className="inputField">
                  <div className="label">
                    <label>Amount($)</label>
                  </div>
                  <div>
                    <input name="amount" type="text" />
                  </div>
                </div>
              </div>
              <button
                disabled={disable}
                style={{ float: "right", marginTop: "9px" }}
                className="btn"
                onClick={transact}
              >
                Transact
              </button>
            </form>
          </div>
        </div>
      </main>
    </div>
  );
};

export default NewTransactionPage;

It has input boxes where we enter the ids of the sender account and the receiver account. There is also an input box where the amount to send is typed. On clicking the “Transact” button, the sender's and receiver's ids are retrieved from the input boxes. Then, the createTransactionMutation method is called with the retrieved values. This call proceeds to make the transaction from both parties and eventually adds the transaction to the transaction table. The fetchAccountReceiver and fetchAccountSender functions retrieve the details of the sender and the receiver respectively from the inputs. This enables the user to see the account name of the sender and the receiver.

We are done building our app. If the Blitz server is not running, spin it up:


yarn dev
# OR
npm run dev

Go to localhost:3000/accounts and start banking.

Test the app

All Accounts

View all accounts
View all accounts

All Transactions

View all transactions
View all transactions

Create Account

Create an account
Create an account
View a created account
View a created account

Transfer Money

Transfer money from a sender to a recipient
Transfer money from a sender to a recipient
View the accounts with their modified balances
View the accounts with their modified balances
View the new transaction
View the new transaction

View Account

View an account
View an account

View Transaction

View a transaction
View a transaction

Delete Account

Delete an account
Delete an account
View the accounts without the deleted account
View the accounts without the deleted account

Conclusion

Blitz.js is an awesome framework, it makes the job easier for devs. We learned a lot here. We started with introducing Blitz.js, detailing its great features and also how to install it. Next, we demonstrated how to use the various features of Blitzjs by creating a minimal banking admin app just like Finacle. Yes, the bank app we built here lacks so many features. I urge you to keep going from this point, it's a great way of learning a new tool and picking up on it very fast.

Source code

References

Why not level up your reading with

Stay up-to-date with the latest developer news every time you open a new tab.

Read more