Build a Dashboard app with React, Vite and Tauri  ๐Ÿ‘จ ๐Ÿ’ป

Build a Dashboard app with React, Vite and Tauri ๐Ÿ‘จ ๐Ÿ’ป

Build an awesome dashboard app with React, Vite and Tauri

ยท

10 min read

In this article, we are going to create a basic Dashboard layout, app shell, window menu, some graphs and a table to show the orders. I think this will give you some confidence and then you are good to go

There are a lot of good libraries out there to build dashboards like Refine, React Admin, etc. For the sake of simplicity, I will not be using any above-mentioned libraries.

Note: This article is part of a series. In my previous article, I discussed setting up React, Vite and Tauri

Introduction ๐Ÿ“

Below is the list of packages we are going to use, you can install them at first or as required.

  1. Mantine UI Library

  2. React Query

  3. Fine Food API

  4. Recharts

  5. Eslint for linting

Write your first line of code in React and Tauri ๐Ÿ’ป

Let us change the title of the App Window. Navigate to src-tauri>tauri.conf.json and change the title to Fine Food Dashboard

 "windows": [
      {
        "fullscreen": false,
        "resizable": true,
        "title": "Fine Food Dashboard",
        "width": 800,
        "height": 600
      }
    ]

Tada !!!!! ๐Ÿ’˜

Install ESLint โ˜‘๏ธ

Let's start by installing eslint and other packages related to eslint.

npm i -D eslint eslint-import-resolver-typescript eslint-plugin-import 
npm i -D eslint-plugin-react eslint-plugin-react-hooks 
npm i -D @tanstack/eslint-plugin-query @typescript-eslint/eslint-plugin
npm i -D @vitejs/plugin-react

Once this is done, now we need to make a .eslintrc.cjs file

module.exports = {
  root: true,
  parser: "@typescript-eslint/parser",
  parserOptions: {
    ecmaVersion: 2021,
    sourceType: "module",
    ecmaFeatures: {
      jsx: true,
    },
  },
  env: {
    browser: true,
    es2021: true,
    node: true,
  },
  plugins: ["react", "react-hooks", "import", "@tanstack/query"],
  extends: [
    "eslint:recommended",
    "plugin:react/recommended",
    "plugin:react-hooks/recommended",
    "plugin:import/recommended",
    "plugin:import/typescript",
    "plugin:@typescript-eslint/eslint-recommended",
    "plugin:@typescript-eslint/recommended",
    "plugin:@tanstack/eslint-plugin-query/recommended",
  ],
  rules: {
    // Add any additional rules or overrides here
    "@tanstack/query/exhaustive-deps": "error",
    "@tanstack/query/prefer-query-object-syntax": "error",
    "react/react-in-jsx-scope": "off",
    "no-unused-vars": "off",
    "@typescript-eslint/no-unused-vars": "error",
    "no-console": "error", // Enforce error for console.log and related statements
    "import/order": [
      "error",
      {
        groups: [
          ["external", "builtin"],
          "internal",
          ["sibling", "parent"],
          "index",
        ],
        pathGroups: [
          {
            pattern: "@(react|react-native)",
            group: "external",
            position: "before",
          },
          {
            pattern: "src/**",
            group: "internal",
          },
        ],
        pathGroupsExcludedImportTypes: ["internal", "react"],
        "newlines-between": "always",
        alphabetize: {
          order: "asc",
          caseInsensitive: true,
        },
      },
    ],
  },
  settings: {
    "import/resolver": {
      node: {},
      typescript: {},
    },
  },
  overrides: [
    {
      files: ["**/*.js", "**/*.jsx", "**/*.ts", "**/*.tsx"],
    },
  ],
};

well, your linting is set up. In any case, if there is any problem you can find my Github repo here

Install Mantine

npm install @mantine/core @mantine/hooks @emotion/react

Integrate Mantine

Inside main.tsx put you <App/> inside <MantineProvider/>

import React from 'react';
import ReactDOM from 'react-dom/client';
import { MantineProvider } from '@mantine/core';

