fix(rtasks): enable public writes + click-to-create in any column

- Add publicWrite to rtasks module so unauthenticated task creation works
  (was blocked by space auth middleware returning 403)
- Click empty column space or "+ Add task" to open create form in that column
- Tasks created in clicked column get that column's status automatically
- Show error message when task creation fails instead of silent failure

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-04-06 21:26:18 -04:00
parent ab5129d7dc
commit 4918787bb5
2 changed files with 42 additions and 14 deletions

View File

@ -20,7 +20,7 @@ class FolkTasksBoard extends HTMLElement {
private isDemo = false;
private dragTaskId: string | null = null;
private editingTaskId: string | null = null;
private showCreateForm = false;
private showCreateForm: string | false = false;
private boardView: "board" | "checklist" = "board";
private dragOverStatus: string | null = null;
private dragOverIndex = -1;
@ -413,21 +413,28 @@ class FolkTasksBoard extends HTMLElement {
return '';
}
private async submitCreateTask(title: string, priority: string, description: string, dueDate?: string) {
private async submitCreateTask(title: string, priority: string, description: string, dueDate?: string, status?: string) {
if (!title.trim()) return;
const taskStatus = status || this.statuses[0] || "TODO";
if (this.isDemo) {
this.tasks.push({ id: `d${Date.now()}`, title: title.trim(), status: this.statuses[0] || "TODO", 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: dueDate || null, sort_order: 0 });
this.showCreateForm = false;
this.render();
return;
}
try {
const base = this.getApiBase();
await fetch(`${base}/api/spaces/${this.workspaceSlug}/tasks`, {
const res = await fetch(`${base}/api/spaces/${this.workspaceSlug}/tasks`, {
method: "POST",
headers: this.authHeaders({ "Content-Type": "application/json" }),
body: JSON.stringify({ title: title.trim(), priority, description: description.trim() || undefined, due_date: dueDate || undefined }),
body: JSON.stringify({ title: title.trim(), priority, status: taskStatus, description: description.trim() || undefined, due_date: dueDate || undefined }),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
this.error = data.error || `Failed to create task (${res.status})`;
this.render();
return;
}
this.showCreateForm = false;
this.loadTasks();
} catch { this.error = "Failed to create task"; this.render(); }
@ -647,6 +654,7 @@ class FolkTasksBoard extends HTMLElement {
.column {
min-width: 240px; max-width: 280px; flex-shrink: 0;
background: var(--rs-bg-surface-sunken); border: 1px solid var(--rs-border); border-radius: 10px; padding: 12px;
cursor: pointer;
}
.col-header { font-size: 13px; font-weight: 600; text-transform: uppercase; color: var(--rs-text-muted); margin-bottom: 10px; display: flex; justify-content: space-between; }
.col-count { background: var(--rs-border); border-radius: 10px; padding: 0 8px; font-size: 11px; }
@ -715,7 +723,8 @@ class FolkTasksBoard extends HTMLElement {
@keyframes pulse-indicator { 0%, 100% { opacity: 0.6; } 50% { opacity: 1; box-shadow: 0 0 8px var(--rs-primary); } }
/* Empty column drop zone */
.empty-drop-zone { border: 2px dashed var(--rs-border); border-radius: 8px; padding: 24px 12px; text-align: center; color: var(--rs-text-muted); font-size: 12px; font-weight: 600; pointer-events: none; opacity: 0.4; transition: all 0.15s; }
.empty-drop-zone { border: 2px dashed var(--rs-border); border-radius: 8px; padding: 24px 12px; text-align: center; color: var(--rs-text-muted); font-size: 12px; font-weight: 600; cursor: pointer; opacity: 0.4; transition: all 0.15s; }
.empty-drop-zone:hover { opacity: 0.7; border-color: var(--rs-primary); color: var(--rs-primary); }
.column.drag-over .empty-drop-zone { opacity: 1; border-color: #22c55e; color: #22c55e; background: rgba(34,197,94,0.05); }
/* Checklist view */
@ -857,11 +866,12 @@ class FolkTasksBoard extends HTMLElement {
this._tour.start();
}
private renderCreateForm(): string {
if (!this.showCreateForm) return "";
private renderCreateForm(forStatus: string): string {
if (this.showCreateForm !== forStatus) return "";
return `
<div class="create-form" id="create-form">
<input type="text" id="cf-title" placeholder="Task title..." autofocus>
<input type="hidden" id="cf-status" value="${this.esc(forStatus)}">
<select id="cf-priority">
<option value="LOW">Low</option>
<option value="MEDIUM" selected>Medium</option>
@ -922,14 +932,14 @@ class FolkTasksBoard extends HTMLElement {
.sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0));
const isDragSource = this.dragTaskId && this.dragSourceStatus === status;
return `
<div class="column${isDragSource ? ' drag-source' : ''}" data-status="${status}">
<div class="column${isDragSource ? ' drag-source' : ''}" data-status="${status}" data-col-click="${status}">
<div class="col-header">
<span>${this.esc(status.replace(/_/g, " "))}</span>
<span class="col-count">${columnTasks.length}</span>
</div>
${status === this.statuses[0] ? this.renderCreateForm() : ""}
${this.renderCreateForm(status)}
${columnTasks.map(t => this.renderTaskCard(t, status)).join("")}
${columnTasks.length === 0 ? '<div class="empty-drop-zone">Drop here</div>' : ''}
${columnTasks.length === 0 && this.showCreateForm !== status ? '<div class="empty-drop-zone" data-col-click="' + this.esc(status) + '">+ Add task</div>' : ''}
</div>
`;
}).join("")}
@ -1104,19 +1114,35 @@ class FolkTasksBoard extends HTMLElement {
this.render();
});
this.shadow.getElementById("create-task")?.addEventListener("click", () => {
this.showCreateForm = !this.showCreateForm;
const firstStatus = this.statuses[0] || "TODO";
this.showCreateForm = this.showCreateForm ? false : firstStatus;
this.render();
if (this.showCreateForm) {
setTimeout(() => this.shadow.getElementById("cf-title")?.focus(), 0);
}
});
// Column click to create task in that column
this.shadow.querySelectorAll("[data-col-click]").forEach(el => {
el.addEventListener("click", (e) => {
// Don't trigger if clicking on a card, button, or input
const target = e.target as HTMLElement;
if (target.closest('.task-card, button, input, select, textarea, .create-form')) return;
const status = (el as HTMLElement).dataset.colClick!;
if (this.showCreateForm === status) return;
this.showCreateForm = status;
this.render();
setTimeout(() => this.shadow.getElementById("cf-title")?.focus(), 0);
});
});
// Create form handlers
this.shadow.getElementById("cf-submit")?.addEventListener("click", () => {
const title = (this.shadow.getElementById("cf-title") as HTMLInputElement)?.value || "";
const priority = (this.shadow.getElementById("cf-priority") as HTMLSelectElement)?.value || "MEDIUM";
const desc = (this.shadow.getElementById("cf-desc") as HTMLTextAreaElement)?.value || "";
this.submitCreateTask(title, priority, desc);
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.showCreateForm = false; this.render();
@ -1126,7 +1152,8 @@ class FolkTasksBoard extends HTMLElement {
const title = (this.shadow.getElementById("cf-title") as HTMLInputElement)?.value || "";
const priority = (this.shadow.getElementById("cf-priority") as HTMLSelectElement)?.value || "MEDIUM";
const desc = (this.shadow.getElementById("cf-desc") as HTMLTextAreaElement)?.value || "";
this.submitCreateTask(title, priority, desc);
const status = (this.shadow.getElementById("cf-status") as HTMLInputElement)?.value || "";
this.submitCreateTask(title, priority, desc, undefined, status);
}
});

View File

@ -854,6 +854,7 @@ export const tasksModule: RSpaceModule = {
id: "rtasks",
name: "rTasks",
icon: "📋",
publicWrite: true,
description: "Kanban workspace boards for collaborative task management",
scoping: { defaultScope: 'space', userConfigurable: false },
docSchemas: [