We finished the first iteration of this tutorial series with a basic time/task management system. It gets the job done, but there’s a lot more that can be done with it.
As the list of tasks grows, eventually it’ll become handy to group them into projects. That way tasks can be organized.
Database Change
Doing this first requires creating the project
table:
create table project (id integer primary key auto_increment, name varchar(100), description text);
Then add an association between project and task to the task
table:
alter table task add column project integer;
Then we do our backend changes. These will be in the index.js
file.
Create the Sequelize model for the projects:
const Project = db.define('project', {
id: {
type: Sequelize.INTEGER,
primaryKey: true,
autoIncrement: true
},
name: Sequelize.STRING,
description: Sequelize.TEXT
}, {
freezeTableName: true,
timestamps: false
});
And update the Task model:
const Task = db.define('task', {
id: {
type: Sequelize.INTEGER,
primaryKey: true,
autoIncrement: true
},
name: Sequelize.STRING,
cron_string: Sequelize.STRING,
completed: Sequelize.BOOLEAN,
project: Sequelize.INTEGER
}, {
freezeTableName: true,
timestamps: false
});
Setting Up the API
First make the ProjectFunctions
object:
const ProjectFunctions = {
async getAll() {
return Project.findAll();
},
async get(id) {
return Project.findByPk(id);
},
async create(data) {
return Project.create({
name: data.name,
description: data.description
});
},
async update(id, data) {
const project = await Project.findByPk(id);
project.name = data.name || project.name;
project.description = data.description || project.description;
await project.save();
return project;
},
async del(id) {
const project = await Project.destroy({where: {id}});
return id;
},
async getProjectTasks(id) {
return Task.findAll({where: {project: id}});
}
}
Over in the existing TaskFunctions
object, we update the create
and update
functions to include the project id:
async create(data) {
const task = Task.create({
name: data.name,
cron_string: data.cron_string,
parent: data.parent || null,
project: data.project || null
});
return task;
},
async update(id, data) {
const task = await Task.findByPk(id);
task.name = data.name || task.name;
task.cron_string = data.cron_string || data.cron_string == ''
? data.cron_string : task.cron_string;
task.completed = [true, false].includes(data.completed) ? data.completed : task.completed;
task.project = data.project || task.project;
await task.save();
return task;
},
Now we can add the actual API routes for dealing with projects:
app.get('/api/project/all', async (req, res) => {
const result = await ProjectFunctions.getAll();
res.send({result});
});
app.get('/api/project/:id', async (req, res) => {
const result = await ProjectFunctions.get(req.params.id);
res.send({result});
});
app.get('/api/project/:id/tasks', async (req, res) => {
const result = await ProjectFunctions.getTasks(req.params.id);
res.send({result});
});
app.post('/api/project', async (req, res) => {
const result = await ProjectFunctions.create(req.body);
res.send({result});
});
app.put('/api/project/:id', async (req, res) => {
const result = await ProjectFunctions.update(req.params.id, req.body);
res.send({result});
});
app.delete('/api/project/:id', async (req, res) => {
const result = await ProjectFunctions.del(+req.params.id);
res.send({result});
});
Listing the Projects
On the frontend, we’ll first create the table which lists the projects. It’ll be similar to, but simpler, than the table created for showing and creating tasks. Because of the similarity, we’ll hurry along a bit in this section. Create the file projects.js
:
import {useState, useEffect} from 'react';
import {Link} from 'react-router-dom';
const url = 'http://localhost:3001/api/project';
const headers = {
'Content-Type': 'application/json'
};
const API = {
async getAll() {
const res = await fetch(`${url}/all`, {method: 'GET', headers});
const {result} = await res.json();
return result;
},
async create(body) {
const res = await fetch(url, {method: 'POST', headers, body: JSON.stringify(body)});
const {result} = await res.json();
return result;
},
async update(id, body) {
const res = await fetch(`${url}/${id}`, {method: 'PUT',headers, body:JSON.stringify(body)});
const {result} = await res.json();
return result;
},
async del(id) {
const res = await fetch(`${url}/${id}`, {method: 'DELETE', headers});
const {result} = await res.json();
return result;
}
}
const NewProject = props => {
const [project, setProject] = useState({name: '', description: ''});
const handleChange = e => setProject({...project, [e.target.name]: e.target.value});
const save = async e => {
const result = await API.create(project);
props.handleSave(result);
setProject({name: '', description: ''});
}
return <tr>
<td></td>
<td><input type='text' name='name' value={project.name} onChange={handleChange} /></td>
<td><input type='text' name='description' value={project.description} onChange={handleChange} /></td>
<td><button onClick={save} disabled={!project.name || !project.description}>Save</button></td>
<td></td>
</tr>
}
const ProjectRow = props => {
const [project, setProject] = useState(props.project);
const handleChange = e => setProject({...project, [e.target.name]: e.target.value});
const update = async e => {
const {id, ...rest} = project;
const result = await API.update(id, rest);
props.handleUpdate(id, result);
}
const del = async e => {
if(window.confirm(`Delete project: ${project.name}?`)) {
const result = await API.del(project.id);
props.handleDelete(project.id);
}
}
return <tr>
<td><Link to={`/project/${project.id}`}>{project.id}</Link></td>
<td><input type='text' name='name' value={project.name} onChange={handleChange} /></td>
<td><input type='text' name='description' value={project.description} onChange={handleChange} /></td>
<td><button onClick={update} disabled={!project.name || !project.description}>Update</button></td>
<td><button onClick={del}>Delete</button></td>
</tr>
}
const ProjectTable = props => {
const [projects, setProjects] = useState([]);
const handleSave = project => {
setProjects([...projects, project]);
}
const handleUpdate = (id, project) => {
const oldProjects = [...projects];
const find = oldProjects.find(t => t.id == id);
if (find) {
find.name = project.name;
find.description = project.description;
}
setProjects(oldProjects);
}
const handleDelete = id => {
const oldProjects = [...projects];
const index = oldProjects.findIndex(t => t.id == id);
if (index > -1) {
oldProjects.splice(index, 1);
}
setProjects(oldProjects);
}
useEffect(() => {
async function getProjects() {
const data = await API.getAll();
setProjects(data);
}
getProjects();
}, []);
return <table border='1'>
<thead>
<tr>
<th>View</th>
<th>Name</th>
<th>Description</th>
<th>Edit</th>
<th>Delete</th>
</tr>
</thead>
<tbody>
{projects.map(p => <ProjectRow key={p.id} project={p} handleUpdate={handleUpdate} handleDelete={handleDelete} />)}
<NewProject handleSave={handleSave} />
</tbody>
</table>
}
export default ProjectTable;
The most interesting difference between this and the tasks is, instead of having that checkbox, it contains a link to the project itself. The page for the individual project shows all the tasks associated with it.
Showing a Project
Over in project.js
, we fetch the project info and the tasks associated with it.
import {useState, useEffect} from 'react';
import {useParams} from 'react-router-dom';
import TaskTable from './tasks';
const url = 'http://localhost:3001/api/project';
const headers = {
'Content-Type': 'application/json'
};
const API = {
async getProject(id) {
const res = await fetch(`${url}/${id}`, {method: 'GET', headers});
const {result} = await res.json();
return result;
}
};
const Project = props => {
const [project, setProject] = useState({});
const {id} = useParams();
useEffect(() => {
async function getProject() {
const data = await API.getProject(id);
setProject(data);
}
getProject();
}, []);
return <>
{project.id && <>
<h1>{project.name}</h1>
<div>{project.description}</div>
</>}
<TaskTable project={id} />
</>
}
export default Project;
This component takes the id
passed in the URL and fetches the project with that. The project is displayed when it’s fetched. We need to modify TaskTable
and NewTask
to account for this new usage.
We make a slightly confusing decision here. One would think the Project
component should fetch the tasks, then pass them to TaskTable
, which either uses them as a prop or as state. But there’s a problem with both approaches:
- If it’s passed as a prop, then we need the functions to add/update/delete the tasks in the
Project
component, which doesn’t make sense. - Alternatively, we could pass it through props and set it in state in
TaskTable
. The problem is, when the array of tasks is first passed, it’s empty because the server call to get the tasks hasn’t finished. In order to update the state, we’d need something like:
useEffect(() => {
setState(props.tasks);
}, [props.tasks]);
Which is just plain unsightly. The best solution seems to be TaskTable
fetching the tasks. To do this we replace getAll
in the API object with this:
async getProjectTasks(id) {
const res = await fetch(`${projectUrl}/${id}/tasks`, {method: 'GET', headers});
const {result} = await res.json();
return result;
},
Then we make 2 changes to TaskTable itself:
- In the
useEffect
, replaceAPI.getAll()
withAPI.getProjectTasks(props.project);
- In the call to
NewTask
, addproject={props.project}
NewTask
only has 1 change:
- In
save
,API.createTask
becomesAPI.create({…task, project: props.project})
Routing and Navigation
A couple easy changes. Over in index.js
, import the two components we’ve created and add their routes with the rest:
import ProjectTable from './projects';
import Project from './project';
...
<Route path='project/:id' element={<Project />} />
<Route path='projects' element={<ProjectTable />} />
Then, we want the project list page to replace the task list page. In nav.js, replace <Link to=’/tasks’>Tasks</Link>
with <Link to=’/projects'>Projects</Link>
Putting It Together
If you go to http://localhost:3000/projects, you should now be able to add a couple projects. Here’s an example:
Clicking on the number next to a project name should take you to that project’s tasks:
And clicking on Projects at the top of the page should return you to the projects list.
Here’s what the files touched by this section should look like now. (You may notice the file names in the Gist start with client_ or server_. Those should be client/ and server/, but Gists don’t allow slashes in the names.)