feat(KB-055): merge feat-github-int into main

This commit is contained in:
gsxdsm
2026-03-29 19:54:21 -07:00
16 changed files with 1622 additions and 425 deletions

View File

@@ -7,7 +7,7 @@ Web-based dashboard for managing kb tasks. Provides a visual kanban board, list
### Task Management
- **Kanban Board**: Drag-and-drop task management across columns (Triage, Todo, In Progress, In Review, Done)
- **Inline Editing**: Quick-edit task title and description directly on the board for Triage and Todo columns. Double-click a card or use the pencil icon that appears on hover.
- **List View**: Alternative tabular view for tasks with sorting, filtering, and collapsible column sections. Click section headers to expand/collapse each column group. Section expansion state is persisted to localStorage.
- **List View**: Alternative tabular view for tasks with sorting and filtering
- **Task Details**: View full task specifications, agent logs, and attachments
- **GitHub Import**: Import issues directly from GitHub repositories
- **PR Management**: Create and track pull requests for in-review tasks
@@ -44,7 +44,8 @@ The Git Manager provides comprehensive repository visualization and management d
- View operation results and error states
### Configuration
- **Settings Modal**: Configure scheduling, worktrees, build commands, merge preferences
- **Settings Modal**: Configure scheduling, worktrees, build commands, merge preferences, and notifications
- **Notifications**: ntfy.sh integration for push notifications when tasks complete or fail
- **Authentication**: OAuth provider management for AI model access
- **Pause Controls**: Soft pause (stop new work) and hard stop (kill all agents)

View File

