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
|
// 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.
|
// wins over it: what somebody typed beats where they happened to be standing.
|
||||||
ProjectID string `json:"project_id,omitempty"`
|
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) {
|
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
|
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{
|
t := &store.Task{
|
||||||
ProjectID: projectID, Content: parsed.Content, Priority: parsed.Priority,
|
ProjectID: projectID, SectionID: sectionID, Content: parsed.Content,
|
||||||
CreatedBy: user.ID, DueDate: parsed.DueDate, RecurrenceRule: parsed.Recurrence,
|
Priority: parsed.Priority, CreatedBy: user.ID, DueDate: parsed.DueDate,
|
||||||
|
RecurrenceRule: parsed.Recurrence,
|
||||||
}
|
}
|
||||||
if parsed.DueDate != "" {
|
if parsed.DueDate != "" {
|
||||||
_, when, err := resolveDue(parsed.DueDate, parsed.DueTime, user.Timezone)
|
_, 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.activity(r, t.ProjectID, t.ID, "task.created", map[string]any{"content": t.Content})
|
||||||
s.publish(t.ProjectID, "task.created", toTaskJSON(*t))
|
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
|
// Alongside the task rather than instead of it: the task was created, and
|
||||||
// this says what could not be honoured while creating it.
|
// this says what could not be honoured while creating it.
|
||||||
writeJSON(w, http.StatusCreated, struct {
|
writeJSON(w, http.StatusCreated, struct {
|
||||||
taskJSON
|
taskJSON
|
||||||
UnknownProject string `json:"unknown_project"`
|
UnknownProject string `json:"unknown_project,omitempty"`
|
||||||
}{toTaskJSON(*t), unknownProject})
|
UnknownSection string `json:"unknown_section,omitempty"`
|
||||||
|
}{toTaskJSON(*t), unknownProject, unknownSection})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusCreated, toTaskJSON(*t))
|
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"])
|
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"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ const (
|
|||||||
KindTime Kind = "time"
|
KindTime Kind = "time"
|
||||||
KindPriority Kind = "priority"
|
KindPriority Kind = "priority"
|
||||||
KindProject Kind = "project"
|
KindProject Kind = "project"
|
||||||
|
KindSection Kind = "section"
|
||||||
KindLabel Kind = "label"
|
KindLabel Kind = "label"
|
||||||
KindRepeat Kind = "repeat"
|
KindRepeat Kind = "repeat"
|
||||||
)
|
)
|
||||||
@@ -53,6 +54,10 @@ type Result struct {
|
|||||||
// Project is the name written after '#', not an id: the caller resolves it
|
// Project is the name written after '#', not an id: the caller resolves it
|
||||||
// against the user's projects and decides what to do when it matches nothing.
|
// against the user's projects and decides what to do when it matches nothing.
|
||||||
Project string `json:"project,omitempty"`
|
Project string `json:"project,omitempty"`
|
||||||
|
// Section is the name written after '/', resolved by the caller against the
|
||||||
|
// sections of whichever project the task lands in. A section belongs to one
|
||||||
|
// project, so it cannot be looked up until the project is known.
|
||||||
|
Section string `json:"section,omitempty"`
|
||||||
Labels []string `json:"labels,omitempty"`
|
Labels []string `json:"labels,omitempty"`
|
||||||
// Priority is 1 (highest) to 4. 4 is both the default and "none stated".
|
// Priority is 1 (highest) to 4. 4 is both the default and "none stated".
|
||||||
Priority int `json:"priority"`
|
Priority int `json:"priority"`
|
||||||
@@ -81,6 +86,7 @@ func Parse(input string, now time.Time, locale string) Result {
|
|||||||
// Sigils first. '#' and '@' are unambiguous, and consuming them stops a project
|
// Sigils first. '#' and '@' are unambiguous, and consuming them stops a project
|
||||||
// called "Fredag" from later being read as a weekday.
|
// called "Fredag" from later being read as a weekday.
|
||||||
res.Project, spans = extractProject(input, spans)
|
res.Project, spans = extractProject(input, spans)
|
||||||
|
res.Section, spans = extractSection(input, spans)
|
||||||
res.Labels, spans = extractLabels(input, spans)
|
res.Labels, spans = extractLabels(input, spans)
|
||||||
res.Priority, spans = extractPriority(input, spans)
|
res.Priority, spans = extractPriority(input, spans)
|
||||||
|
|
||||||
@@ -144,6 +150,16 @@ func Parse(input string, now time.Time, locale string) Result {
|
|||||||
var (
|
var (
|
||||||
reProject = regexp.MustCompile(`(?:^|\s)#(?:"([^"]+)"|([\p{L}\p{N}_/-]+))`)
|
reProject = regexp.MustCompile(`(?:^|\s)#(?:"([^"]+)"|([\p{L}\p{N}_/-]+))`)
|
||||||
reLabel = regexp.MustCompile(`(?:^|\s)@(?:"([^"]+)"|([\p{L}\p{N}_/-]+))`)
|
reLabel = regexp.MustCompile(`(?:^|\s)@(?:"([^"]+)"|([\p{L}\p{N}_/-]+))`)
|
||||||
|
// A section, written '/Produktion' or '/"Kunder og ordrer"'.
|
||||||
|
//
|
||||||
|
// The name may itself contain a slash — "Kunder/Ordrer" is a real section
|
||||||
|
// name — so the bare form is greedy and swallows the lot.
|
||||||
|
//
|
||||||
|
// The leading `(?:^|\s)` is what keeps this out of ordinary text. A slash
|
||||||
|
// inside a word never matches, so "km/t" and "3/4" are safe, and the name has
|
||||||
|
// to start immediately after the slash, so "20 kr / stk" is safe too. Those
|
||||||
|
// three are the shapes a slash actually takes in a Danish task line.
|
||||||
|
reSection = regexp.MustCompile(`(?:^|\s)/(?:"([^"]+)"|([\p{L}\p{N}_/-]+))`)
|
||||||
)
|
)
|
||||||
|
|
||||||
func extractProject(input string, spans []Span) (string, []Span) {
|
func extractProject(input string, spans []Span) (string, []Span) {
|
||||||
@@ -160,6 +176,20 @@ func extractProject(input string, spans []Span) (string, []Span) {
|
|||||||
return name, spans
|
return name, spans
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Last one wins, as with the project: retyping is a correction, not two sections.
|
||||||
|
func extractSection(input string, spans []Span) (string, []Span) {
|
||||||
|
all := reSection.FindAllStringSubmatchIndex(input, -1)
|
||||||
|
if len(all) == 0 {
|
||||||
|
return "", spans
|
||||||
|
}
|
||||||
|
var name string
|
||||||
|
for _, m := range all {
|
||||||
|
name = submatch(input, m)
|
||||||
|
spans = append(spans, sigilSpan(input, m, KindSection))
|
||||||
|
}
|
||||||
|
return name, spans
|
||||||
|
}
|
||||||
|
|
||||||
func extractLabels(input string, spans []Span) ([]string, []Span) {
|
func extractLabels(input string, spans []Span) ([]string, []Span) {
|
||||||
var labels []string
|
var labels []string
|
||||||
seen := map[string]bool{}
|
seen := map[string]bool{}
|
||||||
|
|||||||
@@ -560,3 +560,46 @@ func TestRecurrenceIsHighlighted(t *testing.T) {
|
|||||||
t.Error("no span of kind repeat")
|
t.Error("no span of kind repeat")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A section, and the three shapes a slash takes in ordinary text.
|
||||||
|
//
|
||||||
|
// The sigil is the one that needed thinking about: '#' and '@' never occur in a
|
||||||
|
// Danish task line by accident, and '/' occurs constantly. What keeps it apart is
|
||||||
|
// that the slash must follow whitespace and the name must follow the slash with
|
||||||
|
// nothing in between — which "km/t", "3/4" and "20 kr / stk" all fail.
|
||||||
|
func TestASectionIsWrittenWithASlash(t *testing.T) {
|
||||||
|
for _, c := range []struct {
|
||||||
|
in string
|
||||||
|
section string
|
||||||
|
content string
|
||||||
|
}{
|
||||||
|
{"ring til Karpenhøj /Produktion", "Produktion", "ring til Karpenhøj"},
|
||||||
|
{"/Produktion ring til Karpenhøj", "Produktion", "ring til Karpenhøj"},
|
||||||
|
// A real section name with a slash in it. The bare form is greedy, so the
|
||||||
|
// whole thing comes across rather than stopping at the second slash.
|
||||||
|
{"nye poser /Kunder/Ordrer", "Kunder/Ordrer", "nye poser"},
|
||||||
|
{`nye poser /"Kunder og ordrer"`, "Kunder og ordrer", "nye poser"},
|
||||||
|
// Retyping is a correction, not two sections.
|
||||||
|
{"nye poser /Drift /Produktion", "Produktion", "nye poser"},
|
||||||
|
// And alongside everything else.
|
||||||
|
{"betal moms i morgen kl 10 p1 #Firma /Bogholderi", "Bogholderi", "betal moms"},
|
||||||
|
|
||||||
|
// The ways a slash shows up when nobody meant a section. None of them may
|
||||||
|
// produce one: the slash has to follow whitespace, and the name has to
|
||||||
|
// follow the slash with nothing between.
|
||||||
|
{"kør 80 km/t forbi", "", "kør 80 km/t forbi"},
|
||||||
|
{"20 kr / stk hos grossisten", "", "20 kr / stk hos grossisten"},
|
||||||
|
// "3/4" is a section to nobody and a date to a Dane — the 3rd of April —
|
||||||
|
// and the date parser is right to claim it. What matters here is only that
|
||||||
|
// it did not become a section.
|
||||||
|
{"læs 3/4 af rapporten", "", "læs af rapporten"},
|
||||||
|
} {
|
||||||
|
got := Parse(c.in, ref(t), "da")
|
||||||
|
if got.Section != c.section {
|
||||||
|
t.Errorf("Parse(%q).Section = %q, want %q", c.in, got.Section, c.section)
|
||||||
|
}
|
||||||
|
if got.Content != c.content {
|
||||||
|
t.Errorf("Parse(%q).Content = %q, want %q", c.in, got.Content, c.content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -630,3 +630,36 @@ func (db *DB) ReorderProjects(ctx context.Context, userID string, ids []string)
|
|||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SectionByName resolves a "/name" from quick add within one project.
|
||||||
|
//
|
||||||
|
// Case-insensitive, and scoped to the project the task is landing in — a section
|
||||||
|
// belongs to exactly one project, so the name cannot be looked up before that is
|
||||||
|
// decided. Two projects may both have a "Produktion" and they are different
|
||||||
|
// sections; asking without the project would be asking the wrong question.
|
||||||
|
func (db *DB) SectionByName(ctx context.Context, projectID, name string) (string, error) {
|
||||||
|
var id string
|
||||||
|
err := db.QueryRowContext(ctx, `
|
||||||
|
SELECT id FROM sections
|
||||||
|
WHERE project_id = ? AND lower(name) = lower(?) AND deleted_at IS NULL
|
||||||
|
ORDER BY sort_order
|
||||||
|
LIMIT 1`, projectID, name).Scan(&id)
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return "", ErrNotFound
|
||||||
|
}
|
||||||
|
return id, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// SectionInProject reports whether a section id belongs to the given project.
|
||||||
|
//
|
||||||
|
// Quick add takes a section id from the client — the field at the foot of a
|
||||||
|
// section passes the one it sits in — and a "#project" in the same line can move
|
||||||
|
// the task somewhere else entirely. Without this the id would follow it, and the
|
||||||
|
// task would land in a section belonging to a project it is not in.
|
||||||
|
func (db *DB) SectionInProject(ctx context.Context, sectionID, projectID string) (bool, error) {
|
||||||
|
var n int
|
||||||
|
err := db.QueryRowContext(ctx,
|
||||||
|
`SELECT count(*) FROM sections WHERE id = ? AND project_id = ? AND deleted_at IS NULL`,
|
||||||
|
sectionID, projectID).Scan(&n)
|
||||||
|
return n == 1, err
|
||||||
|
}
|
||||||
|
|||||||
@@ -1336,6 +1336,62 @@ test('projektets navn brydes ikke midt i et ord', async ({ page }) => {
|
|||||||
expect(trouble).toEqual([]);
|
expect(trouble).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A task can be written straight into a section, two ways.
|
||||||
|
*
|
||||||
|
* The box at the top of the page 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.
|
||||||
|
*
|
||||||
|
* The field at the foot of a section passes the section it sits in. The sigil is
|
||||||
|
* for the box at the top, and for Today and Upcoming, where you are not standing
|
||||||
|
* in a section at all.
|
||||||
|
*/
|
||||||
|
test('en opgave kan skrives direkte i en sektion @forms', async ({ page }) => {
|
||||||
|
const trouble = watchForTrouble(page);
|
||||||
|
await page.goto('/');
|
||||||
|
|
||||||
|
const sidebar = page.getByRole('navigation', { name: 'Hovedmenu' });
|
||||||
|
await sidebar.getByRole('button', { name: 'Nyt projekt' }).click();
|
||||||
|
await sidebar.getByLabel('Projektnavn').fill('Risteriet');
|
||||||
|
await sidebar.getByLabel('Projektnavn').press('Enter');
|
||||||
|
await expect(page.getByRole('heading', { name: 'Risteriet' })).toBeVisible();
|
||||||
|
|
||||||
|
// A section whose name contains the sigil, because that is the one that would
|
||||||
|
// break: the bare form has to be greedy enough to swallow the whole name.
|
||||||
|
await page.getByRole('button', { name: '+ Tilføj sektion' }).click();
|
||||||
|
await page.getByLabel('Ny sektion').fill('Kunder/Ordrer');
|
||||||
|
await page.getByLabel('Ny sektion').press('Enter');
|
||||||
|
await expect(page.getByRole('heading', { name: 'Kunder/Ordrer' })).toBeVisible();
|
||||||
|
|
||||||
|
const section = page.locator('section').filter({
|
||||||
|
has: page.getByRole('heading', { name: 'Kunder/Ordrer' })
|
||||||
|
});
|
||||||
|
|
||||||
|
// The field at the foot of the section. No sigil — standing there is enough.
|
||||||
|
await section.getByRole('button', { name: '+ Tilføj opgave' }).click();
|
||||||
|
const field = page.getByLabel('Ny opgave i Kunder/Ordrer');
|
||||||
|
await field.fill('Karpenhøj bestilling i morgen p1');
|
||||||
|
await field.press('Enter');
|
||||||
|
|
||||||
|
const landed = section.locator('.row').filter({ hasText: 'Karpenhøj bestilling' });
|
||||||
|
await expect(landed).toBeVisible();
|
||||||
|
// It went through quick add, so the date and the priority were read as well.
|
||||||
|
await expect(landed.getByText('I morgen')).toBeVisible();
|
||||||
|
|
||||||
|
// And the sigil, from the box at the top, which is not standing anywhere.
|
||||||
|
await page.getByLabel('Ny opgave').fill('nye poser /Kunder/Ordrer');
|
||||||
|
await page.getByLabel('Ny opgave').press('Enter');
|
||||||
|
await expect(section.locator('.row').filter({ hasText: 'nye poser' })).toBeVisible();
|
||||||
|
// The sigil is consumed, not left in the title.
|
||||||
|
await expect(section.getByText('/Kunder/Ordrer')).toHaveCount(0);
|
||||||
|
|
||||||
|
await expect(section.locator('.row')).toHaveCount(2);
|
||||||
|
|
||||||
|
expect(trouble).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The sidebar never scrolls sideways.
|
* The sidebar never scrolls sideways.
|
||||||
*
|
*
|
||||||
|
|||||||
+2
-1
@@ -267,7 +267,8 @@ export const api = {
|
|||||||
reopenTask: (id) => post(`/tasks/${id}/reopen`),
|
reopenTask: (id) => post(`/tasks/${id}/reopen`),
|
||||||
moveTask: (id, data) => post(`/tasks/${id}/move`, data),
|
moveTask: (id, data) => post(`/tasks/${id}/move`, data),
|
||||||
|
|
||||||
quickAdd: (text, projectId) => post('/tasks/quick-add', { text, project_id: projectId }),
|
quickAdd: (text, projectId, sectionId) =>
|
||||||
|
post('/tasks/quick-add', { text, project_id: projectId, section_id: sectionId }),
|
||||||
quickAddPreview: (text, signal) =>
|
quickAddPreview: (text, signal) =>
|
||||||
get(`/tasks/quick-add/preview?text=${encodeURIComponent(text)}`, { signal }),
|
get(`/tasks/quick-add/preview?text=${encodeURIComponent(text)}`, { signal }),
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,23 @@
|
|||||||
let adding = $state(false);
|
let adding = $state(false);
|
||||||
let name = $state('');
|
let name = $state('');
|
||||||
|
|
||||||
|
/** Same as the list view's: through quick add, so a date or a p1 still parses. */
|
||||||
|
let addingIn = $state(null);
|
||||||
|
let newInSection = $state('');
|
||||||
|
|
||||||
|
async function addToSection(event, sectionId) {
|
||||||
|
event.preventDefault();
|
||||||
|
const text = newInSection.trim();
|
||||||
|
if (!text) return;
|
||||||
|
newInSection = '';
|
||||||
|
addingIn = null;
|
||||||
|
try {
|
||||||
|
app.upsert(await api.quickAdd(text, project.id, sectionId));
|
||||||
|
} catch (e) {
|
||||||
|
app.toast(humanMessage(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function addSection(event) {
|
async function addSection(event) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const trimmed = name.trim();
|
const trimmed = name.trim();
|
||||||
@@ -147,6 +164,35 @@
|
|||||||
<p class="empty">{t('view.emptySection')}</p>
|
<p class="empty">{t('view.emptySection')}</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- At the foot of the column, where the next card would go. A board's
|
||||||
|
columns *are* the sections, so this is the one place where "add a
|
||||||
|
task here" needs no explanation at all. -->
|
||||||
|
{#if canEdit}
|
||||||
|
<!-- `column`, which is what the each binds. The unsectioned column has
|
||||||
|
an empty id, and that is exactly what quick add wants for "in this
|
||||||
|
project, no section". -->
|
||||||
|
{#if addingIn === column.id}
|
||||||
|
<form class="add-task" onsubmit={(e) => addToSection(e, column.id)}>
|
||||||
|
<input
|
||||||
|
bind:value={newInSection}
|
||||||
|
use:focusOnMount
|
||||||
|
placeholder={t('view.addTaskHere')}
|
||||||
|
aria-label={t('view.addTaskIn', { name: column.name })}
|
||||||
|
onblur={() => !newInSection.trim() && (addingIn = null)}
|
||||||
|
onkeydown={(e) => e.key === 'Escape' && (addingIn = null)}
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
{:else}
|
||||||
|
<button
|
||||||
|
class="add-task-open"
|
||||||
|
onclick={() => {
|
||||||
|
addingIn = column.id;
|
||||||
|
newInSection = '';
|
||||||
|
}}>{t('view.addTask')}</button
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
@@ -305,6 +351,36 @@
|
|||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.add-task-open {
|
||||||
|
align-self: flex-start;
|
||||||
|
padding: var(--s2);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--ink-faint);
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity var(--fast) var(--ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.column:hover .add-task-open,
|
||||||
|
.add-task-open:focus-visible {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-task-open:hover {
|
||||||
|
color: var(--ink-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-task input {
|
||||||
|
width: 100%;
|
||||||
|
padding: var(--s2);
|
||||||
|
background: var(--ground);
|
||||||
|
border: 1px solid var(--accent);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
font: inherit;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--ink);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
.empty {
|
.empty {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: var(--s3) var(--s2);
|
padding: var(--s3) var(--s2);
|
||||||
|
|||||||
@@ -601,5 +601,9 @@ export const da = {
|
|||||||
'project.more': 'Flere handlinger',
|
'project.more': 'Flere handlinger',
|
||||||
'project.removeMember': 'Fjern {name}',
|
'project.removeMember': 'Fjern {name}',
|
||||||
|
|
||||||
'nav.closeMenu': 'Luk menu'
|
'nav.closeMenu': 'Luk menu',
|
||||||
|
|
||||||
|
'view.addTask': '+ Tilføj opgave',
|
||||||
|
'view.addTaskHere': 'Skriv en opgave — dato, p1 og @etiket virker her også',
|
||||||
|
'view.addTaskIn': 'Ny opgave i {name}'
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -596,5 +596,9 @@ export const en = {
|
|||||||
'project.more': 'More actions',
|
'project.more': 'More actions',
|
||||||
'project.removeMember': 'Remove {name}',
|
'project.removeMember': 'Remove {name}',
|
||||||
|
|
||||||
'nav.closeMenu': 'Close menu'
|
'nav.closeMenu': 'Close menu',
|
||||||
|
|
||||||
|
'view.addTask': '+ Add a task',
|
||||||
|
'view.addTaskHere': 'Write a task — a date, p1 and @label work here too',
|
||||||
|
'view.addTaskIn': 'New task in {name}'
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -277,6 +277,36 @@
|
|||||||
let renamingSection = $state(null);
|
let renamingSection = $state(null);
|
||||||
let sectionName = $state('');
|
let sectionName = $state('');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A task written straight into a section.
|
||||||
|
*
|
||||||
|
* The quick-add box at the top of the page puts things in the project with no
|
||||||
|
* section, which is right for "capture it now" — but a project with sections has
|
||||||
|
* them 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.
|
||||||
|
*
|
||||||
|
* It goes through quick add rather than createTask, so a line written here still
|
||||||
|
* parses its dates, priorities and labels. The section is passed rather than
|
||||||
|
* written, so `/Produktion` is not needed when you are already standing in it.
|
||||||
|
*/
|
||||||
|
let addingIn = $state(null);
|
||||||
|
let newInSection = $state('');
|
||||||
|
|
||||||
|
async function addToSection(event, sectionId) {
|
||||||
|
event.preventDefault();
|
||||||
|
const text = newInSection.trim();
|
||||||
|
if (!text) return;
|
||||||
|
newInSection = '';
|
||||||
|
addingIn = null;
|
||||||
|
try {
|
||||||
|
const created = await api.quickAdd(text, project.id, sectionId);
|
||||||
|
app.upsert(created);
|
||||||
|
} catch (e) {
|
||||||
|
app.toast(humanMessage(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function addSection(event) {
|
async function addSection(event) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const name = sectionName.trim();
|
const name = sectionName.trim();
|
||||||
@@ -684,6 +714,29 @@
|
|||||||
{#if !tasks.length}
|
{#if !tasks.length}
|
||||||
<p class="empty">{t('view.emptySection')}</p>
|
<p class="empty">{t('view.emptySection')}</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if canEdit}
|
||||||
|
{#if addingIn === section.id}
|
||||||
|
<form class="add-task" onsubmit={(e) => addToSection(e, section.id)}>
|
||||||
|
<input
|
||||||
|
bind:value={newInSection}
|
||||||
|
use:focusOnMount
|
||||||
|
placeholder={t('view.addTaskHere')}
|
||||||
|
aria-label={t('view.addTaskIn', { name: section.name })}
|
||||||
|
onblur={() => !newInSection.trim() && (addingIn = null)}
|
||||||
|
onkeydown={(e) => e.key === 'Escape' && (addingIn = null)}
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
{:else}
|
||||||
|
<button
|
||||||
|
class="add-task-open"
|
||||||
|
onclick={() => {
|
||||||
|
addingIn = section.id;
|
||||||
|
newInSection = '';
|
||||||
|
}}>{t('view.addTask')}</button
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
@@ -1267,6 +1320,38 @@
|
|||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Quiet until wanted. A section that is not being added to should read as a
|
||||||
|
heading and its rows, not as a form. */
|
||||||
|
.add-task-open {
|
||||||
|
align-self: flex-start;
|
||||||
|
padding: var(--s2) 0 var(--s2) var(--s2);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--ink-faint);
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity var(--fast) var(--ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
section:hover .add-task-open,
|
||||||
|
.add-task-open:focus-visible {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-task-open:hover {
|
||||||
|
color: var(--ink-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-task input {
|
||||||
|
width: 100%;
|
||||||
|
padding: var(--s2);
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
border-bottom: 1px solid var(--accent);
|
||||||
|
font: inherit;
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
color: var(--ink);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
.add-section {
|
.add-section {
|
||||||
margin-top: var(--s5);
|
margin-top: var(--s5);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user