-
Notifications
You must be signed in to change notification settings - Fork 0
feat(phase5): Cluster B — Inter-Agency Coordination #64
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
9875c3b
71b4cf2
c0d898d
35e1b51
1c1722a
7387789
d0e748b
182b46b
612574e
3b914ee
7ed2ec1
c46c454
6fae9a7
28d9ab0
33a5a4d
55f898e
7fc24dc
c873007
d7d5217
cf3cdb5
48cf645
7516c04
2ba693f
eb15f7b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,138 @@ | ||
| import { describe, it, expect, vi, beforeEach } from 'vitest' | ||
| import { render, screen, fireEvent, waitFor } from '@testing-library/react' | ||
| import '@testing-library/jest-dom/vitest' | ||
| import { CommandChannelPanel } from '../components/CommandChannelPanel.js' | ||
| import { httpsCallable } from 'firebase/functions' | ||
|
|
||
| const { mockOnSnapshot, mockHttpsCallable, mockGetFunctions, mockDb } = vi.hoisted(() => ({ | ||
| mockOnSnapshot: vi.fn(), | ||
| mockHttpsCallable: vi.fn(), | ||
| mockGetFunctions: vi.fn(() => ({})), | ||
| mockDb: {}, | ||
| })) | ||
|
|
||
| interface MockQueryRef { | ||
| type: string | ||
| filters: { type: string; field?: string }[] | ||
| } | ||
|
|
||
| vi.mock('firebase/firestore', () => { | ||
| const collection = vi.fn((_db, name) => ({ type: 'collection', name })) | ||
| const where = vi.fn((field, _op, value) => ({ type: 'where', field, value })) | ||
| const orderBy = vi.fn((field, dir) => ({ type: 'orderBy', field, dir })) | ||
| const limit = vi.fn((n) => ({ type: 'limit', n })) | ||
| const query = vi.fn((...args) => ({ type: 'query', filters: args.slice(1) })) | ||
| return { | ||
| getFirestore: vi.fn(() => mockDb), | ||
| collection, | ||
| query, | ||
| where, | ||
| orderBy, | ||
| limit, | ||
| onSnapshot: mockOnSnapshot, | ||
| doc: vi.fn(), | ||
| } | ||
| }) | ||
|
|
||
| vi.mock('firebase/functions', () => ({ | ||
| httpsCallable: vi.fn(() => mockHttpsCallable), | ||
| getFunctions: mockGetFunctions, | ||
| })) | ||
|
|
||
| const threadSnap = { | ||
| docs: [ | ||
| { | ||
| id: 'th1', | ||
| data: () => ({ | ||
| threadType: 'agency_assistance', | ||
| subject: 'Test thread', | ||
| participantUids: { u1: true, u2: true }, | ||
| lastMessageAt: 1000, | ||
| }), | ||
| }, | ||
| ], | ||
| empty: false, | ||
| } | ||
|
|
||
| const msgSnap = { | ||
| docs: [ | ||
| { | ||
| id: 'm1', | ||
| data: () => ({ | ||
| authorUid: 'u2', | ||
| body: 'Hello world', | ||
| createdAt: 1000, | ||
| }), | ||
| }, | ||
| ], | ||
| empty: false, | ||
| } | ||
|
|
||
| beforeEach(() => { | ||
| mockOnSnapshot.mockReset() | ||
| mockHttpsCallable.mockReset() | ||
| mockHttpsCallable.mockResolvedValue({ data: { status: 'sent' } }) | ||
|
|
||
| mockOnSnapshot.mockImplementation((ref: MockQueryRef, cb) => { | ||
| if (ref.type === 'query') { | ||
| const isMessages = ref.filters.some((f) => f.type === 'where' && f.field === 'threadId') | ||
| cb(isMessages ? msgSnap : threadSnap) | ||
| } | ||
| return vi.fn() | ||
| }) | ||
| }) | ||
|
|
||
| describe('CommandChannelPanel', () => { | ||
| it('renders nothing when no threads', () => { | ||
| mockOnSnapshot.mockImplementation((ref: MockQueryRef, cb) => { | ||
| if (ref.type === 'query') { | ||
| cb({ docs: [], empty: true }) | ||
| } | ||
| return vi.fn() | ||
| }) | ||
| const { container } = render(<CommandChannelPanel reportId="r1" currentUserUid="u1" />) | ||
| expect(container.firstChild).toBeNull() | ||
| }) | ||
|
|
||
| it('renders thread tabs and messages', () => { | ||
| render(<CommandChannelPanel reportId="r1" currentUserUid="u1" />) | ||
| expect(screen.getByText('🏥 Agency')).toBeInTheDocument() | ||
| expect(screen.getByText('Test thread')).toBeInTheDocument() | ||
| expect(screen.getByText('Hello world')).toBeInTheDocument() | ||
| }) | ||
|
|
||
| it('sends a message when clicking Send', async () => { | ||
| render(<CommandChannelPanel reportId="r1" currentUserUid="u1" />) | ||
| const textarea = screen.getByPlaceholderText('Type a message...') | ||
| fireEvent.change(textarea, { target: { value: 'Units dispatched' } }) | ||
| fireEvent.click(screen.getByText('Send')) | ||
| await waitFor(() => { | ||
| expect(httpsCallable).toHaveBeenCalledWith(expect.any(Object), 'addCommandChannelMessage') | ||
| expect(mockHttpsCallable).toHaveBeenCalledWith( | ||
| expect.objectContaining({ | ||
| threadId: 'th1', | ||
| body: 'Units dispatched', | ||
| idempotencyKey: expect.any(String), | ||
| }), | ||
| ) | ||
| }) | ||
| expect(textarea).toHaveValue('') | ||
| }) | ||
|
|
||
| it('displays an error when send fails', async () => { | ||
| mockHttpsCallable.mockRejectedValue(new Error('Network error')) | ||
| render(<CommandChannelPanel reportId="r1" currentUserUid="u1" />) | ||
| const textarea = screen.getByPlaceholderText('Type a message...') | ||
| fireEvent.change(textarea, { target: { value: 'Fail me' } }) | ||
| fireEvent.click(screen.getByText('Send')) | ||
| await waitFor(() => { | ||
| expect(screen.getByText('Network error')).toBeInTheDocument() | ||
| }) | ||
| }) | ||
|
|
||
| it('enforces max length of 2000', () => { | ||
| render(<CommandChannelPanel reportId="r1" currentUserUid="u1" />) | ||
| const textarea = screen.getByPlaceholderText('Type a message...') | ||
| expect(textarea).toHaveAttribute('maxLength', '2000') | ||
| }) | ||
| }) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,75 @@ | ||
| import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' | ||
| import { renderHook, act } from '@testing-library/react' | ||
| import { useFieldModeStore } from '../hooks/useFieldModeStore.js' | ||
|
|
||
| const { mockOnSnapshot, mockHttpsCallable, mockHttpsCallableFn } = vi.hoisted(() => { | ||
| const httpsCallableFn = vi.fn() | ||
| return { | ||
| mockOnSnapshot: vi.fn(), | ||
| mockHttpsCallable: vi.fn(() => httpsCallableFn), | ||
| mockHttpsCallableFn: httpsCallableFn, | ||
| } | ||
| }) | ||
|
|
||
| vi.mock('firebase/firestore', () => ({ | ||
| doc: vi.fn(), | ||
| onSnapshot: mockOnSnapshot, | ||
| getFirestore: vi.fn(() => ({})), | ||
| })) | ||
|
|
||
| vi.mock('firebase/functions', () => ({ | ||
| httpsCallable: mockHttpsCallable, | ||
| getFunctions: vi.fn(() => ({})), | ||
| })) | ||
|
|
||
| const ts = 1700000000000 // fixed timestamp for test determinism | ||
|
|
||
| beforeEach(() => { | ||
| mockOnSnapshot.mockReset() | ||
| mockHttpsCallable.mockClear() | ||
| mockHttpsCallableFn.mockReset() | ||
| mockHttpsCallableFn.mockResolvedValue({ data: { status: 'exited' } }) | ||
| }) | ||
| afterEach(() => { | ||
| vi.useRealTimers() | ||
| }) | ||
|
|
||
| describe('useFieldModeStore', () => { | ||
| it('shows isActive true when session snapshot has isActive true', () => { | ||
| mockOnSnapshot.mockImplementation((_ref, cb) => { | ||
| cb({ | ||
| exists: () => true, | ||
| data: () => ({ isActive: true, expiresAt: ts + 3600000 }), | ||
| }) | ||
| return vi.fn() | ||
| }) | ||
| const { result } = renderHook(() => useFieldModeStore('uid-1')) | ||
| expect(result.current.isActive).toBe(true) | ||
| }) | ||
|
|
||
| it('shows isActive false when no session exists', () => { | ||
| mockOnSnapshot.mockImplementation((_ref, cb) => { | ||
| cb({ exists: () => false, data: () => undefined }) | ||
| return vi.fn() | ||
| }) | ||
| const { result } = renderHook(() => useFieldModeStore('uid-1')) | ||
| expect(result.current.isActive).toBe(false) | ||
| }) | ||
|
|
||
| it('calls exitFieldMode when session expires', () => { | ||
| vi.useFakeTimers() | ||
| const expiredAt = Date.now() - 1000 | ||
| mockOnSnapshot.mockImplementation((_ref, cb) => { | ||
| cb({ | ||
| exists: () => true, | ||
| data: () => ({ isActive: true, expiresAt: expiredAt }), | ||
| }) | ||
| return vi.fn() | ||
| }) | ||
| renderHook(() => useFieldModeStore('uid-1')) | ||
| act(() => { | ||
| vi.advanceTimersByTime(65000) | ||
| }) | ||
| expect(mockHttpsCallable).toHaveBeenCalledWith(expect.anything(), 'exitFieldMode') | ||
| }) | ||
| }) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,160 @@ | ||
| // apps/admin-desktop/src/components/CommandChannelPanel.tsx | ||
| import { useState, useEffect, useRef } from 'react' | ||
| import { | ||
| collection, | ||
| query, | ||
| where, | ||
| orderBy, | ||
| limit, | ||
| onSnapshot, | ||
| type QueryDocumentSnapshot, | ||
| getFirestore, | ||
| } from 'firebase/firestore' | ||
| import { httpsCallable, getFunctions } from 'firebase/functions' | ||
|
|
||
| const db = getFirestore() | ||
| const functions = getFunctions() | ||
|
|
||
| interface Thread { | ||
| id: string | ||
| threadType: 'agency_assistance' | 'border_share' | ||
| subject: string | ||
| participantUids: Record<string, boolean> | ||
| lastMessageAt?: number | ||
| } | ||
|
|
||
| interface Message { | ||
| id: string | ||
| authorUid: string | ||
| body: string | ||
| createdAt: number | ||
| } | ||
|
|
||
| interface Props { | ||
| reportId: string | ||
| currentUserUid: string | ||
| } | ||
|
|
||
| export function CommandChannelPanel({ reportId, currentUserUid }: Props) { | ||
| const [threads, setThreads] = useState<Thread[]>([]) | ||
| const [activeThreadId, setActiveThreadId] = useState<string | null>(null) | ||
| const initialSelectionDoneRef = useRef(false) | ||
| const [messages, setMessages] = useState<Message[]>([]) | ||
| const [input, setInput] = useState('') | ||
| const [error, setError] = useState<string | null>(null) | ||
|
|
||
| useEffect(() => { | ||
| // Reset selection state when reportId changes | ||
| initialSelectionDoneRef.current = false | ||
| queueMicrotask(() => { | ||
| setActiveThreadId(null) | ||
| setMessages([]) | ||
| }) | ||
| const q = query(collection(db, 'command_channel_threads'), where('reportId', '==', reportId)) | ||
| return onSnapshot(q, (snap) => { | ||
| const found = snap.docs.map((d: QueryDocumentSnapshot) => ({ | ||
| id: d.id, | ||
| ...(d.data() as Omit<Thread, 'id'>), | ||
| })) | ||
| setThreads(found) | ||
| if (found.length > 0 && !initialSelectionDoneRef.current) { | ||
| const first = found[0] | ||
| if (first) { | ||
| setActiveThreadId(first.id) | ||
| initialSelectionDoneRef.current = true | ||
| } | ||
| } | ||
| }) | ||
| }, [reportId]) | ||
|
|
||
| useEffect(() => { | ||
| if (!activeThreadId) return | ||
| const q = query( | ||
| collection(db, 'command_channel_messages'), | ||
| where('threadId', '==', activeThreadId), | ||
| orderBy('createdAt', 'desc'), | ||
| limit(50), | ||
| ) | ||
| return onSnapshot(q, (snap) => { | ||
| setMessages( | ||
| snap.docs | ||
| .map((d: QueryDocumentSnapshot) => ({ id: d.id, ...(d.data() as Omit<Message, 'id'>) })) | ||
| .reverse(), | ||
| ) | ||
| }) | ||
| }, [activeThreadId]) | ||
|
Comment on lines
+41
to
+85
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Reset thread selection when
🤖 Prompt for AI Agents |
||
|
|
||
| async function handleSend() { | ||
| if (!activeThreadId || !input.trim()) return | ||
| setError(null) | ||
| try { | ||
| const fn = httpsCallable(functions, 'addCommandChannelMessage') | ||
| await fn({ | ||
| threadId: activeThreadId, | ||
| body: input.trim(), | ||
| idempotencyKey: crypto.randomUUID(), | ||
| }) | ||
| setInput('') | ||
| } catch (err) { | ||
| setError(err instanceof Error ? err.message : 'Failed to send') | ||
| } | ||
| } | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
| if (threads.length === 0) return null | ||
|
|
||
| const activeThread = threads.find((t) => t.id === activeThreadId) | ||
|
|
||
| return ( | ||
| <div className="border rounded-lg p-4 space-y-3"> | ||
| <div className="flex gap-2"> | ||
| {threads.map((t) => ( | ||
| <button | ||
| key={t.id} | ||
| onClick={() => { | ||
| setActiveThreadId(t.id) | ||
| }} | ||
| className={`px-2 py-1 text-xs rounded ${t.id === activeThreadId ? 'bg-blue-600 text-white' : 'bg-gray-100'}`} | ||
| > | ||
| {t.threadType === 'agency_assistance' ? '🏥 Agency' : '🗺️ Border'} | ||
| </button> | ||
| ))} | ||
| </div> | ||
|
|
||
| {activeThread && <p className="text-xs text-gray-500">{activeThread.subject}</p>} | ||
|
|
||
| <div className="h-48 overflow-y-auto space-y-2 border rounded p-2 bg-gray-50"> | ||
| {messages.map((m) => ( | ||
| <div | ||
| key={m.id} | ||
| className={`text-sm ${m.authorUid === currentUserUid ? 'text-right' : ''}`} | ||
| > | ||
| <span className="text-xs text-gray-500">{m.authorUid}</span> | ||
| <p>{m.body}</p> | ||
| </div> | ||
| ))} | ||
| </div> | ||
|
|
||
| <div className="flex gap-2"> | ||
| <textarea | ||
| value={input} | ||
| onChange={(e) => { | ||
| setInput(e.target.value) | ||
| }} | ||
| maxLength={2000} | ||
| rows={2} | ||
| className="flex-1 border rounded px-2 py-1 text-sm resize-none" | ||
| placeholder="Type a message..." | ||
| /> | ||
| <button | ||
| onClick={() => void handleSend()} | ||
| disabled={!input.trim()} | ||
| className="px-3 py-1 bg-blue-600 text-white rounded text-sm disabled:opacity-50" | ||
| > | ||
| Send | ||
| </button> | ||
| </div> | ||
| <p className="text-xs text-right text-gray-400">{input.length}/2000</p> | ||
| {error && <p className="text-xs text-red-600">{error}</p>} | ||
| </div> | ||
| ) | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.