Build a Todo App#
Welcome to the “Build a Todo App” tutorial! In this section, we’re going to create a dynamic Todo App together. Imagine our wind turbine technicians being able to manage their tasks efficiently with this application. We’ll collaborate to develop an app with features like:
Adding, removing, and clearing tasks
Marking tasks as completed
Tracking the number of completed tasks
Dynamically disabling or hiding buttons
Previously, we built a basic todo app using a function-based approach. This time, we’ll employ a more sophisticated Parameterized class-based approach. This method will enhance the extensibility and maintainability of our todo app in the long run.
Requirements
panel
Code
"""An app to manage tasks"""
import param
import panel as pn
BUTTON_WIDTH = 125
pn.extension(sizing_mode="stretch_width")
class Task(pn.viewable.Viewer):
"""A model of a Task"""
value: str = param.String(doc="A description of the task")
completed: bool = param.Boolean(
doc="If True the task has been completed. Otherwise not."
)
def __panel__(self):
completed = pn.ui.Checkbox.from_param(
self.param.completed, label="", align="center", sizing_mode="fixed"
)
content = pn.ui.Markdown(object=self.param.value)
return pn.ui.Row(completed, content, sizing_mode="stretch_width")
class TaskList(param.Parameterized):
"""Provides methods to add and remove tasks as well as calculate summary statistics"""
value: list[Task] = param.List(item_type=Task, doc="A list of Tasks")
total_tasks = param.Integer(doc="The number of Tasks")
has_tasks = param.Boolean(doc="Whether or not the TaskList contains Tasks")
completed_tasks = param.Integer(doc="The number of completed tasks")
status = param.String(
doc="A string explaining the number of completed tasks and total number of tasks."
)
def __init__(self, **params):
self._task_watchers = {}
super().__init__(**params)
def add_task(self, task: Task):
"""Adds a Task to the value list"""
self.value = [*self.value, task]
def remove_task(self, task: Task):
"""Removes a Task from the value list"""
self.value = [item for item in self.value if item is not task]
def remove_all_tasks(self):
"""Removes all tasks from the value list"""
self.value = []
def _handle_completed_changed(self, _=None):
self.completed_tasks = sum(task.completed for task in self.value)
@param.depends("value", watch=True, on_init=True)
def _add_task_watchers(self):
for task in list(self._task_watchers):
if task not in self.value:
task.param.unwatch(self._task_watchers.pop(task))
for task in self.value:
if task not in self._task_watchers:
self._task_watchers[task] = task.param.watch(
self._handle_completed_changed, "completed"
)
@param.depends("value", watch=True, on_init=True)
def _handle_value_changed(self):
self.param.update(
total_tasks=len(self.value),
has_tasks=len(self.value) > 0,
completed_tasks=sum(task.completed for task in self.value),
)
@param.depends("total_tasks", "completed_tasks", watch=True, on_init=True)
def _update_status(self):
self.status = f"{self.completed_tasks} of {self.total_tasks} tasks completed"
class TaskInput(pn.viewable.Viewer):
"""A Widget that provides tasks as input"""
value: Task = param.ClassSelector(class_=Task, doc="""The Task input by the user""")
def __init__(self, **params):
super().__init__(**params)
self.text_input = pn.ui.TextInput(
label="Task", placeholder="Enter a task", sizing_mode="stretch_width"
)
self.submit_task = pn.ui.Button(
label="Add",
align="center",
color="primary",
width=BUTTON_WIDTH,
sizing_mode="fixed",
disabled=self.text_input.param.value_input.rx.not_(),
on_click=self._submit,
)
pn.bind(self._submit, self.text_input, watch=True)
self._layout = pn.ui.Row(self.text_input, self.submit_task, sizing_mode="stretch_width")
def _submit(self, *_):
if self.text_input.value_input:
self.value = Task(value=self.text_input.value_input)
self.text_input.value = self.text_input.value_input = ""
def __panel__(self):
return self._layout
class TaskRow(pn.viewable.Viewer):
"""Display a task in a Row together with a Remove button"""
value: Task = param.ClassSelector(
class_=Task, allow_None=True, doc="The Task to display"
)
remove: bool = param.Event(
doc="The event is triggered when the user clicks the Remove Button"
)
def __init__(self, **params):
super().__init__(**params)
self.remove_button = pn.ui.Button.from_param(
self.param.remove, width=BUTTON_WIDTH, icon="delete", sizing_mode="fixed"
)
self._layout = pn.ui.Row(self.value, self.remove_button)
def __panel__(self):
return self._layout
class TaskListEditor(pn.viewable.Viewer):
"""Component that enables a user to manage a list of tasks"""
value: TaskList = param.ClassSelector(class_=TaskList)
def __init__(self, **params):
super().__init__(**params)
self._task_rows = {}
self.task_input = TaskInput()
self.task_column = pn.ui.Column()
self.clear_button = pn.ui.Button(
label="Remove All",
color="primary",
variant="outlined",
width=BUTTON_WIDTH,
sizing_mode="fixed",
visible=self.value.param.has_tasks,
on_click=lambda e: self.value.remove_all_tasks(),
)
pn.bind(self.value.add_task, self.task_input.param.value, watch=True)
self._layout = pn.ui.Column(
"## WTG Task List",
pn.ui.Markdown(self.value.param.status),
self.task_input,
self.task_column,
pn.ui.Row(pn.ui.Spacer(), self.clear_button),
max_width=500,
)
self._update_task_rows()
@param.depends("value.value", watch=True)
def _update_task_rows(self):
task_rows = {}
for task in self.value.value:
if task in self._task_rows:
task_rows[task] = self._task_rows[task]
continue
row = TaskRow(value=task)
def remove(_, task=task):
self.value.remove_task(task)
pn.bind(remove, row.param.remove, watch=True)
task_rows[task] = row
self._task_rows = task_rows
self.task_column.objects = list(task_rows.values())
def __panel__(self):
return self._layout
if pn.state.served:
task_list = TaskList(
value=[
Task(value="Inspect the blades", completed=True),
Task(value="Inspect the nacelle"),
Task(value="Tighten the bolts"),
]
)
pn.ui.Page(title="Todo App", main=[TaskListEditor(value=task_list)]).servable()
Explanation#
Let’s break down the todo app.
Import Necessary Libraries#
"""An app to manage tasks"""
import param
import panel as pn
pn.extension(sizing_mode="stretch_width")
Here, we import the required libraries for our task management app. We use param for declaring parameters and panel for creating the user interface. The pn.ui components we use below already give the todo app a modern Material look and feel.
Define Constants#
BUTTON_WIDTH = 125
We set a constant BUTTON_WIDTH to control the width of buttons in our app.
Define Task Model#
class Task(pn.viewable.Viewer):
"""A model of a Task"""
value: str = param.String(doc="A description of the task")
completed: bool = param.Boolean(
doc="If True the task has been completed. Otherwise not."
)
def __panel__(self):
completed = pn.ui.Checkbox.from_param(
self.param.completed, label="", align="center", sizing_mode="fixed"
)
content = pn.ui.Markdown(object=self.param.value)
return pn.ui.Row(completed, content, sizing_mode="stretch_width")
Task(value="Inspect the blades")
This class defines the model of a task. It has two attributes: value (description of the task) and completed (whether the task is completed or not). The __panel__ method renders the task as a row containing a checkbox for completion status and the task description.
Define TaskList Class#
class TaskList(param.Parameterized):
"""Provides methods to add and remove tasks as well as calculate summary statistics"""
value: list[Task] = param.List(item_type=Task, doc="A list of Tasks")
total_tasks = param.Integer(doc="The number of Tasks")
has_tasks = param.Boolean(doc="Whether or not the TaskList contains Tasks")
completed_tasks = param.Integer(doc="The number of completed tasks")
status = param.String(
doc="A string explaining the number of completed tasks and total number of tasks."
)
def __init__(self, **params):
self._task_watchers = {}
super().__init__(**params)
def add_task(self, task: Task):
"""Adds a Task to the value list"""
self.value = [*self.value, task]
def remove_task(self, task: Task):
"""Removes a Task from the value list"""
self.value = [item for item in self.value if item is not task]
def remove_all_tasks(self):
"""Removes all tasks from the value list"""
self.value = []
def _handle_completed_changed(self, _=None):
self.completed_tasks = sum(task.completed for task in self.value)
@param.depends("value", watch=True, on_init=True)
def _add_task_watchers(self):
for task in list(self._task_watchers):
if task not in self.value:
task.param.unwatch(self._task_watchers.pop(task))
for task in self.value:
if task not in self._task_watchers:
self._task_watchers[task] = task.param.watch(
self._handle_completed_changed, "completed"
)
@param.depends("value", watch=True, on_init=True)
def _handle_value_changed(self):
self.param.update(
total_tasks=len(self.value),
has_tasks=len(self.value) > 0,
completed_tasks=sum(task.completed for task in self.value),
)
@param.depends("total_tasks", "completed_tasks", watch=True, on_init=True)
def _update_status(self):
self.status = f"{self.completed_tasks} of {self.total_tasks} tasks completed"
TaskList(value=[Task(value="Inspect the blades")])
This class represents a list of tasks. It provides methods to add and remove tasks, as well as calculate summary statistics such as the total number of tasks, the number of completed tasks, and a status message.
Whenever value changes, _add_task_watchers starts watching the completed parameter of new tasks (and stops watching removed ones), while _handle_value_changed recomputes all the counts in a single param.update call. Ticking a checkbox only changes the completed count, which is handled by _handle_completed_changed. Finally _update_status keeps the status message in sync with the counts.
Note
The TaskList and the rest of the todo app follows the DataStore design pattern introduced in Structure with a DataStore.
Define TaskInput Class#
class TaskInput(pn.viewable.Viewer):
"""A Widget that provides tasks as input"""
value: Task = param.ClassSelector(class_=Task, doc="""The Task input by the user""")
def __init__(self, **params):
super().__init__(**params)
self.text_input = pn.ui.TextInput(
label="Task", placeholder="Enter a task", sizing_mode="stretch_width"
)
self.submit_task = pn.ui.Button(
label="Add",
align="center",
color="primary",
width=BUTTON_WIDTH,
sizing_mode="fixed",
disabled=self.text_input.param.value_input.rx.not_(),
on_click=self._submit,
)
pn.bind(self._submit, self.text_input, watch=True)
self._layout = pn.ui.Row(self.text_input, self.submit_task, sizing_mode="stretch_width")
def _submit(self, *_):
if self.text_input.value_input:
self.value = Task(value=self.text_input.value_input)
self.text_input.value = self.text_input.value_input = ""
def __panel__(self):
return self._layout
TaskInput()
This class represents a widget for users to input tasks.
The __init__ method creates the widgets once: a text input field for entering task descriptions and a button to submit the task. The button is disabled while the text input is empty, and a task is submitted either by clicking the button or by pressing Enter. We keep the widgets as public attributes so we can interact with them in tests, and __panel__ simply returns the layout.
Define TaskRow Class#
class TaskRow(pn.viewable.Viewer):
"""Display a task in a Row together with a Remove button"""
value: Task = param.ClassSelector(
class_=Task, allow_None=True, doc="The Task to display"
)
remove: bool = param.Event(
doc="The event is triggered when the user clicks the Remove Button"
)
def __init__(self, **params):
super().__init__(**params)
self.remove_button = pn.ui.Button.from_param(
self.param.remove, width=BUTTON_WIDTH, icon="delete", sizing_mode="fixed"
)
self._layout = pn.ui.Row(self.value, self.remove_button)
def __panel__(self):
return self._layout
TaskRow(value=Task(value="Inspect the blades"))
This class displays a task in a row together with a button to remove it. Clicking the button triggers the remove event.
Define TaskListEditor Class#
class TaskListEditor(pn.viewable.Viewer):
"""Component that enables a user to manage a list of tasks"""
value: TaskList = param.ClassSelector(class_=TaskList)
def __init__(self, **params):
super().__init__(**params)
self._task_rows = {}
self.task_input = TaskInput()
self.task_column = pn.ui.Column()
self.clear_button = pn.ui.Button(
label="Remove All",
color="primary",
variant="outlined",
width=BUTTON_WIDTH,
sizing_mode="fixed",
visible=self.value.param.has_tasks,
on_click=lambda e: self.value.remove_all_tasks(),
)
pn.bind(self.value.add_task, self.task_input.param.value, watch=True)
self._layout = pn.ui.Column(
"## WTG Task List",
pn.ui.Markdown(self.value.param.status),
self.task_input,
self.task_column,
pn.ui.Row(pn.ui.Spacer(), self.clear_button),
max_width=500,
)
self._update_task_rows()
@param.depends("value.value", watch=True)
def _update_task_rows(self):
task_rows = {}
for task in self.value.value:
if task in self._task_rows:
task_rows[task] = self._task_rows[task]
continue
row = TaskRow(value=task)
def remove(_, task=task):
self.value.remove_task(task)
pn.bind(remove, row.param.remove, watch=True)
task_rows[task] = row
self._task_rows = task_rows
self.task_column.objects = list(task_rows.values())
def __panel__(self):
return self._layout
task_list = TaskList(
value=[
Task(value="Inspect the blades", completed=True),
Task(value="Inspect the nacelle"),
Task(value="Tighten the bolts"),
]
)
TaskListEditor(value=task_list)
This class represents a component that allows users to manage a list of tasks.
The __init__ method creates the layout once. It consists of an input field for adding tasks, a column of tasks, and a button to remove all tasks.
Instead of returning a new Column of TaskRows from a @param.depends method every time the list changes, _update_task_rows updates the objects of a single, stable task_column. It reuses the rows of tasks that are still in the list and only creates rows for new tasks, which avoids recreating every row and the flickering that goes with it.
To serve the app, we wrap the editor in a pn.ui.Page:
pn.ui.Page(title="Todo App", main=[TaskListEditor(value=task_list)]).servable()
Recap#
We’ve built a todo app using a Parameterized class-based approach and the DataStore design pattern. Now, our wind turbine technicians can manage their tasks efficiently and collaboratively.