@@ -1,8 +1,9 @@
import { useState, useCallback, useMemo, Fragment, useEffect, useRef } from "react";
import { LayoutGrid, List as ListIcon, ArrowUpDown, ArrowUp, ArrowDown, Search, Link, Columns3, ChevronRight } from "lucide-react";
import { LayoutGrid, List as ListIcon, ArrowUpDown, ArrowUp, ArrowDown, Search, Link, Columns3, EyeOff, Eye } from "lucide-react";
import type { Task, TaskDetail, Column, TaskStep } from "@kb/core";
import { COLUMN_LABELS, COLUMNS } from "@kb/core";
import { fetchTaskDetail } from "../api";
import { InlineCreateCard } from "./InlineCreateCard";
import type { ToastType } from "../hooks/useToast";
const COLUMN_COLOR_MAP: Record<Column, string> = {
@@ -58,12 +59,16 @@ export function ListView({
addToast,
globalPaused,
onNewTask,
isCreating,
onCancelCreate,
onCreateTask,
}: ListViewProps) {
const [sortField, setSortField] = useState<SortField>("createdAt");
const [sortDirection, setSortDirection] = useState<SortDirection>("desc");
const [filter, setFilter] = useState("");
const [draggingTaskId, setDraggingTaskId] = useState<string | null>(null);
const [dragOverColumn, setDragOverColumn] = useState<Column | null>(null);
const [selectedColumn, setSelectedColumn] = useState<Column | null>(null);
// Column visibility state - initialize from localStorage or default to all columns
const [visibleColumns, setVisibleColumns] = useState<Set<ListColumn>>(() => {
@@ -87,6 +92,21 @@ export function ListView({
return new Set(ALL_LIST_COLUMNS);
});
// Hide done tasks state - initialize from localStorage
const [hideDoneTasks, setHideDoneTasks] = useState<boolean>(() => {
if (typeof window !== "undefined") {
try {
const saved = localStorage.getItem("kb-dashboard-hide-done");
if (saved !== null) {
return saved === "true";
}
} catch {
// Invalid localStorage data - fall through to default
}
}
return false; // Default: show done tasks
});
// Persist column visibility changes to localStorage
useEffect(() => {
if (typeof window !== "undefined") {
@@ -94,61 +114,12 @@ export function ListView({
}
}, [visibleColumns]);
/**
* Section expansion state - tracks which column sections are expanded/collapsed.
* Initialized from localStorage with key "kb-dashboard-list-sections".
* Defaults to all sections expanded if no saved state exists.
* Persists changes to localStorage whenever sections are expanded/collapsed.
*/
const [expandedSections, setExpandedSections] = useState<Set<Column>>(() => {
if (typeof window !== "undefined") {
try {
const saved = localStorage.getItem("kb-dashboard-list-sections");
if (saved) {
const parsed = JSON.parse(saved) as Column[];
// Validate that all saved values are valid Column values
const validColumns = parsed.filter((col): col is Column =>
COLUMNS.includes(col as Column)
);
return new Set(validColumns);
}
} catch {
// Invalid localStorage data - fall through to default
}
}
// Default: all sections expanded
return new Set<Column>(COLUMNS);
});
// Persist section expansion state to localStorage
// Persist hide done tasks state to localStorage
useEffect(() => {
if (typeof window !== "undefined") {
localStorage.setItem("kb-dashboard-list-sections", JSON.stringify([...expandedSections]));
localStorage.setItem("kb-dashboard-hide-done", hideDoneTasks.toString());
}
}, [expandedSections]);
// Toggle a section's expansion state
const toggleSection = useCallback((column: Column) => {
setExpandedSections((prev) => {
const next = new Set(prev);
if (next.has(column)) {
next.delete(column);
} else {
next.add(column);
}
return next;
});
}, []);
// Expand all sections
const expandAll = useCallback(() => {
setExpandedSections(new Set<Column>(COLUMNS));
}, []);
// Collapse all sections
const collapseAll = useCallback(() => {
setExpandedSections(new Set<Column>());
}, []);
}, [hideDoneTasks]);
// Column dropdown state
const [columnDropdownOpen, setColumnDropdownOpen] = useState(false);
@@ -216,17 +187,36 @@ export function ListView({
}
}, [sortField]);
const handleColumnFilter = useCallback((column: Column) => {
setSelectedColumn((prev) => (prev === column ? null : column));
}, []);
const clearColumnFilter = useCallback(() => {
setSelectedColumn(null);
}, []);
const groupedTasks = useMemo(() => {
const filtered = filter
// First apply text filter
let filtered = filter
? tasks.filter(
(t) =>
t.id.toLowerCase().includes(filter.toLowerCase()) ||
(t.title && t.title.toLowerCase().includes(filter.toLowerCase())) ||
t.description.toLowerCase().includes(filter.toLowerCase())
)
: tasks;
: [...tasks];
const sorted = [...filtered].sort((a, b) => {
// Then filter out done tasks if hideDoneTasks is enabled
if (hideDoneTasks) {
filtered = filtered.filter((t) => t.column !== "done");
}
// Then apply column filter if selected
const columnFiltered = selectedColumn
? filtered.filter((t) => t.column === selectedColumn)
: filtered;
const sorted = [...columnFiltered].sort((a, b) => {
let comparison = 0;
switch (sortField) {
case "id":
@@ -261,12 +251,23 @@ export function ListView({
};
sorted.forEach(task => groups[task.column].push(task));
return groups;
}, [tasks, filter, sortField, sortDirection]);
}, [tasks, filter, sortField, sortDirection, hideDoneTasks, selectedColumn]);
// Calculate total filtered count from groups
const filteredCount = useMemo(() => {
return Object.values(groupedTasks).reduce((sum, group) => sum + group.length, 0);
}, [groupedTasks]);
// Calculate done task counts for stats display
const doneTaskCount = useMemo(() => {
return tasks.filter((t) => t.column === "done").length;
}, [tasks]);
// Calculate hidden done tasks count
const hiddenDoneCount = useMemo(() => {
if (!hideDoneTasks) return 0;
return doneTaskCount;
}, [hideDoneTasks, doneTaskCount]);
const handleRowClick = useCallback(
async (task: Task) => {
try {
@@ -353,14 +354,6 @@ export function ListView({
</button>
)}
</div>
<div className="list-section-controls">
<button className="btn btn-sm" onClick={expandAll} title="Expand all sections">
Expand All
</button>
<button className="btn btn-sm" onClick={collapseAll} title="Collapse all sections">
Collapse All
</button>
</div>
<div className="list-column-toggle" ref={columnDropdownRef}>
<button
className="btn btn-sm"
@@ -396,8 +389,32 @@ export function ListView({
</div>
)}
</div>
<button
className="btn btn-sm list-hide-done-toggle"
onClick={() => setHideDoneTasks((prev) => !prev)}
aria-pressed={hideDoneTasks}
title={hideDoneTasks ? "Show done tasks" : "Hide done tasks"}
>
{hideDoneTasks ? <Eye size={14} /> : <EyeOff size={14} />}
{hideDoneTasks ? "Show Done" : "Hide Done"}
</button>
<div className="list-stats">
{filteredCount} of {tasks.length} tasks
{selectedColumn
? `${filteredCount} of ${tasks.length} tasks in ${COLUMN_LABELS[selectedColumn]}`
: `${filteredCount} of ${tasks.length} tasks`}
{hiddenDoneCount > 0 && !selectedColumn && (
<span className="list-stats-hidden"> ({hiddenDoneCount} done hidden)</span>
)}
{selectedColumn && (
<button
className="btn btn-sm"
onClick={clearColumnFilter}
aria-label="Clear column filter"
style={{ marginLeft: "8px" }}
>
Clear
</button>
)}
</div>
{onNewTask && (
<button className="btn btn-primary btn-sm" onClick={onNewTask}>
@@ -407,26 +424,33 @@ export function ListView({
</div>
<div className="list-drop-zones">
{COLUMNS.map((column) => (
<div
key={column}
className={`list-drop-zone${dragOverColumn === column ? " drag-over" : ""}`}
onDragOver={(e) => handleColumnDragOver(e, column)}
onDragLeave={handleColumnDragLeave}
onDrop={(e) => handleColumnDrop(e, column)}
data-column={column}
>
<span className="drop-zone-dot" style={{ background: COLUMN_COLOR_MAP[column] }} />
<span className="drop-zone-label">{COLUMN_LABELS[column]}</span>
<span className="drop-zone-count">
{tasks.filter((t) => t.column === column).length}
</span>
</div>
))}
{COLUMNS.map((column) => {
const totalCount = tasks.filter((t) => t.column === column).length;
const visibleCount = hideDoneTasks && column === "done" ? 0 : totalCount;
const showPartial = hideDoneTasks && column === "done" && totalCount > 0;
return (
<div
key={column}
className={`list-drop-zone${dragOverColumn === column ? " drag-over" : ""}${selectedColumn === column ? " active" : ""}`}
onClick={() => handleColumnFilter(column)}
onDragOver={(e) => handleColumnDragOver(e, column)}
onDragLeave={handleColumnDragLeave}
onDrop={(e) => handleColumnDrop(e, column)}
data-column={column}
>
<span className="drop-zone-dot" style={{ background: COLUMN_COLOR_MAP[column] }} />
<span className="drop-zone-label">{COLUMN_LABELS[column]}</span>
<span className="drop-zone-count">
{showPartial ? `${visibleCount} of ${totalCount}` : totalCount}
</span>
</div>
);
})}
</div>
<div className="list-table-container">
{filteredCount === 0 ? (
{filteredCount === 0 && !isCreating ? (
<div className="list-empty">
{filter ? "No tasks match your filter" : "No tasks yet"}
</div>
@@ -474,139 +498,149 @@ export function ListView({
</thead>
<tbody>
{COLUMNS.map((column) => {
// When column filter is active, only show the selected column
if (selectedColumn && column !== selectedColumn) return null;
// Skip done column section when hideDoneTasks is enabled (unless it's the selected column)
if (hideDoneTasks && column === "done" && !selectedColumn) return null;
const columnTasks = groupedTasks[column];
const isEmpty = columnTasks.length === 0;
// When filtering, hide empty sections entirely
if (filter && isEmpty) return null;
// When text filtering, hide empty sections entirely (except triage when creating)
if (filter && isEmpty && !(column === "triage" && isCreating)) return null;
return (
<Fragment key={column}>
{/* Section Header */}
<tr
className={`list-section-header${expandedSections.has(column) ? "" : " list-section-header--collapsed"}`}
onClick={() => toggleSection(column)}
>
<tr className="list-section-header">
<th colSpan={visibleColumns.size} className="list-section-cell">
<span className={`list-section-chevron${expandedSections.has(column) ? " list-section-chevron--expanded" : ""}`}>
<ChevronRight size={16} />
</span>
<span className={`list-section-dot dot-${column}`} />
<span className="list-section-title">{COLUMN_LABELS[column]}</span>
<span className="list-section-count">{columnTasks.length} {columnTasks.length === 1 ? "task" : "tasks"}</span>
<span className="list-section-count">{columnTasks.length}</span>
</th>
</tr>
{/* Task Rows - only render if section is expanded */}
{expandedSections.has(column) && (
<>
{isEmpty ? (
<tr className="list-section-empty">
<td colSpan={visibleColumns.size} className="list-empty-cell">
No tasks
</td>
</tr>
) : (
columnTasks.map((task) => {
const isFailed = task.status === "failed";
const isPaused = task.paused === true;
const isAgentActive =
!globalPaused &&
!isFailed &&
!isPaused &&
(task.column === "in-progress" || ACTIVE_STATUSES.has(task.status as string));
const isDragging = draggingTaskId === task.id;
{/* Inline Create Card for Triage column */}
{column === "triage" && isCreating && onCancelCreate && onCreateTask && (
<tr className="list-inline-create-row">
<td colSpan={visibleColumns.size} className="list-inline-create-cell">
<InlineCreateCard
tasks={tasks}
onSubmit={onCreateTask}
onCancel={onCancelCreate}
addToast={addToast}
/>
</td>
</tr>
)}
return (
<tr
key={task.id}
className={`list-row${isFailed ? " failed" : ""}${isPaused ? " paused" : ""}${
isAgentActive ? " agent-active" : ""
}${isDragging ? " dragging" : ""}`}
onClick={() => handleRowClick(task)}
draggable={!isPaused}
onDragStart={(e) => handleDragStart(e, task)}
onDragEnd={handleDragEnd}
data-id={task.id}
>
{visibleColumns.has("id") && (
<td className="list-cell list-cell-id">{task.id}</td>
{/* Task Rows */}
{isEmpty ? (
<tr className="list-section-empty">
<td colSpan={visibleColumns.size} className="list-empty-cell">
No tasks
</td>
</tr>
) : (
columnTasks.map((task) => {
const isFailed = task.status === "failed";
const isPaused = task.paused === true;
const isAgentActive =
!globalPaused &&
!isFailed &&
!isPaused &&
(task.column === "in-progress" || ACTIVE_STATUSES.has(task.status as string));
const isDragging = draggingTaskId === task.id;
return (
<tr
key={task.id}
className={`list-row${isFailed ? " failed" : ""}${isPaused ? " paused" : ""}${
isAgentActive ? " agent-active" : ""
}${isDragging ? " dragging" : ""}`}
onClick={() => handleRowClick(task)}
draggable={!isPaused}
onDragStart={(e) => handleDragStart(e, task)}
onDragEnd={handleDragEnd}
data-id={task.id}
>
{visibleColumns.has("id") && (
<td className="list-cell list-cell-id">{task.id}</td>
)}
{visibleColumns.has("title") && (
<td className="list-cell list-cell-title">
{task.title || task.description.slice(0, 60) + (task.description.length > 60 ? "…" : "")}
</td>
)}
{visibleColumns.has("status") && (
<td className="list-cell">
{task.status ? (
<span
className={`list-status-badge${isFailed ? " failed" : ""}${
isAgentActive ? " pulsing" : ""
}`}
>
{task.status}
</span>
) : (
<span className="list-status-badge">-</span>
)}
{visibleColumns.has("title") && (
<td className="list-cell list-cell-title">
{task.title || task.description.slice(0, 60) + (task.description.length > 60 ? "…" : "")}
</td>
</td>
)}
{visibleColumns.has("column") && (
<td className="list-cell">
<span
className="list-column-badge"
style={{
background: `${COLUMN_COLOR_MAP[task.column]}20`,
color: COLUMN_COLOR_MAP[task.column],
}}
>
{COLUMN_LABELS[task.column]}
</span>
</td>
)}
{visibleColumns.has("createdAt") && (
<td className="list-cell list-cell-date">{formatDate(task.createdAt)}</td>
)}
{visibleColumns.has("updatedAt") && (
<td className="list-cell list-cell-date">{formatDate(task.updatedAt)}</td>
)}
{visibleColumns.has("dependencies") && (
<td className="list-cell list-cell-deps">
{task.dependencies && task.dependencies.length > 0 ? (
<span className="list-dep-badge" title={task.dependencies.join(", ")}>
<Link size={12} /> {task.dependencies.length}
</span>
) : (
"-"
)}
{visibleColumns.has("status") && (
<td className="list-cell">
{task.status ? (
<span
className={`list-status-badge${isFailed ? " failed" : ""}${
isAgentActive ? " pulsing" : ""
}`}
>
{task.status}
</span>
) : (
<span className="list-status-badge">-</span>
)}
</td>
</td>
)}
{visibleColumns.has("progress") && (
<td className="list-cell list-cell-progress">
{task.steps.length > 0 ? (
<div className="list-progress">
<div className="list-progress-bar">
<div
className="list-progress-fill"
style={{
width: `${getStepProgressPercent(task.steps)}%`,
backgroundColor: COLUMN_COLOR_MAP[task.column],
}}
/>
</div>
<span className="list-progress-label">{getStepProgress(task.steps)}</span>
</div>
) : (
"-"
)}
{visibleColumns.has("column") && (
<td className="list-cell">
<span
className="list-column-badge"
style={{
background: `${COLUMN_COLOR_MAP[task.column]}20`,
color: COLUMN_COLOR_MAP[task.column],
}}
>
{COLUMN_LABELS[task.column]}
</span>
</td>
)}
{visibleColumns.has("createdAt") && (
<td className="list-cell list-cell-date">{formatDate(task.createdAt)}</td>
)}
{visibleColumns.has("updatedAt") && (
<td className="list-cell list-cell-date">{formatDate(task.updatedAt)}</td>
)}
{visibleColumns.has("dependencies") && (
<td className="list-cell list-cell-deps">
{task.dependencies && task.dependencies.length > 0 ? (
<span className="list-dep-badge" title={task.dependencies.join(", ")}>
<Link size={12} /> {task.dependencies.length}
</span>
) : (
"-"
)}
</td>
)}
{visibleColumns.has("progress") && (
<td className="list-cell list-cell-progress">
{task.steps.length > 0 ? (
<div className="list-progress">
<div className="list-progress-bar">
<div
className="list-progress-fill"
style={{
width: `${getStepProgressPercent(task.steps)}%`,
backgroundColor: COLUMN_COLOR_MAP[task.column],
}}
/>
</div>
<span className="list-progress-label">{getStepProgress(task.steps)}</span>
</div>
) : (
"-"
)}
</td>
)}
</tr>
);
})
)}
</>
</td>
)}
</tr>
);
})
)}
</Fragment>
);

View File

@@ -29,6 +29,7 @@ const SETTINGS_SECTIONS = [
{ id: "worktrees", label: "Worktrees" },
{ id: "commands", label: "Commands" },
{ id: "merge", label: "Merge" },
{ id: "notifications", label: "Notifications" },
{ id: "authentication", label: "Authentication" },
] as const;
@@ -488,6 +489,52 @@ export function SettingsModal({ onClose, addToast, initialSection }: SettingsMod
</div>
</>
);
case "notifications":
return (
<>
<h4 className="settings-section-heading">Notifications</h4>
<div className="form-group">
<label htmlFor="ntfyEnabled" className="checkbox-label">
<input
id="ntfyEnabled"
type="checkbox"
checked={form.ntfyEnabled || false}
onChange={(e) =>
setForm((f) => ({ ...f, ntfyEnabled: e.target.checked }))
}
/>
Enable ntfy.sh notifications
</label>
<small>Receive push notifications when tasks complete or fail via ntfy.sh</small>
</div>
{form.ntfyEnabled && (
<div className="form-group">
<label htmlFor="ntfyTopic">ntfy Topic</label>
<input
id="ntfyTopic"
type="text"
placeholder="my-topic-name"
value={form.ntfyTopic || ""}
onChange={(e) => {
const val = e.target.value;
setForm((f) => ({ ...f, ntfyTopic: val || undefined }));
}}
/>
<small>
Your ntfy.sh topic name (164 alphanumeric/hyphen/underscore characters).{" "}
<a href="https://ntfy.sh" target="_blank" rel="noopener noreferrer">
Learn more about ntfy.sh
</a>
</small>
{form.ntfyTopic && !/^[a-zA-Z0-9_-]{1,64}$/.test(form.ntfyTopic) && (
<small className="field-error">
Topic must be 164 alphanumeric, hyphen, or underscore characters
</small>
)}
</div>
)}
</>
);
case "authentication":
return (
<>

View File

@@ -370,6 +370,9 @@ export function TaskCard({
</button>
)}
</div>
<div className="card-title">
{task.title || (task.description ? task.description.slice(0, 60) + (task.description.length > 60 ? "…" : "") : task.id)}
</div>
{task.steps.length > 0 && (() => {
const completedSteps = task.steps.filter(s => s.status === "done").length;
const totalSteps = task.steps.length;
@@ -421,9 +424,6 @@ export function TaskCard({
</>
);
})()}
<div className="card-title">
{task.title || (task.description ? task.description.slice(0, 60) + (task.description.length > 60 ? "…" : "") : task.id)}
</div>
{((task.dependencies && task.dependencies.length > 0) || queued || task.status === "queued" || task.blockedBy) && (
<div className="card-meta">
{task.dependencies && task.dependencies.length > 0 && (

View File

@@ -667,6 +667,185 @@ describe("ListView", () => {
});
});
describe("ListView Column Filtering", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("filters tasks by column when drop zone is clicked", () => {
const tasks = [
createMockTask({ id: "KB-001", column: "triage", title: "Triage Task" }),
createMockTask({ id: "KB-002", column: "todo", title: "Todo Task" }),
createMockTask({ id: "KB-003", column: "in-progress", title: "In Progress Task" }),
];
renderListView({ tasks });
// Click on the triage drop zone
const triageZone = document.querySelector('[data-column="triage"].list-drop-zone')!;
fireEvent.click(triageZone);
// Only triage task should be visible
expect(screen.getByText("KB-001")).toBeDefined();
expect(screen.queryByText("KB-002")).toBeNull();
expect(screen.queryByText("KB-003")).toBeNull();
// Only triage section header should be visible
const sectionHeaders = screen.getAllByRole("row").filter(r => r.className.includes("list-section-header"));
expect(sectionHeaders.length).toBe(1);
expect(sectionHeaders[0].textContent).toContain("Triage");
});
it("clears column filter when same drop zone is clicked again", () => {
const tasks = [
createMockTask({ id: "KB-001", column: "triage", title: "Triage Task" }),
createMockTask({ id: "KB-002", column: "todo", title: "Todo Task" }),
];
renderListView({ tasks });
// Click on the triage drop zone to filter
const triageZone = document.querySelector('[data-column="triage"].list-drop-zone')!;
fireEvent.click(triageZone);
// Verify filter is active - only triage task visible
expect(screen.getByText("KB-001")).toBeDefined();
expect(screen.queryByText("KB-002")).toBeNull();
// Click the same drop zone again to clear filter
fireEvent.click(triageZone);
// All tasks should be visible again
expect(screen.getByText("KB-001")).toBeDefined();
expect(screen.getByText("KB-002")).toBeDefined();
// All 5 section headers should be visible
const sectionHeaders = screen.getAllByRole("row").filter(r => r.className.includes("list-section-header"));
expect(sectionHeaders.length).toBe(5);
});
it("switches column filter when different drop zone is clicked", () => {
const tasks = [
createMockTask({ id: "KB-001", column: "triage", title: "Triage Task" }),
createMockTask({ id: "KB-002", column: "todo", title: "Todo Task" }),
];
renderListView({ tasks });
// Click on the triage drop zone to filter
const triageZone = document.querySelector('[data-column="triage"].list-drop-zone')!;
fireEvent.click(triageZone);
// Verify only triage task visible
expect(screen.getByText("KB-001")).toBeDefined();
expect(screen.queryByText("KB-002")).toBeNull();
// Click on the todo drop zone to switch filter
const todoZone = document.querySelector('[data-column="todo"].list-drop-zone')!;
fireEvent.click(todoZone);
// Only todo task should be visible now
expect(screen.queryByText("KB-001")).toBeNull();
expect(screen.getByText("KB-002")).toBeDefined();
// Only todo section header should be visible
const sectionHeaders = screen.getAllByRole("row").filter(r => r.className.includes("list-section-header"));
expect(sectionHeaders.length).toBe(1);
expect(sectionHeaders[0].textContent).toContain("Todo");
});
it("clears column filter when clear button is clicked", () => {
const tasks = [
createMockTask({ id: "KB-001", column: "triage", title: "Triage Task" }),
createMockTask({ id: "KB-002", column: "todo", title: "Todo Task" }),
];
renderListView({ tasks });
// Click on the triage drop zone to filter
const triageZone = document.querySelector('[data-column="triage"].list-drop-zone')!;
fireEvent.click(triageZone);
// Verify filter is active
expect(screen.queryByText("KB-002")).toBeNull();
// Click the clear button
const clearButton = screen.getByRole("button", { name: /clear column filter/i });
fireEvent.click(clearButton);
// All tasks should be visible again
expect(screen.getByText("KB-001")).toBeDefined();
expect(screen.getByText("KB-002")).toBeDefined();
});
it("shows correct filtered stats when column filter is active", () => {
const tasks = [
createMockTask({ id: "KB-001", column: "triage", title: "Triage Task" }),
createMockTask({ id: "KB-002", column: "triage", title: "Triage Task 2" }),
createMockTask({ id: "KB-003", column: "todo", title: "Todo Task" }),
];
renderListView({ tasks });
// Click on the triage drop zone to filter
const triageZone = document.querySelector('[data-column="triage"].list-drop-zone')!;
fireEvent.click(triageZone);
// Stats should show filtered count with column name
expect(screen.getByText("2 of 3 tasks in Triage")).toBeDefined();
});
it("applies text filter within column filter", () => {
const tasks = [
createMockTask({ id: "KB-001", column: "triage", title: "Alpha Triage Task" }),
createMockTask({ id: "KB-002", column: "triage", title: "Beta Triage Task" }),
createMockTask({ id: "KB-003", column: "todo", title: "Alpha Todo Task" }),
];
renderListView({ tasks });
// Click on the triage drop zone to filter by column
const triageZone = document.querySelector('[data-column="triage"].list-drop-zone')!;
fireEvent.click(triageZone);
// Both triage tasks should be visible
expect(screen.getByText("KB-001")).toBeDefined();
expect(screen.getByText("KB-002")).toBeDefined();
expect(screen.queryByText("KB-003")).toBeNull();
// Apply text filter within the triage column
const filterInput = screen.getByPlaceholderText("Filter by ID or title...");
fireEvent.change(filterInput, { target: { value: "Alpha" } });
// Only Alpha triage task should be visible
expect(screen.getByText("KB-001")).toBeDefined();
expect(screen.queryByText("KB-002")).toBeNull();
expect(screen.queryByText("KB-003")).toBeNull();
// Stats should reflect combined filtering
expect(screen.getByText("1 of 3 tasks in Triage")).toBeDefined();
});
it("applies active class to selected column drop zone", () => {
const tasks = [
createMockTask({ id: "KB-001", column: "triage", title: "Triage Task" }),
];
renderListView({ tasks });
// Click on the triage drop zone
const triageZone = document.querySelector('[data-column="triage"].list-drop-zone')!;
fireEvent.click(triageZone);
// Should have active class
expect(triageZone.classList.contains("active")).toBe(true);
// Other drop zones should not have active class
const todoZone = document.querySelector('[data-column="todo"].list-drop-zone')!;
expect(todoZone.classList.contains("active")).toBe(false);
});
});
describe("ListView Column Visibility", () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -854,264 +1033,259 @@ describe("ListView Column Visibility", () => {
});
});
describe("ListView Collapsible Sections", () => {
describe("ListView Hide Done Tasks", () => {
beforeEach(() => {
vi.clearAllMocks();
// Clear localStorage before each test
localStorage.clear();
});
it("collapses and expands section when header is clicked", () => {
it("renders hide done tasks toggle button", () => {
renderListView();
const hideDoneButton = screen.getByRole("button", { name: /hide done/i });
expect(hideDoneButton).toBeDefined();
});
it("hides done tasks when toggle is activated", () => {
const tasks = [
createMockTask({ id: "KB-001", title: "Task 1", column: "triage" }),
createMockTask({ id: "KB-002", title: "Task 2", column: "triage" }),
createMockTask({ id: "KB-001", column: "done" }),
createMockTask({ id: "KB-002", column: "triage" }),
];
renderListView({ tasks });
// Initially, task should be visible
// Both tasks should be visible initially
expect(screen.getByText("KB-001")).toBeDefined();
expect(screen.getByText("KB-002")).toBeDefined();
// Find and click the triage section header
const sectionHeaders = screen.getAllByRole("row").filter(r => r.className.includes("list-section-header"));
const triageHeader = sectionHeaders.find(h => h.textContent?.includes("Triage"));
expect(triageHeader).toBeDefined();
fireEvent.click(triageHeader!);
// Click hide done button
const hideDoneButton = screen.getByRole("button", { name: /hide done/i });
fireEvent.click(hideDoneButton);
// After collapse, tasks should be hidden but header still visible
// Done task should be hidden, triage task should still be visible
expect(screen.queryByText("KB-001")).toBeNull();
expect(screen.queryByText("KB-002")).toBeNull();
expect(triageHeader).toBeDefined();
// Click header again to expand
fireEvent.click(triageHeader!);
// Tasks should be visible again
expect(screen.getByText("KB-001")).toBeDefined();
expect(screen.getByText("KB-002")).toBeDefined();
});
it("persists section expansion state to localStorage", () => {
it("shows done tasks when toggle is deactivated", () => {
const tasks = [
createMockTask({ id: "KB-001", title: "Task 1", column: "triage" }),
createMockTask({ id: "KB-001", column: "done" }),
createMockTask({ id: "KB-002", column: "triage" }),
];
renderListView({ tasks });
// Collapse the triage section
const sectionHeaders = screen.getAllByRole("row").filter(r => r.className.includes("list-section-header"));
const triageHeader = sectionHeaders.find(h => h.textContent?.includes("Triage"));
fireEvent.click(triageHeader!);
// Click hide done button to hide done tasks
const hideDoneButton = screen.getByRole("button", { name: /hide done/i });
fireEvent.click(hideDoneButton);
// Verify localStorage was updated with only expanded columns
const saved = localStorage.getItem("kb-dashboard-list-sections");
expect(saved).toBeTruthy();
const parsed = JSON.parse(saved!) as string[];
expect(parsed).not.toContain("triage");
// Other columns should still be expanded
expect(parsed).toContain("todo");
expect(parsed).toContain("in-progress");
expect(parsed).toContain("in-review");
expect(parsed).toContain("done");
});
it("restores section expansion state from localStorage on mount", () => {
// Set up localStorage with only todo expanded (triage collapsed)
localStorage.setItem("kb-dashboard-list-sections", JSON.stringify(["todo", "in-progress", "in-review", "done"]));
const tasks = [
createMockTask({ id: "KB-001", title: "Triage Task", column: "triage" }),
createMockTask({ id: "KB-002", title: "Todo Task", column: "todo" }),
];
renderListView({ tasks });
// Triage task should be hidden (collapsed)
// Done task should be hidden
expect(screen.queryByText("KB-001")).toBeNull();
// Todo task should be visible (expanded)
expect(screen.getByText("KB-002")).toBeDefined();
// Click again to show done tasks
fireEvent.click(hideDoneButton);
// Triage header should still be visible with collapsed styling
const triageHeader = screen.getAllByRole("row")
.find(r => r.className.includes("list-section-header") && r.textContent?.includes("Triage"));
expect(triageHeader).toBeDefined();
expect(triageHeader?.className).toContain("list-section-header--collapsed");
// Both tasks should be visible again
expect(screen.getByText("KB-001")).toBeDefined();
expect(screen.getByText("KB-002")).toBeDefined();
});
it("expand all button expands all collapsed sections", () => {
const tasks = [
createMockTask({ id: "KB-001", title: "Triage Task", column: "triage" }),
createMockTask({ id: "KB-002", title: "Todo Task", column: "todo" }),
];
it("persists hide done preference to localStorage", () => {
const tasks = [createMockTask({ id: "KB-001", column: "done" })];
renderListView({ tasks });
// Collapse triage section
const sectionHeaders = screen.getAllByRole("row").filter(r => r.className.includes("list-section-header"));
const triageHeader = sectionHeaders.find(h => h.textContent?.includes("Triage"));
fireEvent.click(triageHeader!);
// Click hide done button
const hideDoneButton = screen.getByRole("button", { name: /hide done/i });
fireEvent.click(hideDoneButton);
// Verify triage task is hidden
// Verify localStorage was updated
expect(localStorage.getItem("kb-dashboard-hide-done")).toBe("true");
});
it("initializes hide done state from localStorage", () => {
// Set up localStorage with hide done enabled
localStorage.setItem("kb-dashboard-hide-done", "true");
const tasks = [
createMockTask({ id: "KB-001", column: "done" }),
createMockTask({ id: "KB-002", column: "triage" }),
];
renderListView({ tasks });
// Button should show "Show Done" text since done tasks are hidden
expect(screen.getByRole("button", { name: /show done/i })).toBeDefined();
// Done task should be hidden initially
expect(screen.queryByText("KB-001")).toBeNull();
// Click Expand All button
const expandAllButton = screen.getByText("Expand All");
fireEvent.click(expandAllButton);
// All tasks should be visible now
expect(screen.getByText("KB-001")).toBeDefined();
expect(screen.getByText("KB-002")).toBeDefined();
// Verify localStorage has all columns
const saved = localStorage.getItem("kb-dashboard-list-sections");
const parsed = JSON.parse(saved!) as string[];
expect(parsed).toContain("triage");
expect(parsed).toContain("todo");
});
it("collapse all button collapses all expanded sections", () => {
it("updates stats text when done tasks are hidden", () => {
const tasks = [
createMockTask({ id: "KB-001", title: "Triage Task", column: "triage" }),
createMockTask({ id: "KB-002", title: "Todo Task", column: "todo" }),
createMockTask({ id: "KB-001", column: "done" }),
createMockTask({ id: "KB-002", column: "triage" }),
createMockTask({ id: "KB-003", column: "done" }),
];
renderListView({ tasks });
// Initially tasks should be visible
expect(screen.getByText("KB-001")).toBeDefined();
expect(screen.getByText("KB-002")).toBeDefined();
// Initial stats should show all tasks
expect(screen.getByText("3 of 3 tasks")).toBeDefined();
// Click Collapse All button
const collapseAllButton = screen.getByText("Collapse All");
fireEvent.click(collapseAllButton);
// Click hide done button
const hideDoneButton = screen.getByRole("button", { name: /hide done/i });
fireEvent.click(hideDoneButton);
// All tasks should be hidden now
// Stats should show filtered count with hidden indicator
expect(screen.getByText("1 of 3 tasks")).toBeDefined();
expect(screen.getByText(/2 done hidden/)).toBeDefined();
});
it("hides done column section header when hide done is active", () => {
const tasks = [
createMockTask({ id: "KB-001", column: "done" }),
createMockTask({ id: "KB-002", column: "triage" }),
];
renderListView({ tasks });
// All section headers should be visible initially
const sectionHeadersBefore = screen.getAllByRole("row").filter(r => r.className.includes("list-section-header"));
expect(sectionHeadersBefore.length).toBe(5); // All 5 columns
// Click hide done button
const hideDoneButton = screen.getByRole("button", { name: /hide done/i });
fireEvent.click(hideDoneButton);
// Done section should be hidden - find section headers and verify done is not present
const doneSection = screen.getAllByRole("row").find(r =>
r.className.includes("list-section-header") && r.textContent?.includes("Done")
);
expect(doneSection).toBeUndefined();
});
it("shows done drop zone with count when hide done is active", () => {
const tasks = [
createMockTask({ id: "KB-001", column: "done" }),
createMockTask({ id: "KB-002", column: "done" }),
];
renderListView({ tasks });
// Click hide done button
const hideDoneButton = screen.getByRole("button", { name: /hide done/i });
fireEvent.click(hideDoneButton);
// Done drop zone should still be visible with "X of Y" format
const doneZone = document.querySelector('[data-column="done"].list-drop-zone');
expect(doneZone).toBeDefined();
expect(doneZone?.textContent).toContain("0 of 2");
});
it("preserves hide done state through filter changes", () => {
const tasks = [
createMockTask({ id: "KB-001", column: "done", title: "Alpha" }),
createMockTask({ id: "KB-002", column: "triage", title: "Beta" }),
];
renderListView({ tasks });
// Hide done tasks
const hideDoneButton = screen.getByRole("button", { name: /hide done/i });
fireEvent.click(hideDoneButton);
// Apply filter
const filterInput = screen.getByPlaceholderText("Filter by ID or title...");
fireEvent.change(filterInput, { target: { value: "Beta" } });
// Done task should remain hidden
expect(screen.queryByText("KB-001")).toBeNull();
expect(screen.queryByText("KB-002")).toBeNull();
// Section headers should still be visible
const sectionHeaders = screen.getAllByRole("row").filter(r => r.className.includes("list-section-header"));
expect(sectionHeaders.length).toBe(5); // All 5 columns
// Verify localStorage is empty (no expanded sections)
const saved = localStorage.getItem("kb-dashboard-list-sections");
const parsed = JSON.parse(saved!) as string[];
expect(parsed.length).toBe(0);
});
it("collapsed sections hide task rows but keep header visible", () => {
const tasks = [
createMockTask({ id: "KB-001", title: "Task 1", column: "triage" }),
];
renderListView({ tasks });
// Get the triage section header
const sectionHeaders = screen.getAllByRole("row").filter(r => r.className.includes("list-section-header"));
const triageHeader = sectionHeaders.find(h => h.textContent?.includes("Triage"));
expect(triageHeader).toBeDefined();
// Verify task is visible before collapse
expect(screen.getByText("KB-001")).toBeDefined();
// Collapse the section
fireEvent.click(triageHeader!);
// Header should still be visible
expect(triageHeader).toBeDefined();
// But with collapsed class
expect(triageHeader?.className).toContain("list-section-header--collapsed");
// Task should be hidden
expect(screen.queryByText("KB-001")).toBeNull();
// Count badge should still show "1 task"
expect(triageHeader?.textContent).toContain("1 task");
});
it("drag and drop still works in expanded sections", () => {
const tasks = [createMockTask({ id: "KB-001", column: "triage" })];
const mockOnMoveTask = vi.fn(() => Promise.resolve(tasks[0]));
renderListView({ tasks, onMoveTask: mockOnMoveTask });
// Find the task row
const row = screen.getByText("KB-001").closest("tr")!;
// Simulate drag start
fireEvent.dragStart(row, {
dataTransfer: {
setData: vi.fn(),
effectAllowed: "move",
},
});
// Simulate drop on todo column drop zone
const todoZone = document.querySelector('[data-column="todo"].list-drop-zone')!;
fireEvent.drop(todoZone, {
preventDefault: vi.fn(),
dataTransfer: {
getData: vi.fn(() => "KB-001"),
},
});
// Verify onMoveTask was called
expect(mockOnMoveTask).toHaveBeenCalledWith("KB-001", "todo");
});
it("shows No tasks placeholder for empty expanded sections", () => {
// Create tasks only in triage, so other columns show "No tasks" placeholders
const tasks = [createMockTask({ id: "KB-001", column: "triage" })];
renderListView({ tasks });
// Should see 4 "No tasks" placeholders for empty columns (todo, in-progress, in-review, done)
const noTasksCells = screen.getAllByText("No tasks");
expect(noTasksCells.length).toBe(4);
});
it("hides No tasks placeholder when section is collapsed", () => {
// Create tasks only in triage
const tasks = [createMockTask({ id: "KB-001", column: "triage" })];
renderListView({ tasks });
// Initially 4 sections show "No tasks" (todo, in-progress, in-review, done)
let noTasksCells = screen.getAllByText("No tasks");
expect(noTasksCells.length).toBe(4);
// Collapse the todo section (which has "No tasks" placeholder)
const sectionHeaders = screen.getAllByRole("row").filter(r => r.className.includes("list-section-header"));
const todoHeader = sectionHeaders.find(h => h.textContent?.includes("Todo"));
fireEvent.click(todoHeader!);
// Now should only show 3 "No tasks" placeholders (todo is collapsed)
noTasksCells = screen.getAllByText("No tasks");
expect(noTasksCells.length).toBe(3);
});
it("all sections expanded by default when no localStorage", () => {
const tasks = [
createMockTask({ id: "KB-001", column: "triage" }),
createMockTask({ id: "KB-002", column: "todo" }),
];
renderListView({ tasks });
// All tasks should be visible by default
expect(screen.getByText("KB-001")).toBeDefined();
// Filtered task should be visible
expect(screen.getByText("KB-002")).toBeDefined();
// Verify localStorage was initialized with all columns
const saved = localStorage.getItem("kb-dashboard-list-sections");
expect(saved).toBeTruthy();
const parsed = JSON.parse(saved!) as string[];
expect(parsed).toContain("triage");
expect(parsed).toContain("todo");
expect(parsed).toContain("in-progress");
expect(parsed).toContain("in-review");
expect(parsed).toContain("done");
});
});
describe("ListView Inline Create Card", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("shows InlineCreateCard when isCreating is true", () => {
renderListView({ isCreating: true, onCancelCreate: vi.fn(), onCreateTask: vi.fn() });
// The inline creation card should be visible with its textarea
expect(screen.getByPlaceholderText("What needs to be done?")).toBeDefined();
});
it("does not show InlineCreateCard when isCreating is false", () => {
renderListView({ isCreating: false, onCancelCreate: vi.fn(), onCreateTask: vi.fn() });
// The inline creation card should not be visible
expect(screen.queryByPlaceholderText("What needs to be done?")).toBeNull();
});
it("does not show InlineCreateCard when onCancelCreate is not provided", () => {
renderListView({ isCreating: true, onCreateTask: vi.fn() });
// The inline creation card should not be visible without onCancelCreate
expect(screen.queryByPlaceholderText("What needs to be done?")).toBeNull();
});
it("does not show InlineCreateCard when onCreateTask is not provided", () => {
renderListView({ isCreating: true, onCancelCreate: vi.fn() });
// The inline creation card should not be visible without onCreateTask
expect(screen.queryByPlaceholderText("What needs to be done?")).toBeNull();
});
it("calls onCreateTask with triage column when task is submitted from inline card", async () => {
const mockOnCreateTask = vi.fn().mockResolvedValue(createMockTask({ id: "KB-002" }));
renderListView({ isCreating: true, onCancelCreate: vi.fn(), onCreateTask: mockOnCreateTask });
const textarea = screen.getByPlaceholderText("What needs to be done?");
fireEvent.change(textarea, { target: { value: "New task description" } });
fireEvent.keyDown(textarea, { key: "Enter" });
await waitFor(() => {
expect(mockOnCreateTask).toHaveBeenCalledWith({
description: "New task description",
column: "triage",
});
});
});
it("calls onCancelCreate when inline card is cancelled via blur", () => {
const mockOnCancelCreate = vi.fn();
renderListView({ isCreating: true, onCancelCreate: mockOnCancelCreate, onCreateTask: vi.fn() });
const textarea = screen.getByPlaceholderText("What needs to be done?");
textarea.focus();
fireEvent.focusOut(textarea, { relatedTarget: null });
expect(mockOnCancelCreate).toHaveBeenCalledTimes(1);
});
it("calls onCancelCreate when inline card is cancelled via Escape key", () => {
const mockOnCancelCreate = vi.fn();
renderListView({ isCreating: true, onCancelCreate: mockOnCancelCreate, onCreateTask: vi.fn() });
const textarea = screen.getByPlaceholderText("What needs to be done?");
fireEvent.keyDown(textarea, { key: "Escape" });
expect(mockOnCancelCreate).toHaveBeenCalledTimes(1);
});
it("renders InlineCreateCard in triage section with correct colSpan", () => {
renderListView({ isCreating: true, onCancelCreate: vi.fn(), onCreateTask: vi.fn() });
// Find the inline create row
const inlineCreateRow = document.querySelector(".list-inline-create-row");
expect(inlineCreateRow).toBeTruthy();
// Check that the cell has the correct colSpan (8 columns by default)
const inlineCreateCell = document.querySelector(".list-inline-create-cell");
expect(inlineCreateCell).toBeTruthy();
expect(inlineCreateCell?.getAttribute("colspan")).toBe("8");
});
});

View File

@@ -15,6 +15,8 @@ const defaultSettings: Settings = {
buildCommand: "",
autoResolveConflicts: true,
smartConflictResolution: true,
ntfyEnabled: false,
ntfyTopic: undefined,
};
vi.mock("../../api", () => ({
@@ -681,17 +683,17 @@ describe("SettingsModal", () => {
expect(layout!.querySelector(".settings-content")).toBeTruthy();
});
it("has .settings-sidebar with 7 .settings-nav-item buttons for all sections", async () => {
it("has .settings-sidebar with 8 .settings-nav-item buttons for all sections", async () => {
const { container } = render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
const sidebar = container.querySelector(".settings-sidebar");
expect(sidebar).toBeTruthy();
const navItems = sidebar!.querySelectorAll(".settings-nav-item");
expect(navItems.length).toBe(7);
expect(navItems.length).toBe(8);
const labels = Array.from(navItems).map((el) => el.textContent);
expect(labels).toEqual(["General", "Model", "Scheduling", "Worktrees", "Commands", "Merge", "Authentication"]);
expect(labels).toEqual(["General", "Model", "Scheduling", "Worktrees", "Commands", "Merge", "Notifications", "Authentication"]);
});
it("has .settings-content as sibling of .settings-sidebar", async () => {
@@ -744,4 +746,136 @@ describe("SettingsModal", () => {
expect(row.querySelector("button")).toBeTruthy();
}
});
// --- Notifications section tests ---
it("shows Notifications in sidebar", async () => {
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
expect(screen.getAllByText("Notifications").length).toBeGreaterThanOrEqual(1);
});
it("shows ntfy enable checkbox in Notifications section", async () => {
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
fireEvent.click(screen.getByText("Notifications"));
const checkbox = screen.getByLabelText("Enable ntfy.sh notifications");
expect(checkbox).toBeTruthy();
expect(checkbox.getAttribute("type")).toBe("checkbox");
});
it("ntfy topic input is hidden when ntfy is disabled", async () => {
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
fireEvent.click(screen.getByText("Notifications"));
expect(screen.queryByLabelText("ntfy Topic")).toBeNull();
});
it("ntfy topic input is visible when ntfy is enabled", async () => {
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
fireEvent.click(screen.getByText("Notifications"));
const checkbox = screen.getByLabelText("Enable ntfy.sh notifications");
fireEvent.click(checkbox);
expect(screen.getByLabelText("ntfy Topic")).toBeTruthy();
});
it("toggling ntfyEnabled checkbox sends true in save payload", async () => {
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
fireEvent.click(screen.getByText("Notifications"));
const checkbox = screen.getByLabelText("Enable ntfy.sh notifications");
fireEvent.click(checkbox);
fireEvent.click(screen.getByText("Save"));
await waitFor(() => expect(updateSettings).toHaveBeenCalledTimes(1));
const payload = (updateSettings as ReturnType<typeof vi.fn>).mock.calls[0][0];
expect(payload.ntfyEnabled).toBe(true);
});
it("ntfy topic field saves correctly when set", async () => {
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
fireEvent.click(screen.getByText("Notifications"));
const checkbox = screen.getByLabelText("Enable ntfy.sh notifications");
fireEvent.click(checkbox);
const input = screen.getByLabelText("ntfy Topic") as HTMLInputElement;
fireEvent.change(input, { target: { value: "my-topic" } });
expect(input.value).toBe("my-topic");
fireEvent.click(screen.getByText("Save"));
await waitFor(() => expect(updateSettings).toHaveBeenCalledTimes(1));
const payload = (updateSettings as ReturnType<typeof vi.fn>).mock.calls[0][0];
expect(payload.ntfyTopic).toBe("my-topic");
});
it("ntfy topic field submits undefined when empty", async () => {
(fetchSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
...defaultSettings,
ntfyEnabled: true,
ntfyTopic: "existing-topic",
});
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
fireEvent.click(screen.getByText("Notifications"));
const input = screen.getByLabelText("ntfy Topic") as HTMLInputElement;
fireEvent.change(input, { target: { value: "" } });
fireEvent.click(screen.getByText("Save"));
await waitFor(() => expect(updateSettings).toHaveBeenCalledTimes(1));
const payload = (updateSettings as ReturnType<typeof vi.fn>).mock.calls[0][0];
expect(payload.ntfyTopic).toBeUndefined();
});
it("ntfy topic shows validation error for invalid input", async () => {
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
fireEvent.click(screen.getByText("Notifications"));
const checkbox = screen.getByLabelText("Enable ntfy.sh notifications");
fireEvent.click(checkbox);
const input = screen.getByLabelText("ntfy Topic") as HTMLInputElement;
fireEvent.change(input, { target: { value: "invalid topic with spaces!" } });
expect(screen.getByText("Topic must be 164 alphanumeric, hyphen, or underscore characters")).toBeTruthy();
});
it("ntfyEnabled defaults to false when setting is undefined", async () => {
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
fireEvent.click(screen.getByText("Notifications"));
const checkbox = screen.getByLabelText("Enable ntfy.sh notifications") as HTMLInputElement;
expect(checkbox.checked).toBe(false);
});
it("ntfyEnabled shows correct state when enabled in settings", async () => {
(fetchSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
...defaultSettings,
ntfyEnabled: true,
ntfyTopic: "my-topic",
});
render(<SettingsModal onClose={onClose} addToast={addToast} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
fireEvent.click(screen.getByText("Notifications"));
const checkbox = screen.getByLabelText("Enable ntfy.sh notifications") as HTMLInputElement;
expect(checkbox.checked).toBe(true);
expect(screen.getByLabelText("ntfy Topic")).toBeTruthy();
});
});

View File

@@ -2306,6 +2306,11 @@ body {
white-space: nowrap;
}
.list-stats-hidden {
color: var(--text-dim);
font-style: italic;
}
/* Column toggle dropdown */
.list-column-toggle {
position: relative;
@@ -2378,6 +2383,13 @@ body {
cursor: not-allowed;
}
/* Hide done tasks toggle */
.list-hide-done-toggle {
display: flex;
align-items: center;
gap: 6px;
}
/* Drop zones for drag and drop */
.list-drop-zones {
display: flex;
@@ -2406,6 +2418,12 @@ body {
border-color: var(--text-muted);
}
.list-drop-zone.active {
border-color: var(--todo);
background: rgba(88, 166, 255, 0.15);
box-shadow: 0 0 0 1px var(--todo);
}
.list-drop-zone.drag-over {
border-color: var(--todo);
box-shadow: 0 0 0 1px var(--todo);