Flaxon 0.3.0 and Teloce-Py 0.3.0 are available on PyPI. Release instructions are maintained on this site; source links identify the framework snapshot. Check version and upgrade guidance.
10 | Task components, signals and progress
Goal
Complete the task detail screen using reusable components and reactive progress.
What you are building
TaskForm emits a create event; TaskList emits status and remove events. ProjectDetails owns API calls and the task collection. signal stores that collection, computed derives completion, and an effect copies the value into displayed component data. Stop the effect before unmount. Teloce's .html compiler imports detected signal helpers automatically: do not add a redundant import. Ordinary TypeScript modules do not get the same automatic imports. Replacing the collection via its setter keeps progress and task display consistent. Empty projects return 0 percent.
Build this chapter
Stop the server. Work in project_manager. Follow each CREATE, whole-file replacement, or marked-block edit below in order.
Step 1: CREATE - make parent folders, then create this file - modules/projects/ui/components/TaskForm.html
Create the reusable task form. Collect title, status and due date, validate feedback and emit the saved task to its parent.
Create the parent directories, then save this complete file.
<template>
<form @submit.prevent="submit" class="card">
<h2>Add a task</h2>
<label
>Task title<input name="task-title" v-model="title" maxlength="160" required
/></label>
<label>Due date<input name="due-date" type="date" v-model="dueDate" /></label>
<button :disabled="busy">Add task</button>
</form>
</template>
<script lang="ts">
export default {
props: ["busy"],
emits: ["create"],
data() {
return { title: "", dueDate: "" };
},
methods: {
submit() {
if (this.busy || !this.title.trim()) return;
this.$emit("create", { title: this.title, due_date: this.dueDate || null });
},
},
};
</script>
<style scoped>
* {
box-sizing: border-box;
}
a {
color: #1d4ed8;
}
h1 {
font-size: clamp(1.8rem, 5vw, 3rem);
line-height: 1.15;
}
h2 {
font-size: 1.2rem;
}
p {
line-height: 1.6;
}
[role="alert"] {
color: #a21c2b;
padding: 0.8rem;
border-left: 4px solid;
}
:focus-visible {
outline: 3px solid #f59e0b;
outline-offset: 3px;
}
.card {
display: block;
background: white;
border: 1px solid #d8e0ed;
border-radius: 12px;
padding: 1.2rem;
margin: 1rem 0;
color: inherit;
text-decoration: none;
}
label {
display: grid;
gap: 0.4rem;
margin: 0.9rem 0;
font-weight: 600;
}
input,
select,
textarea,
button {
font: inherit;
border-radius: 7px;
padding: 0.7rem;
min-height: 44px;
}
input,
select,
textarea {
width: 100%;
border: 1px solid #abb9cf;
background: white;
color: #17243a;
}
button {
display: inline-block;
border: 0;
background: #2563eb;
color: white;
padding: 0.7rem 1rem;
cursor: pointer;
margin: 0.3rem 0.5rem 0.3rem 0;
}
button:disabled {
opacity: 0.55;
cursor: wait;
}
.secondary {
background: #e4eaf5;
color: #17243a;
}
.danger {
background: #b42332;
}
</style>
Say: "Create the reusable task form. Collect title, status and due date, validate feedback and emit the saved task to its parent."
Type: Follow the action above in modules/projects/ui/components/TaskForm.html; explain each new function or binding as you add it.
Viewers should see: The saved file matches this step. After all steps, run the chapter checks below and show the expected result.
Step 2: CREATE - make parent folders, then create this file - modules/projects/ui/components/TaskList.html
Create the reusable task list. Use stable task IDs for keyed rows and show the filtered workflow without changing database ownership.
Create the parent directories, then save this complete file.
<template>
<div>
<p v-if="tasks.length === 0">No tasks match this filter.</p>
<ul class="task-list">
<li v-for="task in tasks" :key="task.id" class="card">
<strong>{{ task.title }}</strong
><span>{{ task.due_date ? "Due " + task.due_date : "No due date" }}</span>
<label
>Status<select
aria-label="Task status"
:value="task.status"
@change="$emit('status', { id: task.id, status: $event.target.value })"
:disabled="busy"
>
<option value="todo">To do</option>
<option value="doing">Doing</option>
<option value="done">Done</option>
</select></label
>
<button
class="danger"
type="button"
@click="$emit('remove', task.id)"
:disabled="busy"
>
Delete task
</button>
</li>
</ul>
</div>
</template>
<script lang="ts">
export default { props: ["tasks", "busy"], emits: ["status", "remove"] };
</script>
<style scoped>
* {
box-sizing: border-box;
}
a {
color: #1d4ed8;
}
h1 {
font-size: clamp(1.8rem, 5vw, 3rem);
line-height: 1.15;
}
h2 {
font-size: 1.2rem;
}
p {
line-height: 1.6;
}
[role="alert"] {
color: #a21c2b;
padding: 0.8rem;
border-left: 4px solid;
}
:focus-visible {
outline: 3px solid #f59e0b;
outline-offset: 3px;
}
.card {
display: block;
background: white;
border: 1px solid #d8e0ed;
border-radius: 12px;
padding: 1.2rem;
margin: 1rem 0;
color: inherit;
text-decoration: none;
}
label {
display: grid;
gap: 0.4rem;
margin: 0.9rem 0;
font-weight: 600;
}
input,
select,
textarea,
button {
font: inherit;
border-radius: 7px;
padding: 0.7rem;
min-height: 44px;
}
input,
select,
textarea {
width: 100%;
border: 1px solid #abb9cf;
background: white;
color: #17243a;
}
button {
display: inline-block;
border: 0;
background: #2563eb;
color: white;
padding: 0.7rem 1rem;
cursor: pointer;
margin: 0.3rem 0.5rem 0.3rem 0;
}
button:disabled {
opacity: 0.55;
cursor: wait;
}
.secondary {
background: #e4eaf5;
color: #17243a;
}
.danger {
background: #b42332;
}
.task-list {
list-style: none;
padding: 0;
}
.task-list span {
display: block;
color: #526079;
margin-top: 0.5rem;
}
</style>
Say: "Create the reusable task list. Use stable task IDs for keyed rows and show the filtered workflow without changing database ownership."
Type: Follow the action above in modules/projects/ui/components/TaskList.html; explain each new function or binding as you add it.
Viewers should see: The saved file matches this step. After all steps, run the chapter checks below and show the expected result.
Step 3: EDIT - replace the entire file - modules/projects/ui/pages/ProjectDetails/[id].html
Replace the placeholder with the project detail page. Load the owned project and its tasks using the router ID, then compose the task form, list and progress display.
Select all existing contents of this file and replace them with the complete code below.
<template>
<section>
<a href="/projects" data-teloce-link>← All projects</a>
<p v-if="loading" role="status">Loading project…</p>
<p v-if="message" role="alert">{{ message }}</p>
<div v-if="project">
<h1>{{ project.name }}</h1>
<p>{{ project.description }}</p>
<p aria-live="polite">{{ progress }}% complete</p>
<progress :value="progress" max="100" aria-label="Project progress"></progress>
<form @submit.prevent="saveProject" class="card">
<h2>Edit project</h2>
<label>Name<input v-model="name" required maxlength="120" /></label>
<label
>Description<textarea v-model="description" maxlength="1000"></textarea>
</label>
<button :disabled="busy">Save project</button>
<button type="button" class="danger" @click="deleteProject" :disabled="busy">
Delete project
</button>
</form>
<TaskForm :busy="busy" @create="createTask" />
<label
>Filter tasks<select v-model="filter">
<option value="all">All tasks</option>
<option value="todo">To do</option>
<option value="doing">Doing</option>
<option value="done">Done</option>
</select></label
>
<TaskList
:tasks="visibleTasks"
:busy="busy"
@status="changeStatus"
@remove="removeTask"
/>
</div>
</section>
</template>
<script lang="ts">
import TaskForm from "../../components/TaskForm.html";
import TaskList from "../../components/TaskList.html";
import { api } from "../../../../../ui/api.ts";
import type { Project, Task } from "../../../../../ui/types.ts";
// The compiler imports these signal helpers automatically for this .html component.
const taskState = signal([]);
const completion = computed(() => {
const tasks = taskState();
return tasks.length
? Math.round(
(tasks.filter((task) => task.status === "done").length * 100) / tasks.length,
)
: 0;
});
export default {
props: ["id"],
components: { TaskForm, TaskList },
data() {
return {
project: null,
tasks: [],
name: "",
description: "",
filter: "all",
progress: 0,
busy: false,
loading: true,
message: "",
};
},
computed: {
visibleTasks() {
return this.filter === "all"
? this.tasks
: this.tasks.filter((task) => task.status === this.filter);
},
},
async mounted() {
taskState.set([]);
this.progressEffect = effect(() => {
this.progress = completion();
});
try {
const [project, tasks] = await Promise.all([
api<Project>("/api/projects/" + this.id),
api<Task[]>("/api/tasks/project/" + this.id),
]);
this.project = project;
this.name = project.name;
this.description = project.description;
this.setTasks(tasks);
} catch (error) {
this.message = error.message;
} finally {
this.loading = false;
}
},
beforeUnmount() {
this.progressEffect?.stop();
},
methods: {
setTasks(tasks) {
this.tasks = tasks;
taskState.set(tasks);
},
async mutate(operation) {
if (this.busy) return;
this.busy = true;
this.message = "";
try {
await operation();
} catch (error) {
this.message = error.message;
} finally {
this.busy = false;
}
},
async saveProject() {
await this.mutate(async () => {
this.project = await api<Project>("/api/projects/" + this.id, "PUT", {
name: this.name,
description: this.description,
});
});
},
async deleteProject() {
if (!window.confirm("Delete this project and all its tasks?")) return;
await this.mutate(async () => {
await api("/api/projects/" + this.id, "DELETE", {});
window.__TELOCE_ROUTER__.navigate("/projects");
});
},
async createTask(data) {
await this.mutate(async () => {
const task = await api<Task>("/api/tasks/project/" + this.id, "POST", data);
this.setTasks([task, ...this.tasks]);
});
},
async changeStatus(data) {
await this.mutate(async () => {
const updated = await api<Task>("/api/tasks/" + data.id, "PATCH", {
status: data.status,
});
this.setTasks(
this.tasks.map((task) => (task.id === updated.id ? updated : task)),
);
});
},
async removeTask(id) {
if (!window.confirm("Delete this task?")) return;
await this.mutate(async () => {
await api("/api/tasks/" + id, "DELETE", {});
this.setTasks(this.tasks.filter((task) => task.id !== id));
});
},
},
};
</script>
<style scoped>
* {
box-sizing: border-box;
}
a {
color: #1d4ed8;
}
h1 {
font-size: clamp(1.8rem, 5vw, 3rem);
line-height: 1.15;
}
h2 {
font-size: 1.2rem;
}
p {
line-height: 1.6;
}
[role="alert"] {
color: #a21c2b;
padding: 0.8rem;
border-left: 4px solid;
}
:focus-visible {
outline: 3px solid #f59e0b;
outline-offset: 3px;
}
.card {
display: block;
background: white;
border: 1px solid #d8e0ed;
border-radius: 12px;
padding: 1.2rem;
margin: 1rem 0;
color: inherit;
text-decoration: none;
}
label {
display: grid;
gap: 0.4rem;
margin: 0.9rem 0;
font-weight: 600;
}
input,
select,
textarea,
button {
font: inherit;
border-radius: 7px;
padding: 0.7rem;
min-height: 44px;
}
input,
select,
textarea {
width: 100%;
border: 1px solid #abb9cf;
background: white;
color: #17243a;
}
button {
display: inline-block;
border: 0;
background: #2563eb;
color: white;
padding: 0.7rem 1rem;
cursor: pointer;
margin: 0.3rem 0.5rem 0.3rem 0;
}
button:disabled {
opacity: 0.55;
cursor: wait;
}
.secondary {
background: #e4eaf5;
color: #17243a;
}
.danger {
background: #b42332;
}
progress {
width: 100%;
height: 1.2rem;
accent-color: #2563eb;
}
</style>
Say: "Replace the placeholder with the project detail page. Load the owned project and its tasks using the router ID, then compose the task form, list and progress display."
Type: Follow the action above in modules/projects/ui/pages/ProjectDetails/[id].html; explain each new function or binding as you add it.
Viewers should see: The saved file matches this step. After all steps, run the chapter checks below and show the expected result.
Run and check
python management.py runserver
# Navigate to a project by its SPA link and add two tasks.
# Direct detail-page refresh is added in chapter 11.
Expected result
Create two tasks and complete one: progress becomes 50 percent. Filtering to done changes the visible rows while overall project progress remains 50 percent.
Common errors
Read a signal by calling it and replace it through set. An effect left alive after navigation can update an obsolete screen. Do not calculate project progress from only filtered rows.
Short exercise
Complete both tasks and expect 100 percent, then delete one and verify progress stays correct.
What to say and show
Say: "Complete the task detail screen using reusable components and reactive progress. The server remains responsible for persistence and authorization; the browser presents the result."
Show the expected result above, then point to the file responsible for it. Run the chapter check before the next lesson.
Save your checkpoint
git add .
git commit -m "Complete chapter 10: Task components, signals and progress"