Tauri CRUD Boilerplate

buchslava

Vyacheslav Chub

Posted on April 10, 2024

Tauri CRUD Boilerplate

Hi, dear Tauri! Long time no see. I published my first post, Developing a Desktop Application via Rust and NextJS. The Tauri Way almost a year ago. Since then, Tauri has become stronger. I'm happy about that! And now, I am very pleased to make a useful contribution to the Tauri community. As a full-stack developer, I frequently face situations where I need to start a DB-based UI project as fast as possible. It's stressful if I need to start the project from 100% scratch. I prefer to keep some boilerplates on hand, which will save me time and nerves and will be the subject of this article.

I want to present you with my first version of the Tauri CRUD Boilerplate, which will help you bootstrap and prototype a new Tauri project from scratch. Let me focus on explanations in a What-Where-When style.

My Tauri CRUD Boilerplate is not a silver bullet; it's just a set of valuable scratches that help you build and connect the DB and UI parts via Tauri. Also, the Tauri CRUD Boilerplate is useful primarily for fast prototyping because it contains React and Ant Design as UI players inside, and, of course, you can substitute them with another library or framework or even provide your custom solution instead. If we focus on the DB part, I use SQLite due to its simplicity and portability. In the future, you can change SQLite. As a roadmap, I'll think about the second version of the bootstrap when DB will be substituted with the equivalent REST API plus authentication.

Let's get started.

There are a couple of tables, person and todo.

CREATE TABLE IF NOT EXISTS person (
    id INTEGER PRIMARY KEY NOT NULL,
    name VARCHAR(250) NOT NULL
);
Enter fullscreen mode Exit fullscreen mode
CREATE TABLE IF NOT EXISTS todo (
    id INTEGER PRIMARY KEY NOT NULL,
    date VARCHAR(20) NOT NULL,
    notes TEXT,
    person_id INTEGER NOT NULL,
    completed INTEGER NOT NULL,
    FOREIGN KEY(person_id) REFERENCES person(id)
);
Enter fullscreen mode Exit fullscreen mode

As we can see, each person can contain a set of todos. person_id is a foreign key here.

I implemented the following functionality.

  1. Data management (see Data tab below). The main aim is to create an empty SQLite DB (according to the definitions above) and fill it with the test data.
  2. The Persons tab contains a table that shows a set of persons and provides functionality for adding, editing, and deleting them.
  3. The Todos tab contains two related widgets: Persons as a read-only list and a table that shows a set of person-related todos. It provides functionality for adding, editing, and deleting them.

Data

Data tab

Let me highlight some essential points in the Data tab code.

  • You can find Create DB-related DB code here and UI code here.
  • You can find Fill DB-related DB code here and UI code here.

It's important to have test data and the ability to recreate it when needed (via Create DB). If you want to remove the DB, you can just delete a related file.

DB connect

It's time to know how the connection with the DB works. First, we need to get the SQLite DB path

fn get_database_path() -> io::Result<PathBuf> {
    let mut exe = env::current_exe()?;
    exe.set_file_name("./db");
    #[cfg(dev)]
    exe.set_file_name("../../../db");
    Ok(exe)
}
Enter fullscreen mode Exit fullscreen mode

In production mode, we use the current folder to keep the DB, and the DB will be kept in the same folder as the executable file. But in the case of the debug mode, I prefer to keep the database file at the root of the project. Because the executable file in debug mode is placed in [project folder]/src-tauri/target/debug, we conditionally use the ../../../ path that's equal to the project folder in debug mode. Fortunately, the cfg attribute helps us to do that. BTW, you can change this logic if needed.

After we have a DB part, we need to get a path as a string

pub fn get_database() -> String {
    let db_url = match get_database_path() {
        Ok(path) => path.into_os_string().into_string().unwrap(),
        Err(e) => e.to_string(),
    };
    return db_url;
}
Enter fullscreen mode Exit fullscreen mode

We use the util function above in other functions the following way. For example, when we need to insert a new record into the person table.

#[tauri::command]
pub async fn person_insert(name: &str) -> Result<i64, String> {
    // get DB url as a string
    let db_url = util::db::get_database();
    // Connect with the DB; get connection from the pool
    let db = SqlitePool::connect(&db_url).await.unwrap();
    // Do all needed DB stuff
    let query_result = sqlx
        ::query("INSERT INTO person (name) VALUES (?)")
        .bind(name)
        .execute(&db).await;
    if query_result.is_err() {
        db.close().await;
        return Err(format!("{:?}", query_result.err()));
    }

    let id = query_result.unwrap().last_insert_rowid();
    // Close the connection
    db.close().await;
    Ok(id)
}
Enter fullscreen mode Exit fullscreen mode

Persons

It's time to understand how our first CRUD works on the example of Persons.

Let's dig into the UI part. You can read full text of the component here.

