Skip to content
Shop

CommunityJoin Our PatreonDonate

Sponsored Ads

Sponsored Ads

Make a Tauri Crud App

September 12, 2024

You will learn how to make a Tauri CRUD app using only Javascript!

Mockup By zakka2521

What you will need:

Data Models

Todo

A task to complete

ColumnTypeDescription
idintunique id for the task
nametextname of the task
descriptionstrdescription of the task
completedbooleanwhether the task is complete or not
start_datetextdate started
due_datetextdate due

UI/UX

This is the final UI. I left the other components outside of the Todos.vue and Todo.vue files blank so that you can update them to look however you'd like.

Requirements

Functional Requirements

  • CRUD tasks
  • IMPORT / EXPORT tasks in a JSON format

Non Functional Requirements

  • Beautiful - Nice looking UI
  • Useful - could be used for real work
  • Stable - No bugs

Technical Requirements

  • Desktop
  • Responsive

Business Rules

These business rules were generated by Google Gemini. These are only ideas for future features and are not included in this project

Core Functionality

  1. Task Ownership: Each task must be assigned to a specific user.
  2. Task Priority: Tasks can be assigned a priority level (e.g., low, medium, high, urgent) to guide workload management.
  3. Task Dependencies: Tasks can be linked to other tasks as dependencies, ensuring that certain tasks must be completed before others can begin.
  4. Category Hierarchy: Categories can be organized into a hierarchical structure, allowing for granular task classification.
  5. Category Permissions: Users can have different levels of access to different categories, restricting or granting permission to create, edit, or view tasks within those categories.
  6. Recurring Tasks: Tasks can be set to recur on a regular schedule (e.g., daily, weekly, monthly).
  7. Task Templates: Users can create pre-defined task templates to streamline the creation of common task types.

Create a new Tauri App

Create a new tauri app and run it

bash
nvm use 18.18.0
yarn create tauri-app --rc
cd tauri-app
yarn
yarn tauri dev

Install dependencies

Yarn packages

bash
yarn run tauri add store
yarn add vue-router

Configuration

Set up the configuration for tailwind and the tauri store plugin.

index.html - Add the tailwind CDN

/src/main.js - Set up vue and vue-router

/src-tauri/src/lib.rs - this should be updated automatically by the yarn run tauri add store command.

html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" type="image/svg+xml" href="/vite.svg" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Tauri + Vue 3 App</title>
    <script src="https://cdn.tailwindcss.com"></script>
  </head>

  <body>
    <div id="app"></div>
    <script type="module" src="/src/main.js"></script>
  </body>
</html>
js
import { createApp } from "vue";
import { createMemoryHistory, createRouter } from 'vue-router'
import App from "./App.vue";
import Home from './components/Home.vue'
import Login from './components/Login.vue'
import Register from './components/Register.vue'
import Todos from './components/Todos.vue'
import Todo from './components/Todo.vue'
import Collections from './components/Collections.vue'
const app = createApp(App)

const routes = [
    { path: '/', component: Home },
    { path: '/login', component: Login },
    { path: '/register', component: Register },
    { path: '/todos', component: Todos },
    { name: 'todo', path: '/todos/:todo', component: Todo, props: true },
    { path: '/collections', component: Collections }
]
const router = createRouter({
    history: createMemoryHistory(),
    routes,
})

app.use(router)

app.mount("#app");
rust
// 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)
}

#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
    tauri::Builder::default()
        .plugin(tauri_plugin_store::Builder::new().build())
        .plugin(tauri_plugin_shell::init())
        .invoke_handler(tauri::generate_handler![greet])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

The Code

All components live in /components/.

You will need to add your own logo to the /public/ directory and replace the logo image in Nav.vue. For example /public/logo.png.

You can use the default vue code in Home.vue for other components such as Collections.vue Login.vue and Register.vue as these views haven't been implemented and are there for your convenience.

You will need to click "Create Database" in order to create a new database or the app will error.

Note

The todoCount and completion status still need a little work (there is a bug with it if you can find it). All Discord Subscribers can leave feedback if there are any other comments or issues.

vue
<script setup>
import Home from "./components/Home.vue";
import Nav from "./components/Nav.vue";
</script>

<template>
  <div>
    <Nav />
    <RouterView />
  </div>
</template>

<style scoped>

</style>
vue
<script setup>
import { ref } from "vue";
</script>

