diff --git a/ui/src/components/EditEntryDialog.tsx b/ui/src/components/EditEntryDialog.tsx index f1409e2..1fecd75 100644 --- a/ui/src/components/EditEntryDialog.tsx +++ b/ui/src/components/EditEntryDialog.tsx @@ -3,8 +3,7 @@ import { api } from "~/api"; import { fromLocalHHMM, toLocalHHMM } from "~/lib/entryFormat"; import type { Entry } from "~/types"; import Button from "./ui/Button"; -import ProjectPicker from "./ui/ProjectPicker"; -import TaskPicker from "./ui/TaskPicker"; +import ProjectTaskPicker from "./ui/ProjectTaskPicker"; import Toggle from "./ui/Toggle"; export default function EditEntryDialog(props: { @@ -30,14 +29,11 @@ export default function EditEntryDialog(props: { const [projects] = createResource(() => api.listProjects(), { initialValue: [], }); - // Tasks for the currently-selected project. Re-fetches when projectId - // flips. An empty projectId resolves to an empty list and the TaskPicker - // stays disabled — no point hitting the IPC. - const [tasks] = createResource( - () => projectId(), - async (pid) => (pid ? await api.listTasks(pid) : []), - { initialValue: [] }, - ); + // All tasks across all projects, eager-loaded once. The combined picker + // groups by project_id itself. + const [tasks] = createResource(() => api.listTasks(null), { + initialValue: [], + }); const isCompleted = createMemo(() => Boolean(props.entry.end_at)); @@ -116,36 +112,18 @@ export default function EditEntryDialog(props: {
- { - // Tasks scope to projects — changing project must discard - // the staged task selection so Save doesn't send a - // task_id that doesn't belong to the new project. - setTaskId(null); - setProjectId(id); + { + setProjectId(v.projectId); + setTaskId(v.taskId); }} projects={projects() ?? []} - placeholder="No project" - size="sm" - /> -
-
- -
- -
-
diff --git a/ui/src/components/TimerCard.tsx b/ui/src/components/TimerCard.tsx index aca7d35..d3dfea7 100644 --- a/ui/src/components/TimerCard.tsx +++ b/ui/src/components/TimerCard.tsx @@ -3,17 +3,16 @@ import { api } from "~/api"; import Duration from "./Duration"; import StartAtPicker, { type StartAtValue } from "./StartAtPicker"; import Button from "./ui/Button"; -import ProjectPicker from "./ui/ProjectPicker"; +import ProjectTaskPicker from "./ui/ProjectTaskPicker"; import SectionLabel from "./ui/SectionLabel"; import StatusDot from "./ui/StatusDot"; -import TaskPicker from "./ui/TaskPicker"; import Toggle from "./ui/Toggle"; import { useTimerStore } from "~/stores/timer"; export default function TimerCard() { const timer = useTimerStore(); const [description, setDescription] = createSignal(""); - const [projectId, setProjectId] = createSignal(""); + const [projectId, setProjectId] = createSignal(null); const [taskId, setTaskId] = createSignal(null); const [billable, setBillable] = createSignal(false); const [startAt, setStartAt] = createSignal(null); @@ -22,24 +21,30 @@ export default function TimerCard() { }); const projectList = () => projects() ?? []; - // Tasks for the *start form's* selected project. Re-fetched whenever the - // project changes; an empty project resolves to an empty list and the - // TaskPicker stays disabled (no point hitting the IPC). - const [startFormTasks] = createResource( - () => projectId() || null, - async (pid) => (pid ? await api.listTasks(pid) : []), - { initialValue: [] }, - ); + // All tasks across all projects, eager-loaded once. The combined picker + // groups by project_id itself, so we don't have to refetch per selection. + const [tasks] = createResource(() => api.listTasks(null), { + initialValue: [], + }); - // Tasks for the *running entry's* project. Same shape, different source — - // the running entry's project_id might differ from the start form's - // (e.g. when the user is editing the live entry's project inline). - const runningProjectId = () => timer.running()?.project_id ?? null; - const [runningTasks] = createResource( - runningProjectId, - async (pid) => (pid ? await api.listTasks(pid) : []), - { initialValue: [] }, - ); + /// Apply a project+task change to a running entry. Always clears the + /// task first so a queued patch can't carry a task_id from the old + /// project, then sets the new project, then sets the new task. + async function applyLiveChange(args: { + localUuid: string; + hadTask: boolean; + nextProjectId: string | null; + nextTaskId: string | null; + }) { + if (args.hadTask) { + await api.setEntryTask(args.localUuid, null); + } + await api.setEntryProject(args.localUuid, args.nextProjectId); + if (args.nextTaskId) { + await api.setEntryTask(args.localUuid, args.nextTaskId); + } + await timer.refresh(); + } return (
@@ -55,7 +60,7 @@ export default function TimerCard() { timer .start( d, - projectId() || undefined, + projectId() ?? undefined, taskId() ?? undefined, billable(), startAt() ?? undefined, @@ -65,6 +70,7 @@ export default function TimerCard() { setBillable(false); setStartAt(null); setTaskId(null); + setProjectId(null); }); }} > @@ -78,26 +84,15 @@ export default function TimerCard() {
- { - // Tasks scope to projects — changing project must - // discard the old task selection or we'd send a - // task_id that doesn't belong to the new project. - setTaskId(null); - setProjectId(id ?? ""); + { + setProjectId(v.projectId); + setTaskId(v.taskId); }} projects={projectList()} - placeholder="No project" - /> -
-
-
@@ -126,33 +121,22 @@ export default function TimerCard() {
- { - // Switching project on a live entry: clear the task - // first so the queued update doesn't carry a stale - // task_id from the old project. - if (t().task_id) { - await api.setEntryTask(t().local_uuid, null); - } - await api.setEntryProject(t().local_uuid, id); - await timer.refresh(); + + applyLiveChange({ + localUuid: t().local_uuid, + hadTask: Boolean(t().task_id), + nextProjectId: v.projectId, + nextTaskId: v.taskId, + }) + } projects={projectList()} - placeholder="No project" - size="sm" - /> -
-
- { - await api.setEntryTask(t().local_uuid, id); - await timer.refresh(); - }} - tasks={runningTasks() ?? []} - projectSelected={Boolean(t().project_id)} - placeholder="No task" + tasks={tasks() ?? []} + placeholder="Project / task" size="sm" />
diff --git a/ui/src/components/ui/ProjectTaskPicker.tsx b/ui/src/components/ui/ProjectTaskPicker.tsx new file mode 100644 index 0000000..84492fd --- /dev/null +++ b/ui/src/components/ui/ProjectTaskPicker.tsx @@ -0,0 +1,504 @@ +import { Popover } from "@kobalte/core/popover"; +import { + For, + Show, + createEffect, + createMemo, + createSignal, + on, +} from "solid-js"; +import { buildPickerOptions, type PickerOption } from "~/lib/projectPickerSort"; +import type { Project, Task } from "~/types"; + +/** + * Single combined picker for project + task selection. Replaces the + * ProjectPicker + TaskPicker pairing wherever both are needed (popover + * start form, TimerCard start form, TimerCard live entry, EditEntryDialog). + * + * The simpler ProjectPicker stays in use for Settings (default project) and + * CalendarSection (calendar default project) — they only need a project, + * no tasks. + * + * Layout: tree. "(No project)" sentinel at top, projects rendered as bold + * rows with a chevron when they have tasks (no chevron for childless + * projects). Project rows are selectable (selecting one yields + * project-only, task_id=null). + * + * Search: smart filter. Typing matches projects AND tasks. Matching tasks + * auto-expand their parent project; if the parent's name doesn't match the + * query the parent is shown dimmed (still clickable for project-only). + * Search-driven expansions don't persist after the query clears. + * + * Default expansion: all collapsed. The project containing the currently + * selected task auto-expands when the dropdown opens. + * + * Keyboard: ↑↓ traverses visible rows; Enter selects; Esc closes; Right + * expands a collapsed project; Left collapses an expanded project (or + * jumps from a task back to its parent). + */ + +export type ProjectTaskValue = { + projectId: string | null; + taskId: string | null; +}; + +type Row = + | { kind: "none"; key: string } + | { + kind: "project"; + key: string; + project: PickerOption; + dim: boolean; + hasTasks: boolean; + } + | { kind: "task"; key: string; project: PickerOption; task: Task }; + +interface Props { + value: ProjectTaskValue; + onChange: (v: ProjectTaskValue) => void | Promise; + projects: Project[]; + /** All tasks across all projects. The component groups by task.project_id. */ + tasks: Task[]; + placeholder?: string; + size?: "sm" | "md"; + disabled?: boolean; +} + +export default function ProjectTaskPicker(props: Props) { + const [open, setOpen] = createSignal(false); + const [query, setQuery] = createSignal(""); + // User-driven expansions only — survives query changes. Persists for + // the lifetime of the dropdown instance. + const [userExpanded, setUserExpanded] = createSignal>(new Set()); + const [highlightIdx, setHighlightIdx] = createSignal(0); + + // Group tasks by project_id (active tasks only) and produce a stable accessor. + const tasksByProject = createMemo(() => { + const m = new Map(); + for (const t of props.tasks) { + if (t.done) continue; + const list = m.get(t.project_id); + if (list) list.push(t); + else m.set(t.project_id, [t]); + } + for (const list of m.values()) { + list.sort((a, b) => a.name.localeCompare(b.name)); + } + return m; + }); + + const projectOptions = createMemo(() => buildPickerOptions(props.projects)); + + // Projects auto-expanded purely because the active search query has + // matching tasks under them. Recomputed from the query; not stored in + // userExpanded, so clearing the query collapses them back automatically. + const searchExpansions = createMemo>(() => { + const q = query().trim().toLowerCase(); + if (!q) return new Set(); + const out = new Set(); + for (const p of projectOptions()) { + const projMatch = p.name.toLowerCase().includes(q); + const projTasks = tasksByProject().get(p.id) ?? []; + const hasMatchingTask = projTasks.some((t) => + t.name.toLowerCase().includes(q), + ); + if (projMatch || hasMatchingTask) out.add(p.id); + } + return out; + }); + + // The effective expanded set is the union of user-driven and search- + // driven expansions. Searching shows more without losing user state; + // clearing search reverts to user state alone. + const effectiveExpanded = createMemo>(() => { + const u = userExpanded(); + const s = searchExpansions(); + if (s.size === 0) return u; + const ns = new Set(u); + for (const id of s) ns.add(id); + return ns; + }); + + // Visible rows: respects search query AND effective expansion state. + const rows = createMemo(() => { + const q = query().trim().toLowerCase(); + const exp = effectiveExpanded(); + const out: Row[] = [{ kind: "none", key: "__none__" }]; + + for (const p of projectOptions()) { + const projMatch = !q || p.name.toLowerCase().includes(q); + const projTasks = tasksByProject().get(p.id) ?? []; + const matchingTasks = q + ? projTasks.filter((t) => t.name.toLowerCase().includes(q)) + : projTasks; + const hasTasks = projTasks.length > 0; + + if (q) { + // Search mode: include project if its name matches OR it has any + // matching task. When the project's own name doesn't match, dim + // it to signal "the children matched, but you can still pick the + // project itself if that's what you want." + if (!projMatch && matchingTasks.length === 0) continue; + out.push({ + kind: "project", + key: `p:${p.id}`, + project: p, + dim: !projMatch, + hasTasks, + }); + for (const t of matchingTasks) { + out.push({ + kind: "task", + key: `t:${t.solidtime_id}`, + project: p, + task: t, + }); + } + } else { + // Normal mode: project always visible; tasks only if expanded. + out.push({ + kind: "project", + key: `p:${p.id}`, + project: p, + dim: false, + hasTasks, + }); + if (exp.has(p.id)) { + for (const t of projTasks) { + out.push({ + kind: "task", + key: `t:${t.solidtime_id}`, + project: p, + task: t, + }); + } + } + } + } + return out; + }); + + // When the dropdown opens, auto-expand the project that owns the + // currently selected task so the user can see the current value + // without first having to expand by hand. This counts as a user + // expansion since the user effectively selected this previously. + createEffect( + on(open, (isOpen) => { + if (!isOpen) return; + const v = props.value; + if (v.taskId && v.projectId) { + setUserExpanded((s) => { + if (s.has(v.projectId!)) return s; + const ns = new Set(s); + ns.add(v.projectId!); + return ns; + }); + } + // Reset query + highlight on every open so the user starts clean. + setQuery(""); + setHighlightIdx(0); + }), + ); + + // Reset highlight on query change to avoid pointing at a filtered-out row. + createEffect( + on(query, () => { + setHighlightIdx(0); + }), + ); + + // Resolve the displayed label for the current value. + const valueLabel = createMemo(() => { + const v = props.value; + if (v.projectId == null) return null; + const p = projectOptions().find((o) => o.id === v.projectId); + if (!p) return null; + if (v.taskId == null) return p.name; + const t = props.tasks.find((x) => x.solidtime_id === v.taskId); + return t ? `${p.name} / ${t.name}` : p.name; + }); + + function commitRow(row: Row) { + switch (row.kind) { + case "none": + void props.onChange({ projectId: null, taskId: null }); + break; + case "project": + void props.onChange({ projectId: row.project.id, taskId: null }); + break; + case "task": + void props.onChange({ + projectId: row.project.id, + taskId: row.task.solidtime_id, + }); + break; + } + setOpen(false); + } + + function toggleUserExpand(projectId: string) { + setUserExpanded((s) => { + const ns = new Set(s); + if (ns.has(projectId)) ns.delete(projectId); + else ns.add(projectId); + return ns; + }); + } + + function onKeyDown(e: KeyboardEvent) { + const list = rows(); + const idx = highlightIdx(); + switch (e.key) { + case "ArrowDown": { + e.preventDefault(); + if (list.length > 0) { + setHighlightIdx(Math.min(list.length - 1, idx + 1)); + } + break; + } + case "ArrowUp": { + e.preventDefault(); + setHighlightIdx(Math.max(0, idx - 1)); + break; + } + case "ArrowRight": { + const row = list[idx]; + if ( + row?.kind === "project" && + row.hasTasks && + !effectiveExpanded().has(row.project.id) + ) { + e.preventDefault(); + toggleUserExpand(row.project.id); + } + break; + } + case "ArrowLeft": { + const row = list[idx]; + if ( + row?.kind === "project" && + effectiveExpanded().has(row.project.id) + ) { + e.preventDefault(); + toggleUserExpand(row.project.id); + } else if (row?.kind === "task") { + e.preventDefault(); + const parentIdx = list.findIndex( + (r) => r.kind === "project" && r.project.id === row.project.id, + ); + if (parentIdx >= 0) setHighlightIdx(parentIdx); + } + break; + } + case "Enter": { + e.preventDefault(); + const row = list[idx]; + if (row) commitRow(row); + break; + } + case "Escape": { + e.preventDefault(); + setOpen(false); + break; + } + } + } + + const sizeClass = () => + props.size === "sm" ? "px-2.5 py-1.5 text-[12px]" : "px-3 py-1.5 text-sm"; + + return ( + + + + + {props.placeholder ?? "Select project or task…"} + + } + > + {(label) => {label()}} + + + + ▾ + + + + e.preventDefault()} + > + setQuery(e.currentTarget.value)} + onKeyDown={onKeyDown} + /> +
    + + {(row, i) => ( + commitRow(row)} + onToggle={() => { + if (row.kind === "project") toggleUserExpand(row.project.id); + }} + onHover={() => setHighlightIdx(i())} + /> + )} + + +
  • + No projects or tasks match "{query()}" +
  • +
    +
+
+
+
+ ); +} + +function RowItem(props: { + row: Row; + highlighted: boolean; + isExpanded: boolean; + onSelect: () => void; + onToggle: () => void; + onHover: () => void; +}) { + const baseClass = () => + `flex cursor-pointer items-center gap-1 rounded px-2 py-1.5 text-sm outline-none ${ + props.highlighted ? "bg-zinc-100 dark:bg-zinc-800" : "" + }`; + + return ( +
  • { + // Clicks on the chevron-zone (data-chevron) only toggle expansion, + // not select. Clicks anywhere else in the row select. + const target = e.target as HTMLElement; + if (target.closest("[data-chevron]")) return; + props.onSelect(); + }} + > + {(() => { + switch (props.row.kind) { + case "none": + return ( + + No project + + ); + case "project": { + const row = props.row; + return ( + <> + + • + + } + > + + + + {row.project.name} + + + + {row.project.clientName} + + + + ); + } + case "task": + return ( + <> + + + – + + + {props.row.task.name} + + + ); + } + })()} +
  • + ); +} diff --git a/ui/src/routes/Popover.tsx b/ui/src/routes/Popover.tsx index 8114516..b7e401f 100644 --- a/ui/src/routes/Popover.tsx +++ b/ui/src/routes/Popover.tsx @@ -5,7 +5,7 @@ import { api } from "~/api"; import Duration from "~/components/Duration"; import StartAtPicker, { type StartAtValue } from "~/components/StartAtPicker"; import Button from "~/components/ui/Button"; -import ProjectPicker from "~/components/ui/ProjectPicker"; +import ProjectTaskPicker from "~/components/ui/ProjectTaskPicker"; import SectionLabel from "~/components/ui/SectionLabel"; import StatusDot from "~/components/ui/StatusDot"; import Toggle from "~/components/ui/Toggle"; @@ -17,7 +17,8 @@ import { useTimerStore } from "~/stores/timer"; export default function Popover() { const timer = useTimerStore(); const [description, setDescription] = createSignal(""); - const [projectId, setProjectId] = createSignal(""); + const [projectId, setProjectId] = createSignal(null); + const [taskId, setTaskId] = createSignal(null); const [billable, setBillable] = createSignal(false); const [startAt, setStartAt] = createSignal(null); const [entries, { refetch: refetchEntries }] = createResource( @@ -27,6 +28,11 @@ export default function Popover() { const [projects] = createResource(() => api.listProjects(), { initialValue: [], }); + // All tasks across projects, eager-loaded once. The combined picker + // groups by project_id itself. + const [tasks] = createResource(() => api.listTasks(null), { + initialValue: [], + }); const unlistenEntries = listen("entries:changed", () => refetchEntries()); onCleanup(() => { unlistenEntries.then((fn) => fn()).catch(() => {}); @@ -88,8 +94,8 @@ export default function Popover() { timer .start( d, - projectId() || undefined, - undefined, + projectId() ?? undefined, + taskId() ?? undefined, billable(), startAt() ?? undefined, ) @@ -97,6 +103,11 @@ export default function Popover() { setDescription(""); setBillable(false); setStartAt(null); + // Clear the task so the next start doesn't silently + // inherit it. Keep the project — the old single-picker + // popover preserved project across starts and users + // rely on that for back-to-back same-project work. + setTaskId(null); }); }} > @@ -110,10 +121,14 @@ export default function Popover() {
    - setProjectId(id ?? "")} + { + setProjectId(v.projectId); + setTaskId(v.taskId); + }} projects={projects() ?? []} + tasks={tasks() ?? []} placeholder="No project" size="sm" /> diff --git a/ui/src/test/components/EditEntryDialog.test.tsx b/ui/src/test/components/EditEntryDialog.test.tsx index d78f046..75c6726 100644 --- a/ui/src/test/components/EditEntryDialog.test.tsx +++ b/ui/src/test/components/EditEntryDialog.test.tsx @@ -47,7 +47,7 @@ beforeEach(() => { }); describe("", () => { - it("renders the description, project picker, billable toggle, and time inputs for a completed entry", () => { + it("renders the description, project/task picker, billable toggle, and time inputs for a completed entry", () => { const { getByText, getByLabelText, container } = render(() => ( ", () => { )); expect(getByText("Edit entry")).toBeDefined(); expect(getByText("Description")).toBeDefined(); - expect(getByText("Project")).toBeDefined(); + expect(getByText("Project / task")).toBeDefined(); expect(getByText("Start")).toBeDefined(); expect(getByText("End")).toBeDefined(); - expect(getByLabelText("Open project list")).toBeDefined(); + expect(getByLabelText("Open project or task list")).toBeDefined(); const times = container.querySelectorAll('input[type="time"]'); expect(times.length).toBe(2); }); @@ -178,31 +178,19 @@ describe("", () => { expect(onClose).toHaveBeenCalled(); }); - it("renders a Task field and picker when the entry has a project", async () => { - const { getByText, getByLabelText } = render(() => ( + it("eagerly loads all tasks for the combined project/task picker", async () => { + render(() => ( )); - expect(getByText("Task")).toBeDefined(); - const trigger = getByLabelText("Open task list"); - const taskInput = trigger.parentElement?.querySelector("input") as HTMLInputElement; - expect(taskInput.disabled).toBe(false); - }); - - it("disables the Task picker when the entry has no project", async () => { - const { getByLabelText } = render(() => ( - - )); - const trigger = getByLabelText("Open task list"); - const taskInput = trigger.parentElement?.querySelector("input") as HTMLInputElement; - expect(taskInput.disabled).toBe(true); + await flush(); + // The combined picker fetches all tasks upfront (project_id=null) so + // it can group them under their parents. The previous separate + // TaskPicker fetched per-project on demand. + expect(api.listTasks).toHaveBeenCalledWith(null); }); it("Save without touching the task leaves setEntryTask uncalled", async () => { diff --git a/ui/src/test/components/EntryRow.test.tsx b/ui/src/test/components/EntryRow.test.tsx index 1439a16..f151d70 100644 --- a/ui/src/test/components/EntryRow.test.tsx +++ b/ui/src/test/components/EntryRow.test.tsx @@ -7,8 +7,10 @@ vi.mock("~/api", () => ({ { id: "p-1", name: "Tet", color: null, client_id: null, client_name: null, archived: 0 }, { id: "p-2", name: "Other", color: null, client_id: null, client_name: null, archived: 0 }, ]), + listTasks: vi.fn().mockResolvedValue([]), updateDescription: vi.fn().mockResolvedValue(undefined), setEntryProject: vi.fn().mockResolvedValue(undefined), + setEntryTask: vi.fn().mockResolvedValue(undefined), setEntryBillable: vi.fn().mockResolvedValue(undefined), updateEntryTimes: vi.fn().mockResolvedValue(undefined), deleteEntry: vi.fn().mockResolvedValue(undefined), @@ -118,14 +120,14 @@ describe("", () => { expect(queryByText("Edit entry")).not.toBeNull(); }); - it("dialog shows the ProjectPicker after the row is clicked", async () => { + it("dialog shows the combined project/task picker after the row is clicked", async () => { const { container, queryByLabelText } = render(() => ( )); - expect(queryByLabelText("Open project list")).toBeNull(); + expect(queryByLabelText("Open project or task list")).toBeNull(); fireEvent.click(container.querySelector("button")!); await flush(); - expect(queryByLabelText("Open project list")).not.toBeNull(); + expect(queryByLabelText("Open project or task list")).not.toBeNull(); }); it("renders a Restart button on completed entries", () => { diff --git a/ui/src/test/components/ProjectTaskPicker.test.tsx b/ui/src/test/components/ProjectTaskPicker.test.tsx new file mode 100644 index 0000000..decd1f7 --- /dev/null +++ b/ui/src/test/components/ProjectTaskPicker.test.tsx @@ -0,0 +1,310 @@ +import { describe, expect, it, vi } from "vitest"; +import { fireEvent, render, screen } from "@solidjs/testing-library"; + +import ProjectTaskPicker from "~/components/ui/ProjectTaskPicker"; +import type { Project, Task } from "~/types"; + +const proj = (over: Partial = {}): Project => ({ + id: "p-1", + name: "Tet", + color: null, + client_id: null, + client_name: null, + archived: 0, + ...over, +}); + +const task = (over: Partial = {}): Task => ({ + solidtime_id: "t-1", + project_id: "p-1", + name: "Implement picker", + done: false, + ...over, +}); + +const flush = () => new Promise((r) => setTimeout(r, 0)); + +/// Kobalte's Popover.Portal renders outside the render container into +/// document.body, so queries inside the dropdown use `screen` (which +/// targets document.body) rather than the render-scoped helpers. +describe("", () => { + it("renders the trigger with the placeholder when no value is set", () => { + const { getByLabelText, getByText } = render(() => ( + + )); + expect(getByLabelText("Open project or task list")).toBeDefined(); + expect(getByText("Choose…")).toBeDefined(); + }); + + it("renders the project name when only a project is selected", () => { + const { getByText } = render(() => ( + + )); + expect(getByText("Tet")).toBeDefined(); + }); + + it("renders 'Project / Task' when both are selected", () => { + const { getByText } = render(() => ( + + )); + expect(getByText("Tet / Refactor")).toBeDefined(); + }); + + it("opens the dropdown and lists 'No project' + the project rows", async () => { + const { getByLabelText } = render(() => ( + + )); + fireEvent.click(getByLabelText("Open project or task list")); + await flush(); + expect(screen.queryByText("No project")).not.toBeNull(); + expect(screen.queryByText("Alpha")).not.toBeNull(); + expect(screen.queryByText("Beta")).not.toBeNull(); + }); + + it("clicking 'No project' fires onChange with both ids null and closes the dropdown", async () => { + const onChange = vi.fn(); + const { getByLabelText } = render(() => ( + + )); + fireEvent.click(getByLabelText("Open project or task list")); + await flush(); + fireEvent.click(screen.getByText("No project")); + await flush(); + expect(onChange).toHaveBeenCalledWith({ projectId: null, taskId: null }); + expect(screen.queryByText("No project")).toBeNull(); + }); + + it("clicking a project header selects project-only (task_id stays null)", async () => { + const onChange = vi.fn(); + const { getByLabelText } = render(() => ( + + )); + fireEvent.click(getByLabelText("Open project or task list")); + await flush(); + fireEvent.click(screen.getByText("Alpha")); + await flush(); + expect(onChange).toHaveBeenCalledWith({ projectId: "p-1", taskId: null }); + }); + + it("expanding a project reveals its tasks; clicking a task selects project + task", async () => { + const onChange = vi.fn(); + const { getByLabelText } = render(() => ( + + )); + fireEvent.click(getByLabelText("Open project or task list")); + await flush(); + expect(screen.queryByText("First")).toBeNull(); + fireEvent.click(screen.getByLabelText("Expand")); + await flush(); + expect(screen.queryByText("First")).not.toBeNull(); + expect(screen.queryByText("Second")).not.toBeNull(); + fireEvent.click(screen.getByText("Second")); + await flush(); + expect(onChange).toHaveBeenCalledWith({ + projectId: "p-1", + taskId: "t-2", + }); + }); + + it("auto-expands the project owning the currently selected task on open", async () => { + const { getByLabelText } = render(() => ( + + )); + fireEvent.click(getByLabelText("Open project or task list")); + await flush(); + expect(screen.queryByText("First")).not.toBeNull(); + }); + + it("smart search auto-expands projects with matching tasks", async () => { + const { getByLabelText } = render(() => ( + + )); + fireEvent.click(getByLabelText("Open project or task list")); + await flush(); + const search = screen.getByPlaceholderText( + "Search projects + tasks…", + ) as HTMLInputElement; + search.value = "refactor"; + fireEvent.input(search); + await flush(); + expect(screen.queryByText("Refactor picker")).not.toBeNull(); + expect(screen.queryByText("Beta")).toBeNull(); + }); + + it("filters out tasks marked done", async () => { + const { getByLabelText } = render(() => ( + + )); + fireEvent.click(getByLabelText("Open project or task list")); + await flush(); + // Project auto-expands because the parent has projectId in value. + // Expand explicitly in case auto-expand only triggers when taskId is set. + fireEvent.click(screen.getByLabelText("Expand")); + await flush(); + expect(screen.queryByText("Active")).not.toBeNull(); + expect(screen.queryByText("Finished")).toBeNull(); + }); + + it("does NOT render a chevron for projects with no tasks", async () => { + const { getByLabelText } = render(() => ( + + )); + fireEvent.click(getByLabelText("Open project or task list")); + await flush(); + // Tasks have no chevron when there are no children. + expect(screen.queryByLabelText("Expand")).toBeNull(); + }); + + it("search-driven expansions clear when the query is removed", async () => { + const { getByLabelText } = render(() => ( + + )); + fireEvent.click(getByLabelText("Open project or task list")); + await flush(); + // Beta starts collapsed — its task not visible. + expect(screen.queryByText("Unique")).toBeNull(); + // Search opens it. + const search = screen.getByPlaceholderText( + "Search projects + tasks…", + ) as HTMLInputElement; + search.value = "unique"; + fireEvent.input(search); + await flush(); + expect(screen.queryByText("Unique")).not.toBeNull(); + // Clear the query — Beta should collapse back since the expansion was + // search-driven, not user-driven. + search.value = ""; + fireEvent.input(search); + await flush(); + expect(screen.queryByText("Unique")).toBeNull(); + }); + + it("user-driven expansions persist across search activity", async () => { + const { getByLabelText } = render(() => ( + + )); + fireEvent.click(getByLabelText("Open project or task list")); + await flush(); + // User expands Alpha manually. + fireEvent.click(screen.getByLabelText("Expand")); + await flush(); + expect(screen.queryByText("Visible")).not.toBeNull(); + // Type a query that doesn't match anything. + const search = screen.getByPlaceholderText( + "Search projects + tasks…", + ) as HTMLInputElement; + search.value = "zzz-nope"; + fireEvent.input(search); + await flush(); + // Clear the query — the user's manual expansion should still hold. + search.value = ""; + fireEvent.input(search); + await flush(); + expect(screen.queryByText("Visible")).not.toBeNull(); + }); + + it("shows an empty-state message when search has no matches", async () => { + const { getByLabelText } = render(() => ( + + )); + fireEvent.click(getByLabelText("Open project or task list")); + await flush(); + const search = screen.getByPlaceholderText( + "Search projects + tasks…", + ) as HTMLInputElement; + search.value = "nonexistent"; + fireEvent.input(search); + await flush(); + expect(screen.queryByText(/No projects or tasks match/)).not.toBeNull(); + }); +}); diff --git a/ui/src/test/components/TimerCard.test.tsx b/ui/src/test/components/TimerCard.test.tsx index df9e448..feb0e9b 100644 --- a/ui/src/test/components/TimerCard.test.tsx +++ b/ui/src/test/components/TimerCard.test.tsx @@ -67,12 +67,12 @@ beforeEach(() => { }); describe(" — start form (no timer running)", () => { - it("renders the description input, a project picker, and a Start button", () => { + it("renders the description input, the combined project/task picker, and a Start button", () => { const { getByPlaceholderText, getByText, getByLabelText } = render(() => ( )); expect(getByPlaceholderText("What are you working on?")).toBeDefined(); - expect(getByLabelText("Open project list")).toBeDefined(); + expect(getByLabelText("Open project or task list")).toBeDefined(); expect(getByText("Start")).toBeDefined(); }); @@ -110,14 +110,13 @@ describe(" — start form (no timer running)", () => { ); }); - it("renders a TaskPicker disabled until a project is selected", async () => { - const { getByLabelText } = render(() => ); + it("loads all tasks (cross-project) on mount for the combined picker", async () => { + render(() => ); await flushMicrotasks(); - const trigger = getByLabelText("Open task list") as HTMLButtonElement; - expect(trigger).toBeDefined(); - // The Combobox input is the one that goes disabled. - const taskInput = trigger.parentElement?.querySelector("input") as HTMLInputElement; - expect(taskInput.disabled).toBe(true); + // The combined ProjectTaskPicker fetches all tasks upfront (passing + // null to scope to "everything") so the dropdown can render tasks + // grouped under their parent projects without per-expansion fetches. + expect(api.listTasks).toHaveBeenCalledWith(null); }); it("does not call start when the description is blank", async () => { @@ -153,28 +152,10 @@ describe(" — running timer panel", () => { expect(storeMock.stop).toHaveBeenCalledTimes(1); }); - it("running panel shows the ProjectPicker for live project changes", async () => { + it("running panel shows the combined project/task picker for live changes", async () => { setRunning(runningTimer({ description: "x" })); const { getByLabelText } = render(() => ); await flushMicrotasks(); - expect(getByLabelText("Open project list")).toBeDefined(); - }); - - it("running panel exposes a TaskPicker (disabled when no project)", async () => { - setRunning(runningTimer({ description: "x", project_id: null })); - const { getByLabelText } = render(() => ); - await flushMicrotasks(); - const trigger = getByLabelText("Open task list") as HTMLButtonElement; - const taskInput = trigger.parentElement?.querySelector("input") as HTMLInputElement; - expect(taskInput.disabled).toBe(true); - }); - - it("running panel enables the TaskPicker when the entry has a project", async () => { - setRunning(runningTimer({ description: "x", project_id: "p-1" })); - const { getByLabelText } = render(() => ); - await flushMicrotasks(); - const trigger = getByLabelText("Open task list") as HTMLButtonElement; - const taskInput = trigger.parentElement?.querySelector("input") as HTMLInputElement; - expect(taskInput.disabled).toBe(false); + expect(getByLabelText("Open project or task list")).toBeDefined(); }); }); diff --git a/ui/src/test/routes/Popover.test.tsx b/ui/src/test/routes/Popover.test.tsx index fdb285e..7ba3e8c 100644 --- a/ui/src/test/routes/Popover.test.tsx +++ b/ui/src/test/routes/Popover.test.tsx @@ -34,6 +34,7 @@ vi.mock("~/api", () => ({ listProjects: vi.fn().mockResolvedValue([ { id: "p-1", name: "Tet", color: null, client_id: null, client_name: null, archived: 0 }, ]), + listTasks: vi.fn().mockResolvedValue([]), }, })); diff --git a/ui/src/test/routes/Today.test.tsx b/ui/src/test/routes/Today.test.tsx index c76a215..17b7e65 100644 --- a/ui/src/test/routes/Today.test.tsx +++ b/ui/src/test/routes/Today.test.tsx @@ -25,6 +25,7 @@ vi.mock("~/api", () => ({ api: { listToday: vi.fn().mockResolvedValue([]), listProjects: vi.fn().mockResolvedValue([]), + listTasks: vi.fn().mockResolvedValue([]), syncNow: vi.fn().mockResolvedValue(0), deleteEntry: vi.fn().mockResolvedValue(undefined), listSyncErrors: vi.fn().mockResolvedValue([]),