Build a Dashboard app with React, Vite and Tauri ๐จ ๐ป
Build an awesome dashboard app with React, Vite and Tauri
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.
Mantine UI Library
React Query
Recharts
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