feat(tasks): move, repeat, delegate and re-read a task from its drawer
Four things you could not do to a task you had open, and one that had never worked at all. **Project, recurrence and assignee** are now fields in the drawer. A task created in the wrong place could only be fixed by dragging it, a repeat could only be set by typing it into the title when the task was created, and delegation had no interface whatsoever despite assignee_id being in the API from the start. The assignee only appears on a project with somebody else in it, because on your own there is nobody to choose. **The title is read the way quick add reads it.** Typing "i morgen kl 14 p1" into a title used to leave it as those words, so where you typed a sentence decided what it meant. The parse runs on the server — the grammar is Danish and English and lives there — and applies only when it consumed something. What it took is said out loud, and the fields on screen are updated to match: the drawer otherwise seeds its inputs once per task so a save cannot overwrite what you are typing, and that rule has to bend exactly here, where the save changed the very fields in front of you. **And the member list has never worked.** The share panel, and now the assignee field, ask for it. Every call errored: the ORDER BY named an expression, and SQLite only accepts result-set columns across a UNION, so it failed at query time with "1st ORDER BY term does not match any column in the result set". Found by the smoke test that opens a task, which is the second real bug that suite has caught by watching for failed requests rather than only for what renders. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
committed by
Kristian Wind
co-authored by
Claude Opus 5
parent
afc46a5c20
commit
9056c44c9a
@@ -153,3 +153,44 @@ func TestReorderProjectsLeavesSomebodyElsesAlone(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The member list errored on every project — the ORDER BY named an expression,
|
||||||
|
// which SQLite refuses across a UNION — so the share panel had never shown
|
||||||
|
// anybody, not even the owner.
|
||||||
|
func TestListMembersWorksAtAll(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
|
||||||
|
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]string{"name": "Firma"})
|
||||||
|
|
||||||
|
resp, body := ts.do(t, "GET", "/api/v1/projects/"+project["id"].(string)+"/members", nil)
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("status %d, body %v", resp.StatusCode, body)
|
||||||
|
}
|
||||||
|
members, _ := body["members"].([]any)
|
||||||
|
if len(members) != 1 {
|
||||||
|
t.Fatalf("a project with no invites lists %d members, want its owner", len(members))
|
||||||
|
}
|
||||||
|
if m := members[0].(map[string]any); m["role"] != "owner" {
|
||||||
|
t.Errorf("role = %v, want owner", m["role"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// And the Inbox, which is the one every task drawer asks about.
|
||||||
|
func TestListMembersWorksForTheInbox(t *testing.T) {
|
||||||
|
ts := newTestServer(t)
|
||||||
|
ts.bootstrap(t)
|
||||||
|
|
||||||
|
_, list := ts.do(t, "GET", "/api/v1/projects", nil)
|
||||||
|
var inboxID string
|
||||||
|
for _, raw := range list["projects"].([]any) {
|
||||||
|
if p := raw.(map[string]any); p["is_inbox"] == true {
|
||||||
|
inboxID = p["id"].(string)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, body := ts.do(t, "GET", "/api/v1/projects/"+inboxID+"/members", nil)
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("status %d, body %v", resp.StatusCode, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -328,15 +328,27 @@ type Member struct {
|
|||||||
// project_members row — ownership is the projects table — so they are unioned in
|
// project_members row — ownership is the projects table — so they are unioned in
|
||||||
// rather than left out of the list of people who can see it.
|
// rather than left out of the list of people who can see it.
|
||||||
func (db *DB) ListMembers(ctx context.Context, projectID string) ([]Member, error) {
|
func (db *DB) ListMembers(ctx context.Context, projectID string) ([]Member, error) {
|
||||||
|
// The owner first, then everybody else by name.
|
||||||
|
//
|
||||||
|
// The ordering is a column rather than an expression, and the whole compound
|
||||||
|
// select is wrapped: SQLite only accepts ORDER BY terms from the result set
|
||||||
|
// of a UNION, so `ORDER BY role = 'owner' DESC` — which reads perfectly well
|
||||||
|
// — fails at query time with "1st ORDER BY term does not match any column in
|
||||||
|
// the result set". Every call errored, so the share panel had never listed
|
||||||
|
// anybody.
|
||||||
rows, err := db.QueryContext(ctx, `
|
rows, err := db.QueryContext(ctx, `
|
||||||
SELECT u.id, u.email, u.name, u.avatar_color, 'owner' AS role, p.created_at
|
SELECT id, email, name, avatar_color, role, added_at FROM (
|
||||||
FROM projects p JOIN users u ON u.id = p.owner_id
|
SELECT u.id, u.email, u.name, u.avatar_color, 'owner' AS role,
|
||||||
WHERE p.id = ?
|
p.created_at AS added_at, 0 AS rank
|
||||||
UNION ALL
|
FROM projects p JOIN users u ON u.id = p.owner_id
|
||||||
SELECT u.id, u.email, u.name, u.avatar_color, m.role, m.added_at
|
WHERE p.id = ?
|
||||||
FROM project_members m JOIN users u ON u.id = m.user_id
|
UNION ALL
|
||||||
WHERE m.project_id = ?
|
SELECT u.id, u.email, u.name, u.avatar_color, m.role,
|
||||||
ORDER BY role = 'owner' DESC, name`, projectID, projectID)
|
m.added_at, 1 AS rank
|
||||||
|
FROM project_members m JOIN users u ON u.id = m.user_id
|
||||||
|
WHERE m.project_id = ?
|
||||||
|
)
|
||||||
|
ORDER BY rank, name`, projectID, projectID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,23 @@
|
|||||||
let priority = $state(4);
|
let priority = $state(4);
|
||||||
let dueDate = $state('');
|
let dueDate = $state('');
|
||||||
let labels = $state('');
|
let labels = $state('');
|
||||||
|
let projectId = $state('');
|
||||||
|
let recurrence = $state('');
|
||||||
|
let assignee = $state('');
|
||||||
|
|
||||||
|
/** Who the task can be given to. Only ever more than one on a shared project. */
|
||||||
|
let members = $state([]);
|
||||||
|
|
||||||
|
// The rules worth a menu entry. Anything more specific is typed into the
|
||||||
|
// title, where the parser understands far more than a list ever could.
|
||||||
|
const REPEATS = [
|
||||||
|
{ rule: '', label: 'Aldrig' },
|
||||||
|
{ rule: 'FREQ=DAILY', label: 'Hver dag' },
|
||||||
|
{ rule: 'FREQ=WEEKLY', label: 'Hver uge' },
|
||||||
|
{ rule: 'FREQ=WEEKLY;INTERVAL=2', label: 'Hver anden uge' },
|
||||||
|
{ rule: 'FREQ=MONTHLY', label: 'Hver måned' },
|
||||||
|
{ rule: 'FREQ=YEARLY', label: 'Hvert år' }
|
||||||
|
];
|
||||||
|
|
||||||
let subtasks = $state([]);
|
let subtasks = $state([]);
|
||||||
let comments = $state([]);
|
let comments = $state([]);
|
||||||
@@ -46,6 +63,17 @@
|
|||||||
priority = task.priority;
|
priority = task.priority;
|
||||||
dueDate = task.due_date ?? '';
|
dueDate = task.due_date ?? '';
|
||||||
labels = (task.labels ?? []).join(', ');
|
labels = (task.labels ?? []).join(', ');
|
||||||
|
projectId = task.project_id;
|
||||||
|
recurrence = task.recurrence_rule ?? '';
|
||||||
|
assignee = task.assignee_id ?? '';
|
||||||
|
|
||||||
|
members = [];
|
||||||
|
api.listMembers(task.project_id)
|
||||||
|
.then((r) => r.members.length > 1 && (members = r.members))
|
||||||
|
.catch(() => {
|
||||||
|
// A project you are not the owner of may refuse the member list.
|
||||||
|
// Then there is nobody to choose from, which is the same as none.
|
||||||
|
});
|
||||||
|
|
||||||
subtasks = [];
|
subtasks = [];
|
||||||
comments = [];
|
comments = [];
|
||||||
@@ -73,13 +101,83 @@
|
|||||||
await app.update(task.id, patch);
|
await app.update(task.id, patch);
|
||||||
}
|
}
|
||||||
|
|
||||||
function saveContent() {
|
/**
|
||||||
|
* Saves the title, reading it the way quick add would.
|
||||||
|
*
|
||||||
|
* Typing "i morgen kl. 14" into a title used to leave it as those words. The
|
||||||
|
* same sentence in the quick-add box becomes a date, which made where you
|
||||||
|
* typed it decide what it meant.
|
||||||
|
*
|
||||||
|
* The parse runs on the server — the grammar is Danish and English and lives
|
||||||
|
* there — and is only applied when it actually consumed something. What it
|
||||||
|
* took is said out loud, because a title that edits itself without a word is
|
||||||
|
* worse than one that does nothing.
|
||||||
|
*/
|
||||||
|
async function saveContent() {
|
||||||
const trimmed = content.trim();
|
const trimmed = content.trim();
|
||||||
if (!trimmed || trimmed === task.content) {
|
if (!trimmed || trimmed === task.content) {
|
||||||
content = task.content;
|
content = task.content;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
save({ content: trimmed });
|
|
||||||
|
let parsed = null;
|
||||||
|
try {
|
||||||
|
parsed = await api.quickAddPreview(trimmed);
|
||||||
|
} catch {
|
||||||
|
// Offline, or the parser refused. Saving the words is still right.
|
||||||
|
}
|
||||||
|
|
||||||
|
const patch = { content: trimmed };
|
||||||
|
const took = [];
|
||||||
|
if (parsed && parsed.content && parsed.content !== trimmed) {
|
||||||
|
patch.content = parsed.content;
|
||||||
|
if (parsed.due_date) {
|
||||||
|
patch.due_date = parsed.due_date;
|
||||||
|
took.push(parsed.due_date);
|
||||||
|
}
|
||||||
|
if (parsed.priority && parsed.priority < 4) {
|
||||||
|
patch.priority = parsed.priority;
|
||||||
|
took.push(`P${parsed.priority}`);
|
||||||
|
}
|
||||||
|
if (parsed.recurrence) {
|
||||||
|
patch.recurrence_rule = parsed.recurrence;
|
||||||
|
took.push('gentagelse');
|
||||||
|
}
|
||||||
|
if (parsed.labels?.length) {
|
||||||
|
patch.labels = [...new Set([...(task.labels ?? []), ...parsed.labels])];
|
||||||
|
took.push(parsed.labels.map((l) => `@${l}`).join(' '));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await save(patch);
|
||||||
|
|
||||||
|
// The fields are seeded once per task so a save cannot overwrite what
|
||||||
|
// somebody is typing. That rule has to bend exactly here: this save
|
||||||
|
// changed the very fields on screen, and leaving them showing what was
|
||||||
|
// typed while the toast reports something else is the worst of both.
|
||||||
|
if (took.length) {
|
||||||
|
content = patch.content;
|
||||||
|
if (patch.due_date) dueDate = patch.due_date;
|
||||||
|
if (patch.priority) priority = patch.priority;
|
||||||
|
if (patch.recurrence_rule) recurrence = patch.recurrence_rule;
|
||||||
|
if (patch.labels) labels = patch.labels.join(', ');
|
||||||
|
app.toast(`Læst som ${took.join(', ')}.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveProject() {
|
||||||
|
if (projectId === task.project_id) return;
|
||||||
|
await save({ project_id: projectId });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveRecurrence() {
|
||||||
|
if (recurrence === (task.recurrence_rule ?? '')) return;
|
||||||
|
await save({ recurrence_rule: recurrence });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveAssignee() {
|
||||||
|
if (assignee === (task.assignee_id ?? '')) return;
|
||||||
|
await save({ assignee_id: assignee });
|
||||||
}
|
}
|
||||||
|
|
||||||
function saveDescription() {
|
function saveDescription() {
|
||||||
@@ -316,8 +414,42 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if task.recurrence_text}
|
<div class="grid">
|
||||||
<p class="repeats">Gentages {task.recurrence_text}.</p>
|
<div class="field">
|
||||||
|
<label for="project">Projekt</label>
|
||||||
|
<select id="project" bind:value={projectId} onchange={saveProject}>
|
||||||
|
{#each app.projects as project (project.id)}
|
||||||
|
<option value={project.id}>{project.name}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="repeat">Gentages</label>
|
||||||
|
<select id="repeat" bind:value={recurrence} onchange={saveRecurrence}>
|
||||||
|
{#each REPEATS as option (option.rule)}
|
||||||
|
<option value={option.rule}>{option.label}</option>
|
||||||
|
{/each}
|
||||||
|
<!-- A rule typed as "hver anden tirsdag" has no entry in the list;
|
||||||
|
showing it rather than snapping to the nearest preset is the
|
||||||
|
difference between a menu and a menu that quietly edits. -->
|
||||||
|
{#if recurrence && !REPEATS.some((o) => o.rule === recurrence)}
|
||||||
|
<option value={recurrence}>{task.recurrence_text ?? 'Som skrevet'}</option>
|
||||||
|
{/if}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if members.length > 1}
|
||||||
|
<div class="field">
|
||||||
|
<label for="assignee">Ansvarlig</label>
|
||||||
|
<select id="assignee" bind:value={assignee} onchange={saveAssignee}>
|
||||||
|
<option value="">Ingen</option>
|
||||||
|
{#each members as member (member.user_id)}
|
||||||
|
<option value={member.user_id}>{member.name}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
|
|||||||
Reference in New Issue
Block a user