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:
@@ -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/);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user