Also, let's dig into the data loading. Please, read comment in the following code.

  const load = async () => {
    try {
      // get the data
      const result = await apiCall("person_select");
      // make it as a JSON
      const items = JSON.parse(JSON.parse(result as unknown as string));
      // set the related React state variable
      setData(
        items.map((item: any) => ({
          key: item.id,
          ...item,
        }))
      );
    } catch (e) {
      console.error(e);
      errorMessage.open({
        type: "error",
        content: "Can't load the person list",
      });
    }
  };
Enter fullscreen mode Exit fullscreen mode

Let's focus on apiCall function. This function is important because it's a link between React and Rust parts.

import { InvokeArgs, invoke } from "@tauri-apps/api/tauri";

export const apiCall = async <T>(
  name: string,
  parameters?: InvokeArgs
): Promise<T> =>
  new Promise((resolve, reject) =>
    invoke(name, parameters)
      .then(resolve as (value: unknown) => PromiseLike<T>)
      .catch(reject)
  );
Enter fullscreen mode Exit fullscreen mode

We import invoke function from @tauri-apps/api/tauri and just call the following functionality in Rust part. In this example we are talking about person_select

#[tauri::command]
pub async fn person_select() -> Result<String, String> {
    let db_url = util::db::get_database();
    let db = SqlitePool::connect(&db_url).await.unwrap();
    let query_result = sqlx
        ::query_as::<_, Person>("SELECT id, name FROM person ORDER BY id DESC")
        .fetch_all(&db).await;
    if query_result.is_err() {
        db.close().await;
        return Err(format!("{:?}", query_result.err()));
    }
    let results = query_result.unwrap();
    let encoded_message = serde_json::to_string(&results).unwrap();
    db.close().await;
    Ok(format!("{:?}", encoded_message))
}
Enter fullscreen mode Exit fullscreen mode

Also, I'd like to focus your attention on the following data structure.

#[derive(Serialize, Clone, FromRow, Debug)]
pub struct Person {
    id: i64,
    name: String,
}
Enter fullscreen mode Exit fullscreen mode

Persons tab

After that, I intend to be brief because I don't want to waste your time. That's why I am providing you with some significant points regarding the code.

Let's look at Insert and Edit...

Person edit

  1. Press New Person button or Edit button on each row of data.
  2. Both of addNewRow and doEdit works with the modal window here that use PersonEdit component.
  3. The following logic works when the form from the component above has been submitted. Its main goal is to make all expected changes (call Rust code), hide the modal window, or show the error if it is not OK.

Todos

Todos functionality is a bit more complicated than Persons because it contains Persons and is represented by TodoContainer component that you see below. Let's focus on how TodoContainer works.

  1. It based on a Antd's Tab component.
  2. Tab's items are persons.
  3. Each Tab-person has its own Todo table that also contains adding and editing functionalities.

Todos tab

Focusing on the Todo and TodoEdit components doesn't matter because their logic is similar to that of Person and PersonEdit.

Todo edit

How to use

I want to stop discussing the code and focus on some practical aspects. Let's run the app in dev mode.

Traditionally,

npm ci
Enter fullscreen mode Exit fullscreen mode

Run in dev mode

npm run tauri dev
Enter fullscreen mode Exit fullscreen mode

Persons actions

Todos actions

In production mode:

npm run tauri build
Enter fullscreen mode Exit fullscreen mode

You can found:

  • An installer-based (dmg) app in [project root]/src-tauri/target/release/bundle/dmg if you want to run the installer
  • Or just the app in [project root]/src-tauri/target/release/bundle/macos if you want to run the app directly

Components refreshing

The app's most tricky part concerns data synchronization. Imagine we just added a new person to the Persons tab and moved them to the Todo tab. The person list on the left side should be refreshed, shouldn't it? Let me share some thoughts about data synchronization.

I provided a context that contains the following data.

import React, { createContext, useContext, useState } from "react";

export interface DataRefreshDescriptor {
  person: Date;
  todo: Date;
}

type GlobalContextProps = {
  refreshDescriptor: DataRefreshDescriptor;
  refreshPerson: () => void;
  refreshTodo: () => void;
};

const GlobalContext = createContext({} as GlobalContextProps);

export type TargetKey = React.MouseEvent | React.KeyboardEvent | string;

export const GlobalProvider = ({ children }: { children: React.ReactNode }) => {
  const now = new Date();
  const [refreshDescriptor, setRefreshDescriptor] =
    useState<DataRefreshDescriptor>({
      person: now,
      todo: now,
    });

  const refreshPerson = () => {
    setRefreshDescriptor({ ...refreshDescriptor, person: new Date() });
  };

  const refreshTodo = () => {
    setRefreshDescriptor({ ...refreshDescriptor, todo: new Date() });
  };

  return (
    <GlobalContext.Provider
      value={{
        refreshDescriptor,
        refreshPerson,
        refreshTodo,
      }}
    >
      {children}
    </GlobalContext.Provider>
  );
};

