Skip to content

Commit 4e5f321

Browse files
[dev] [carhartlewis] lewis/comp-filter-evidence-tasks (#2106)
* feat(tasks): add framework instances support to task filtering * feat(tasks): define FrameworkInstanceForTasks type for task components and added a handler for non-existent frameworks * feat(tasks): add validation for frameworkFilter in TaskList component --------- Co-authored-by: Lewis Carhart <lewis@trycomp.ai>
1 parent d73b798 commit 4e5f321

5 files changed

Lines changed: 124 additions & 12 deletions

File tree

apps/app/src/app/(app)/[orgId]/tasks/components/TaskList.tsx

Lines changed: 68 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import { Check, Circle, FolderTree, List, Search, XCircle } from 'lucide-react';
2828
import { useParams } from 'next/navigation';
2929
import { useQueryState } from 'nuqs';
3030
import { useEffect, useMemo, useState } from 'react';
31+
import type { FrameworkInstanceForTasks } from '../types';
3132
import { ModernTaskList } from './ModernTaskList';
3233
import { TasksByCategory } from './TasksByCategory';
3334

@@ -42,6 +43,7 @@ const statuses = [
4243
export function TaskList({
4344
tasks: initialTasks,
4445
members,
46+
frameworkInstances,
4547
activeTab,
4648
}: {
4749
tasks: (Task & {
@@ -61,20 +63,34 @@ export function TaskList({
6163
}>;
6264
})[];
6365
members: (Member & { user: User })[];
66+
frameworkInstances: FrameworkInstanceForTasks[];
6467
activeTab: 'categories' | 'list';
6568
}) {
6669
const params = useParams();
6770
const orgId = params.orgId as string;
6871
const [searchQuery, setSearchQuery] = useState('');
6972
const [statusFilter, setStatusFilter] = useQueryState('status');
7073
const [assigneeFilter, setAssigneeFilter] = useQueryState('assignee');
74+
const [frameworkFilter, setFrameworkFilter] = useQueryState('framework');
7175
const [currentTab, setCurrentTab] = useState<'categories' | 'list'>(activeTab);
7276

7377
// Sync activeTab prop with state when it changes
7478
useEffect(() => {
7579
setCurrentTab(activeTab);
7680
}, [activeTab]);
7781

82+
// Clear frameworkFilter when it's invalid or frameworks are empty.
83+
// Prevents invisible filter (no dropdown when empty) and stale bookmarked URLs.
84+
useEffect(() => {
85+
if (!frameworkFilter) return;
86+
const isValid =
87+
frameworkInstances.length > 0 &&
88+
frameworkInstances.some((fw) => fw.id === frameworkFilter);
89+
if (!isValid) {
90+
setFrameworkFilter(null);
91+
}
92+
}, [frameworkFilter, frameworkInstances, setFrameworkFilter]);
93+
7894
const handleTabChange = async (value: string) => {
7995
const newTab = value as 'categories' | 'list';
8096
setCurrentTab(newTab);
@@ -100,7 +116,17 @@ export function TaskList({
100116
});
101117
}, [members]);
102118

103-
// Filter tasks by search query, status, and assignee
119+
// Build a map of control IDs to their framework instances for efficient lookup
120+
const frameworkControlIds = useMemo(() => {
121+
const map = new Map<string, Set<string>>();
122+
for (const fw of frameworkInstances) {
123+
const controlIds = new Set(fw.requirementsMapped.map((r) => r.controlId));
124+
map.set(fw.id, controlIds);
125+
}
126+
return map;
127+
}, [frameworkInstances]);
128+
129+
// Filter tasks by search query, status, assignee, and framework
104130
const filteredTasks = initialTasks.filter((task) => {
105131
const matchesSearch =
106132
searchQuery === '' ||
@@ -110,7 +136,16 @@ export function TaskList({
110136
const matchesStatus = !statusFilter || task.status === statusFilter;
111137
const matchesAssignee = !assigneeFilter || task.assigneeId === assigneeFilter;
112138

113-
return matchesSearch && matchesStatus && matchesAssignee;
139+
const matchesFramework =
140+
!frameworkFilter ||
141+
(() => {
142+
const fwControlIds = frameworkControlIds.get(frameworkFilter);
143+
// Stale/invalid framework ID (e.g. from bookmarked URL): treat as "All frameworks" to match dropdown display
144+
if (!fwControlIds) return true;
145+
return task.controls.some((c) => fwControlIds.has(c.id));
146+
})();
147+
148+
return matchesSearch && matchesStatus && matchesAssignee && matchesFramework;
114149
});
115150

116151
// Calculate overall stats from all tasks (not filtered)
@@ -571,6 +606,36 @@ export function TaskList({
571606
</SelectContent>
572607
</Select>
573608

609+
{frameworkInstances.length > 0 && (
610+
<Select
611+
value={frameworkFilter || 'all'}
612+
onValueChange={(value) => setFrameworkFilter(value === 'all' ? null : value)}
613+
>
614+
<SelectTrigger size="sm">
615+
<SelectValue placeholder="All frameworks">
616+
{(() => {
617+
if (!frameworkFilter) return 'All frameworks';
618+
const selectedFramework = frameworkInstances.find(
619+
(fw) => fw.id === frameworkFilter,
620+
);
621+
if (!selectedFramework) return 'All frameworks';
622+
return selectedFramework.framework.name;
623+
})()}
624+
</SelectValue>
625+
</SelectTrigger>
626+
<SelectContent>
627+
<SelectItem value="all">
628+
<span className="text-xs">All frameworks</span>
629+
</SelectItem>
630+
{frameworkInstances.map((fw) => (
631+
<SelectItem key={fw.id} value={fw.id}>
632+
<span className="text-xs">{fw.framework.name}</span>
633+
</SelectItem>
634+
))}
635+
</SelectContent>
636+
</Select>
637+
)}
638+
574639
<Select
575640
value={assigneeFilter || 'all'}
576641
onValueChange={(value) => setAssigneeFilter(value === 'all' ? null : value)}
@@ -629,7 +694,7 @@ export function TaskList({
629694
</Select>
630695
</div>
631696
{/* Result Count */}
632-
{(searchQuery || statusFilter || assigneeFilter) && (
697+
{(searchQuery || statusFilter || assigneeFilter || frameworkFilter) && (
633698
<div className="text-muted-foreground text-xs tabular-nums whitespace-nowrap lg:ml-auto">
634699
{filteredTasks.length} {filteredTasks.length === 1 ? 'result' : 'results'}
635700
</div>
@@ -664,7 +729,6 @@ export function TaskList({
664729
</div>
665730
</Stack>
666731
</Tabs>
667-
668732
</Stack>
669733
);
670734
}

apps/app/src/app/(app)/[orgId]/tasks/components/TasksPageClient.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
import { Add, ArrowDown } from '@trycompai/design-system/icons';
1818
import { useState } from 'react';
1919
import { toast } from 'sonner';
20+
import type { FrameworkInstanceForTasks } from '../types';
2021
import { CreateTaskSheet } from './CreateTaskSheet';
2122
import { TaskList } from './TaskList';
2223

@@ -39,6 +40,7 @@ interface TasksPageClientProps {
3940
})[];
4041
members: (Member & { user: User })[];
4142
controls: { id: string; name: string }[];
43+
frameworkInstances: FrameworkInstanceForTasks[];
4244
activeTab: 'categories' | 'list';
4345
orgId: string;
4446
organizationName: string | null;
@@ -49,6 +51,7 @@ export function TasksPageClient({
4951
tasks,
5052
members,
5153
controls,
54+
frameworkInstances,
5255
activeTab,
5356
orgId,
5457
organizationName,
@@ -121,7 +124,12 @@ export function TasksPageClient({
121124
}
122125
padding="default"
123126
>
124-
<TaskList tasks={tasks} members={members} activeTab={activeTab} />
127+
<TaskList
128+
tasks={tasks}
129+
members={members}
130+
frameworkInstances={frameworkInstances}
131+
activeTab={activeTab}
132+
/>
125133
<CreateTaskSheet
126134
members={members}
127135
controls={controls}

apps/app/src/app/(app)/[orgId]/tasks/page.tsx

Lines changed: 37 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,8 @@ export default async function TasksPage({
2727
const tasks = await getTasks();
2828
const members = await getMembersWithMetadata();
2929
const controls = await getControls();
30-
const { hasEvidenceExportAccess, organizationName } =
31-
await getEvidenceExportContext(orgId);
30+
const frameworkInstances = await getFrameworkInstances();
31+
const { hasEvidenceExportAccess, organizationName } = await getEvidenceExportContext(orgId);
3232

3333
// Read tab preference from cookie (server-side, no hydration issues)
3434
const cookieStore = await cookies();
@@ -40,6 +40,7 @@ export default async function TasksPage({
4040
tasks={tasks}
4141
members={members}
4242
controls={controls}
43+
frameworkInstances={frameworkInstances}
4344
activeTab={activeTab}
4445
orgId={orgId}
4546
organizationName={organizationName}
@@ -83,9 +84,7 @@ const getEvidenceExportContext = async (organizationId: string) => {
8384

8485
const roles = parseRolesString(member?.role);
8586
const hasEvidenceExportAccess =
86-
roles.includes(Role.auditor) ||
87-
roles.includes(Role.admin) ||
88-
roles.includes(Role.owner);
87+
roles.includes(Role.auditor) || roles.includes(Role.admin) || roles.includes(Role.owner);
8988

9089
return {
9190
hasEvidenceExportAccess,
@@ -195,3 +194,36 @@ const getControls = async () => {
195194

196195
return controls;
197196
};
197+
198+
const getFrameworkInstances = async () => {
199+
const session = await auth.api.getSession({
200+
headers: await headers(),
201+
});
202+
203+
const orgId = session?.session.activeOrganizationId;
204+
205+
if (!orgId) {
206+
return [];
207+
}
208+
209+
const frameworkInstances = await db.frameworkInstance.findMany({
210+
where: {
211+
organizationId: orgId,
212+
},
213+
include: {
214+
framework: {
215+
select: {
216+
id: true,
217+
name: true,
218+
},
219+
},
220+
requirementsMapped: {
221+
select: {
222+
controlId: true,
223+
},
224+
},
225+
},
226+
});
227+
228+
return frameworkInstances;
229+
};
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import type { FrameworkInstance } from '@db';
2+
3+
/**
4+
* Shape of framework instance as returned by getFrameworkInstances and used by
5+
* TaskList and TasksPageClient. Single source of truth to avoid type drift.
6+
*/
7+
export type FrameworkInstanceForTasks = Pick<FrameworkInstance, 'id'> & {
8+
framework: { id: string; name: string };
9+
requirementsMapped: { controlId: string }[];
10+
};

packages/docs/openapi.json

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7024,7 +7024,6 @@
70247024
"enum": [
70257025
"todo",
70267026
"in_progress",
7027-
"in_review",
70287027
"done",
70297028
"not_relevant",
70307029
"failed"
@@ -7333,7 +7332,6 @@
73337332
"enum": [
73347333
"todo",
73357334
"in_progress",
7336-
"in_review",
73377335
"done",
73387336
"not_relevant",
73397337
"failed"

0 commit comments

Comments
 (0)