feat(rtasks): assignee dropdown from space members + drag guard

- Load space members via /api/spaces/:slug/members for assignee dropdown
- Detail panel shows <select> with space members when available, falls
  back to text input when unauthenticated or no members loaded
- Assignee badge shown on task cards with resolved display names
- Assignee selectable on task creation form
- Server accepts assignee_id on POST /api/spaces/:slug/tasks
- Add _justDragged guard to prevent column click-to-create from
  firing after a drag operation ends (100ms debounce)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-04-07 22:32:20 -04:00
parent 64ba4c1f1f
commit 883f4b5f2c
2 changed files with 75 additions and 21 deletions

View File

@ -41,6 +41,10 @@ class FolkTasksBoard extends HTMLElement {
private _boardLabels: string[] = []; private _boardLabels: string[] = [];
// Inline delete confirmation // Inline delete confirmation
private _confirmDeleteId: string | null = null; private _confirmDeleteId: string | null = null;
// Space members for assignee dropdown
private _spaceMembers: { did: string; displayName?: string; role: string; isOwner: boolean }[] = [];
// Drag guard — prevents column click from firing after drag
private _justDragged = false;
// ClickUp integration state // ClickUp integration state
private _cuConnected = false; private _cuConnected = false;
private _cuTeamName = ''; private _cuTeamName = '';
@ -84,6 +88,7 @@ class FolkTasksBoard extends HTMLElement {
this.subscribeOffline(); this.subscribeOffline();
this.loadTasks(); this.loadTasks();
this.loadClickUpStatus(); this.loadClickUpStatus();
this.loadSpaceMembers();
this.render(); this.render();
} }
if (!localStorage.getItem("rtasks_tour_done")) { if (!localStorage.getItem("rtasks_tour_done")) {
@ -206,6 +211,18 @@ class FolkTasksBoard extends HTMLElement {
this.render(); this.render();
} }
private async loadSpaceMembers() {
if (this.isDemo) return;
try {
// Members API is at /api/spaces/:slug/members (global, not module-scoped)
const res = await fetch(`/api/spaces/${this.space}/members`, { headers: this.authHeaders() });
if (res.ok) {
const data = await res.json();
this._spaceMembers = data.members || [];
}
} catch { /* not authenticated or no members — fallback to text input */ }
}
private async loadClickUpStatus() { private async loadClickUpStatus() {
if (this.isDemo) return; if (this.isDemo) return;
try { try {
@ -413,11 +430,11 @@ class FolkTasksBoard extends HTMLElement {
return ''; return '';
} }
private async submitCreateTask(title: string, priority: string, description: string, dueDate?: string, status?: string) { private async submitCreateTask(title: string, priority: string, description: string, opts?: { dueDate?: string; status?: string; assignee?: string }) {
if (!title.trim()) return; if (!title.trim()) return;
const taskStatus = status || this.statuses[0] || "TODO"; const taskStatus = opts?.status || this.statuses[0] || "TODO";
if (this.isDemo) { if (this.isDemo) {
this.tasks.push({ id: `d${Date.now()}`, title: title.trim(), status: taskStatus, priority, labels: [], description: description.trim() || undefined, due_date: dueDate || null, sort_order: 0 }); this.tasks.push({ id: `d${Date.now()}`, title: title.trim(), status: taskStatus, priority, labels: [], description: description.trim() || undefined, due_date: opts?.dueDate || null, assignee_id: opts?.assignee || null, sort_order: 0 });
this.showCreateForm = false; this.showCreateForm = false;
this.render(); this.render();
return; return;
@ -427,7 +444,7 @@ class FolkTasksBoard extends HTMLElement {
const res = await fetch(`${base}/api/spaces/${this.workspaceSlug}/tasks`, { const res = await fetch(`${base}/api/spaces/${this.workspaceSlug}/tasks`, {
method: "POST", method: "POST",
headers: this.authHeaders({ "Content-Type": "application/json" }), headers: this.authHeaders({ "Content-Type": "application/json" }),
body: JSON.stringify({ title: title.trim(), priority, status: taskStatus, description: description.trim() || undefined, due_date: dueDate || undefined }), body: JSON.stringify({ title: title.trim(), priority, status: taskStatus, description: description.trim() || undefined, due_date: opts?.dueDate || undefined, assignee_id: opts?.assignee || undefined }),
}); });
if (!res.ok) { if (!res.ok) {
const data = await res.json().catch(() => ({})); const data = await res.json().catch(() => ({}));
@ -683,6 +700,7 @@ class FolkTasksBoard extends HTMLElement {
.badge-high { background: #3b2611; color: #fb923c; } .badge-high { background: #3b2611; color: #fb923c; }
.badge-medium { background: #3b3511; color: #facc15; } .badge-medium { background: #3b3511; color: #facc15; }
.badge-low { background: #112a3b; color: #60a5fa; } .badge-low { background: #112a3b; color: #60a5fa; }
.badge-assignee { background: #1a2332; color: #93c5fd; font-style: italic; }
.move-btns { display: flex; gap: 4px; margin-top: 6px; } .move-btns { display: flex; gap: 4px; margin-top: 6px; }
.move-btn { font-size: 11px; padding: 6px 10px; min-height: 36px; border-radius: 4px; border: 1px solid var(--rs-border-strong); background: var(--rs-bg-surface-sunken); color: var(--rs-text-muted); cursor: pointer; } .move-btn { font-size: 11px; padding: 6px 10px; min-height: 36px; border-radius: 4px; border: 1px solid var(--rs-border-strong); background: var(--rs-bg-surface-sunken); color: var(--rs-text-muted); cursor: pointer; }
@ -878,6 +896,11 @@ class FolkTasksBoard extends HTMLElement {
<option value="HIGH">High</option> <option value="HIGH">High</option>
<option value="URGENT">Urgent</option> <option value="URGENT">Urgent</option>
</select> </select>
${this._spaceMembers.length > 0 ? `
<select id="cf-assignee">
<option value="">Unassigned</option>
${this._spaceMembers.map(m => `<option value="${this.esc(m.did)}">${this.esc(m.displayName || m.did.slice(0, 12))}${m.isOwner ? ' (owner)' : ''}</option>`).join('')}
</select>` : ''}
<textarea id="cf-desc" placeholder="Description (optional)" rows="2"></textarea> <textarea id="cf-desc" placeholder="Description (optional)" rows="2"></textarea>
<div class="create-form-actions"> <div class="create-form-actions">
<button class="cf-submit" id="cf-submit">Add Task</button> <button class="cf-submit" id="cf-submit">Add Task</button>
@ -1002,6 +1025,12 @@ class FolkTasksBoard extends HTMLElement {
})(); })();
const labelFilter = this._filterLabel; const labelFilter = this._filterLabel;
const confirmingDelete = this._confirmDeleteId === task.id; const confirmingDelete = this._confirmDeleteId === task.id;
const assigneeName = (() => {
const aid = task.assignee_id || task.assignee;
if (!aid) return '';
const member = this._spaceMembers.find(m => m.did === aid);
return member?.displayName || aid.slice(0, 10);
})();
return ` return `
<div class="task-card" draggable="${isEditing ? "false" : "true"}" data-task-id="${task.id}" data-collab-id="task:${task.id}"> <div class="task-card" draggable="${isEditing ? "false" : "true"}" data-task-id="${task.id}" data-collab-id="task:${task.id}">
<button class="task-delete-btn" data-quick-delete="${task.id}" title="Delete">&times;</button> <button class="task-delete-btn" data-quick-delete="${task.id}" title="Delete">&times;</button>
@ -1012,6 +1041,7 @@ class FolkTasksBoard extends HTMLElement {
<div class="task-meta"> <div class="task-meta">
${priorityBadge(task.priority || "")} ${priorityBadge(task.priority || "")}
${dueDateBadge} ${dueDateBadge}
${assigneeName ? `<span class="badge badge-assignee">${this.esc(assigneeName)}</span>` : ''}
${(task.labels || []).map((l: string) => `<span class="badge clickable${labelFilter === l ? ' filter-active' : ''}" data-filter-label="${l}">${this.esc(l)}</span>`).join("")} ${(task.labels || []).map((l: string) => `<span class="badge clickable${labelFilter === l ? ' filter-active' : ''}" data-filter-label="${l}">${this.esc(l)}</span>`).join("")}
</div> </div>
${confirmingDelete ? `<div class="confirm-delete"><span>Delete?</span><button class="confirm-yes" data-confirm-yes="${task.id}">Yes</button><button class="confirm-no" data-confirm-no="${task.id}">No</button></div>` : ''} ${confirmingDelete ? `<div class="confirm-delete"><span>Delete?</span><button class="confirm-yes" data-confirm-yes="${task.id}">Yes</button><button class="confirm-no" data-confirm-no="${task.id}">No</button></div>` : ''}
@ -1062,7 +1092,17 @@ class FolkTasksBoard extends HTMLElement {
</div> </div>
<div class="detail-field"> <div class="detail-field">
<label>Assignee</label> <label>Assignee</label>
<input type="text" id="detail-assignee" value="${this.esc(task.assignee_id || task.assignee || '')}" placeholder="Assignee..."> ${this._spaceMembers.length > 0 ? `
<select id="detail-assignee">
<option value="">Unassigned</option>
${this._spaceMembers.map(m => {
const name = m.displayName || m.did.slice(0, 12);
const val = m.did;
const selected = (task.assignee_id || task.assignee || '') === val;
return `<option value="${this.esc(val)}" ${selected ? 'selected' : ''}>${this.esc(name)}${m.isOwner ? ' (owner)' : ''}</option>`;
}).join('')}
</select>
` : `<input type="text" id="detail-assignee" value="${this.esc(task.assignee_id || task.assignee || '')}" placeholder="Assignee...">`}
</div> </div>
<div class="detail-field"> <div class="detail-field">
<label>Due Date</label> <label>Due Date</label>
@ -1125,6 +1165,8 @@ class FolkTasksBoard extends HTMLElement {
// Column click to create task in that column // Column click to create task in that column
this.shadow.querySelectorAll("[data-col-click]").forEach(el => { this.shadow.querySelectorAll("[data-col-click]").forEach(el => {
el.addEventListener("click", (e) => { el.addEventListener("click", (e) => {
// Don't trigger if a drag just ended
if (this._justDragged) return;
// Don't trigger if clicking on a card, button, or input // Don't trigger if clicking on a card, button, or input
const target = e.target as HTMLElement; const target = e.target as HTMLElement;
if (target.closest('.task-card, button, input, select, textarea, .create-form')) return; if (target.closest('.task-card, button, input, select, textarea, .create-form')) return;
@ -1137,23 +1179,24 @@ class FolkTasksBoard extends HTMLElement {
}); });
// Create form handlers // Create form handlers
const getCreateFormValues = () => ({
title: (this.shadow.getElementById("cf-title") as HTMLInputElement)?.value || "",
priority: (this.shadow.getElementById("cf-priority") as HTMLSelectElement)?.value || "MEDIUM",
desc: (this.shadow.getElementById("cf-desc") as HTMLTextAreaElement)?.value || "",
status: (this.shadow.getElementById("cf-status") as HTMLInputElement)?.value || "",
assignee: (this.shadow.getElementById("cf-assignee") as HTMLSelectElement)?.value || "",
});
this.shadow.getElementById("cf-submit")?.addEventListener("click", () => { this.shadow.getElementById("cf-submit")?.addEventListener("click", () => {
const title = (this.shadow.getElementById("cf-title") as HTMLInputElement)?.value || ""; const v = getCreateFormValues();
const priority = (this.shadow.getElementById("cf-priority") as HTMLSelectElement)?.value || "MEDIUM"; this.submitCreateTask(v.title, v.priority, v.desc, { status: v.status, assignee: v.assignee });
const desc = (this.shadow.getElementById("cf-desc") as HTMLTextAreaElement)?.value || "";
const status = (this.shadow.getElementById("cf-status") as HTMLInputElement)?.value || "";
this.submitCreateTask(title, priority, desc, undefined, status);
}); });
this.shadow.getElementById("cf-cancel")?.addEventListener("click", () => { this.shadow.getElementById("cf-cancel")?.addEventListener("click", () => {
this.showCreateForm = false; this.render(); this.showCreateForm = false; this.render();
}); });
this.shadow.getElementById("cf-title")?.addEventListener("keydown", (e) => { this.shadow.getElementById("cf-title")?.addEventListener("keydown", (e) => {
if ((e as KeyboardEvent).key === "Enter") { if ((e as KeyboardEvent).key === "Enter") {
const title = (this.shadow.getElementById("cf-title") as HTMLInputElement)?.value || ""; const v = getCreateFormValues();
const priority = (this.shadow.getElementById("cf-priority") as HTMLSelectElement)?.value || "MEDIUM"; this.submitCreateTask(v.title, v.priority, v.desc, { status: v.status, assignee: v.assignee });
const desc = (this.shadow.getElementById("cf-desc") as HTMLTextAreaElement)?.value || "";
const status = (this.shadow.getElementById("cf-status") as HTMLInputElement)?.value || "";
this.submitCreateTask(title, priority, desc, undefined, status);
} }
}); });
@ -1259,9 +1302,13 @@ class FolkTasksBoard extends HTMLElement {
this.shadow.getElementById("detail-priority")?.addEventListener("change", (e) => { this.shadow.getElementById("detail-priority")?.addEventListener("change", (e) => {
detailFieldSave('priority', (e.target as HTMLSelectElement).value || null); detailFieldSave('priority', (e.target as HTMLSelectElement).value || null);
}); });
this.shadow.getElementById("detail-assignee")?.addEventListener("blur", (e) => { const assigneeEl = this.shadow.getElementById("detail-assignee");
detailFieldSave('assignee_id', (e.target as HTMLInputElement).value.trim() || null); if (assigneeEl) {
}); const event = assigneeEl.tagName === 'SELECT' ? 'change' : 'blur';
assigneeEl.addEventListener(event, (e) => {
detailFieldSave('assignee_id', (e.target as HTMLInputElement | HTMLSelectElement).value.trim() || null);
});
}
this.shadow.getElementById("detail-due")?.addEventListener("change", (e) => { this.shadow.getElementById("detail-due")?.addEventListener("change", (e) => {
const v = (e.target as HTMLInputElement).value; const v = (e.target as HTMLInputElement).value;
detailFieldSave('due_date', v ? new Date(v).toISOString() : null); detailFieldSave('due_date', v ? new Date(v).toISOString() : null);
@ -1535,6 +1582,9 @@ class FolkTasksBoard extends HTMLElement {
}); });
el.addEventListener("pointerup", (e) => { el.addEventListener("pointerup", (e) => {
if (!this.dragTaskId) return; if (!this.dragTaskId) return;
// Prevent column click handler from firing after drag
this._justDragged = true;
setTimeout(() => { this._justDragged = false; }, 100);
el.classList.remove("dragging"); el.classList.remove("dragging");
el.style.touchAction = ""; el.style.touchAction = "";
this.shadow.querySelectorAll(".column[data-status]").forEach(c => { this.shadow.querySelectorAll(".column[data-status]").forEach(c => {
@ -1567,6 +1617,8 @@ class FolkTasksBoard extends HTMLElement {
this.dragOverIndex = -1; this.dragOverIndex = -1;
}); });
el.addEventListener("pointercancel", () => { el.addEventListener("pointercancel", () => {
this._justDragged = true;
setTimeout(() => { this._justDragged = false; }, 100);
el.classList.remove("dragging"); el.classList.remove("dragging");
el.style.touchAction = ""; el.style.touchAction = "";
this.shadow.querySelectorAll(".column[data-status]").forEach(c => { this.shadow.querySelectorAll(".column[data-status]").forEach(c => {

View File

@ -370,7 +370,7 @@ routes.post("/api/spaces/:slug/tasks", async (c) => {
const slug = c.req.param("slug"); const slug = c.req.param("slug");
const body = await c.req.json(); const body = await c.req.json();
const { title, description, status, priority, labels, due_date } = body; const { title, description, status, priority, labels, due_date, assignee_id } = body;
if (!title?.trim()) return c.json({ error: "Title required" }, 400); if (!title?.trim()) return c.json({ error: "Title required" }, 400);
const doc = ensureDoc(slug); const doc = ensureDoc(slug);
@ -380,7 +380,7 @@ routes.post("/api/spaces/:slug/tasks", async (c) => {
const docId = boardDocId(slug, slug); const docId = boardDocId(slug, slug);
_syncServer!.changeDoc<BoardDoc>(docId, `Create task ${taskId}`, (d) => { _syncServer!.changeDoc<BoardDoc>(docId, `Create task ${taskId}`, (d) => {
d.tasks[taskId] = createTaskItem(taskId, slug, title.trim(), { const task = createTaskItem(taskId, slug, title.trim(), {
description: description || '', description: description || '',
status: taskStatus, status: taskStatus,
priority: priority || 'MEDIUM', priority: priority || 'MEDIUM',
@ -388,6 +388,8 @@ routes.post("/api/spaces/:slug/tasks", async (c) => {
dueDate: due_date ? new Date(due_date).getTime() : null, dueDate: due_date ? new Date(due_date).getTime() : null,
createdBy, createdBy,
}); });
if (assignee_id) task.assigneeId = assignee_id;
d.tasks[taskId] = task;
}); });
// Notify space members about the new task // Notify space members about the new task
@ -405,7 +407,7 @@ routes.post("/api/spaces/:slug/tasks", async (c) => {
status: taskStatus, status: taskStatus,
priority: priority || "MEDIUM", priority: priority || "MEDIUM",
labels: labels || [], labels: labels || [],
assignee_id: null, assignee_id: assignee_id || null,
created_by: createdBy, created_by: createdBy,
sort_order: 0, sort_order: 0,
due_date: due_date ? new Date(due_date).toISOString() : null, due_date: due_date ? new Date(due_date).toISOString() : null,