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:
@@ -100,6 +100,7 @@
|
|||||||
"list_finish_confirm_yes": "Yes, finish",
|
"list_finish_confirm_yes": "Yes, finish",
|
||||||
"list_finish_confirm_no": "Keep shopping",
|
"list_finish_confirm_no": "Keep shopping",
|
||||||
"tasks_title": "Tasks",
|
"tasks_title": "Tasks",
|
||||||
|
"tasks_new_list": "New list",
|
||||||
"tasks_no_lists": "No task lists yet",
|
"tasks_no_lists": "No task lists yet",
|
||||||
"tasks_create_first": "Create your first task list",
|
"tasks_create_first": "Create your first task list",
|
||||||
"tasks_new_list_placeholder": "New task list",
|
"tasks_new_list_placeholder": "New task list",
|
||||||
|
|||||||
@@ -100,6 +100,7 @@
|
|||||||
"list_finish_confirm_yes": "Sí, terminar",
|
"list_finish_confirm_yes": "Sí, terminar",
|
||||||
"list_finish_confirm_no": "Seguir comprando",
|
"list_finish_confirm_no": "Seguir comprando",
|
||||||
"tasks_title": "Tareas",
|
"tasks_title": "Tareas",
|
||||||
|
"tasks_new_list": "Nueva lista",
|
||||||
"tasks_no_lists": "Sin listas de tareas",
|
"tasks_no_lists": "Sin listas de tareas",
|
||||||
"tasks_create_first": "Crea tu primera lista de tareas",
|
"tasks_create_first": "Crea tu primera lista de tareas",
|
||||||
"tasks_new_list_placeholder": "Nueva lista de tareas",
|
"tasks_new_list_placeholder": "Nueva lista de tareas",
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
import { currentCollective } from '$lib/stores/collective';
|
import { currentCollective } from '$lib/stores/collective';
|
||||||
import { currentUser } from '$lib/stores/auth';
|
import { currentUser } from '$lib/stores/auth';
|
||||||
import {
|
import {
|
||||||
@@ -12,9 +13,7 @@
|
|||||||
import * as m from '$lib/paraglide/messages';
|
import * as m from '$lib/paraglide/messages';
|
||||||
import ScreenMasthead from '$lib/components/ScreenMasthead.svelte';
|
import ScreenMasthead from '$lib/components/ScreenMasthead.svelte';
|
||||||
|
|
||||||
let newName = $state('');
|
|
||||||
let creating = $state(false);
|
let creating = $state(false);
|
||||||
let nameInput: HTMLInputElement | undefined = $state();
|
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if ($currentCollective) {
|
if ($currentCollective) {
|
||||||
@@ -31,18 +30,14 @@
|
|||||||
return () => clearInterval(id);
|
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() {
|
async function handleCreate() {
|
||||||
const name = newName.trim();
|
if (!$currentCollective || !$currentUser || creating) return;
|
||||||
if (!name || !$currentCollective || !$currentUser) return;
|
|
||||||
creating = true;
|
creating = true;
|
||||||
newName = '';
|
const created = await createTaskList($currentCollective.id, '', $currentUser.id);
|
||||||
await createTaskList($currentCollective.id, name, $currentUser.id);
|
|
||||||
creating = false;
|
creating = false;
|
||||||
nameInput?.focus();
|
if (created) await goto(`/tasks/${created.id}`);
|
||||||
}
|
|
||||||
|
|
||||||
function handleKeydown(e: KeyboardEvent) {
|
|
||||||
if (e.key === 'Enter') handleCreate();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleDelete(id: string) {
|
async function handleDelete(id: string) {
|
||||||
@@ -54,7 +49,19 @@
|
|||||||
<svelte:head><title>{m.tasks_title()} — Colectivo</title></svelte:head>
|
<svelte:head><title>{m.tasks_title()} — Colectivo</title></svelte:head>
|
||||||
|
|
||||||
<div class="flex h-full flex-col overflow-hidden">
|
<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">
|
<div class="flex-1 overflow-y-auto px-4 py-4 md:px-6 md:py-6">
|
||||||
{#if $taskListsLoading && $taskLists.length === 0}
|
{#if $taskListsLoading && $taskLists.length === 0}
|
||||||
@@ -85,17 +92,4 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|||||||
@@ -12,7 +12,8 @@
|
|||||||
renameTask,
|
renameTask,
|
||||||
completeTask,
|
completeTask,
|
||||||
deleteTask,
|
deleteTask,
|
||||||
reorderTasks
|
reorderTasks,
|
||||||
|
renameTaskList
|
||||||
} from '$lib/stores/tasks';
|
} from '$lib/stores/tasks';
|
||||||
import { getSupabase } from '$lib/supabase';
|
import { getSupabase } from '$lib/supabase';
|
||||||
import type { Task, TaskList } from '@colectivo/types';
|
import type { Task, TaskList } from '@colectivo/types';
|
||||||
@@ -25,6 +26,11 @@
|
|||||||
let tasks = $state<Task[]>([]);
|
let tasks = $state<Task[]>([]);
|
||||||
let loading = $state(true);
|
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 newTitle = $state('');
|
||||||
let titleInput: HTMLInputElement | undefined = $state();
|
let titleInput: HTMLInputElement | undefined = $state();
|
||||||
|
|
||||||
@@ -48,6 +54,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
list = listRes.data as TaskList;
|
list = listRes.data as TaskList;
|
||||||
|
listName = list.name ?? '';
|
||||||
tasks = tasksRes;
|
tasks = tasksRes;
|
||||||
loading = false;
|
loading = false;
|
||||||
|
|
||||||
@@ -91,10 +98,32 @@
|
|||||||
}, 5_000);
|
}, 5_000);
|
||||||
});
|
});
|
||||||
|
|
||||||
onDestroy(() => {
|
onDestroy(async () => {
|
||||||
if (pollingId) clearInterval(pollingId);
|
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 {
|
function nameFor(userId: string | null): string {
|
||||||
if (!userId) return '';
|
if (!userId) return '';
|
||||||
const member = $collectiveMembers.find((mm) => mm.user_id === userId);
|
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()}>
|
<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} />
|
<ArrowLeft size={18} strokeWidth={1.5} />
|
||||||
</a>
|
</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>
|
</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}
|
{#if loading}
|
||||||
<p class="text-sm text-text-secondary">{m.loading()}</p>
|
<p class="text-sm text-text-secondary">{m.loading()}</p>
|
||||||
{:else if tasks.length === 0}
|
{:else if tasks.length === 0}
|
||||||
@@ -312,16 +358,26 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Sticky add-task input -->
|
<!-- 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">
|
<div class="flex items-center gap-2">
|
||||||
<Plus size={16} strokeWidth={1.5} class="text-text-secondary" />
|
<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
|
<input
|
||||||
bind:this={titleInput}
|
bind:this={titleInput}
|
||||||
bind:value={newTitle}
|
bind:value={newTitle}
|
||||||
onkeydown={handleKeydown}
|
onkeydown={handleKeydown}
|
||||||
placeholder={m.tasks_new_task_placeholder()}
|
placeholder={m.tasks_new_task_placeholder()}
|
||||||
class="flex-1 bg-transparent text-sm text-text-primary placeholder:text-text-secondary focus:outline-none"
|
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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -14,17 +14,20 @@ const NEW_TASK_PLACEHOLDER = /add task|añadir tarea/i;
|
|||||||
|
|
||||||
async function gotoTasksClean(page: Page) {
|
async function gotoTasksClean(page: Page) {
|
||||||
await page.goto('/tasks');
|
await page.goto('/tasks');
|
||||||
await expect(page.getByPlaceholder(NEW_LIST_PLACEHOLDER)).toBeVisible({ timeout: 15_000 });
|
await expect(
|
||||||
|
page.getByRole('button', { name: /new list|nueva lista/i })
|
||||||
|
).toBeVisible({ timeout: 15_000 });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createTaskList(page: Page, name: string) {
|
async function createTaskList(page: Page, name: string) {
|
||||||
const input = page.getByPlaceholder(NEW_LIST_PLACEHOLDER);
|
// Masthead "New list" button → creates empty list and navigates to the detail.
|
||||||
await input.fill(name);
|
await page.getByRole('button', { name: /new list|nueva lista/i }).click();
|
||||||
await input.press('Enter');
|
await expect(page).toHaveURL(/\/tasks\/[0-9a-f-]+$/, { timeout: 10_000 });
|
||||||
const heading = page.getByRole('heading', { name: new RegExp(`^${name}$`) });
|
const titleInput = page.getByPlaceholder(NEW_LIST_PLACEHOLDER);
|
||||||
await expect(heading).toBeVisible({ timeout: 5_000 });
|
await expect(titleInput).toBeVisible({ timeout: 10_000 });
|
||||||
await heading.click();
|
await titleInput.fill(name);
|
||||||
await expect(page).toHaveURL(/\/tasks\/[0-9a-f-]+$/, { timeout: 5_000 });
|
await titleInput.blur();
|
||||||
|
await page.waitForTimeout(900); // autosave debounce
|
||||||
}
|
}
|
||||||
|
|
||||||
test.describe('Tasks — member (Borja)', () => {
|
test.describe('Tasks — member (Borja)', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user