@allpepper/task-orchestrator-tui 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +78 -0
- package/package.json +54 -0
- package/src/tui/app.tsx +308 -0
- package/src/tui/components/column-filter-bar.tsx +52 -0
- package/src/tui/components/confirm-dialog.tsx +45 -0
- package/src/tui/components/dependency-list.tsx +115 -0
- package/src/tui/components/empty-state.tsx +28 -0
- package/src/tui/components/entity-table.tsx +120 -0
- package/src/tui/components/error-message.tsx +41 -0
- package/src/tui/components/feature-kanban-card.tsx +216 -0
- package/src/tui/components/footer.tsx +34 -0
- package/src/tui/components/form-dialog.tsx +338 -0
- package/src/tui/components/header.tsx +54 -0
- package/src/tui/components/index.ts +16 -0
- package/src/tui/components/kanban-board.tsx +335 -0
- package/src/tui/components/kanban-card.tsx +70 -0
- package/src/tui/components/kanban-column.tsx +173 -0
- package/src/tui/components/priority-badge.tsx +16 -0
- package/src/tui/components/section-list.tsx +96 -0
- package/src/tui/components/status-actions.tsx +87 -0
- package/src/tui/components/status-badge.tsx +22 -0
- package/src/tui/components/tree-view.tsx +295 -0
- package/src/tui/components/view-mode-chips.tsx +23 -0
- package/src/tui/index.tsx +33 -0
- package/src/tui/screens/dashboard.tsx +248 -0
- package/src/tui/screens/feature-detail.tsx +312 -0
- package/src/tui/screens/index.ts +6 -0
- package/src/tui/screens/kanban-view.tsx +251 -0
- package/src/tui/screens/project-detail.tsx +305 -0
- package/src/tui/screens/project-view.tsx +498 -0
- package/src/tui/screens/search.tsx +257 -0
- package/src/tui/screens/task-detail.tsx +294 -0
- package/src/ui/adapters/direct.ts +429 -0
- package/src/ui/adapters/index.ts +14 -0
- package/src/ui/adapters/types.ts +269 -0
- package/src/ui/context/adapter-context.tsx +31 -0
- package/src/ui/context/theme-context.tsx +43 -0
- package/src/ui/hooks/index.ts +20 -0
- package/src/ui/hooks/use-data.ts +919 -0
- package/src/ui/hooks/use-debounce.ts +37 -0
- package/src/ui/hooks/use-feature-kanban.ts +151 -0
- package/src/ui/hooks/use-kanban.ts +96 -0
- package/src/ui/hooks/use-navigation.tsx +94 -0
- package/src/ui/index.ts +73 -0
- package/src/ui/lib/colors.ts +79 -0
- package/src/ui/lib/format.ts +114 -0
- package/src/ui/lib/types.ts +157 -0
- package/src/ui/themes/dark.ts +63 -0
- package/src/ui/themes/light.ts +63 -0
- package/src/ui/themes/types.ts +71 -0
|
@@ -0,0 +1,919 @@
|
|
|
1
|
+
import { useState, useEffect, useCallback, useMemo } from 'react';
|
|
2
|
+
import { useAdapter } from '../context/adapter-context';
|
|
3
|
+
import type { Project, Task, Section, EntityType, TaskStatus, Priority, FeatureStatus } from 'task-orchestrator-bun/src/domain/types';
|
|
4
|
+
import type { FeatureWithTasks, ProjectOverview, SearchResults, DependencyInfo, BoardCard, BoardTask } from '../lib/types';
|
|
5
|
+
import type { TreeRow } from '../../tui/components/tree-view';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Task counts structure
|
|
9
|
+
*/
|
|
10
|
+
export interface TaskCounts {
|
|
11
|
+
total: number;
|
|
12
|
+
completed: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Calculate task counts from an array of tasks
|
|
17
|
+
*/
|
|
18
|
+
export function calculateTaskCounts(tasks: Task[]): TaskCounts {
|
|
19
|
+
return {
|
|
20
|
+
total: tasks.length,
|
|
21
|
+
completed: tasks.filter(t => t.status === 'COMPLETED').length,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Group tasks by project ID and calculate counts for each
|
|
27
|
+
*/
|
|
28
|
+
export function calculateTaskCountsByProject(tasks: Task[]): Map<string, TaskCounts> {
|
|
29
|
+
const countsByProject = new Map<string, TaskCounts>();
|
|
30
|
+
|
|
31
|
+
for (const task of tasks) {
|
|
32
|
+
if (task.projectId) {
|
|
33
|
+
const counts = countsByProject.get(task.projectId) || { total: 0, completed: 0 };
|
|
34
|
+
counts.total++;
|
|
35
|
+
if (task.status === 'COMPLETED') {
|
|
36
|
+
counts.completed++;
|
|
37
|
+
}
|
|
38
|
+
countsByProject.set(task.projectId, counts);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return countsByProject;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Project with task count information for dashboard display
|
|
47
|
+
*/
|
|
48
|
+
export interface ProjectWithCounts extends Project {
|
|
49
|
+
taskCounts: TaskCounts;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Hook for fetching and managing the list of projects for the dashboard.
|
|
54
|
+
* Includes task counts for each project.
|
|
55
|
+
*
|
|
56
|
+
* @returns Project list state with loading/error states and refresh function
|
|
57
|
+
*
|
|
58
|
+
* @example
|
|
59
|
+
* ```tsx
|
|
60
|
+
* const { projects, loading, error, refresh } = useProjects();
|
|
61
|
+
* ```
|
|
62
|
+
*/
|
|
63
|
+
export function useProjects() {
|
|
64
|
+
const { adapter } = useAdapter();
|
|
65
|
+
const [projects, setProjects] = useState<ProjectWithCounts[]>([]);
|
|
66
|
+
const [loading, setLoading] = useState(true);
|
|
67
|
+
const [error, setError] = useState<string | null>(null);
|
|
68
|
+
|
|
69
|
+
const loadProjects = useCallback(async () => {
|
|
70
|
+
setLoading(true);
|
|
71
|
+
setError(null);
|
|
72
|
+
|
|
73
|
+
// Fetch projects and all tasks in parallel
|
|
74
|
+
const [projectsResult, tasksResult] = await Promise.all([
|
|
75
|
+
adapter.getProjects(),
|
|
76
|
+
adapter.getTasks({ limit: 1000 }), // Get all tasks to count by project
|
|
77
|
+
]);
|
|
78
|
+
|
|
79
|
+
if (!projectsResult.success) {
|
|
80
|
+
setError(projectsResult.error);
|
|
81
|
+
setLoading(false);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Build task counts by project using shared utility
|
|
86
|
+
const taskCountsByProject = tasksResult.success
|
|
87
|
+
? calculateTaskCountsByProject(tasksResult.data)
|
|
88
|
+
: new Map<string, TaskCounts>();
|
|
89
|
+
|
|
90
|
+
// Merge task counts into projects
|
|
91
|
+
const projectsWithCounts: ProjectWithCounts[] = projectsResult.data.map(project => ({
|
|
92
|
+
...project,
|
|
93
|
+
taskCounts: taskCountsByProject.get(project.id) || { total: 0, completed: 0 },
|
|
94
|
+
}));
|
|
95
|
+
|
|
96
|
+
setProjects(projectsWithCounts);
|
|
97
|
+
setLoading(false);
|
|
98
|
+
}, [adapter]);
|
|
99
|
+
|
|
100
|
+
useEffect(() => {
|
|
101
|
+
loadProjects();
|
|
102
|
+
}, [loadProjects]);
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
projects,
|
|
106
|
+
loading,
|
|
107
|
+
error,
|
|
108
|
+
refresh: loadProjects,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Hook for fetching a single project with its overview statistics.
|
|
114
|
+
*
|
|
115
|
+
* @param id - The project ID
|
|
116
|
+
* @returns Project and overview state with loading/error states and refresh function
|
|
117
|
+
*
|
|
118
|
+
* @example
|
|
119
|
+
* ```tsx
|
|
120
|
+
* const { project, overview, loading, error, refresh } = useProjectOverview('proj-123');
|
|
121
|
+
* ```
|
|
122
|
+
*/
|
|
123
|
+
export function useProjectOverview(id: string) {
|
|
124
|
+
const { adapter } = useAdapter();
|
|
125
|
+
const [project, setProject] = useState<Project | null>(null);
|
|
126
|
+
const [overview, setOverview] = useState<ProjectOverview | null>(null);
|
|
127
|
+
const [loading, setLoading] = useState(true);
|
|
128
|
+
const [error, setError] = useState<string | null>(null);
|
|
129
|
+
|
|
130
|
+
const loadProjectOverview = useCallback(async () => {
|
|
131
|
+
setLoading(true);
|
|
132
|
+
setError(null);
|
|
133
|
+
|
|
134
|
+
const [projectResult, overviewResult] = await Promise.all([
|
|
135
|
+
adapter.getProject(id),
|
|
136
|
+
adapter.getProjectOverview(id),
|
|
137
|
+
]);
|
|
138
|
+
|
|
139
|
+
if (projectResult.success) {
|
|
140
|
+
setProject(projectResult.data);
|
|
141
|
+
} else {
|
|
142
|
+
setError(projectResult.error);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (overviewResult.success) {
|
|
146
|
+
setOverview(overviewResult.data);
|
|
147
|
+
} else if (!error) {
|
|
148
|
+
// Only set error if project fetch didn't already fail
|
|
149
|
+
setError(overviewResult.error);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
setLoading(false);
|
|
153
|
+
}, [adapter, id, error]);
|
|
154
|
+
|
|
155
|
+
useEffect(() => {
|
|
156
|
+
loadProjectOverview();
|
|
157
|
+
}, [loadProjectOverview]);
|
|
158
|
+
|
|
159
|
+
return {
|
|
160
|
+
project,
|
|
161
|
+
overview,
|
|
162
|
+
loading,
|
|
163
|
+
error,
|
|
164
|
+
refresh: loadProjectOverview,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Status order for grouping tasks - active statuses first, then terminal statuses
|
|
170
|
+
*/
|
|
171
|
+
const STATUS_ORDER: TaskStatus[] = [
|
|
172
|
+
'PENDING' as TaskStatus,
|
|
173
|
+
'IN_PROGRESS' as TaskStatus,
|
|
174
|
+
'IN_REVIEW' as TaskStatus,
|
|
175
|
+
'BLOCKED' as TaskStatus,
|
|
176
|
+
'ON_HOLD' as TaskStatus,
|
|
177
|
+
'COMPLETED' as TaskStatus,
|
|
178
|
+
'CANCELLED' as TaskStatus,
|
|
179
|
+
];
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Priority order for sorting tasks within status groups
|
|
183
|
+
*/
|
|
184
|
+
const PRIORITY_ORDER: Record<Priority, number> = {
|
|
185
|
+
HIGH: 3,
|
|
186
|
+
MEDIUM: 2,
|
|
187
|
+
LOW: 1,
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
const BOARD_STATUS_ORDER: TaskStatus[] = [
|
|
191
|
+
'PENDING' as TaskStatus,
|
|
192
|
+
'IN_PROGRESS' as TaskStatus,
|
|
193
|
+
'IN_REVIEW' as TaskStatus,
|
|
194
|
+
'BLOCKED' as TaskStatus,
|
|
195
|
+
'COMPLETED' as TaskStatus,
|
|
196
|
+
];
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Display names for task statuses
|
|
200
|
+
*/
|
|
201
|
+
const STATUS_DISPLAY_NAMES: Record<string, string> = {
|
|
202
|
+
BACKLOG: 'Backlog',
|
|
203
|
+
PENDING: 'Pending',
|
|
204
|
+
IN_PROGRESS: 'In Progress',
|
|
205
|
+
IN_REVIEW: 'In Review',
|
|
206
|
+
CHANGES_REQUESTED: 'Changes Requested',
|
|
207
|
+
TESTING: 'Testing',
|
|
208
|
+
READY_FOR_QA: 'Ready for QA',
|
|
209
|
+
INVESTIGATING: 'Investigating',
|
|
210
|
+
BLOCKED: 'Blocked',
|
|
211
|
+
ON_HOLD: 'On Hold',
|
|
212
|
+
DEPLOYED: 'Deployed',
|
|
213
|
+
COMPLETED: 'Completed',
|
|
214
|
+
CANCELLED: 'Cancelled',
|
|
215
|
+
DEFERRED: 'Deferred',
|
|
216
|
+
DRAFT: 'Draft',
|
|
217
|
+
PLANNING: 'Planning',
|
|
218
|
+
IN_DEVELOPMENT: 'In Development',
|
|
219
|
+
VALIDATING: 'Validating',
|
|
220
|
+
PENDING_REVIEW: 'Pending Review',
|
|
221
|
+
ARCHIVED: 'Archived',
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Feature status order for grouping features by their own status
|
|
226
|
+
*/
|
|
227
|
+
const FEATURE_STATUS_ORDER: FeatureStatus[] = [
|
|
228
|
+
'DRAFT' as FeatureStatus,
|
|
229
|
+
'PLANNING' as FeatureStatus,
|
|
230
|
+
'IN_DEVELOPMENT' as FeatureStatus,
|
|
231
|
+
'TESTING' as FeatureStatus,
|
|
232
|
+
'VALIDATING' as FeatureStatus,
|
|
233
|
+
'PENDING_REVIEW' as FeatureStatus,
|
|
234
|
+
'BLOCKED' as FeatureStatus,
|
|
235
|
+
'ON_HOLD' as FeatureStatus,
|
|
236
|
+
'DEPLOYED' as FeatureStatus,
|
|
237
|
+
'COMPLETED' as FeatureStatus,
|
|
238
|
+
'ARCHIVED' as FeatureStatus,
|
|
239
|
+
];
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Build status-grouped tree rows for tasks
|
|
243
|
+
* Groups tasks by status, then by feature within each status
|
|
244
|
+
*
|
|
245
|
+
* @param tasks - All tasks to group
|
|
246
|
+
* @param features - Features to lookup task feature info
|
|
247
|
+
* @param expandedGroups - Set of expanded group IDs (both status groups and composite feature keys)
|
|
248
|
+
* @returns TreeRow[] grouped by status → feature → tasks
|
|
249
|
+
*/
|
|
250
|
+
function buildStatusGroupedRows(
|
|
251
|
+
tasks: Task[],
|
|
252
|
+
features: FeatureWithTasks[],
|
|
253
|
+
expandedGroups: Set<string>
|
|
254
|
+
): TreeRow[] {
|
|
255
|
+
const rows: TreeRow[] = [];
|
|
256
|
+
|
|
257
|
+
const featureMap = new Map<string, FeatureWithTasks>();
|
|
258
|
+
for (const feature of features) {
|
|
259
|
+
featureMap.set(feature.id, feature);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Group tasks by status
|
|
263
|
+
const tasksByStatus = new Map<TaskStatus, Task[]>();
|
|
264
|
+
for (const task of tasks) {
|
|
265
|
+
const status = task.status as TaskStatus;
|
|
266
|
+
const group = tasksByStatus.get(status) || [];
|
|
267
|
+
group.push(task);
|
|
268
|
+
tasksByStatus.set(status, group);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const featureStatusToTaskStatus = (status: FeatureStatus): TaskStatus => {
|
|
272
|
+
switch (status) {
|
|
273
|
+
case 'COMPLETED':
|
|
274
|
+
case 'DEPLOYED':
|
|
275
|
+
return 'COMPLETED' as TaskStatus;
|
|
276
|
+
case 'BLOCKED':
|
|
277
|
+
return 'BLOCKED' as TaskStatus;
|
|
278
|
+
case 'ON_HOLD':
|
|
279
|
+
return 'ON_HOLD' as TaskStatus;
|
|
280
|
+
case 'ARCHIVED':
|
|
281
|
+
return 'CANCELLED' as TaskStatus;
|
|
282
|
+
case 'PENDING_REVIEW':
|
|
283
|
+
return 'IN_REVIEW' as TaskStatus;
|
|
284
|
+
case 'IN_DEVELOPMENT':
|
|
285
|
+
case 'TESTING':
|
|
286
|
+
case 'VALIDATING':
|
|
287
|
+
return 'IN_PROGRESS' as TaskStatus;
|
|
288
|
+
case 'PLANNING':
|
|
289
|
+
case 'DRAFT':
|
|
290
|
+
default:
|
|
291
|
+
return 'PENDING' as TaskStatus;
|
|
292
|
+
}
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
// Only inject empty features into status buckets to avoid duplicate feature rows across statuses.
|
|
296
|
+
const featureHasTasks = new Set<string>();
|
|
297
|
+
for (const task of tasks) {
|
|
298
|
+
if (task.featureId) {
|
|
299
|
+
featureHasTasks.add(task.featureId);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Group features by their mapped status bucket
|
|
304
|
+
const featuresByStatus = new Map<TaskStatus, FeatureWithTasks[]>();
|
|
305
|
+
for (const feature of features) {
|
|
306
|
+
if (featureHasTasks.has(feature.id)) {
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
309
|
+
const mappedStatus = featureStatusToTaskStatus(feature.status as FeatureStatus);
|
|
310
|
+
const group = featuresByStatus.get(mappedStatus) || [];
|
|
311
|
+
group.push(feature);
|
|
312
|
+
featuresByStatus.set(mappedStatus, group);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// Build rows in status order
|
|
316
|
+
for (const status of STATUS_ORDER) {
|
|
317
|
+
const statusTasks = tasksByStatus.get(status) || [];
|
|
318
|
+
const statusFeatures = featuresByStatus.get(status) || [];
|
|
319
|
+
if (statusTasks.length === 0 && statusFeatures.length === 0) continue;
|
|
320
|
+
|
|
321
|
+
const statusGroupId = status;
|
|
322
|
+
const statusExpanded = expandedGroups.has(statusGroupId);
|
|
323
|
+
const statusExpandable = statusTasks.length > 0 || statusFeatures.length > 0;
|
|
324
|
+
|
|
325
|
+
// Add status group row (depth 0)
|
|
326
|
+
rows.push({
|
|
327
|
+
type: 'group',
|
|
328
|
+
id: statusGroupId,
|
|
329
|
+
label: STATUS_DISPLAY_NAMES[status] || status,
|
|
330
|
+
status,
|
|
331
|
+
taskCount: statusTasks.length,
|
|
332
|
+
expanded: statusExpanded,
|
|
333
|
+
depth: 0,
|
|
334
|
+
expandable: statusExpandable,
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
// If status group is expanded, group tasks by feature
|
|
338
|
+
if (statusExpanded) {
|
|
339
|
+
// Group tasks by featureId (with null for unassigned)
|
|
340
|
+
const tasksByFeature = new Map<string | null, Task[]>();
|
|
341
|
+
for (const task of statusTasks) {
|
|
342
|
+
const featureId = task.featureId || null;
|
|
343
|
+
const group = tasksByFeature.get(featureId) || [];
|
|
344
|
+
group.push(task);
|
|
345
|
+
tasksByFeature.set(featureId, group);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// Sort tasks within each feature by priority (descending) then title
|
|
349
|
+
for (const [_, featureTasks] of tasksByFeature.entries()) {
|
|
350
|
+
featureTasks.sort((a, b) => {
|
|
351
|
+
const priorityDiff = PRIORITY_ORDER[b.priority] - PRIORITY_ORDER[a.priority];
|
|
352
|
+
if (priorityDiff !== 0) return priorityDiff;
|
|
353
|
+
return a.title.localeCompare(b.title);
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// Build feature sub-groups
|
|
358
|
+
// First, collect features that have tasks in this status (sorted by creation date)
|
|
359
|
+
const tasksByFeatureId = new Map<string, Task[]>();
|
|
360
|
+
for (const [featureId, featureTasks] of tasksByFeature.entries()) {
|
|
361
|
+
if (featureId !== null) {
|
|
362
|
+
tasksByFeatureId.set(featureId, featureTasks);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// Sort mapped features by creation date (ascending, oldest first), including empty features
|
|
367
|
+
const statusFeatureMap = new Map<string, FeatureWithTasks>();
|
|
368
|
+
for (const feature of statusFeatures) {
|
|
369
|
+
statusFeatureMap.set(feature.id, feature);
|
|
370
|
+
}
|
|
371
|
+
for (const featureId of tasksByFeatureId.keys()) {
|
|
372
|
+
const feature = featureMap.get(featureId);
|
|
373
|
+
if (feature) {
|
|
374
|
+
statusFeatureMap.set(feature.id, feature);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const sortedStatusFeatures = [...statusFeatureMap.values()].sort((a, b) =>
|
|
379
|
+
new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
|
|
380
|
+
);
|
|
381
|
+
|
|
382
|
+
// Add feature sub-group rows
|
|
383
|
+
for (const feature of sortedStatusFeatures) {
|
|
384
|
+
const featureId = feature.id;
|
|
385
|
+
const featureTasks = tasksByFeatureId.get(featureId) || [];
|
|
386
|
+
const compositeFeatureId = `${status}:${featureId}`;
|
|
387
|
+
const featureExpandable = featureTasks.length > 0;
|
|
388
|
+
const featureExpanded = featureExpandable && expandedGroups.has(compositeFeatureId);
|
|
389
|
+
|
|
390
|
+
// Add feature group row (depth 1)
|
|
391
|
+
rows.push({
|
|
392
|
+
type: 'group',
|
|
393
|
+
id: compositeFeatureId,
|
|
394
|
+
label: feature.name,
|
|
395
|
+
status: feature.status,
|
|
396
|
+
taskCount: featureTasks.length,
|
|
397
|
+
expanded: featureExpanded,
|
|
398
|
+
depth: 1,
|
|
399
|
+
expandable: featureExpandable,
|
|
400
|
+
featureId,
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
// Add task rows if feature is expanded (depth 1)
|
|
404
|
+
if (featureExpanded) {
|
|
405
|
+
featureTasks.forEach((task, index) => {
|
|
406
|
+
const isLast = index === featureTasks.length - 1;
|
|
407
|
+
rows.push({
|
|
408
|
+
type: 'task',
|
|
409
|
+
task,
|
|
410
|
+
isLast,
|
|
411
|
+
depth: 2,
|
|
412
|
+
// No featureName needed - tasks are nested under their feature
|
|
413
|
+
});
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// Add unassigned tasks (if any)
|
|
419
|
+
const unassignedTasks = tasksByFeature.get(null);
|
|
420
|
+
if (unassignedTasks && unassignedTasks.length > 0) {
|
|
421
|
+
const unassignedId = `${status}:unassigned`;
|
|
422
|
+
const unassignedExpanded = expandedGroups.has(unassignedId);
|
|
423
|
+
|
|
424
|
+
// Sort unassigned tasks by priority (descending) then title
|
|
425
|
+
unassignedTasks.sort((a, b) => {
|
|
426
|
+
const priorityDiff = PRIORITY_ORDER[b.priority] - PRIORITY_ORDER[a.priority];
|
|
427
|
+
if (priorityDiff !== 0) return priorityDiff;
|
|
428
|
+
return a.title.localeCompare(b.title);
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
// Add unassigned group row (depth 1)
|
|
432
|
+
rows.push({
|
|
433
|
+
type: 'group',
|
|
434
|
+
id: unassignedId,
|
|
435
|
+
label: 'Unassigned',
|
|
436
|
+
status: status, // Use parent status for consistency
|
|
437
|
+
taskCount: unassignedTasks.length,
|
|
438
|
+
expanded: unassignedExpanded,
|
|
439
|
+
depth: 1,
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
// Add task rows if unassigned group is expanded (depth 1)
|
|
443
|
+
if (unassignedExpanded) {
|
|
444
|
+
unassignedTasks.forEach((task, index) => {
|
|
445
|
+
const isLast = index === unassignedTasks.length - 1;
|
|
446
|
+
rows.push({
|
|
447
|
+
type: 'task',
|
|
448
|
+
task,
|
|
449
|
+
isLast,
|
|
450
|
+
depth: 2,
|
|
451
|
+
});
|
|
452
|
+
});
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
return rows;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* Build feature-status-grouped tree rows
|
|
463
|
+
* Groups features by their feature status, then nests tasks within each feature
|
|
464
|
+
*
|
|
465
|
+
* @param features - All features with their tasks
|
|
466
|
+
* @param expandedGroups - Set of expanded group IDs
|
|
467
|
+
* @returns TreeRow[] grouped by feature status → feature → tasks
|
|
468
|
+
*/
|
|
469
|
+
function buildFeatureStatusGroupedRows(
|
|
470
|
+
features: FeatureWithTasks[],
|
|
471
|
+
expandedGroups: Set<string>
|
|
472
|
+
): TreeRow[] {
|
|
473
|
+
const rows: TreeRow[] = [];
|
|
474
|
+
|
|
475
|
+
// Group features by their status
|
|
476
|
+
const featuresByStatus = new Map<string, FeatureWithTasks[]>();
|
|
477
|
+
for (const feature of features) {
|
|
478
|
+
const status = feature.status as string;
|
|
479
|
+
const group = featuresByStatus.get(status) || [];
|
|
480
|
+
group.push(feature);
|
|
481
|
+
featuresByStatus.set(status, group);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// Build rows in feature status order
|
|
485
|
+
for (const status of FEATURE_STATUS_ORDER) {
|
|
486
|
+
const statusFeatures = featuresByStatus.get(status) || [];
|
|
487
|
+
if (statusFeatures.length === 0) continue;
|
|
488
|
+
|
|
489
|
+
const statusGroupId = `fs:${status}`;
|
|
490
|
+
const statusExpanded = expandedGroups.has(statusGroupId);
|
|
491
|
+
|
|
492
|
+
// Count total tasks across all features in this status
|
|
493
|
+
const totalTasks = statusFeatures.reduce((sum, f) => sum + f.tasks.length, 0);
|
|
494
|
+
|
|
495
|
+
// Add status group row (depth 0)
|
|
496
|
+
rows.push({
|
|
497
|
+
type: 'group',
|
|
498
|
+
id: statusGroupId,
|
|
499
|
+
label: STATUS_DISPLAY_NAMES[status] || status,
|
|
500
|
+
status,
|
|
501
|
+
taskCount: statusFeatures.length,
|
|
502
|
+
expanded: statusExpanded,
|
|
503
|
+
depth: 0,
|
|
504
|
+
expandable: true,
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
if (statusExpanded) {
|
|
508
|
+
// Sort features by creation date (oldest first)
|
|
509
|
+
const sortedFeatures = [...statusFeatures].sort(
|
|
510
|
+
(a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
|
|
511
|
+
);
|
|
512
|
+
|
|
513
|
+
for (const feature of sortedFeatures) {
|
|
514
|
+
const compositeFeatureId = `fs:${status}:${feature.id}`;
|
|
515
|
+
const featureExpandable = feature.tasks.length > 0;
|
|
516
|
+
const featureExpanded = featureExpandable && expandedGroups.has(compositeFeatureId);
|
|
517
|
+
|
|
518
|
+
// Add feature group row (depth 1)
|
|
519
|
+
rows.push({
|
|
520
|
+
type: 'group',
|
|
521
|
+
id: compositeFeatureId,
|
|
522
|
+
label: feature.name,
|
|
523
|
+
status: feature.status,
|
|
524
|
+
taskCount: feature.tasks.length,
|
|
525
|
+
expanded: featureExpanded,
|
|
526
|
+
depth: 1,
|
|
527
|
+
expandable: featureExpandable,
|
|
528
|
+
featureId: feature.id,
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
// Add task rows if feature is expanded (depth 2)
|
|
532
|
+
if (featureExpanded) {
|
|
533
|
+
// Sort tasks by priority (descending) then title
|
|
534
|
+
const sortedTasks = [...feature.tasks].sort((a, b) => {
|
|
535
|
+
const priorityDiff = PRIORITY_ORDER[b.priority] - PRIORITY_ORDER[a.priority];
|
|
536
|
+
if (priorityDiff !== 0) return priorityDiff;
|
|
537
|
+
return a.title.localeCompare(b.title);
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
sortedTasks.forEach((task, index) => {
|
|
541
|
+
rows.push({
|
|
542
|
+
type: 'task',
|
|
543
|
+
task,
|
|
544
|
+
isLast: index === sortedTasks.length - 1,
|
|
545
|
+
depth: 2,
|
|
546
|
+
});
|
|
547
|
+
});
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
return rows;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/**
|
|
557
|
+
* Hook for fetching a project tree with features and their tasks.
|
|
558
|
+
* Also includes unassigned tasks (tasks without a feature).
|
|
559
|
+
*
|
|
560
|
+
* @param projectId - The project ID
|
|
561
|
+
* @returns Project, features with nested tasks, unassigned tasks, task counts, status-grouped rows, loading/error states, and refresh function
|
|
562
|
+
*
|
|
563
|
+
* @example
|
|
564
|
+
* ```tsx
|
|
565
|
+
* const { project, features, unassignedTasks, taskCounts, statusGroupedRows, loading, error, refresh } = useProjectTree('proj-123');
|
|
566
|
+
* ```
|
|
567
|
+
*/
|
|
568
|
+
export function useProjectTree(projectId: string, expandedGroups: Set<string> = new Set()) {
|
|
569
|
+
const { adapter } = useAdapter();
|
|
570
|
+
const [project, setProject] = useState<Project | null>(null);
|
|
571
|
+
const [features, setFeatures] = useState<FeatureWithTasks[]>([]);
|
|
572
|
+
const [unassignedTasks, setUnassignedTasks] = useState<Task[]>([]);
|
|
573
|
+
const [taskCounts, setTaskCounts] = useState<TaskCounts>({ total: 0, completed: 0 });
|
|
574
|
+
const [allTasks, setAllTasks] = useState<Task[]>([]);
|
|
575
|
+
const [loading, setLoading] = useState(true);
|
|
576
|
+
const [error, setError] = useState<string | null>(null);
|
|
577
|
+
|
|
578
|
+
const loadProjectTree = useCallback(async () => {
|
|
579
|
+
setLoading(true);
|
|
580
|
+
setError(null);
|
|
581
|
+
|
|
582
|
+
const [projectResult, featuresResult, tasksResult] = await Promise.all([
|
|
583
|
+
adapter.getProject(projectId),
|
|
584
|
+
adapter.getFeatures({ projectId }),
|
|
585
|
+
adapter.getTasks({ projectId }),
|
|
586
|
+
]);
|
|
587
|
+
|
|
588
|
+
if (!projectResult.success) {
|
|
589
|
+
setError(projectResult.error);
|
|
590
|
+
setLoading(false);
|
|
591
|
+
return;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
if (!featuresResult.success) {
|
|
595
|
+
setError(featuresResult.error);
|
|
596
|
+
setLoading(false);
|
|
597
|
+
return;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
if (!tasksResult.success) {
|
|
601
|
+
setError(tasksResult.error);
|
|
602
|
+
setLoading(false);
|
|
603
|
+
return;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
const projectData = projectResult.data;
|
|
607
|
+
const allFeatures = featuresResult.data;
|
|
608
|
+
const tasks = tasksResult.data;
|
|
609
|
+
|
|
610
|
+
// Sort features by creation date ascending (oldest first)
|
|
611
|
+
const sortedFeatures = [...allFeatures].sort(
|
|
612
|
+
(a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
|
|
613
|
+
);
|
|
614
|
+
|
|
615
|
+
// Build feature tree with nested tasks
|
|
616
|
+
const featuresWithTasks: FeatureWithTasks[] = sortedFeatures.map((feature) => ({
|
|
617
|
+
...feature,
|
|
618
|
+
tasks: tasks.filter((task) => task.featureId === feature.id),
|
|
619
|
+
}));
|
|
620
|
+
|
|
621
|
+
// Find unassigned tasks (no featureId)
|
|
622
|
+
const unassigned = tasks.filter((task) => !task.featureId);
|
|
623
|
+
|
|
624
|
+
setProject(projectData);
|
|
625
|
+
setFeatures(featuresWithTasks);
|
|
626
|
+
setUnassignedTasks(unassigned);
|
|
627
|
+
setAllTasks(tasks);
|
|
628
|
+
setTaskCounts(calculateTaskCounts(tasks));
|
|
629
|
+
setLoading(false);
|
|
630
|
+
}, [adapter, projectId]);
|
|
631
|
+
|
|
632
|
+
useEffect(() => {
|
|
633
|
+
loadProjectTree();
|
|
634
|
+
}, [loadProjectTree]);
|
|
635
|
+
|
|
636
|
+
// Build status-grouped rows using useMemo to recalculate when data changes
|
|
637
|
+
const statusGroupedRows = useMemo(() => {
|
|
638
|
+
return buildStatusGroupedRows(allTasks, features, expandedGroups);
|
|
639
|
+
}, [allTasks, features, expandedGroups]);
|
|
640
|
+
|
|
641
|
+
// Build feature-status-grouped rows
|
|
642
|
+
const featureStatusGroupedRows = useMemo(() => {
|
|
643
|
+
return buildFeatureStatusGroupedRows(features, expandedGroups);
|
|
644
|
+
}, [features, expandedGroups]);
|
|
645
|
+
|
|
646
|
+
return {
|
|
647
|
+
project,
|
|
648
|
+
features,
|
|
649
|
+
unassignedTasks,
|
|
650
|
+
taskCounts,
|
|
651
|
+
statusGroupedRows,
|
|
652
|
+
featureStatusGroupedRows,
|
|
653
|
+
loading,
|
|
654
|
+
error,
|
|
655
|
+
refresh: loadProjectTree,
|
|
656
|
+
};
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
/**
|
|
660
|
+
* Hook for fetching board data (kanban columns) for a project.
|
|
661
|
+
* Returns tasks grouped by status with feature labels for each card.
|
|
662
|
+
*/
|
|
663
|
+
export function useBoardData(projectId: string) {
|
|
664
|
+
const { adapter } = useAdapter();
|
|
665
|
+
const [columnsByStatus, setColumnsByStatus] = useState<Map<string, BoardCard[]>>(new Map());
|
|
666
|
+
const [loading, setLoading] = useState(true);
|
|
667
|
+
const [error, setError] = useState<string | null>(null);
|
|
668
|
+
const [refreshTrigger, setRefreshTrigger] = useState(0);
|
|
669
|
+
|
|
670
|
+
const refresh = useCallback(() => {
|
|
671
|
+
setRefreshTrigger((prev) => prev + 1);
|
|
672
|
+
}, []);
|
|
673
|
+
|
|
674
|
+
const loadBoardData = useCallback(async () => {
|
|
675
|
+
setLoading(true);
|
|
676
|
+
setError(null);
|
|
677
|
+
|
|
678
|
+
const [tasksResult, featuresResult] = await Promise.all([
|
|
679
|
+
adapter.getTasks({ projectId }),
|
|
680
|
+
adapter.getFeatures({ projectId }),
|
|
681
|
+
]);
|
|
682
|
+
|
|
683
|
+
if (!tasksResult.success) {
|
|
684
|
+
setError(tasksResult.error);
|
|
685
|
+
setLoading(false);
|
|
686
|
+
return;
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
if (!featuresResult.success) {
|
|
690
|
+
setError(featuresResult.error);
|
|
691
|
+
setLoading(false);
|
|
692
|
+
return;
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
const featureNameById = new Map(featuresResult.data.map((feature) => [feature.id, feature.name] as const));
|
|
696
|
+
const grouped = new Map<string, BoardCard[]>();
|
|
697
|
+
|
|
698
|
+
for (const status of BOARD_STATUS_ORDER) {
|
|
699
|
+
grouped.set(status, []);
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
for (const task of tasksResult.data) {
|
|
703
|
+
if (!grouped.has(task.status)) continue;
|
|
704
|
+
|
|
705
|
+
const featureName = task.featureId ? featureNameById.get(task.featureId) ?? null : null;
|
|
706
|
+
const boardTask: BoardTask = {
|
|
707
|
+
...task,
|
|
708
|
+
featureName: featureName ?? undefined,
|
|
709
|
+
};
|
|
710
|
+
|
|
711
|
+
const card: BoardCard = {
|
|
712
|
+
id: task.id,
|
|
713
|
+
title: task.title,
|
|
714
|
+
featureName,
|
|
715
|
+
priority: task.priority,
|
|
716
|
+
task: boardTask,
|
|
717
|
+
};
|
|
718
|
+
|
|
719
|
+
grouped.get(task.status)?.push(card);
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
setColumnsByStatus(grouped);
|
|
723
|
+
setLoading(false);
|
|
724
|
+
}, [adapter, projectId]);
|
|
725
|
+
|
|
726
|
+
useEffect(() => {
|
|
727
|
+
loadBoardData();
|
|
728
|
+
}, [loadBoardData, refreshTrigger]);
|
|
729
|
+
|
|
730
|
+
return {
|
|
731
|
+
columnsByStatus,
|
|
732
|
+
loading,
|
|
733
|
+
error,
|
|
734
|
+
refresh,
|
|
735
|
+
};
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
/**
|
|
739
|
+
* Hook for fetching a single task with its sections and dependencies.
|
|
740
|
+
*
|
|
741
|
+
* @param id - The task ID
|
|
742
|
+
* @returns Task, sections, dependencies, loading/error states, and refresh function
|
|
743
|
+
*
|
|
744
|
+
* @example
|
|
745
|
+
* ```tsx
|
|
746
|
+
* const { task, sections, dependencies, loading, error, refresh } = useTask('task-123');
|
|
747
|
+
* ```
|
|
748
|
+
*/
|
|
749
|
+
export function useTask(id: string) {
|
|
750
|
+
const { adapter } = useAdapter();
|
|
751
|
+
const [task, setTask] = useState<Task | null>(null);
|
|
752
|
+
const [sections, setSections] = useState<Section[]>([]);
|
|
753
|
+
const [dependencies, setDependencies] = useState<DependencyInfo | null>(null);
|
|
754
|
+
const [loading, setLoading] = useState(true);
|
|
755
|
+
const [error, setError] = useState<string | null>(null);
|
|
756
|
+
|
|
757
|
+
const loadTask = useCallback(async () => {
|
|
758
|
+
setLoading(true);
|
|
759
|
+
setError(null);
|
|
760
|
+
|
|
761
|
+
const [taskResult, sectionsResult, dependenciesResult] = await Promise.all([
|
|
762
|
+
adapter.getTask(id),
|
|
763
|
+
adapter.getSections('TASK' as EntityType, id),
|
|
764
|
+
adapter.getDependencies(id),
|
|
765
|
+
]);
|
|
766
|
+
|
|
767
|
+
if (taskResult.success) {
|
|
768
|
+
setTask(taskResult.data);
|
|
769
|
+
} else {
|
|
770
|
+
setError(taskResult.error);
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
if (sectionsResult.success) {
|
|
774
|
+
setSections(sectionsResult.data);
|
|
775
|
+
} else if (!error) {
|
|
776
|
+
setError(sectionsResult.error);
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
if (dependenciesResult.success) {
|
|
780
|
+
setDependencies(dependenciesResult.data);
|
|
781
|
+
} else if (!error) {
|
|
782
|
+
setError(dependenciesResult.error);
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
setLoading(false);
|
|
786
|
+
}, [adapter, id, error]);
|
|
787
|
+
|
|
788
|
+
useEffect(() => {
|
|
789
|
+
loadTask();
|
|
790
|
+
}, [loadTask]);
|
|
791
|
+
|
|
792
|
+
return {
|
|
793
|
+
task,
|
|
794
|
+
sections,
|
|
795
|
+
dependencies,
|
|
796
|
+
loading,
|
|
797
|
+
error,
|
|
798
|
+
refresh: loadTask,
|
|
799
|
+
};
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
/**
|
|
803
|
+
* Hook for fetching a single feature with its tasks and sections.
|
|
804
|
+
*
|
|
805
|
+
* @param id - The feature ID
|
|
806
|
+
* @returns Feature, tasks, sections, loading/error states, and refresh function
|
|
807
|
+
*
|
|
808
|
+
* @example
|
|
809
|
+
* ```tsx
|
|
810
|
+
* const { feature, tasks, sections, loading, error, refresh } = useFeature('feat-123');
|
|
811
|
+
* ```
|
|
812
|
+
*/
|
|
813
|
+
export function useFeature(id: string) {
|
|
814
|
+
const { adapter } = useAdapter();
|
|
815
|
+
const [feature, setFeature] = useState<FeatureWithTasks | null>(null);
|
|
816
|
+
const [sections, setSections] = useState<Section[]>([]);
|
|
817
|
+
const [loading, setLoading] = useState(true);
|
|
818
|
+
const [error, setError] = useState<string | null>(null);
|
|
819
|
+
|
|
820
|
+
const loadFeature = useCallback(async () => {
|
|
821
|
+
setLoading(true);
|
|
822
|
+
setError(null);
|
|
823
|
+
|
|
824
|
+
const [featureResult, tasksResult, sectionsResult] = await Promise.all([
|
|
825
|
+
adapter.getFeature(id),
|
|
826
|
+
adapter.getTasks({ featureId: id }),
|
|
827
|
+
adapter.getSections('FEATURE' as EntityType, id),
|
|
828
|
+
]);
|
|
829
|
+
|
|
830
|
+
let loadError: string | null = null;
|
|
831
|
+
|
|
832
|
+
if (featureResult.success && featureResult.data) {
|
|
833
|
+
const featureWithTasks: FeatureWithTasks = {
|
|
834
|
+
...featureResult.data,
|
|
835
|
+
tasks: tasksResult.success ? tasksResult.data : [],
|
|
836
|
+
};
|
|
837
|
+
setFeature(featureWithTasks);
|
|
838
|
+
} else if (!featureResult.success) {
|
|
839
|
+
loadError = featureResult.error;
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
if (sectionsResult.success) {
|
|
843
|
+
setSections(sectionsResult.data);
|
|
844
|
+
} else if (!loadError) {
|
|
845
|
+
loadError = sectionsResult.error;
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
setError(loadError);
|
|
849
|
+
setLoading(false);
|
|
850
|
+
}, [adapter, id]);
|
|
851
|
+
|
|
852
|
+
useEffect(() => {
|
|
853
|
+
loadFeature();
|
|
854
|
+
}, [loadFeature]);
|
|
855
|
+
|
|
856
|
+
return {
|
|
857
|
+
feature,
|
|
858
|
+
tasks: feature?.tasks || [],
|
|
859
|
+
sections,
|
|
860
|
+
loading,
|
|
861
|
+
error,
|
|
862
|
+
refresh: loadFeature,
|
|
863
|
+
};
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
/**
|
|
867
|
+
* Hook for performing full-text search across all entities.
|
|
868
|
+
* Use with useDebounce to avoid excessive API calls.
|
|
869
|
+
*
|
|
870
|
+
* @param query - The search query string
|
|
871
|
+
* @returns Search results with loading/error states
|
|
872
|
+
*
|
|
873
|
+
* @example
|
|
874
|
+
* ```tsx
|
|
875
|
+
* const [inputValue, setInputValue] = useState('');
|
|
876
|
+
* const debouncedQuery = useDebounce(inputValue, 300);
|
|
877
|
+
* const { results, loading, error } = useSearch(debouncedQuery);
|
|
878
|
+
* ```
|
|
879
|
+
*/
|
|
880
|
+
export function useSearch(query: string) {
|
|
881
|
+
const { adapter } = useAdapter();
|
|
882
|
+
const [results, setResults] = useState<SearchResults | null>(null);
|
|
883
|
+
const [loading, setLoading] = useState(false);
|
|
884
|
+
const [error, setError] = useState<string | null>(null);
|
|
885
|
+
|
|
886
|
+
useEffect(() => {
|
|
887
|
+
// Don't search for empty queries
|
|
888
|
+
if (!query.trim()) {
|
|
889
|
+
setResults(null);
|
|
890
|
+
setLoading(false);
|
|
891
|
+
setError(null);
|
|
892
|
+
return;
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
const performSearch = async () => {
|
|
896
|
+
setLoading(true);
|
|
897
|
+
setError(null);
|
|
898
|
+
|
|
899
|
+
const result = await adapter.search(query);
|
|
900
|
+
|
|
901
|
+
if (result.success) {
|
|
902
|
+
setResults(result.data);
|
|
903
|
+
} else {
|
|
904
|
+
setError(result.error);
|
|
905
|
+
setResults(null);
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
setLoading(false);
|
|
909
|
+
};
|
|
910
|
+
|
|
911
|
+
performSearch();
|
|
912
|
+
}, [adapter, query]);
|
|
913
|
+
|
|
914
|
+
return {
|
|
915
|
+
results,
|
|
916
|
+
loading,
|
|
917
|
+
error,
|
|
918
|
+
};
|
|
919
|
+
}
|