import App from './App';
import './styles.css';

ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
  <React.StrictMode>
    <MantineProvider withGlobalStyles withNormalizeCSS>
      <App />
    </MantineProvider>
  </React.StrictMode>
);

and now you can use themes easily.

AppShell ๐Ÿš

AppShell is a layout component that can be used to create a common Header, Navbar, Footer, Aside and Content layout pattern.

import { AppShell as MantineAppShell } from '@mantine/core';
import { ReactNode } from 'react';
import { Navbar } from './Navbar';
import { Header } from './Header';

export function AppShell({ children }: { children: ReactNode }) {
  return (
    <MantineAppShell
      padding='sm'
      navbar={<Navbar />}
      header={<Header />}
      styles={(theme) => ({
        main: {
          backgroundColor:
            theme.colorScheme === 'dark'
              ? theme.colors.dark[8]
              : theme.colors.gray[0],
        },
      })}
    >
      {children}
    </MantineAppShell>
  );
}

You can easily find Navbar and Header samples from my GitHub repo.

Adding Window Menu ๐Ÿ—”

Time to write some Rust code ๐Ÿ˜….

Inside main.rs file you need to write a function to add a menu to the window and handle events for each menu. This is pretty easy believe me. I have created two functions createmenu and handle_menu_event

// Prevents additional console window on Windows in release, DO NOT REMOVE!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]

use tauri::{CustomMenuItem, Menu, Submenu};
use tauri::WindowMenuEvent;

// Learn more about Tauri commands at https://tauri.app/v1/guides/features/command
#[tauri::command]
fn greet(name: &str) -> String {
    format!("Hello, {}! You've been greeted from Rust!", name)
}

// Event handler to handle menu events
fn handle_menu_event(event: WindowMenuEvent)  {

    // You can see what event looks like 
    // print!("{:?}",event);

    match event.menu_item_id() {
        "quit" => {
          std::process::exit(0);
        }
        "close" => {
          event.window().close().unwrap();
        }
        _ => {}
      }

}

fn createmenu() -> Menu {

    let quit = CustomMenuItem::new("quit".to_string(), "Quit");
    let close = CustomMenuItem::new("close".to_string(), "Close");
    let zoom_in = CustomMenuItem::new("zoom_in".to_string(), "Zoom In");
    let zoom_out = CustomMenuItem::new("zoom_out".to_string(), "Zoom Out");
    let reset_zoom = CustomMenuItem::new("reset_zoom".to_string(), "Reset Zoom");
    let check_for_updates = CustomMenuItem::new("check_for_updates".to_string(), "Check for Updates");
    let about = CustomMenuItem::new("about".to_string(), "About");

    let file_submenu = Submenu::new("File", Menu::new().add_item(quit).add_item(close));
    let view_submenu = Submenu::new("View", Menu::new().add_item(zoom_in).add_item(zoom_out).add_item(reset_zoom));
    let help_submenu = Submenu::new("Help", Menu::new().add_item(check_for_updates).add_item(about));

     Menu::new()
      .add_submenu(file_submenu)
      .add_submenu(view_submenu)
      .add_submenu(help_submenu) 

}