<template>
<div>
  <div class="p-10">
    <h1 class="text-3xl">WELCOME TO THE APP!</h1>
  </div>

</div>
</template>
vue
<script setup>
import { ref } from "vue";
</script>

<template>
  <div>
    <nav class="sticky top-0 z-50 p-4 border-b-2 border-zinc-200">
      <ul class="flex items-center justify-start">
        <li>
          <router-link to="/">
            <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" class="bi bi-list"
              viewBox="0 0 16 16">
              <path fill-rule="evenodd"
                d="M2.5 12a.5.5 0 0 1 .5-.5h10a.5.5 0 0 1 0 1H3a.5.5 0 0 1-.5-.5zm0-4a.5.5 0 0 1 .5-.5h10a.5.5 0 0 1 0 1H3a.5.5 0 0 1-.5-.5zm0-4a.5.5 0 0 1 .5-.5h10a.5.5 0 0 1 0 1H3a.5.5 0 0 1-.5-.5z">
              </path>
            </svg>
          </router-link>
        </li>
        <li>
          <router-link to="/"><img src="/todo_logo.png" class="ml-2 w-8 h-8 logo vite" alt="Vite logo" />
          </router-link>
        </li>
        <li class="ml-4"><router-link to="/todos">All Todos</router-link></li>
        <li class="ml-4"><router-link to="/collections">Collections</router-link></li>

        <li class="ml-auto">
          <p id="status-message" class="hidden rounded-lg bg-yellow-200 border border-yellow-800 text-yellow-800 p-2">
            status message</p>
        </li>

        <li class="ml-auto flex items-center">
          <span class="ml-2"><router-link to="/login" class="px-5 p-[.2rem] bg-zinc-200 flex items-center rounded-full">
              Login
            </router-link></span>
        </li>

        <li class="ml-4 flex items-center">
          <span class="ml-2"><router-link to="/register"
              class="px-5 p-[.2rem] bg-zinc-200 flex items-center rounded-full">
              Register
            </router-link></span>
        </li>
      </ul>
    </nav>
  </div>
</template>
vue
<script setup>
import { ref, onMounted, computed } from "vue";
import { invoke } from "@tauri-apps/api/core";
import { Store } from '@tauri-apps/plugin-store';

// TODO: todoCount and Status need work

const store = new Store('store.bin');
const greetMsg = ref("");
const name = ref("joe");
const input_text = ref("")
const form_data = ref({ text: '', completed: false, description: '' })
const all_todos = ref([])
const editing = ref(false)
const showData = ref(false)
const username = ref('Tutorial Doctor')

const users = ref([
    { name: "Jessica Jones", email: "", profile_image: "https://images.prismic.io/upskil/ZrudT0aF0TcGI52W_20e6082b-ccd3-4170-9d55-d57591c3b4ca.webp?auto=format,compress" },
    { name: "Jessica Jones", email: "", profile_image: "https://images.prismic.io/upskil/ZrudjEaF0TcGI52a_8d04ac08-6dae-4b2f-b0a4-c984eba0fe8d.webp?auto=format,compresss" },
    { name: "Jessica Jones", email: "", profile_image: "https://images.prismic.io/upskil/ZrvZvEaF0TcGI6NT_55f5c200-1c67-4df3-89f2-82f5a25913f1.webp?auto=format,compress" },
    { name: "Jessica Jones", email: "", profile_image: "https://images.prismic.io/upskil/ZrvRh0aF0TcGI6Kq_valley.webp?auto=format,compress" },
    { name: "Jessica Jones", email: "", profile_image: "https://images.prismic.io/upskil/ZrudT0aF0TcGI52W_20e6082b-ccd3-4170-9d55-d57591c3b4ca.webp?auto=format,compress" },
    { name: "Jessica Jones", email: "", profile_image: "https://images.prismic.io/upskil/ZrudjEaF0TcGI52a_8d04ac08-6dae-4b2f-b0a4-c984eba0fe8d.webp?auto=format,compresss" },
    { name: "Jessica Jones", email: "", profile_image: "https://images.prismic.io/upskil/ZrvZvEaF0TcGI6NT_55f5c200-1c67-4df3-89f2-82f5a25913f1.webp?auto=format,compress" }
])

// RUST
async function greet() {
    greetMsg.value = await invoke("greet", { name: name.value });
}
// END RUST


// BEGIN CRUD
async function createDB() {
    await store.set('todos', all_todos.value);
    setStatusMessage("DATABASE CREATED")
}

