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:
- Tauri v2 - Version 2 of Tauri
- Tauri Store Plugin - for persistent key-value storage
- Tailwind CDN - for fast CSS styling
- Vue - frontend framework of choice
- Vue Router - for page routing
Data Models
Todo
A task to complete
Column | Type | Description |
---|---|---|
id | int | unique id for the task |
name | text | name of the task |
description | str | description of the task |
completed | boolean | whether the task is complete or not |
start_date | text | date started |
due_date | text | date 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
- Task Ownership: Each task must be assigned to a specific user.
- Task Priority: Tasks can be assigned a priority level (e.g., low, medium, high, urgent) to guide workload management.
- Task Dependencies: Tasks can be linked to other tasks as dependencies, ensuring that certain tasks must be completed before others can begin.
- Category Hierarchy: Categories can be organized into a hierarchical structure, allowing for granular task classification.
- 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.
- Recurring Tasks: Tasks can be set to recur on a regular schedule (e.g., daily, weekly, monthly).
- 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
nvm use 18.18.0
yarn create tauri-app --rc
cd tauri-app
yarn
yarn tauri dev
Install dependencies
Yarn packages
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.
<!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>
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");
// 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.
<script setup>
import Home from "./components/Home.vue";
import Nav from "./components/Nav.vue";
</script>
<template>
<div>
<Nav />
<RouterView />
</div>
</template>
<style scoped>
</style>
<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>
<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>
<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>
<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
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:
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");
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)
},
}
});
<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
<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>