fn main() {

    tauri::Builder::default()
        // Add Menu
        .menu(createmenu())
        // Handle events for menu
        .on_menu_event(|event| {
            handle_menu_event(event)
          })
        .invoke_handler(tauri::generate_handler![greet])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

Dashboard Graph ๐Ÿ“Š

We will make a daily revenue graph. For the rest of the graph code please see my GitHub

Let's make DailyRevenuePresentation Component. The purpose of this component is to fetch the graph data and pass it to DailyRevenueChartContainer Component.

import { useMantineTheme } from "@mantine/core";
import { useQuery } from "@tanstack/react-query";
import {
  LineChart,
  Line,
  CartesianGrid,
  Tooltip,
  ResponsiveContainer,
  XAxis,
  Label,
} from "recharts";

import { ApiError } from "@common/components/error";
import { LoadingSkeleton } from "@common/components/loading";
import {
  QueryStringParams,
  getFormattedQueryStringParams,
} from "@common/react-query/utils";
import { ResourceList } from "@common/route";

type DailyRevenue = {
  date: string;
  value: number;
};

interface IDailyRevenueResponse {
  data: DailyRevenue[];
  total: number;
  trend: number;
}

const fetchData = async (
  queryStringParams: QueryStringParams
): Promise<IDailyRevenueResponse> => {
  const queryParams = getFormattedQueryStringParams(queryStringParams);

  const response = await fetch(
    `${import.meta.env.VITE_FINE_FOOD_API_URL}/${
      ResourceList.DAILY_REVENUE
    }?${queryParams}`
  );

  if (!response.ok) {
    if (response.status === 404) {
      throw new Error("Data not found");
    } else {
      throw new Error("Failed to fetch data");
    }
  }
  return response.json();
};

const DailyRevenuePresentation = () => {
  // You can make this dynamic using date picker
  const start = "Sun, 28 May 2023 18:30:00 GMT";
  const end = "Sun, 04 Jun 2023 18:30:00 GMT";
  const queryStringParams = {
    start,
    end,
  };

  const { data, isLoading, isError } = useQuery(
    ["dailyRevenue", queryStringParams],
    () => fetchData(queryStringParams)
  );

  if (isLoading) {
    return <LoadingSkeleton />;
  }

  if (isError) {
    return <ApiError />;
  }
  return <DailyRevenueChartContainer data={data} />;
};

export const DailyRevenueChart = DailyRevenuePresentation;

Now let's write code for DailyRevenueChartContainer, this component renders the graph based on data received.

const DailyRevenueChartContainer = ({
  data,
}: {
  data: IDailyRevenueResponse;
}) => {
  const theme = useMantineTheme();

  return (
    <ResponsiveContainer width="100%" height="100%">
      <LineChart
        width={500}
        height={300}
        data={data.data}
        margin={{
          top: 5,
          right: 30,
          left: 20,
          bottom: 5,
        }}
      >
        <CartesianGrid strokeDasharray="3 3" />
        <Tooltip
          formatter={(value) => `$ ${value}`}
          labelFormatter={() => ""}
          itemStyle={{ color: theme.colors.dark[9] }}
        />
        <svg
          width="200"
          height="100"
          x="-5%"
          y="-1%"
          className="responsive-text"
        >
          <text
            x="50%"
            y="50%"
            textAnchor="middle"
            fontWeight="bold"
            dominantBaseline="middle"
          >
            $ {(data.total / 1000).toFixed(1)} K
          </text>
          <path d="M100 20 L90 30 L110 30 Z" fill={theme.colors.green[7]} />
        </svg>

        <Line
          type="monotone"
          dataKey="value"
          fill={theme.colors.green[4]}
          stroke={theme.colors.green[8]}
        />
        <XAxis dataKey="name">
          <Label value="Daily Revenue" offset={0} position="insideBottom" />
        </XAxis>
      </LineChart>
    </ResponsiveContainer>
  );
};

The final code looks something like this

import { useMantineTheme } from "@mantine/core";
import { useQuery } from "@tanstack/react-query";
import {
  LineChart,
  Line,
  CartesianGrid,
  Tooltip,
  ResponsiveContainer,
  XAxis,
  Label,
} from "recharts";

import { ApiError } from "@common/components/error";
import { LoadingSkeleton } from "@common/components/loading";
import {
  QueryStringParams,
  getFormattedQueryStringParams,
} from "@common/react-query/utils";
import { ResourceList } from "@common/route";

type DailyRevenue = {
  date: string;
  value: number;
};

interface IDailyRevenueResponse {
  data: DailyRevenue[];
  total: number;
  trend: number;
}

const fetchData = async (
  queryStringParams: QueryStringParams
): Promise<IDailyRevenueResponse> => {
  const queryParams = getFormattedQueryStringParams(queryStringParams);

  const response = await fetch(
    `${import.meta.env.VITE_FINE_FOOD_API_URL}/${
      ResourceList.DAILY_REVENUE
    }?${queryParams}`
  );

  if (!response.ok) {
    if (response.status === 404) {
      throw new Error("Data not found");
    } else {
      throw new Error("Failed to fetch data");
    }
  }
  return response.json();
};

const DailyRevenuePresentation = () => {
  // You can make this dynamic using date picker
  const start = "Sun, 28 May 2023 18:30:00 GMT";
  const end = "Sun, 04 Jun 2023 18:30:00 GMT";
  const queryStringParams = {
    start,
    end,
  };

  const { data, isLoading, isError } = useQuery(
    ["dailyRevenue", queryStringParams],
    () => fetchData(queryStringParams)
  );

  if (isLoading) {
    return <LoadingSkeleton />;
  }

  if (isError) {
    return <ApiError />;
  }
  return <DailyRevenueChartContainer data={data} />;
};

const DailyRevenueChartContainer = ({
  data,
}: {
  data: IDailyRevenueResponse;
}) => {
  const theme = useMantineTheme();

  return (
    <ResponsiveContainer width="100%" height="100%">
      <LineChart
        width={500}
        height={300}
        data={data.data}
        margin={{
          top: 5,
          right: 30,
          left: 20,
          bottom: 5,
        }}
      >
        <CartesianGrid strokeDasharray="3 3" />
        <Tooltip
          formatter={(value) => `$ ${value}`}
          labelFormatter={() => ""}
          itemStyle={{ color: theme.colors.dark[9] }}
        />
        <svg
          width="200"
          height="100"
          x="-5%"
          y="-1%"
          className="responsive-text"
        >
          <text
            x="50%"
            y="50%"
            textAnchor="middle"
            fontWeight="bold"
            dominantBaseline="middle"
          >
            $ {(data.total / 1000).toFixed(1)} K
          </text>
          <path d="M100 20 L90 30 L110 30 Z" fill={theme.colors.green[7]} />
        </svg>

        <Line
          type="monotone"
          dataKey="value"
          fill={theme.colors.green[4]}
          stroke={theme.colors.green[8]}
        />
        <XAxis dataKey="name">
          <Label value="Daily Revenue" offset={0} position="insideBottom" />
        </XAxis>
      </LineChart>
    </ResponsiveContainer>
  );
};

export const DailyRevenueChart = DailyRevenuePresentation;

Dashboard Orders Table ๐ŸชŸ

We are going to make OrderDataTable Component. This table will render the order data, you can search/filter/sort order numbers.

Well, we will fetch data using react query. We will use _start and _end for pagination and q to search Order Number.

  const { data, isLoading, isError } = useQuery(
    ["orders", { _start: startPage, _end: endPage, q: debounced.orderNumber }],
    () =>
      fetchData({ _start: startPage, _end: endPage, q: debounced.orderNumber }),
    {
      keepPreviousData: true,
      staleTime: 5000,
    }
  );

Now we can just pass data to MaintineTable. The final Code looks something like this

import { useState, CSSProperties } from "react";

import {
  MantineTheme,
  Skeleton,
  TextInput,
  rem,
  useMantineTheme,
} from "@mantine/core";
import { useDebouncedValue } from "@mantine/hooks";
import { IconSearch } from "@tabler/icons-react";
import { useQuery } from "@tanstack/react-query";
import { DataTable } from "mantine-datatable";

import { ApiError } from "@common/components/error";
import {
  QueryStringParams,
  getFormattedQueryStringParams,
} from "@common/react-query/utils";
import { ResourceList } from "@common/route";

import { IOrdersResponse, Status } from "./types";

type TStatusStyles = { [x in Status]: CSSProperties };

const statusStyles = (theme: MantineTheme, status: Status) => {
  const mapStyles: TStatusStyles = {
    [Status.Delivered]: {
      backgroundColor: theme.colors.green[5],
      fontWeight: "bold",
    },
    [Status.OnTheWay]: {
      backgroundColor: theme.colors.gray[5],
      fontWeight: "bold",
      color: theme.colors.dark[4],
    },
    [Status.Cancelled]: {
      backgroundColor: theme.colors.red[3],
      fontWeight: "bold",
      color: theme.colors.dark[4],
    },
    [Status.Pending]: {
      backgroundColor: theme.colors.orange[3],
      fontWeight: "bold",
      color: theme.colors.dark[4],
    },
    [Status.Ready]: {
      backgroundColor: theme.colors.blue[2],
      fontWeight: "bold",
      color: theme.colors.dark[4],
    },
  };

  return mapStyles[status];
};

const fetchData = async (
  queryStringParams: QueryStringParams
): Promise<IOrdersResponse[]> => {
  const queryParams = getFormattedQueryStringParams(queryStringParams);

  const response = await fetch(
    `${import.meta.env.VITE_FINE_FOOD_API_URL}/${
      ResourceList.ORDERS
    }?${queryParams}`
  );

  if (!response.ok) {
    if (response.status === 404) {
      throw new Error("Data not found");
    } else {
      throw new Error("Failed to fetch data");
    }
  }
  return response.json();
};

const PAGE_SIZES = [10, 15, 20];

export const OrderDataTable = () => {
  const [page, setPaginationOptions] = useState({
    currentPage: 1,
    startPage: 0,
    endPage: 20,
    recordsPerPage: PAGE_SIZES[2],
  });

  const [searchFilters, setSearchFilter] = useState({
    orderNumber: "",
  });
  const [debounced] = useDebouncedValue(searchFilters, 500);

  const { orderNumber } = searchFilters;

  const { currentPage, startPage, endPage, recordsPerPage } = page;

  const { data, isLoading, isError } = useQuery({
    queryKey: [
      "orders",
      { _start: startPage, _end: endPage, q: debounced.orderNumber },
    ],
    queryFn: () =>
      fetchData({
        _start: startPage,
        _end: endPage,
        q: debounced.orderNumber,
      }),
    keepPreviousData: true,
    staleTime: 5000,
  });

  const theme = useMantineTheme();

  if (isLoading) {
    return <Skeleton height={`calc(100vh - ${rem(84)} - ${rem(30)})`} />;
  }

  if (isError) {
    return <ApiError />;
  }

  const getOrders = () => {
    const orders = data?.map(
      ({ orderNumber, status, amount, store, user, products, createdAt }) => ({
        orderNumber,
        status: status.text,
        amount,
        store: store.title,
        user: user.fullName,
        products: `${products.length} Item`,
        createdAt,
      })
    );

    return orders;
  };

  return (
    <DataTable
      columns={[
        {
          accessor: "orderNumber",
          filter: (
            <TextInput
              label="Order Number"
              description="Show orders"
              placeholder="Search orders..."
              icon={<IconSearch size={16} />}
              value={orderNumber}
              onChange={(e) =>
                setSearchFilter((prevState) => ({
                  ...prevState,
                  orderNumber: e.currentTarget.value,
                }))
              }
            />
          ),
          filtering: orderNumber !== "",
        },
        {
          accessor: "status",
          cellsStyle: ({ status }) => statusStyles(theme, status),
        },
        { accessor: "amount" },
        { accessor: "store" },
        { accessor: "user" },
        { accessor: "products" },
        { accessor: "createdAt" },
      ]}
      idAccessor="orderNumber"
      records={getOrders()}
      withBorder
      // No way to render infinte button ?
      totalRecords={299}
      paginationColor="grape"
      recordsPerPage={recordsPerPage}
      page={currentPage}
      onPageChange={(p) =>
        setPaginationOptions(() => ({
          currentPage: p,
          startPage: (p - 1) * recordsPerPage,
          endPage: (p - 1) * recordsPerPage + recordsPerPage,
          recordsPerPage,
        }))
      }
      recordsPerPageOptions={PAGE_SIZES}
      onRecordsPerPageChange={(selectedRecordPerPage) =>
        setPaginationOptions((prevState) => ({
          ...prevState,
          startPage: (currentPage - 1) * selectedRecordPerPage,
          endPage:
            (currentPage - 1) * selectedRecordPerPage + selectedRecordPerPage,
          recordsPerPage: selectedRecordPerPage,
        }))
      }
    />
  );
};

Well I will end this article here, To see the code for other pages like Products, Stores see my Github

ย