async function getAllTodos() {
    all_todos.value = await store.get('todos');
}

async function createTodo(t) {
    console.log(all_todos.value)
    if (all_todos.value.length > 0) {
        form_data.value['id'] = all_todos.value.length + 1
    }else{
        form_data.value['id'] = 1
    }
    const new_data = form_data.value
    all_todos.value.push(new_data)
    await store.set('todos', all_todos.value);
    await store.save();
    setStatusMessage("CREATED")
    clearForm()
    getAllTodos()
}

async function retrieveTodo(id) {
    const val = await store.get('todos');
    const todo = val.find(obj => obj['id'] === id)
    setStatusMessage("RETRIEVED")
    return todo
}

async function updateTodo(id) {
    const val = await store.get('todos');
    const todo_index = val.findIndex(obj => obj.id == id);
    const todo = val.find(obj => obj['id'] === id);
    all_todos.value[todo_index] = form_data.value
    await store.set('todos', all_todos.value);
    await store.save();
    setStatusMessage("UPDATED")
}

async function deleteTodo(id) {
    const val = await store.get('todos');
    const todo = val.find(obj => obj['id'] === id);
    let new_array = val.filter(a => a !== todo)
    await store.set('todos', new_array);
    await store.save();
    setStatusMessage("DELETED")
    getAllTodos()
}

async function deleteTodos() {
    await store.set('todos', []);
    setStatusMessage("DELETED ALL TODOS")
    clearForm()
    getAllTodos()
}
// END CRUD

// HELPERS
function clearForm() {
    document.getElementById('myform').reset()
    form_data.value = {}
    editing.value = false
}

function toggleData() {
    showData.value = !showData.value
}

function setCurrentTodo(x) {
    form_data.value = x
    editing.value = true
}

function setStatusMessage(msg) {
    console.log("SET")
    document.getElementById("status-message").classList.remove('hidden')
    setTimeout(() => {
        document.getElementById("status-message").classList.add('hidden')
        console.log("HIDING")
    }, 5000)
    document.getElementById("status-message").innerHTML = msg
}

function getDate() {
    const options = {
        weekday: 'long',
        year: 'numeric',
        month: 'long',
        day: 'numeric',
        // hour: 'numeric', 
        // minute: 'numeric', 
        hour12: true
    };
    const currentDate = new Date();
    const formattedDate = currentDate.toLocaleString('en-US', options);
    return formattedDate
}
// END HELPERS

// COMPUTED PROPERTIES
const todoCount = computed(() => {
    if (all_todos.value.length > 0) {
    return all_todos.value.filter(a => a['completed'] === false).length
    }else{
        return 0
    }
})

// HOOKS
onMounted(() => {
    getAllTodos()
})

</script>

<template>
    <div class="p-4">
        <header
            class="hide-scrollbar overflow-y-scroll rounded-t-[1.25rem] p-8 text-white bg-gradient-to-r from-indigo-500 via-purple-500 to-pink-500 h-[400px]">
            <p>Hello, {{ username }}</p>
            <p class="mt-2 text-xl text-semibold">{{ getDate() }}</p>
            <p class="mt-2"><span class="text-6xl font-bold">{{ todoCount }} / {{ all_todos.length }}</span> tasks left
            </p>
            <div class="mt-4 gap-4 flex flex-wrap">
                <div v-for="(user, ndx) in users" :key="ndx">
                    <img :src="user.profile_image" class="w-12 h-12 border-4 object-cover object-top rounded-full">
                </div>
            </div>
            <button @click="createDB()" class="mt-4 bg-white py-1 px-2 rounded-full text-gray-600 ">Create Database</button>
        </header>

        <div class="p-8">
            <h1 class="text-center text-xl text-gray-600 font-semibold">Add a Task</h1>

            <hr class="my-6" />

            <form id="myform">
                <div>
                    <label for="text" class="font-semibold text-gray-600">Title</label><br />
                    <input required name="text" type="text" class="mt-2 rounded-lg w-full bg-gray-100 p-2"
                        v-model="form_data.text">
                </div>

                <div class="mt-4">
                    <label for="text" class="font-semibold text-gray-600">Complete?</label><br />
                    <input required name="completed" type="checkbox" class="mt-2 rounded-lg w-full bg-gray-100 p-2"
                        v-model="form_data.completed">
                </div>

                <div class="mt-4 grid grid-cols-1 md:grid-cols-2 gap-4">
                    <div>
                        <label for="start_date" class="font-semibold text-gray-600">Start Date</label><br />
                        <input required name="start_date" type="datetime-local"
                            class="mt-2 rounded-lg w-full bg-gray-100 p-2" v-model="form_data.start_date">
                    </div>
                    <div>
                        <label for="due_date" class="font-semibold text-gray-600">Due Date</label><br />
                        <input required name="due_date" type="datetime-local"
                            class="mt-2 rounded-lg w-full bg-gray-100 p-2" v-model="form_data.due_date">
                    </div>
                </div>

                <div class="mt-4">
                    <label for="text" class="font-semibold text-gray-600">Description</label><br />
                    <textarea name="description" class="mt-2 rounded-lg w-full bg-gray-100 p-2"
                        v-model="form_data.description">
