add new tasks
This commit is contained in:
306
src/tasks/widgets/AddTaskForm.py
Normal file
306
src/tasks/widgets/AddTaskForm.py
Normal file
@@ -0,0 +1,306 @@
|
||||
"""Reusable Add Task form widget for Tasks TUI.
|
||||
|
||||
This widget can be used standalone in modals or embedded in other screens
|
||||
(e.g., the mail app for creating tasks from emails).
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from textual.app import ComposeResult
|
||||
from textual.containers import Horizontal, Vertical
|
||||
from textual.message import Message
|
||||
from textual.widget import Widget
|
||||
from textual.widgets import Input, Label, RadioButton, RadioSet, Select, TextArea
|
||||
from textual.widgets._select import NoSelection
|
||||
|
||||
from src.tasks.backend import TaskPriority
|
||||
|
||||
|
||||
@dataclass
|
||||
class TaskFormData:
|
||||
"""Data from the add task form."""
|
||||
|
||||
summary: str
|
||||
project: Optional[str] = None
|
||||
tags: list[str] | None = None
|
||||
priority: TaskPriority = TaskPriority.P2
|
||||
due: Optional[datetime] = None
|
||||
notes: Optional[str] = None
|
||||
|
||||
|
||||
class AddTaskForm(Widget):
|
||||
"""A reusable form widget for creating/editing tasks.
|
||||
|
||||
This widget emits a TaskFormData when submitted and can be embedded
|
||||
in various contexts (modal screens, sidebars, etc.)
|
||||
"""
|
||||
|
||||
DEFAULT_CSS = """
|
||||
AddTaskForm {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
padding: 1;
|
||||
}
|
||||
|
||||
AddTaskForm .form-row {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
margin-bottom: 1;
|
||||
}
|
||||
|
||||
AddTaskForm .form-label {
|
||||
width: 12;
|
||||
height: 1;
|
||||
padding-right: 1;
|
||||
}
|
||||
|
||||
AddTaskForm .form-input {
|
||||
width: 1fr;
|
||||
}
|
||||
|
||||
AddTaskForm #summary-input {
|
||||
width: 1fr;
|
||||
}
|
||||
|
||||
AddTaskForm #project-select {
|
||||
width: 1fr;
|
||||
}
|
||||
|
||||
AddTaskForm #tags-input {
|
||||
width: 1fr;
|
||||
}
|
||||
|
||||
AddTaskForm #due-input {
|
||||
width: 20;
|
||||
}
|
||||
|
||||
AddTaskForm #priority-set {
|
||||
width: 1fr;
|
||||
height: auto;
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 0;
|
||||
layout: horizontal;
|
||||
}
|
||||
|
||||
AddTaskForm #priority-set RadioButton {
|
||||
width: auto;
|
||||
padding: 0 2;
|
||||
background: transparent;
|
||||
height: 1;
|
||||
}
|
||||
|
||||
AddTaskForm #notes-textarea {
|
||||
width: 1fr;
|
||||
height: 6;
|
||||
}
|
||||
|
||||
AddTaskForm .required {
|
||||
color: $error;
|
||||
}
|
||||
"""
|
||||
|
||||
class Submitted(Message):
|
||||
"""Message emitted when the form is submitted."""
|
||||
|
||||
def __init__(self, data: TaskFormData) -> None:
|
||||
super().__init__()
|
||||
self.data = data
|
||||
|
||||
class Cancelled(Message):
|
||||
"""Message emitted when the form is cancelled."""
|
||||
|
||||
pass
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
projects: list[str] | None = None,
|
||||
initial_data: TaskFormData | None = None,
|
||||
show_notes: bool = True,
|
||||
mail_link: str | None = None,
|
||||
**kwargs,
|
||||
):
|
||||
"""Initialize the add task form.
|
||||
|
||||
Args:
|
||||
projects: List of available project names for the dropdown
|
||||
initial_data: Pre-populate form with this data
|
||||
show_notes: Whether to show the notes field
|
||||
mail_link: Optional mail link to prepend to notes (mail://message-id)
|
||||
"""
|
||||
super().__init__(**kwargs)
|
||||
self._projects = projects or []
|
||||
self._initial_data = initial_data
|
||||
self._show_notes = show_notes
|
||||
self._mail_link = mail_link
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
"""Compose the form layout."""
|
||||
initial = self._initial_data or TaskFormData(summary="")
|
||||
|
||||
# Summary (required)
|
||||
with Horizontal(classes="form-row"):
|
||||
yield Label("Summary", classes="form-label")
|
||||
yield Label("*", classes="required")
|
||||
yield Input(
|
||||
value=initial.summary,
|
||||
placeholder="Task summary...",
|
||||
id="summary-input",
|
||||
classes="form-input",
|
||||
)
|
||||
|
||||
# Project (optional dropdown)
|
||||
with Horizontal(classes="form-row"):
|
||||
yield Label("Project", classes="form-label")
|
||||
# Build options list with empty option for "none"
|
||||
options = [("(none)", "")] + [(p, p) for p in self._projects]
|
||||
yield Select(
|
||||
options=options,
|
||||
value=initial.project or "",
|
||||
id="project-select",
|
||||
allow_blank=True,
|
||||
)
|
||||
|
||||
# Tags (comma-separated input)
|
||||
with Horizontal(classes="form-row"):
|
||||
yield Label("Tags", classes="form-label")
|
||||
tags_str = ", ".join(initial.tags) if initial.tags else ""
|
||||
yield Input(
|
||||
value=tags_str,
|
||||
placeholder="tag1, tag2, tag3...",
|
||||
id="tags-input",
|
||||
classes="form-input",
|
||||
)
|
||||
|
||||
# Priority (radio buttons)
|
||||
with Horizontal(classes="form-row"):
|
||||
yield Label("Priority", classes="form-label")
|
||||
with RadioSet(id="priority-set"):
|
||||
yield RadioButton(
|
||||
"P0", value=initial.priority == TaskPriority.P0, id="priority-p0"
|
||||
)
|
||||
yield RadioButton(
|
||||
"P1", value=initial.priority == TaskPriority.P1, id="priority-p1"
|
||||
)
|
||||
yield RadioButton(
|
||||
"P2", value=initial.priority == TaskPriority.P2, id="priority-p2"
|
||||
)
|
||||
yield RadioButton(
|
||||
"P3", value=initial.priority == TaskPriority.P3, id="priority-p3"
|
||||
)
|
||||
|
||||
# Due date (input with date format)
|
||||
with Horizontal(classes="form-row"):
|
||||
yield Label("Due", classes="form-label")
|
||||
due_str = initial.due.strftime("%Y-%m-%d") if initial.due else ""
|
||||
yield Input(
|
||||
value=due_str,
|
||||
placeholder="YYYY-MM-DD",
|
||||
id="due-input",
|
||||
)
|
||||
|
||||
# Notes (optional textarea)
|
||||
if self._show_notes:
|
||||
with Vertical(classes="form-row"):
|
||||
yield Label("Notes", classes="form-label")
|
||||
# If mail_link is provided, prepend it to notes
|
||||
notes_content = initial.notes or ""
|
||||
if self._mail_link:
|
||||
notes_content = f"<!-- {self._mail_link} -->\n\n{notes_content}"
|
||||
yield TextArea(
|
||||
notes_content,
|
||||
id="notes-textarea",
|
||||
language="markdown",
|
||||
)
|
||||
|
||||
def get_form_data(self) -> TaskFormData:
|
||||
"""Extract current form data.
|
||||
|
||||
Returns:
|
||||
TaskFormData with current form values
|
||||
"""
|
||||
summary = self.query_one("#summary-input", Input).value.strip()
|
||||
|
||||
# Get project (handle NoSelection and empty string)
|
||||
project_select = self.query_one("#project-select", Select)
|
||||
project_value = project_select.value
|
||||
project: str | None = None
|
||||
if isinstance(project_value, str) and project_value:
|
||||
project = project_value
|
||||
|
||||
# Get tags (parse comma-separated)
|
||||
tags_str = self.query_one("#tags-input", Input).value.strip()
|
||||
tags = (
|
||||
[t.strip() for t in tags_str.split(",") if t.strip()] if tags_str else None
|
||||
)
|
||||
|
||||
# Get priority from radio set
|
||||
priority_set = self.query_one("#priority-set", RadioSet)
|
||||
priority = TaskPriority.P2 # default
|
||||
if priority_set.pressed_button and priority_set.pressed_button.id:
|
||||
priority_map = {
|
||||
"priority-p0": TaskPriority.P0,
|
||||
"priority-p1": TaskPriority.P1,
|
||||
"priority-p2": TaskPriority.P2,
|
||||
"priority-p3": TaskPriority.P3,
|
||||
}
|
||||
priority = priority_map.get(priority_set.pressed_button.id, TaskPriority.P2)
|
||||
|
||||
# Get due date
|
||||
due_str = self.query_one("#due-input", Input).value.strip()
|
||||
due = None
|
||||
if due_str:
|
||||
try:
|
||||
due = datetime.strptime(due_str, "%Y-%m-%d")
|
||||
except ValueError:
|
||||
pass # Invalid date format, ignore
|
||||
|
||||
# Get notes
|
||||
notes = None
|
||||
if self._show_notes:
|
||||
try:
|
||||
notes_area = self.query_one("#notes-textarea", TextArea)
|
||||
notes = notes_area.text if notes_area.text.strip() else None
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return TaskFormData(
|
||||
summary=summary,
|
||||
project=project,
|
||||
tags=tags,
|
||||
priority=priority,
|
||||
due=due,
|
||||
notes=notes,
|
||||
)
|
||||
|
||||
def validate(self) -> tuple[bool, str]:
|
||||
"""Validate the form data.
|
||||
|
||||
Returns:
|
||||
Tuple of (is_valid, error_message)
|
||||
"""
|
||||
data = self.get_form_data()
|
||||
|
||||
if not data.summary:
|
||||
return False, "Summary is required"
|
||||
|
||||
return True, ""
|
||||
|
||||
def submit(self) -> bool:
|
||||
"""Validate and submit the form.
|
||||
|
||||
Returns:
|
||||
True if form was valid and submitted, False otherwise
|
||||
"""
|
||||
is_valid, error = self.validate()
|
||||
if not is_valid:
|
||||
return False
|
||||
|
||||
self.post_message(self.Submitted(self.get_form_data()))
|
||||
return True
|
||||
|
||||
def cancel(self) -> None:
|
||||
"""Cancel the form."""
|
||||
self.post_message(self.Cancelled())
|
||||
Reference in New Issue
Block a user