feat(FN-1842): add Documents view with search and grouping

- Add GET /documents API endpoint in dashboard routes
- Add getAllDocuments store method with search support in core
- Add useDocuments hook and API wrapper for frontend consumption
- Add DocumentsView component with search, grouping by type/status, and sortable columns
- Add Documents nav item to Header and MobileNavBar with route /documents
- Integrate DocumentsView into App routing
- Add tests for DocumentsView and useDocuments hook
- Add changeset for @gsxdsm/fusion
This commit is contained in:
Fusion
2026-04-16 08:52:01 -07:00
committed by gsxdsm
parent 3f412e9aa3
commit e0f1ec5bd7
16 changed files with 1400 additions and 10 deletions

View File

@@ -284,4 +284,126 @@ describe("TaskStore task documents", () => {
).rejects.toThrow(/Invalid document key/);
}
});
describe("getAllDocuments", () => {
it("returns empty array when no documents exist", async () => {
const results = await store.getAllDocuments();
expect(results).toEqual([]);
});
it("returns documents across multiple tasks with task metadata", async () => {
const task1 = await store.createTask({ description: "Task One for getAllDocuments" });
const task2 = await store.createTask({ description: "Task Two for getAllDocuments" });
await store.upsertTaskDocument(task1.id, { key: "plan", content: "Plan for task 1" });
await store.upsertTaskDocument(task1.id, { key: "notes", content: "Notes for task 1" });
await store.upsertTaskDocument(task2.id, { key: "research", content: "Research for task 2" });
const results = await store.getAllDocuments();
expect(results).toHaveLength(3);
const task1Docs = results.filter((d) => d.taskId === task1.id);
expect(task1Docs).toHaveLength(2);
expect(task1Docs[0].taskTitle).toBeDefined();
expect(task1Docs[0].taskColumn).toBe("triage");
const task2Docs = results.filter((d) => d.taskId === task2.id);
expect(task2Docs).toHaveLength(1);
expect(task2Docs[0].key).toBe("research");
expect(task2Docs[0].content).toBe("Research for task 2");
expect(task2Docs[0].taskTitle).toBeDefined();
});
it("filters by search query matching document key", async () => {
const task = await store.createTask({ description: "Search key test task" });
await store.upsertTaskDocument(task.id, { key: "plan", content: "Some content" });
await store.upsertTaskDocument(task.id, { key: "notes", content: "Other content" });
const results = await store.getAllDocuments({ searchQuery: "plan" });
expect(results).toHaveLength(1);
expect(results[0].key).toBe("plan");
});
it("filters by search query matching document content", async () => {
const task = await store.createTask({ description: "Search content test task" });
await store.upsertTaskDocument(task.id, { key: "doc-a", content: "Alpha content here" });
await store.upsertTaskDocument(task.id, { key: "doc-b", content: "Beta content here" });
const results = await store.getAllDocuments({ searchQuery: "Alpha" });
expect(results).toHaveLength(1);
expect(results[0].key).toBe("doc-a");
});
it("filters by search query matching task title", async () => {
const task = await store.createTask({ title: "Unique task title for search 12345", description: "Some description" });
await store.upsertTaskDocument(task.id, { key: "plan", content: "Content" });
const results = await store.getAllDocuments({ searchQuery: "12345" });
expect(results).toHaveLength(1);
expect(results[0].taskId).toBe(task.id);
});
it("respects limit parameter", async () => {
const task = await store.createTask({ description: "Limit test task" });
await store.upsertTaskDocument(task.id, { key: "doc-1", content: "Content 1" });
await store.upsertTaskDocument(task.id, { key: "doc-2", content: "Content 2" });
await store.upsertTaskDocument(task.id, { key: "doc-3", content: "Content 3" });
const results = await store.getAllDocuments({ limit: 2 });
expect(results).toHaveLength(2);
});
it("respects offset parameter", async () => {
const task = await store.createTask({ description: "Offset test task" });
await store.upsertTaskDocument(task.id, { key: "doc-1", content: "Content 1" });
await store.upsertTaskDocument(task.id, { key: "doc-2", content: "Content 2" });
await store.upsertTaskDocument(task.id, { key: "doc-3", content: "Content 3" });
const allResults = await store.getAllDocuments();
const offsetResults = await store.getAllDocuments({ offset: 1 });
expect(offsetResults).toHaveLength(allResults.length - 1);
expect(offsetResults[0].key).toBe(allResults[1].key);
});
it("caps limit at 1000", async () => {
const task = await store.createTask({ description: "Cap limit test task" });
await store.upsertTaskDocument(task.id, { key: "plan", content: "Content" });
const results = await store.getAllDocuments({ limit: 9999 });
expect(results).toHaveLength(1);
// Verify it didn't error - SQLite would error on LIMIT 9999
// We check the actual limit by looking at the query result
});
it("orders by updatedAt descending", async () => {
const task = await store.createTask({ description: "Order test task" });
await store.upsertTaskDocument(task.id, { key: "first", content: "First doc" });
await sleep(10);
await store.upsertTaskDocument(task.id, { key: "second", content: "Second doc" });
await sleep(10);
await store.upsertTaskDocument(task.id, { key: "third", content: "Third doc" });
const results = await store.getAllDocuments();
expect(results[0].key).toBe("third");
expect(results[1].key).toBe("second");
expect(results[2].key).toBe("first");
});
it("combines search query with limit and offset", async () => {
const task = await store.createTask({ description: "Combined test task" });
await store.upsertTaskDocument(task.id, { key: "plan", content: "Alpha content" });
await store.upsertTaskDocument(task.id, { key: "notes", content: "Beta content" });
await store.upsertTaskDocument(task.id, { key: "research", content: "Gamma content" });
const results = await store.getAllDocuments({ searchQuery: "content", limit: 2, offset: 0 });
expect(results).toHaveLength(2);
// All results should contain "content" in key or content
for (const doc of results) {
expect(doc.key + doc.content).toMatch(/content/);
}
});
});
});

