From 8318695a8e62c2f1d3113f29ee7797d6f0cce6f0 Mon Sep 17 00:00:00 2001 From: Tofik Hasanov Date: Mon, 30 Mar 2026 11:18:47 -0400 Subject: [PATCH] feat(people): add status filter to team members page - Add `deactivated` field to API response (MEMBER_SELECT + PeopleResponseDto) so the client can properly distinguish deactivated vs inactive members - Extract filtering logic from TeamMembersClient into testable pure functions (buildDisplayItems, filterDisplayItems) in filter-members.ts - Replace lucide-react Loader2 with InProgress from @trycompai/design-system/icons - Add deactivated field to client-side PeopleResponseDto - Add API test for includeDeactivated query parameter - Add 17 unit tests covering status/search/role filter combinations Resolves SALE-6 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/people/dto/people-responses.dto.ts | 6 + apps/api/src/people/people.controller.spec.ts | 11 + apps/api/src/people/utils/member-queries.ts | 1 + .../all/components/TeamMembersClient.tsx | 83 +----- .../all/components/filter-members.test.ts | 272 ++++++++++++++++++ .../people/all/components/filter-members.ts | 89 ++++++ apps/app/src/hooks/use-people-api.ts | 1 + 7 files changed, 389 insertions(+), 74 deletions(-) create mode 100644 apps/app/src/app/(app)/[orgId]/people/all/components/filter-members.test.ts create mode 100644 apps/app/src/app/(app)/[orgId]/people/all/components/filter-members.ts diff --git a/apps/api/src/people/dto/people-responses.dto.ts b/apps/api/src/people/dto/people-responses.dto.ts index 86aee2e23..b01ca3ebb 100644 --- a/apps/api/src/people/dto/people-responses.dto.ts +++ b/apps/api/src/people/dto/people-responses.dto.ts @@ -111,6 +111,12 @@ export class PeopleResponseDto { }) isActive: boolean; + @ApiProperty({ + description: 'Whether member is deactivated', + example: false, + }) + deactivated: boolean; + @ApiProperty({ description: 'FleetDM label ID for member devices', example: 123, diff --git a/apps/api/src/people/people.controller.spec.ts b/apps/api/src/people/people.controller.spec.ts index 11f376991..c7111a8b5 100644 --- a/apps/api/src/people/people.controller.spec.ts +++ b/apps/api/src/people/people.controller.spec.ts @@ -103,6 +103,17 @@ describe('PeopleController', () => { ); }); + it('should pass includeDeactivated=true to the service', async () => { + mockPeopleService.findAllByOrganization.mockResolvedValue([]); + + await controller.getAllPeople('org_123', mockAuthContext, 'true'); + + expect(peopleService.findAllByOrganization).toHaveBeenCalledWith( + 'org_123', + true, + ); + }); + it('should not include authenticatedUser when userId is missing', async () => { const apiKeyContext: AuthContext = { ...mockAuthContext, diff --git a/apps/api/src/people/utils/member-queries.ts b/apps/api/src/people/utils/member-queries.ts index 97e456312..409a7feab 100644 --- a/apps/api/src/people/utils/member-queries.ts +++ b/apps/api/src/people/utils/member-queries.ts @@ -19,6 +19,7 @@ export class MemberQueries { department: true, jobTitle: true, isActive: true, + deactivated: true, fleetDmLabelId: true, user: { select: { diff --git a/apps/app/src/app/(app)/[orgId]/people/all/components/TeamMembersClient.tsx b/apps/app/src/app/(app)/[orgId]/people/all/components/TeamMembersClient.tsx index ff75c30a5..83728c901 100644 --- a/apps/app/src/app/(app)/[orgId]/people/all/components/TeamMembersClient.tsx +++ b/apps/app/src/app/(app)/[orgId]/people/all/components/TeamMembersClient.tsx @@ -1,6 +1,5 @@ 'use client'; -import { Loader2 } from 'lucide-react'; import Image from 'next/image'; import { useRouter } from 'next/navigation'; import { useState } from 'react'; @@ -34,9 +33,10 @@ import { TableRow, Button, } from '@trycompai/design-system'; -import { Search } from '@trycompai/design-system/icons'; +import { InProgress, Search } from '@trycompai/design-system/icons'; import { apiClient } from '@/lib/api-client'; +import { buildDisplayItems, filterDisplayItems } from './filter-members'; import { MemberRow } from './MemberRow'; import { PendingInvitationRow } from './PendingInvitationRow'; import { RampRoleMappingSheet } from './RampRoleMappingSheet'; @@ -57,18 +57,6 @@ interface TeamMembersClientProps { memberIdsWithDeviceAgent: string[]; } -// Define a simplified type for merged list items -interface DisplayItem extends Partial, Partial { - type: 'member' | 'invitation'; - displayName: string; - displayEmail: string; - displayRole: string | string[]; // Simplified role display, could be comma-separated - displayStatus: 'active' | 'pending' | 'deactivated'; - displayId: string; // Use member.id or invitation.id - processedRoles: string[]; - isDeactivated?: boolean; -} - export function TeamMembersClient({ data, organizationId, @@ -135,65 +123,12 @@ export function TeamMembersClient({ } }; - // Combine and type members and invitations for filtering/display - const allItems: DisplayItem[] = [ - ...data.members.map((member) => { - // Process the role to handle comma-separated values - const roles = parseRolesString(member.role); - - const isInactive = member.deactivated || !member.isActive; - - return { - ...member, - type: 'member' as const, - displayName: member.user.name || member.user.email || '', - displayEmail: member.user.email || '', - displayRole: member.role, // Keep original for filtering - displayStatus: isInactive ? ('deactivated' as const) : ('active' as const), - displayId: member.id, - // Add processed roles for rendering - processedRoles: roles, - isDeactivated: isInactive, - }; - }), - ...data.pendingInvitations.map((invitation) => { - // Process the role to handle comma-separated values - const roles = parseRolesString(invitation.role); - - return { - ...invitation, - type: 'invitation' as const, - displayName: invitation.email.split('@')[0], // Or just email - displayEmail: invitation.email, - displayRole: invitation.role, // Keep original for filtering - displayStatus: 'pending' as const, - displayId: invitation.id, - // Add processed roles for rendering - processedRoles: roles, - }; - }), - ]; - - const filteredItems = allItems.filter((item) => { - const matchesSearch = - item.displayName.toLowerCase().includes(searchQuery.toLowerCase()) || - item.displayEmail.toLowerCase().includes(searchQuery.toLowerCase()); - - // Check if the role filter matches any of the member's roles - const matchesRole = !roleFilter || item.processedRoles.includes(roleFilter); - - // Status filter: by default (no filter), hide deactivated members - // 'active' explicitly shows non-deactivated members + pending invitations - // 'deactivated' shows only deactivated members - // 'all' shows everything - const matchesStatus = - (statusFilter === 'all') || - (statusFilter === 'deactivated' && item.displayStatus === 'deactivated') || - (statusFilter === 'pending' && item.displayStatus === 'pending') || - (!statusFilter && item.displayStatus !== 'deactivated') || - (statusFilter === 'active' && item.displayStatus === 'active'); - - return matchesSearch && matchesRole && matchesStatus; + const allItems = buildDisplayItems(data); + const filteredItems = filterDisplayItems({ + items: allItems, + searchQuery, + roleFilter, + statusFilter, }); const activeMembers = filteredItems.filter((item) => item.type === 'member'); @@ -362,7 +297,7 @@ export function TeamMembersClient({ {isSyncing ? ( <> - + Syncing... ) : selectedProvider ? ( diff --git a/apps/app/src/app/(app)/[orgId]/people/all/components/filter-members.test.ts b/apps/app/src/app/(app)/[orgId]/people/all/components/filter-members.test.ts new file mode 100644 index 000000000..c23731abc --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/people/all/components/filter-members.test.ts @@ -0,0 +1,272 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { DisplayItem } from './filter-members'; +import type { MemberWithUser } from './TeamMembers'; +import type { Invitation } from '@db'; + +// Mock @/lib/permissions to avoid resolving @trycompai/auth +vi.mock('@/lib/permissions', () => ({ + parseRolesString: (rolesStr: string | null | undefined): string[] => { + if (!rolesStr) return []; + return rolesStr + .split(',') + .map((r) => r.trim()) + .filter((r) => r.length > 0); + }, +})); + +// Import after mock setup +const { buildDisplayItems, filterDisplayItems } = await import('./filter-members'); + +// Minimal member factory for testing +function makeMember(overrides: Partial & { id: string; role: string }): MemberWithUser { + return { + organizationId: 'org_1', + userId: `usr_${overrides.id}`, + createdAt: new Date(), + department: 'none' as never, + jobTitle: null, + isActive: true, + deactivated: false, + externalUserId: null, + externalUserSource: null, + fleetDmLabelId: null, + user: { + id: `usr_${overrides.id}`, + name: `User ${overrides.id}`, + email: `user-${overrides.id}@test.com`, + emailVerified: true, + image: null, + createdAt: new Date(), + updatedAt: new Date(), + lastLogin: null, + role: 'user', + banned: false, + banReason: null, + banExpires: null, + twoFactorEnabled: false, + }, + ...overrides, + } as MemberWithUser; +} + +function makeInvitation(overrides: Partial & { id: string; email: string; role: string }): Invitation { + return { + organizationId: 'org_1', + inviterId: 'usr_inv', + teamId: null, + status: 'pending', + expiresAt: new Date(), + ...overrides, + } as Invitation; +} + +const activeMember = makeMember({ id: 'mem_1', role: 'employee', isActive: true, deactivated: false }); +const deactivatedMember = makeMember({ id: 'mem_2', role: 'admin', isActive: false, deactivated: true }); +const inactiveMember = makeMember({ id: 'mem_3', role: 'employee', isActive: false, deactivated: false }); +const pendingInvite = makeInvitation({ id: 'inv_1', email: 'pending@test.com', role: 'employee' }); + +describe('buildDisplayItems', () => { + it('should mark active members as active', () => { + const items = buildDisplayItems({ members: [activeMember], pendingInvitations: [] }); + + expect(items).toHaveLength(1); + expect(items[0].displayStatus).toBe('active'); + expect(items[0].type).toBe('member'); + expect(items[0].isDeactivated).toBe(false); + }); + + it('should mark deactivated members as deactivated', () => { + const items = buildDisplayItems({ members: [deactivatedMember], pendingInvitations: [] }); + + expect(items).toHaveLength(1); + expect(items[0].displayStatus).toBe('deactivated'); + expect(items[0].isDeactivated).toBe(true); + }); + + it('should mark inactive (isActive=false) members as deactivated', () => { + const items = buildDisplayItems({ members: [inactiveMember], pendingInvitations: [] }); + + expect(items).toHaveLength(1); + expect(items[0].displayStatus).toBe('deactivated'); + expect(items[0].isDeactivated).toBe(true); + }); + + it('should mark pending invitations as pending', () => { + const items = buildDisplayItems({ members: [], pendingInvitations: [pendingInvite] }); + + expect(items).toHaveLength(1); + expect(items[0].displayStatus).toBe('pending'); + expect(items[0].type).toBe('invitation'); + }); + + it('should parse roles from comma-separated string', () => { + const multiRoleMember = makeMember({ id: 'mem_multi', role: 'admin,employee' }); + const items = buildDisplayItems({ members: [multiRoleMember], pendingInvitations: [] }); + + expect(items[0].processedRoles).toEqual(['admin', 'employee']); + }); +}); + +describe('filterDisplayItems', () => { + let allItems: DisplayItem[]; + + const buildAll = () => + buildDisplayItems({ + members: [activeMember, deactivatedMember, inactiveMember], + pendingInvitations: [pendingInvite], + }); + + beforeEach(() => { + allItems = buildAll(); + }); + + describe('status filter', () => { + it('should hide deactivated members by default (no status filter)', () => { + const result = filterDisplayItems({ + items: allItems, + searchQuery: '', + roleFilter: '', + statusFilter: '', + }); + + expect(result.some((i) => i.displayStatus === 'deactivated')).toBe(false); + expect(result.some((i) => i.displayStatus === 'active')).toBe(true); + expect(result.some((i) => i.displayStatus === 'pending')).toBe(true); + }); + + it('should show only active members when status is "active"', () => { + const result = filterDisplayItems({ + items: allItems, + searchQuery: '', + roleFilter: '', + statusFilter: 'active', + }); + + expect(result.every((i) => i.displayStatus === 'active')).toBe(true); + expect(result.length).toBeGreaterThan(0); + }); + + it('should show only deactivated members when status is "deactivated"', () => { + const result = filterDisplayItems({ + items: allItems, + searchQuery: '', + roleFilter: '', + statusFilter: 'deactivated', + }); + + expect(result.every((i) => i.displayStatus === 'deactivated')).toBe(true); + // Both deactivatedMember and inactiveMember should appear + expect(result).toHaveLength(2); + }); + + it('should show only pending invitations when status is "pending"', () => { + const result = filterDisplayItems({ + items: allItems, + searchQuery: '', + roleFilter: '', + statusFilter: 'pending', + }); + + expect(result.every((i) => i.displayStatus === 'pending')).toBe(true); + expect(result).toHaveLength(1); + }); + + it('should show everything when status is "all"', () => { + const result = filterDisplayItems({ + items: allItems, + searchQuery: '', + roleFilter: '', + statusFilter: 'all', + }); + + expect(result).toHaveLength(allItems.length); + }); + }); + + describe('search filter', () => { + it('should filter by name', () => { + const result = filterDisplayItems({ + items: allItems, + searchQuery: 'User mem_1', + roleFilter: '', + statusFilter: 'all', + }); + + expect(result).toHaveLength(1); + expect(result[0].displayId).toBe('mem_1'); + }); + + it('should filter by email', () => { + const result = filterDisplayItems({ + items: allItems, + searchQuery: 'pending@test.com', + roleFilter: '', + statusFilter: 'all', + }); + + expect(result).toHaveLength(1); + expect(result[0].displayId).toBe('inv_1'); + }); + + it('should be case-insensitive', () => { + const result = filterDisplayItems({ + items: allItems, + searchQuery: 'USER MEM_1', + roleFilter: '', + statusFilter: 'all', + }); + + expect(result).toHaveLength(1); + }); + }); + + describe('role filter', () => { + it('should filter by role', () => { + const result = filterDisplayItems({ + items: allItems, + searchQuery: '', + roleFilter: 'admin', + statusFilter: 'all', + }); + + expect(result.every((i) => i.processedRoles.includes('admin'))).toBe(true); + }); + + it('should show all when no role filter set', () => { + const result = filterDisplayItems({ + items: allItems, + searchQuery: '', + roleFilter: '', + statusFilter: 'all', + }); + + expect(result).toHaveLength(allItems.length); + }); + }); + + describe('combined filters', () => { + it('should apply search and status filters together', () => { + const result = filterDisplayItems({ + items: allItems, + searchQuery: 'user-mem', + roleFilter: '', + statusFilter: 'deactivated', + }); + + // Only deactivated members whose name or email contains "user-mem" + expect(result.every((i) => i.displayStatus === 'deactivated')).toBe(true); + expect(result.length).toBeGreaterThan(0); + }); + + it('should return empty when no items match all filters', () => { + const result = filterDisplayItems({ + items: allItems, + searchQuery: 'nonexistent', + roleFilter: 'owner', + statusFilter: 'pending', + }); + + expect(result).toHaveLength(0); + }); + }); +}); diff --git a/apps/app/src/app/(app)/[orgId]/people/all/components/filter-members.ts b/apps/app/src/app/(app)/[orgId]/people/all/components/filter-members.ts new file mode 100644 index 000000000..0ed79226b --- /dev/null +++ b/apps/app/src/app/(app)/[orgId]/people/all/components/filter-members.ts @@ -0,0 +1,89 @@ +import { parseRolesString } from '@/lib/permissions'; +import type { Invitation } from '@db'; +import type { MemberWithUser } from './TeamMembers'; + +export interface DisplayItem extends Partial, Partial { + type: 'member' | 'invitation'; + displayName: string; + displayEmail: string; + displayRole: string | string[]; + displayStatus: 'active' | 'pending' | 'deactivated'; + displayId: string; + processedRoles: string[]; + isDeactivated?: boolean; +} + +export function buildDisplayItems({ + members, + pendingInvitations, +}: { + members: MemberWithUser[]; + pendingInvitations: Invitation[]; +}): DisplayItem[] { + return [ + ...members.map((member) => { + const roles = parseRolesString(member.role); + const isInactive = member.deactivated || !member.isActive; + + return { + ...member, + type: 'member' as const, + displayName: member.user.name || member.user.email || '', + displayEmail: member.user.email || '', + displayRole: member.role, + displayStatus: isInactive ? ('deactivated' as const) : ('active' as const), + displayId: member.id, + processedRoles: roles, + isDeactivated: isInactive, + }; + }), + ...pendingInvitations.map((invitation) => { + const roles = parseRolesString(invitation.role); + + return { + ...invitation, + type: 'invitation' as const, + displayName: invitation.email.split('@')[0], + displayEmail: invitation.email, + displayRole: invitation.role, + displayStatus: 'pending' as const, + displayId: invitation.id, + processedRoles: roles, + }; + }), + ]; +} + +export function filterDisplayItems({ + items, + searchQuery, + roleFilter, + statusFilter, +}: { + items: DisplayItem[]; + searchQuery: string; + roleFilter: string; + statusFilter: string; +}): DisplayItem[] { + return items.filter((item) => { + const matchesSearch = + item.displayName.toLowerCase().includes(searchQuery.toLowerCase()) || + item.displayEmail.toLowerCase().includes(searchQuery.toLowerCase()); + + const matchesRole = !roleFilter || item.processedRoles.includes(roleFilter); + + // Status filter: by default (no filter), hide deactivated members + // 'active' explicitly shows only active members + // 'deactivated' shows only deactivated members + // 'pending' shows only pending invitations + // 'all' shows everything + const matchesStatus = + statusFilter === 'all' || + (statusFilter === 'deactivated' && item.displayStatus === 'deactivated') || + (statusFilter === 'pending' && item.displayStatus === 'pending') || + (!statusFilter && item.displayStatus !== 'deactivated') || + (statusFilter === 'active' && item.displayStatus === 'active'); + + return matchesSearch && matchesRole && matchesStatus; + }); +} diff --git a/apps/app/src/hooks/use-people-api.ts b/apps/app/src/hooks/use-people-api.ts index 20980946d..34b043836 100644 --- a/apps/app/src/hooks/use-people-api.ts +++ b/apps/app/src/hooks/use-people-api.ts @@ -11,6 +11,7 @@ export interface PeopleResponseDto { createdAt: string; // ISO string from API department: string; isActive: boolean; + deactivated: boolean; fleetDmLabelId: number | null; user: { id: string;