Skip to main content

Building a to-do app

In this tutorial we will walk you through the process of creating a to-do list in Volca.

Before you start, make sure you have gone through the project setup guide.

Create a database migration

All database migrations are defined in the file services/api/src/lib/db/migrations.ts.

Add your migration to the file like this to create the todos table:

services/api/src/lib/db/migrations.ts
...
{
name: '09_create_todos_table',
up: (knex: Knex) => {
return knex.schema.createTable('todos', (table) => {
table.uuid('id').defaultTo(knex.raw('uuid_generate_v4()')).primary();
table.uuid('project_id').notNullable().index().references('id').inTable('projects');
table.string('title').notNullable();
});
},
down: (knex: Knex) => {
return knex.schema.dropTable('todos');
},
...

Run the command yarn migrate:latest:local from the services/api folder to apply the migration and create the table.

Create an entity

Create a new file services/api/src/entities/todo.ts

services/api/src/entities/todo.ts
import { Model } from 'objection';

export class Todo extends Model {
id!: string;
title!: string;

static get tableName() {
return 'todos';
}
}

And export it from the services/api/src/entities/index.ts file:

services/api/src/entities/index.ts
export * from './todo';

Create a service

Create a new file services/api/src/services/todo-service.ts.

services/api/src/services/todo-service.ts
import { injectable } from 'tsyringe';
import { Todo } from '../entities';

@injectable()
export class TodoService {
public async list(projectId: string): Promise<Todo[]> {
return Todo.query().where({ projectId });
}
}

And export it from the services/api/src/services/index.ts file:

services/api/src/services/index.ts
export * from './todo-service';

Create an API action

Create a new file services/api/src/actions/todos/list-todos.ts

services/api/src/actions/todos/list-todos.ts
import { container } from 'tsyringe';
import { useApiAction } from '../utils/api-action';
import { TodoService } from '../../services';
import { CustomContext } from 'src/types';

export const listTodosAction = useApiAction(async (ctx: CustomContext) => {
const todoService = container.resolve(TodoService);

const { projectId } = ctx.params;

const todos = await todoService.list(projectId);

return {
body: { todos },
};
});

And export it from a new file services/api/src/actions/todos/index.ts

services/api/src/actions/todos/index.ts
export * from './list-todos';

Create an API endpoint

Import the listTodosAction at the top of the file services/api/src/router.ts

services/api/src/router.ts
import { listTodosAction } from './actions/todos';

And add a new route that lists the todos:

services/api/src/router.ts
router.get('/projects/:projectId/todos', authenticationMiddleware, projectUserMiddleware, listTodosAction);

In this case we want to add two middlewares:

authenticationMiddleware that makes sure the user is logged in before returning the todos projectUserMiddleware that makes sure that the user has access to the project that holds the todos

Now we are done with the backend API. Let's move on to the frontend webapp.

Create a type for a todo

Create a new file clients/web/src/types/todo.ts

clients/web/src/types/todo.ts
export type Todo = {
id: string;
title: string;
};

And export it from clients/web/src/types/index.ts

clients/web/src/types/index.ts
export * from './todo';

Create a function to call the API

Import the Todo type at the top of clients/web/src/lib/clients/api-client.ts

clients/web/src/lib/clients/api-client.ts
import { ..., Todo, ... } from '../../types';

Add a type for the response body in the same file

clients/web/src/lib/clients/api-client.ts
type GetTodosResponse = {
todos: Todo[];
};

And add a function to the ApiClient class in the same file

clients/web/src/lib/clients/api-client.ts
static async getTodos(projectId: string): Promise<User[]> {
const { todos } = await this.handleApiError(
this.tokenClient.get(`projects/${id}/todos`).json<GetTodosResponse>()
);
return todos;
}

Create a hook that fetches todos

Create a new file clients/web/src/hooks/todo-actions.ts

clients/web/src/hooks/todo-actions.ts
import { useCallback } from 'react';

import { ApiClient } from '../lib/clients/api-client';
import { Todo } from '../types';
import { useApiActions } from './api-actions';

export const useTodoActions = () => {
const { executeApiAction } = useApiActions();

const getTodos = async (projectId: string) =>
await executeApiAction<Todo[]>({
action: () => ApiClient.getTodos(projectId),
errorMessage: 'Failed to load todos',
});

return {
getTodos: useCallback(getTodos, [executeApiAction]),
};
};

And export it from clients/web/src/hooks/index.ts

clients/web/src/hooks/index.ts
export * from './todo-actions';

Create a page that displays a table of todos

Create a new file clients/web/src/pages/todos.tsx

clients/web/src/pages/todos.tsx
import { Heading, Box, SimpleGrid } from '@chakra-ui/react';
import React, { useEffect, useState } from 'react';
import { MdWork } from 'react-icons/md';

import { AuthenticatedLayout } from '../layouts';
import { PageHeading } from '../components/generic/PageHeading';
import { SoftCard } from '../components/generic/SoftCard';
import { selectedProjectState } from '../state';
import { useRecoilValue } from 'recoil';
import { useTodoActions } from '../hooks';
import { Todo } from '../types';

export const TodosPage: React.FC = () => {
const project = useRecoilValue(selectedProjectState);
const [todos, setTodos] = useState<Todo[]>([]);
const { getTodos } = useTodoActions();
useEffect(() => {
const loadTodos = async () => {
console.log('loading');
console.log(project?.name);
if (project) {
const data = await getTodos(project.id);
setTodos(data || []);
}
};
loadTodos();
}, [project, getTodos]);

return (
<AuthenticatedLayout sidebar={false}>
<PageHeading title="Todos" icon={MdWork} />
<Box mt={8} />
<SimpleGrid minChildWidth="200px" width="100%" spacingX="40px" spacingY="20px">
{todos.map((todo) => (
<SoftCard key={todo.id}>
<Heading as="h3" size="sm" textAlign="center">
{todo.title}
</Heading>
</SoftCard>
))}
</SimpleGrid>
</AuthenticatedLayout>
);
};

And export it from clients/web/src/pages/index.tsx

clients/web/src/pages/index.tsx
export * from './todos';

Create a route for the page

Import the page in the file clients/web/src/App.tsx.

clients/web/src/App.tsx
import {
TodosPage,
} from './pages';

Add a new route that displays the page in the same file

clients/web/src/App.tsx
<Route
path="/projects/:id/todos"
element={
<AuthenticatedRoute>
<TodosPage />
</AuthenticatedRoute>
}
></Route>

Since this page should only be available to authenticated users, we wrap it in <AuthenticatedRoute>.

Create a menu item

Add a meu item to the LinkItems array in the file clients/web/src/components/layout/sidebar.tsx

clients/web/src/components/layout/sidebar.tsx
{
name: 'Todos',
onClick: () => navigate(selectedProject ? `/projects/${selectedProject.id}/todos` : '/'),
icon: MdGroups,
},

Finished!

Now you should be able to navigate to your page and see any todos stored in the database. Since we have not developed a UI to create todos, you can add them to the database manually for now to see that everything is working properly