View File

@@ -1,5 +1,5 @@
export { COLUMNS, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, isGlobalSettingsKey, isProjectSettingsKey, THINKING_LEVELS, THEME_MODES, COLOR_THEMES, WORKFLOW_STEP_TEMPLATES, AGENT_PERMISSIONS, agentToConfigSnapshot, diffConfigSnapshots, CheckoutConflictError } from "./types.js";
export type { Column, IssueInfo, IssueState, PrInfo, PrStatus, Task, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskCreateInput, TaskDetail, InboxTask, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, MergeDetails, MergeResult, Settings, GlobalSettings, ProjectSettings, SettingsScope, DaemonTokenSettings, TaskStep, StepStatus, TaskLogEntry, RunMutationContext, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, TaskAssignSource, AgentAccessState, AgentHeartbeatConfig, AgentBudgetConfig, AgentBudgetStatus, InstructionsBundleConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, BlockedStateSnapshot, HeartbeatInvocationSource, AgentTaskSession, AgentRating, AgentRatingSummary, AgentRatingInput, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, ReflectionTrigger, ReflectionMetrics, AgentReflection, AgentPerformanceSummary, NtfyNotificationEvent, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, Mailbox, CheckoutLease, RunAuditDomain, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter } from "./types.js";
export type { Column, IssueInfo, IssueState, PrInfo, PrStatus, Task, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, TaskCreateInput, TaskDetail, InboxTask, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, MergeDetails, MergeResult, Settings, GlobalSettings, ProjectSettings, SettingsScope, DaemonTokenSettings, TaskStep, StepStatus, TaskLogEntry, RunMutationContext, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, TaskAssignSource, AgentAccessState, AgentHeartbeatConfig, AgentBudgetConfig, AgentBudgetStatus, InstructionsBundleConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, BlockedStateSnapshot, HeartbeatInvocationSource, AgentTaskSession, AgentRating, AgentRatingSummary, AgentRatingInput, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, ReflectionTrigger, ReflectionMetrics, AgentReflection, AgentPerformanceSummary, NtfyNotificationEvent, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, Mailbox, CheckoutLease, RunAuditDomain, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter } from "./types.js";
export { AGENT_VALID_TRANSITIONS } from "./types.js";
export {
BUILTIN_AGENT_PROMPTS,

View File

@@ -3,7 +3,7 @@ import { randomUUID } from "node:crypto";
import { appendFile, mkdir, open, readFile, writeFile, rename, unlink } from "node:fs/promises";
import { join } from "node:path";
import { existsSync, watch, type FSWatcher } from "node:fs";
import type { Task, TaskDetail, TaskCreateInput, TaskAttachment, AgentLogEntry, BoardConfig, Column, MergeResult, Settings, GlobalSettings, ProjectSettings, ActivityLogEntry, ActivityEventType, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, InboxTask, TaskLogEntry, RunMutationContext, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter } from "./types.js";
import type { Task, TaskDetail, TaskCreateInput, TaskAttachment, AgentLogEntry, BoardConfig, Column, MergeResult, Settings, GlobalSettings, ProjectSettings, ActivityLogEntry, ActivityEventType, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, InboxTask, TaskLogEntry, RunMutationContext, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter } from "./types.js";
import { VALID_TRANSITIONS, DEFAULT_SETTINGS, isGlobalSettingsKey, WORKFLOW_STEP_TEMPLATES, validateDocumentKey } from "./types.js";
import { GlobalSettingsStore } from "./global-settings.js";
import { Database, toJson, toJsonNullable, fromJson } from "./db.js";
@@ -3441,6 +3441,46 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
return rows.map((row) => this.rowToTaskDocument(row));
}
/**
* List all documents across all tasks, optionally filtered by search query.
* Each document includes its parent task's title and column for display.
*/
async getAllDocuments(options?: {
searchQuery?: string;
limit?: number;
offset?: number;
}): Promise<TaskDocumentWithTask[]> {
const limit = Math.min(Math.max(1, options?.limit ?? 200), 1000);
const offset = Math.max(0, options?.offset ?? 0);
let sql = `
SELECT td.*, t.title as taskTitle, t.description as taskDescription, t.column as taskColumn
FROM task_documents td
JOIN tasks t ON td.taskId = t.id
`;
const params: any[] = [];
if (options?.searchQuery && options.searchQuery.trim() !== "") {
const query = `%${options.searchQuery.trim()}%`;
sql += ` WHERE td.key LIKE ? OR td.content LIKE ? OR t.title LIKE ?`;
params.push(query, query, query);
}
sql += ` ORDER BY td.updatedAt DESC LIMIT ? OFFSET ?`;
params.push(limit, offset);
const rows = this.db.prepare(sql).all(...params) as any[];
return rows.map((row) => {
const doc = this.rowToTaskDocument(row);
return {
...doc,
taskTitle: row.taskTitle,
taskDescription: row.taskDescription,
taskColumn: row.taskColumn,
};
});
}
/**
* Get the current revision of a specific task document.
*/

View File

@@ -573,6 +573,18 @@ export interface TaskDocumentCreateInput {
metadata?: Record<string, unknown>;
}
/**
* TaskDocument extended with its parent task metadata for display in the documents view.
*/
export interface TaskDocumentWithTask extends TaskDocument {
/** Title of the parent task */
taskTitle?: string;
/** Description of the parent task */
taskDescription?: string;
/** Column of the parent task (e.g., "triage", "todo", "in-progress", "done", "in-review", "archived") */
taskColumn?: string;
}
export const DOCUMENT_KEY_RE = /^[a-zA-Z0-9_-]{1,64}$/;
export function validateDocumentKey(key: string): void {