</textarea>
                </div>

                <input name="id" class="hidden" v-if="editing" v-model="form_data.id">
                <input v-else class="hidden" name="id" :value="all_todos.length + 1">

                <button class="mt-4 w-32 border border-blue-500 text-blue-500 p-2 rounded-lg"
                    @click.prevent="clearForm()">Clear</button>
                <button class="ml-2 w-32 bg-blue-500 text-white p-2 rounded-lg" v-if="editing"
                    @click.prevent="updateTodo(form_data.id)">Save</button>
                <button class="ml-2 w-32 bg-blue-500 text-white p-2 rounded-lg" v-else
                    @click.prevent="createTodo()">Create</button>
            </form>

            <section class="mt-8 space-y-4">
                <div class="border-2 border-gray-200 p-4 cursor-pointer rounded-xl"
                    :class="[form_data == todo ? 'border-pink-400' : '']" @click="setCurrentTodo(todo)"
                    v-for="(todo, ndx) in all_todos" :key="todo.id">
                    <p class="text-2xl">{{ todo.text }}</p>
                    <p class="mt-1 text-gray-400">{{ todo.description }}</p>
                    <div class="mt-3 bg-gray-100 p-3 rounded-lg flex justify-start">
                        <p>8:00AM</p>
                        <div class="ml-auto flex items-center ">
                            <p class="bg-white py-1 px-2 rounded-md"
                                :class="[todo.completed ? 'text-green-500' : 'text-orange-500']">{{ todo.completed ?
                                    'Complete' : 'In Progress' }}</p>
                            <svg v-if="todo.completed" xmlns="http://www.w3.org/2000/svg" width="16" height="16"
                                fill="currentColor" class="text-green-500 ml-2 bi bi-check-circle-fill"
                                viewBox="0 0 16 16">
                                <path
                                    d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0m-3.97-3.03a.75.75 0 0 0-1.08.022L7.477 9.417 5.384 7.323a.75.75 0 0 0-1.06 1.06L6.97 11.03a.75.75 0 0 0 1.079-.02l3.992-4.99a.75.75 0 0 0-.01-1.05z" />
                            </svg>

                            <svg v-else xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor"
                                class="ml-2 text-orange-500 bi bi-clock-fill" viewBox="0 0 16 16">
                                <path
                                    d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0M8 3.5a.5.5 0 0 0-1 0V9a.5.5 0 0 0 .252.434l3.5 2a.5.5 0 0 0 .496-.868L8 8.71z" />
                            </svg>

                        </div>
                    </div>
                    <button class="w-32 text-red-500 border border-red-500 p-1 text-xs rounded-lg font-semibold my-4"
                        @click="deleteTodo(todo.id)">Remove</button>

                    <RouterLink
                        class="ml-2 w-32 text-gray-500 border border-gray-500 p-1 text-xs rounded-lg font-semibold my-4"
                        :to="{ name: 'todo', params: { todo: JSON.stringify(todo) }, props: { id: todo } }">Show
                    </RouterLink>
                </div>
            </section>

            <button class="w-full bg-red-500 text-white p-2 rounded-lg font-semibold my-4" @click="deleteTodos()">DELETE
                ALL</button>

            <button @click="toggleData()" class="my-2 px-2 py-1 rounded-lg bg-gray-100">
                Toggle Data
            </button>

            <div v-if="showData" class="bg-gray-100 p-4 rounded-lg">{{ all_todos }}</div>
        </div>
    </div>
</template>

<style>
input[type='checkbox'] {
    -webkit-appearance: none;
    width: 30px;
    height: 30px;
    background: white;
    border-radius: 5px;
    border: 2px solid #f0eded;
}