export const useGlobalContext = () => useContext(GlobalContext);
Enter fullscreen mode Exit fullscreen mode

The following fragment of code illustrates us how to use the refreshDescriptor. Please, read comments there.

// let's skip imports
export default function TodoContainer() {
  // get the refreshDescriptor
  const { refreshDescriptor } = useGlobalContext();
  const [errorMessage, errorMessageHolder] = message.useMessage();
  const [tabs, setTabs] = useState<TabsProps["items"]>([]);

  const loadPersons = async () => {
    // let's skip the details
  };

  useEffect(() => {
    // if refreshDescriptor.person has been changed, we need to reload the person list
    loadPersons();
  }, [refreshDescriptor.person]);

  return (
    <>
      {errorMessageHolder}
      <Tabs tabPosition={"left"} items={tabs} />
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

Please, look at another example of refreshing. You can find the full version here. Please, read comments.

// imports ...

export default function Person() {
  const { refreshDescriptor, refreshPerson } = useGlobalContext();
  // skip...

  const doDelete = async (id: number) => {
    try {
      await apiCall("person_delete", { id });
      // change the 'person' field of the descriptor if a new person was deleted
      refreshPerson();
    } catch (e) {
      // skip...
    }
  };

  // skip...

  const load = async () => {
    // skip...
  };

  useEffect(() => {
    load();
    const columns: ColumnsType<DataType> = [
      /// skip...
    ];
    setColumns(columns);
  }, [refreshDescriptor.person]);

  const doEditOk = () => {
    editFormRef.current.submit();
  };

  const handleEditOk = async (formData: DataType) => {
    try {
      if (currentRecord?.id) {
        await apiCall("person_update", {
          name: formData.name,
          id: currentRecord.id,
        });
      } else {
        await apiCall("person_insert", {
          name: formData.name,
        });
      }
      setEditVisible(false);
      // change the 'person' field of the descriptor if a new person was added or changed
      refreshPerson();
    } catch (e) {
      setEditVisible(false);
      // skip...
    }
  };

  // skip...

  return !columns ? (
    <></>
  ) : (
    <div>
      {errorMessageHolder}
      <Button onClick={addNewRow} style={{ margin: 10 }}>
        New Person
      </Button>
      <Table
        columns={columns}
        dataSource={data}
        pagination={false}
        scroll={{ y: "calc(100vh - 200px)" }}
      />
      <Modal
        title="Person"
        open={editVisible}
        onOk={doEditOk}
        onCancel={handleEditCancel}
      >
        <PersonEdit
          ref={editFormRef}
          currentRecord={currentRecord}
          handleEditOk={handleEditOk}
        ></PersonEdit>
      </Modal>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

We call refreshPerson if we expect another or the current widget refreshing. If you add some new functionality in the future, you need to:

  • add new fields to refreshDescriptor
  • provide the related functions and add them to the context
  • use them the way described above

The App component

I suppose it's the simplest part of the solution.

import { Tabs, TabsProps } from "antd";
import "./App.css";
import Person from "./Person";
import TodoContainer from "./TodoContainer";
import Data from "./Data";
import { useGlobalContext } from "./GlobalContext";

const tabs: TabsProps["items"] = [
  {
    key: "persons",
    label: "Persons",
    children: <Person />,
  },
  {
    key: "todo",
    label: "Todo",
    children: <TodoContainer />,
  },
  {
    key: "data",
    label: "Data",
    children: <Data />,
  },
];

function App() {
  const { refreshPerson, refreshTodo } = useGlobalContext();

  return (
    <Tabs
      items={tabs}
      onTabClick={(key: string) => {
        if (key === "persons") {
          refreshPerson();
        }
        if (key === "todo") {
          refreshTodo();
        }
      }}
    />
  );
}

export default App;
Enter fullscreen mode Exit fullscreen mode

Please pay attention to the onTabClick handler. We change the related fields of refreshDescriptor every time we open a related tab.

The recommendations

This part is essential because all that I just told you should be continued with your, my dear reader, part. I aim to summarize and guide you regarding your future steps with the boilerplate. Of course, all that I intend to share with you is not dogma; it's just my subjective opinion that I hope will help you.

  1. I recommend starting with the Rust part.
  2. You could create a separate rs-file. This is a good example. Also, don't forget to add it to mod.rs
  3. After you need to make the following changes in main.rs: use the module and tell Tauri about the new functionality. That's it regarding Rust-part. It's time to code in React.
  4. Create a table-based component like the following
  5. Create a form-based component like the following
  6. Use the table-based component in the following way

PS: It's important to note that the solution above is just one of my first attempts at the topic; therefore, do not judge strictly. Also, I'd like to know how this solution works under Windows. Anyway, happy hacking, guys ;)

💖 💪 🙅 🚩
buchslava
Vyacheslav Chub

Posted on April 10, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related

Tauri CRUD Boilerplate
tauri Tauri CRUD Boilerplate

April 10, 2024