fix(tasks): match /lists + /notes create pattern
Two concrete problems the user hit:
1) /tasks overview had no visible "create" button — only an enter-to-submit
input hidden behind a decorative Plus icon.
2) /tasks/[id] add-task form had no visible submit button either.
Both now mirror the /lists + /notes pattern:
/tasks — new "New list" button in the masthead (bg-slate-900 primary
style matching "New list" on /lists and "New note" on /notes). Click
creates an empty task_list and navigates to /tasks/[id] where the
user names it inline via the new big editable title input
(autosaves after NAME_SAVE_DEBOUNCE_MS, 500 ms — same hook as
/lists/[id] and /notes/[id]).
/tasks/[id] — sticky add-task footer gains a filled "+" submit button
on the right; clicking it or pressing Enter both fire handleAdd.
Input is now wrapped in the same rounded-lg card used on /lists/[id]
so the two screens look identical.
i18n
tasks_new_list = "New list" / "Nueva lista" (en / es).
Tests
tasks.test.ts helper updated to drive the new button-then-title flow
(same shape as lists.test.ts). T-UI-01..03 all pass.
just test-all → exit 0, 228 green, 2 skipped.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { currentCollective } from '$lib/stores/collective';
|
||||
import { currentUser } from '$lib/stores/auth';
|
||||
import {
|
||||
@@ -12,9 +13,7 @@
|
||||
import * as m from '$lib/paraglide/messages';
|
||||
import ScreenMasthead from '$lib/components/ScreenMasthead.svelte';
|
||||
|
||||
let newName = $state('');
|
||||
let creating = $state(false);
|
||||
let nameInput: HTMLInputElement | undefined = $state();
|
||||
|
||||
$effect(() => {
|
||||
if ($currentCollective) {
|
||||
@@ -31,18 +30,14 @@
|
||||
return () => clearInterval(id);
|
||||
});
|
||||
|
||||
// Matches the /notes + /lists flow: masthead button creates an empty list
|
||||
// and sends the user to the detail view where the name is set inline.
|
||||
async function handleCreate() {
|
||||
const name = newName.trim();
|
||||
if (!name || !$currentCollective || !$currentUser) return;
|
||||
if (!$currentCollective || !$currentUser || creating) return;
|
||||
creating = true;
|
||||
newName = '';
|
||||
await createTaskList($currentCollective.id, name, $currentUser.id);
|
||||
const created = await createTaskList($currentCollective.id, '', $currentUser.id);
|
||||
creating = false;
|
||||
nameInput?.focus();
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter') handleCreate();
|
||||
if (created) await goto(`/tasks/${created.id}`);
|
||||
}
|
||||
|
||||
async function handleDelete(id: string) {
|
||||
@@ -54,7 +49,19 @@
|
||||
<svelte:head><title>{m.tasks_title()} — Colectivo</title></svelte:head>
|
||||
|
||||
<div class="flex h-full flex-col overflow-hidden">
|
||||
<ScreenMasthead label={m.masthead_tasks_label()} title={m.tasks_title()} />
|
||||
<ScreenMasthead label={m.masthead_tasks_label()} title={m.tasks_title()}>
|
||||
{#snippet actions()}
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleCreate}
|
||||
disabled={creating || !$currentCollective}
|
||||
class="ml-1 inline-flex items-center gap-1 rounded-md bg-slate-900 px-3 py-1.5 text-sm font-medium text-white hover:bg-slate-800 disabled:opacity-60 dark:bg-slate-100 dark:text-slate-900"
|
||||
>
|
||||
<Plus size={14} strokeWidth={2} />
|
||||
{m.tasks_new_list()}
|
||||
</button>
|
||||
{/snippet}
|
||||
</ScreenMasthead>
|
||||
|
||||
<div class="flex-1 overflow-y-auto px-4 py-4 md:px-6 md:py-6">
|
||||
{#if $taskListsLoading && $taskLists.length === 0}
|
||||
@@ -85,17 +92,4 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="border-t border-black/5 bg-surface-raised px-6 py-4 dark:border-white/5">
|
||||
<div class="flex items-center gap-2">
|
||||
<Plus size={16} strokeWidth={1.5} class="text-text-secondary" />
|
||||
<input
|
||||
bind:this={nameInput}
|
||||
bind:value={newName}
|
||||
onkeydown={handleKeydown}
|
||||
placeholder={m.tasks_new_list_placeholder()}
|
||||
class="flex-1 bg-transparent text-sm text-text-primary placeholder:text-text-secondary focus:outline-none"
|
||||
disabled={creating || !$currentCollective}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -12,7 +12,8 @@
|
||||
renameTask,
|
||||
completeTask,
|
||||
deleteTask,
|
||||
reorderTasks
|
||||
reorderTasks,
|
||||
renameTaskList
|
||||
} from '$lib/stores/tasks';
|
||||
import { getSupabase } from '$lib/supabase';
|
||||
import type { Task, TaskList } from '@colectivo/types';
|
||||
@@ -25,6 +26,11 @@
|
||||
let tasks = $state<Task[]>([]);
|
||||
let loading = $state(true);
|
||||
|
||||
// Inline rename of the task list (same pattern as /lists/[id], /notes/[id]).
|
||||
let listName = $state('');
|
||||
let nameSaveTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
const NAME_SAVE_DEBOUNCE_MS = 500;
|
||||
|
||||
let newTitle = $state('');
|
||||
let titleInput: HTMLInputElement | undefined = $state();
|
||||
|
||||
@@ -48,6 +54,7 @@
|
||||
}
|
||||
|
||||
list = listRes.data as TaskList;
|
||||
listName = list.name ?? '';
|
||||
tasks = tasksRes;
|
||||
loading = false;
|
||||
|
||||
@@ -91,10 +98,32 @@
|
||||
}, 5_000);
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
onDestroy(async () => {
|
||||
if (pollingId) clearInterval(pollingId);
|
||||
if (nameSaveTimer) {
|
||||
clearTimeout(nameSaveTimer);
|
||||
await flushNameSave();
|
||||
}
|
||||
});
|
||||
|
||||
// ── Inline rename ──────────────────────────────────────────────────────────
|
||||
|
||||
function scheduleNameSave() {
|
||||
if (nameSaveTimer) clearTimeout(nameSaveTimer);
|
||||
nameSaveTimer = setTimeout(() => {
|
||||
nameSaveTimer = null;
|
||||
void flushNameSave();
|
||||
}, NAME_SAVE_DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
async function flushNameSave() {
|
||||
if (!list) return;
|
||||
const next = listName.trim();
|
||||
if (next === (list.name ?? '')) return;
|
||||
list = { ...list, name: next };
|
||||
await renameTaskList(list.id, next);
|
||||
}
|
||||
|
||||
function nameFor(userId: string | null): string {
|
||||
if (!userId) return '';
|
||||
const member = $collectiveMembers.find((mm) => mm.user_id === userId);
|
||||
@@ -196,10 +225,27 @@
|
||||
<a href="/tasks" class="rounded p-1 text-text-secondary hover:bg-black/5 dark:hover:bg-white/5" aria-label={m.notes_back_to_board()}>
|
||||
<ArrowLeft size={18} strokeWidth={1.5} />
|
||||
</a>
|
||||
<h1 class="text-base font-semibold text-text-primary">{list?.name ?? ''}</h1>
|
||||
<h1 class="truncate text-[15px] font-medium text-text-secondary md:hidden">
|
||||
{list?.name || m.tasks_new_list_placeholder()}
|
||||
</h1>
|
||||
</header>
|
||||
|
||||
<div class="flex-1 overflow-y-auto px-6 py-4">
|
||||
<div class="flex-1 overflow-y-auto px-4 py-4 md:px-6">
|
||||
<!-- Inline rename title input (autosave on idle) -->
|
||||
<div class="pt-2 pb-4 md:pt-4 md:pb-6">
|
||||
<p class="mb-1 text-[13px] font-semibold uppercase tracking-[0.05em] text-text-secondary">
|
||||
{m.masthead_tasks_label()}
|
||||
</p>
|
||||
<input
|
||||
bind:value={listName}
|
||||
oninput={scheduleNameSave}
|
||||
onblur={flushNameSave}
|
||||
placeholder={m.tasks_new_list_placeholder()}
|
||||
class="w-full bg-transparent text-[26px] md:text-[32px] font-semibold tracking-[-0.02em] text-text-primary placeholder:text-text-secondary focus:outline-none"
|
||||
aria-label={m.tasks_new_list_placeholder()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<p class="text-sm text-text-secondary">{m.loading()}</p>
|
||||
{:else if tasks.length === 0}
|
||||
@@ -312,16 +358,26 @@
|
||||
</div>
|
||||
|
||||
<!-- Sticky add-task input -->
|
||||
<div class="border-t border-black/5 bg-surface-raised px-6 py-4 dark:border-white/5">
|
||||
<div class="border-t border-black/5 bg-surface-raised px-4 py-3 md:px-6 md:py-4 dark:border-white/5">
|
||||
<div class="flex items-center gap-2">
|
||||
<Plus size={16} strokeWidth={1.5} class="text-text-secondary" />
|
||||
<input
|
||||
bind:this={titleInput}
|
||||
bind:value={newTitle}
|
||||
onkeydown={handleKeydown}
|
||||
placeholder={m.tasks_new_task_placeholder()}
|
||||
class="flex-1 bg-transparent text-sm text-text-primary placeholder:text-text-secondary focus:outline-none"
|
||||
/>
|
||||
<div class="flex flex-1 items-center gap-2 rounded-lg bg-surface px-3 py-2 shadow-[0px_2px_8px_rgba(15,23,42,0.06)]">
|
||||
<input
|
||||
bind:this={titleInput}
|
||||
bind:value={newTitle}
|
||||
onkeydown={handleKeydown}
|
||||
placeholder={m.tasks_new_task_placeholder()}
|
||||
class="min-w-0 flex-1 bg-transparent text-sm text-text-primary placeholder:text-text-secondary focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onclick={handleAdd}
|
||||
disabled={!newTitle.trim()}
|
||||
aria-label={m.add()}
|
||||
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-slate-900 text-white hover:opacity-90 disabled:opacity-40 dark:bg-slate-100 dark:text-slate-900 transition-opacity"
|
||||
>
|
||||
<Plus size={18} strokeWidth={2} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user