input[type='checkbox']:checked {
    background: #22c55e;
}

/* Hide scrollbar for Chrome, Safari and Opera */
.hide-scrollbar::-webkit-scrollbar {
    display: none;
}

/* Hide scrollbar for IE, Edge and Firefox */
.hide-scrollbar {
    -ms-overflow-style: none;
    /* IE and Edge */
    scrollbar-width: none;
    /* Firefox */
}
</style>
vue
<script setup>
import { ref } from "vue";
import { invoke } from "@tauri-apps/api/core";
import { useRoute } from 'vue-router'
const route = useRoute()
const props = defineProps(['todo']) // optional
const data = JSON.parse(route.params.todo)
</script>

<template>
  <div>
    <div class="p-10">
      <h1 class="text-3xl">TODO</h1>

      <h2 class="mt-3 text-3xl">{{ data.text }}</h2>
      <p class="mt-2">{{ data.description }}</p>
      <p class="mt-2 bg-gray-100 w-32 text-center py-1 px-2 rounded-md"
        :class="[data.completed ? 'text-green-500' : 'text-orange-500']">{{ data.completed ? 'Complete' : 'In Progress' }}
      </p>
    </div>

  </div>
</template>

Test the App

yarn tauri dev

Build the App for Release

bash
yarn tauri build

Notes

  • For Vue, @click="clearPrompt(this)" has to be @click="clearPrompt($event)"
  • Building produces large files (gigs)

Adding Pinia (optional)

If you'd like to use Pinia, here is how you'd do that. The stores folder is in the root of the application.

Add Pinia

yarn add pinia

How to add Pinia to vue and use in a component:

javascript
import { createApp } from "vue";
import { createMemoryHistory, createRouter } from 'vue-router'
import { createPinia } from 'pinia'
import App from "./App.vue";
import Settings from './components/Component.vue'

const pinia = createPinia()
const app = createApp(App)
app.use(pinia)

const routes = [
    { path: '/component', component: Component },
]
const router = createRouter({
    history: createMemoryHistory(),
    routes,
})

app.use(router)

app.mount("#app");
javascript
import { defineStore } from "pinia";
import { Store } from "@tauri-apps/plugin-store";

export const dataStore = defineStore("dataStore", {
  state: () => {
    return {
      db: new Store("store.bin"),
      name: 'My Store',
      counter: 0,
      todos:[],
    }
  },
  getters: {
    doubleCount: (state) => state.counter * 2,
    oddOrEven: (state) => {
      if (state.counter % 2 === 0) return 'even'
      return 'odd'
    }
  },
  actions: {
    increment() {
      this.counter++
    },
    decrement() {
      if (this.counter > 0){
        this.counter--
      }
    },
    adToCart(x) {
      this.cart.push(x)
    },
  }
});
vue
<script setup>
import { ref, onMounted } from "vue";
import { dataStore } from "../../stores/store";

const store = dataStore();

const all_todos = ref([])

async function creteDatabase() {
    await store.db.set('todos', all_todos.value);
}

async function getAllTodos() {
    all_todos.value = await store.db.get('todos')
}

onMounted(() => {
    getAllTodos()
})

</script>

<template>
    {{store.name}}
    {{all_todos}}
</template>

Update Dec. 2024

Below is an example Vue component using Tauri Store.

Tauri Store Example
vue
<script setup>
import { ref,computed,onMounted } from 'vue';
import { LazyStore } from '@tauri-apps/plugin-store';
const store = new LazyStore('settings.json', { autoSave: true });

const info = ref({first_name:"",last_name:""})

async function create(){
    await store.set('info', { first_name: info.value.first_name, last_name: info.value.last_name});
}

async function kill(){
    await store.delete('info');
}

async function increment() {
    count.value++;
    info.value = await store.get('info')
}

async function load_db(){
    info.value = await store.get('info');
}

onMounted(()=>{
    load_db()
})

</script>

<template>
<div>
  <div class="p-10">
    <h1 class="text-3xl">WELCOME TO THE APP!</h1>
    {{info}}
    <form>
        <input v-model="info.first_name" name="first_name"/>
        <input v-model="info.last_name" name="last_name"/>
    </form>
    <button @click="create">CREATE</button>
    <button @click="increment">GET</button>
    <button @click="kill">DELETE</button>
  </div>

</div>
</template>

Resources