feat(tasks): write a task straight into a section
The box at the top puts things in the project with no section, which is right for capturing. But a project has sections because the work belongs somewhere, and dragging every new task down into the right one is a second gesture for something you already knew when you typed it. Two ways in, because they answer different moments. A field at the foot of each section and each board column — standing there is enough, no syntax to know. And `/section` in quick add, for the box at the top and for Today and Upcoming, where you are not standing in a section at all. The field goes through quick add rather than createTask, so a line written there still parses its dates, priorities and labels. The sigil needed care: '#' and '@' never turn up in a Danish task line by accident and '/' turns up constantly. It has to follow whitespace and be followed immediately by the name, which "km/t", "3/4" and "20 kr / stk" all fail. The bare form is greedy, because "Kunder/Ordrer" is a real section name and the sigil is inside it. A section id from the client is checked against the project the task actually landed in. A `#project` in the same line can move the task elsewhere, and the id would otherwise follow it into a project that section does not belong to. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
9cc7df69f0
commit
0dbf7a40bf
@@ -475,6 +475,10 @@ type quickAddRequest struct {
|
||||
// ProjectID is the view the user was looking at. A "#project" in the text
|
||||
// wins over it: what somebody typed beats where they happened to be standing.
|
||||
ProjectID string `json:"project_id,omitempty"`
|
||||
// SectionID is the section the box was opened in, for the field that sits at
|
||||
// the foot of one. Same rule as the project: a "/section" in the text wins,
|
||||
// because typing it is a choice and standing there is a circumstance.
|
||||
SectionID string `json:"section_id,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Server) handleQuickAdd(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -518,9 +522,31 @@ func (s *Server) handleQuickAdd(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Resolved after the project, because a section belongs to one project and the
|
||||
// same name can exist in several. A name that matches nothing is reported the
|
||||
// same way an unknown project is: the task is still created, in the project
|
||||
// without a section, and the caller is told which word did not land.
|
||||
sectionID, unknownSection := "", ""
|
||||
// The section the box belongs to, unless the text names one. Checked against
|
||||
// the project the task actually landed in, so a "#project" that moved it
|
||||
// elsewhere cannot drag a section id from another project along with it.
|
||||
if req.SectionID != "" {
|
||||
if belongs, err := s.db.SectionInProject(r.Context(), req.SectionID, projectID); err == nil && belongs {
|
||||
sectionID = req.SectionID
|
||||
}
|
||||
}
|
||||
if parsed.Section != "" {
|
||||
if id, err := s.db.SectionByName(r.Context(), projectID, parsed.Section); err == nil {
|
||||
sectionID = id
|
||||
} else {
|
||||
unknownSection = parsed.Section
|
||||
}
|
||||
}
|
||||
|
||||
t := &store.Task{
|
||||
ProjectID: projectID, Content: parsed.Content, Priority: parsed.Priority,
|
||||
CreatedBy: user.ID, DueDate: parsed.DueDate, RecurrenceRule: parsed.Recurrence,
|
||||
ProjectID: projectID, SectionID: sectionID, Content: parsed.Content,
|
||||
Priority: parsed.Priority, CreatedBy: user.ID, DueDate: parsed.DueDate,
|
||||
RecurrenceRule: parsed.Recurrence,
|
||||
}
|
||||
if parsed.DueDate != "" {
|
||||
_, when, err := resolveDue(parsed.DueDate, parsed.DueTime, user.Timezone)
|
||||
@@ -537,13 +563,14 @@ func (s *Server) handleQuickAdd(w http.ResponseWriter, r *http.Request) {
|
||||
s.activity(r, t.ProjectID, t.ID, "task.created", map[string]any{"content": t.Content})
|
||||
s.publish(t.ProjectID, "task.created", toTaskJSON(*t))
|
||||
|
||||
if unknownProject != "" {
|
||||
if unknownProject != "" || unknownSection != "" {
|
||||
// Alongside the task rather than instead of it: the task was created, and
|
||||
// this says what could not be honoured while creating it.
|
||||
writeJSON(w, http.StatusCreated, struct {
|
||||
taskJSON
|
||||
UnknownProject string `json:"unknown_project"`
|
||||
}{toTaskJSON(*t), unknownProject})
|
||||
UnknownProject string `json:"unknown_project,omitempty"`
|
||||
UnknownSection string `json:"unknown_section,omitempty"`
|
||||
}{toTaskJSON(*t), unknownProject, unknownSection})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, toTaskJSON(*t))
|
||||
|
||||
@@ -1682,3 +1682,98 @@ func TestNotifications(t *testing.T) {
|
||||
t.Errorf("unread = %v after marking everything read", after["unread"])
|
||||
}
|
||||
}
|
||||
|
||||
// Quick add can put a task straight into a section.
|
||||
//
|
||||
// A section belongs to one project, so the name is resolved after the project is
|
||||
// decided — and two projects may both have a "Produktion", which are different
|
||||
// sections. A name that matches nothing is reported the way an unknown project is:
|
||||
// the task is still created, and the caller is told which word did not land.
|
||||
func TestQuickAddPutsATaskInASection(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Risteriet"})
|
||||
projectID := project["id"].(string)
|
||||
_, section := ts.do(t, "POST", "/api/v1/projects/"+projectID+"/sections",
|
||||
map[string]any{"name": "Kunder/Ordrer"})
|
||||
sectionID := section["id"].(string)
|
||||
|
||||
// The section name has a slash in it, which is also the sigil. The bare form is
|
||||
// greedy, so the whole name comes across.
|
||||
resp, task := ts.do(t, "POST", "/api/v1/tasks/quick-add",
|
||||
map[string]any{"text": "Karpenhøj kaffe bestilling #Risteriet /Kunder/Ordrer"})
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
t.Fatalf("quick add: status %d, body %v", resp.StatusCode, task)
|
||||
}
|
||||
if task["section_id"] != sectionID {
|
||||
t.Errorf("section_id = %v, want %v", task["section_id"], sectionID)
|
||||
}
|
||||
if task["content"] != "Karpenhøj kaffe bestilling" {
|
||||
t.Errorf("content = %v — the sigils should be gone", task["content"])
|
||||
}
|
||||
|
||||
// Case-insensitive, like the project sigil.
|
||||
_, lower := ts.do(t, "POST", "/api/v1/tasks/quick-add",
|
||||
map[string]any{"text": `nye poser #Risteriet /"kunder/ordrer"`})
|
||||
if lower["section_id"] != sectionID {
|
||||
t.Errorf("a differently-cased name did not resolve: %v", lower["section_id"])
|
||||
}
|
||||
|
||||
// A name that matches nothing still creates the task, and says so.
|
||||
_, missing := ts.do(t, "POST", "/api/v1/tasks/quick-add",
|
||||
map[string]any{"text": "noget andet #Risteriet /Findesikke"})
|
||||
if missing["section_id"] != nil && missing["section_id"] != "" {
|
||||
t.Errorf("an unknown section should leave the task unsectioned: %v", missing["section_id"])
|
||||
}
|
||||
if missing["unknown_section"] != "Findesikke" {
|
||||
t.Errorf("unknown_section = %v, want Findesikke", missing["unknown_section"])
|
||||
}
|
||||
if missing["content"] != "noget andet" {
|
||||
t.Errorf("content = %v", missing["content"])
|
||||
}
|
||||
|
||||
// A section in another project is a different section, and must not be found.
|
||||
_, other := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Andet"})
|
||||
ts.do(t, "POST", "/api/v1/projects/"+other["id"].(string)+"/sections",
|
||||
map[string]any{"name": "Egen"})
|
||||
_, crossed := ts.do(t, "POST", "/api/v1/tasks/quick-add",
|
||||
map[string]any{"text": "forkert sted #Risteriet /Egen"})
|
||||
if crossed["unknown_section"] != "Egen" {
|
||||
t.Errorf("a section from another project was found: %v", crossed["section_id"])
|
||||
}
|
||||
}
|
||||
|
||||
// The field at the foot of a section passes the section it sits in, and a
|
||||
// "#project" in the same line can move the task out from under it.
|
||||
func TestQuickAddSectionIDIsCheckedAgainstTheProject(t *testing.T) {
|
||||
ts := newTestServer(t)
|
||||
ts.bootstrap(t)
|
||||
|
||||
_, here := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Her"})
|
||||
hereID := here["id"].(string)
|
||||
_, section := ts.do(t, "POST", "/api/v1/projects/"+hereID+"/sections",
|
||||
map[string]any{"name": "Produktion"})
|
||||
sectionID := section["id"].(string)
|
||||
|
||||
// Standing in the section is enough; no sigil needed.
|
||||
_, plain := ts.do(t, "POST", "/api/v1/tasks/quick-add", map[string]any{
|
||||
"text": "male gavlen", "project_id": hereID, "section_id": sectionID,
|
||||
})
|
||||
if plain["section_id"] != sectionID {
|
||||
t.Errorf("section_id = %v, want %v", plain["section_id"], sectionID)
|
||||
}
|
||||
|
||||
// A "#project" that moves the task must not drag the section id with it: that
|
||||
// section belongs to the project being left behind.
|
||||
ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Andetsted"})
|
||||
_, moved := ts.do(t, "POST", "/api/v1/tasks/quick-add", map[string]any{
|
||||
"text": "male gavlen #Andetsted", "project_id": hereID, "section_id": sectionID,
|
||||
})
|
||||
if moved["section_id"] != nil && moved["section_id"] != "" {
|
||||
t.Errorf("the section came along to another project: %v", moved["section_id"])
|
||||
}
|
||||
if moved["content"] != "male gavlen" {
|
||||
t.Errorf("content = %v", moved["content"])
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user