From 2c5d87fe9555a3249a1302d91fae68e06f8e87e5 Mon Sep 17 00:00:00 2001 From: Matt Rubens Date: Mon, 7 Jul 2025 18:01:35 -0400 Subject: [PATCH] Use hash-based navigation for modals --- apps/web/src/actions/analytics/events.ts | 23 +++ .../src/app/(authenticated)/usage/Usage.tsx | 101 +++++++++++- .../src/hooks/__tests__/useTaskHash.test.ts | 146 ++++++++++++++++++ apps/web/src/hooks/useTaskHash.ts | 68 ++++++++ 4 files changed, 335 insertions(+), 3 deletions(-) create mode 100644 apps/web/src/hooks/__tests__/useTaskHash.test.ts create mode 100644 apps/web/src/hooks/useTaskHash.ts diff --git a/apps/web/src/actions/analytics/events.ts b/apps/web/src/actions/analytics/events.ts index eba1b6e7eb..73a25d9f0a 100644 --- a/apps/web/src/actions/analytics/events.ts +++ b/apps/web/src/actions/analytics/events.ts @@ -673,3 +673,26 @@ export const getHourlyUsageByUser = async ({ .map((usage) => ({ ...usage, user: users[usage.userId] })) .filter((usage): usage is HourlyUsageByUser => !!usage.user); }; + +/** + * getTaskById - Convenience function to fetch a single task by ID + */ +export const getTaskById = async ({ + taskId, + orgId, + userId, +}: { + taskId: string; + orgId?: string | null; + userId?: string | null; +}): Promise => { + const result = await getTasks({ + taskId, + orgId, + userId, + limit: 1, + allowCrossUserAccess: false, + }); + + return result.tasks[0] || null; +}; diff --git a/apps/web/src/app/(authenticated)/usage/Usage.tsx b/apps/web/src/app/(authenticated)/usage/Usage.tsx index 17fec0b48a..fba5461fd7 100644 --- a/apps/web/src/app/(authenticated)/usage/Usage.tsx +++ b/apps/web/src/app/(authenticated)/usage/Usage.tsx @@ -2,13 +2,15 @@ import { useState, useCallback, useEffect } from 'react'; import { useTranslations } from 'next-intl'; -import { useUser } from '@clerk/nextjs'; +import { useUser, useAuth } from '@clerk/nextjs'; import { X, AlertCircle } from 'lucide-react'; import type { TaskWithUser } from '@/actions/analytics'; +import { getTaskById } from '@/actions/analytics'; import { Button } from '@/components/ui'; import { UsageCard } from '@/components/usage'; import { Loading } from '@/components/layout'; +import { useTaskHash } from '@/hooks/useTaskHash'; import { type Filter, type ViewMode, viewModes, filterExists } from './types'; import { Developers } from './Developers'; @@ -30,12 +32,18 @@ export const Usage = ({ error, }: UsageProps) => { const { isSignedIn } = useUser(); + const { orgId, userId } = useAuth(); const t = useTranslations('Analytics'); const [viewMode, setViewMode] = useState('tasks'); const [filters, setFilters] = useState([]); const [task, setTask] = useState(null); + const [isModalOpen, setIsModalOpen] = useState(false); + const [isLoadingTask, setIsLoadingTask] = useState(false); const [showError, setShowError] = useState(!!error); + // Hash-based routing for deep linking + const { taskIdFromHash, setTaskHash } = useTaskHash(); + const onAddFilter = useCallback((newFilter: Filter) => { setFilters((currentFilters) => { // Don't add if filter already exists @@ -47,6 +55,68 @@ export const Usage = ({ setViewMode('tasks'); }, []); + // Handle hash-based task loading + useEffect(() => { + const loadTaskFromHash = async () => { + if (taskIdFromHash) { + // Wait for auth to be available before attempting to load task + if (!isSignedIn || (orgId === undefined && userId === undefined)) { + return; // Wait for auth to be ready + } + + // Only load if we don't have the task or it's a different task + if (!task || task.taskId !== taskIdFromHash) { + setIsLoadingTask(true); + try { + const taskData = await getTaskById({ + taskId: taskIdFromHash, + orgId, + userId, + }); + + if (taskData) { + setTask(taskData); + setIsModalOpen(true); + } else { + // Task not found or no permission, clear hash + setTaskHash(null); + setShowError(true); + } + } catch (error) { + console.error('Failed to load task:', error); + setTaskHash(null); + setShowError(true); + } finally { + setIsLoadingTask(false); + } + } else if (task && task.taskId === taskIdFromHash && !isModalOpen) { + // We have the right task but modal is closed, open it + setIsModalOpen(true); + } + } else if (!taskIdFromHash) { + // No hash, ensure modal is closed and task is cleared + if (isModalOpen) { + setIsModalOpen(false); + } + if (task) { + setTask(null); + } + // Always clear loading state when no hash + setIsLoadingTask(false); + } + }; + + loadTaskFromHash(); + }, [ + taskIdFromHash, + task, + orgId, + userId, + isModalOpen, + isSignedIn, + setTaskHash, + ]); + useEffect(() => { if (error) { const timer = setTimeout(() => setShowError(false), 10_000); @@ -75,6 +145,22 @@ export const Usage = ({ ); }, []); + // Handle task selection with hash routing + const handleTaskSelect = useCallback( + (selectedTask: TaskWithUser) => { + setTask(selectedTask); + setIsModalOpen(true); + setTaskHash(selectedTask.taskId); + }, + [setTaskHash], + ); + + // Handle modal close with hash routing + const handleTaskClose = useCallback(() => { + // Clear hash first, which will trigger the effect to close the modal + setTaskHash(null); + }, [setTaskHash]); + // For members, automatically set filter to their user ID and hide other tabs. const isMember = userRole === 'member'; const availableViewModes = isMember ? (['tasks'] as const) : viewModes; @@ -140,7 +226,7 @@ export const Usage = ({ {} : onAddFilter} - onTaskSelected={(task: TaskWithUser) => setTask(task)} + onTaskSelected={handleTaskSelect} userRole={userRole} currentUserId={currentUserId} /> @@ -152,8 +238,17 @@ export const Usage = ({ )} + + {/* Loading indicator for hash-based task loading */} + {isLoadingTask && ( +
+ +
+ )} + + {/* Task Modal with hash-based routing */} {task && ( - setTask(null)} /> + )} ); diff --git a/apps/web/src/hooks/__tests__/useTaskHash.test.ts b/apps/web/src/hooks/__tests__/useTaskHash.test.ts new file mode 100644 index 0000000000..cbe1add311 --- /dev/null +++ b/apps/web/src/hooks/__tests__/useTaskHash.test.ts @@ -0,0 +1,146 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { useTaskHash } from '../useTaskHash'; + +// Mock window.location and history +const mockLocation = { + hash: '', + href: 'http://localhost:3000/usage', + pathname: '/usage', + search: '', +}; + +const mockHistory = { + pushState: vi.fn(), +}; + +Object.defineProperty(window, 'location', { + value: mockLocation, + writable: true, +}); + +Object.defineProperty(window, 'history', { + value: mockHistory, + writable: true, +}); + +// Mock addEventListener and removeEventListener +const mockAddEventListener = vi.fn(); +const mockRemoveEventListener = vi.fn(); +Object.defineProperty(window, 'addEventListener', { + value: mockAddEventListener, +}); +Object.defineProperty(window, 'removeEventListener', { + value: mockRemoveEventListener, +}); + +describe('useTaskHash', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockLocation.hash = ''; + mockLocation.href = 'http://localhost:3000/usage'; + }); + + it('should initialize with null taskId when no hash is present', () => { + const { result } = renderHook(() => useTaskHash()); + + expect(result.current.taskIdFromHash).toBeNull(); + }); + + it('should parse taskId from hash on mount', () => { + mockLocation.hash = '#task-abc123'; + + const { result } = renderHook(() => useTaskHash()); + + expect(result.current.taskIdFromHash).toBe('abc123'); + }); + + it('should set task hash in URL', () => { + const { result } = renderHook(() => useTaskHash()); + + act(() => { + result.current.setTaskHash('test-task-id'); + }); + + expect(mockLocation.hash).toBe('task-test-task-id'); + }); + + it('should clear hash when setting null', () => { + mockLocation.hash = '#task-abc123'; + const { result } = renderHook(() => useTaskHash()); + + act(() => { + result.current.setTaskHash(null); + }); + + expect(mockHistory.pushState).toHaveBeenCalledWith( + null, + '', + 'http://localhost:3000/usage', + ); + }); + + it('should clear hash using clearHash method', () => { + mockLocation.hash = '#task-abc123'; + const { result } = renderHook(() => useTaskHash()); + + act(() => { + result.current.clearHash(); + }); + + expect(mockHistory.pushState).toHaveBeenCalledWith( + null, + '', + 'http://localhost:3000/usage', + ); + }); + + it('should register event listeners on mount', () => { + renderHook(() => useTaskHash()); + + expect(mockAddEventListener).toHaveBeenCalledWith( + 'hashchange', + expect.any(Function), + ); + expect(mockAddEventListener).toHaveBeenCalledWith( + 'popstate', + expect.any(Function), + ); + }); + + it('should handle invalid hash formats', () => { + mockLocation.hash = '#invalid-hash'; + + const { result } = renderHook(() => useTaskHash()); + + expect(result.current.taskIdFromHash).toBeNull(); + }); + + it('should handle empty task ID in hash', () => { + mockLocation.hash = '#task-'; + + const { result } = renderHook(() => useTaskHash()); + + expect(result.current.taskIdFromHash).toBeNull(); + }); + + it('should handle hash changes after mount', () => { + const { result } = renderHook(() => useTaskHash()); + + expect(result.current.taskIdFromHash).toBeNull(); + + // Simulate hash change + mockLocation.hash = '#task-new-task'; + const hashChangeHandler = mockAddEventListener.mock.calls.find( + (call) => call[0] === 'hashchange', + )?.[1]; + + if (hashChangeHandler) { + act(() => { + hashChangeHandler(); + }); + } + + expect(result.current.taskIdFromHash).toBe('new-task'); + }); +}); diff --git a/apps/web/src/hooks/useTaskHash.ts b/apps/web/src/hooks/useTaskHash.ts new file mode 100644 index 0000000000..4c334923b2 --- /dev/null +++ b/apps/web/src/hooks/useTaskHash.ts @@ -0,0 +1,68 @@ +import { useEffect, useState, useCallback } from 'react'; + +export type TaskHashState = { + taskIdFromHash: string | null; + setTaskHash: (taskId: string | null) => void; + clearHash: () => void; +}; + +/** + * Hook to manage task-related URL hash state for deep linking + * Supports hash format: #task- + */ +export const useTaskHash = (): TaskHashState => { + const [taskIdFromHash, setTaskIdFromHash] = useState(null); + + // Parse task ID from hash + const parseTaskHash = useCallback((hash: string): string | null => { + const match = hash.match(/^#task-(.+)$/); + return match ? match[1] || null : null; + }, []); + + // Set task hash in URL + const setTaskHash = useCallback((taskId: string | null) => { + if (taskId) { + window.location.hash = `task-${taskId}`; + } else { + // Clear hash without triggering scroll + const url = new URL(window.location.href); + url.hash = ''; + window.history.pushState(null, '', url.toString()); + // Manually update state since pushState doesn't trigger hashchange + setTaskIdFromHash(null); + } + }, []); + + // Clear hash (alias for setTaskHash(null)) + const clearHash = useCallback(() => { + setTaskHash(null); + }, [setTaskHash]); + + // Handle hash changes + useEffect(() => { + const handleHashChange = () => { + const taskId = parseTaskHash(window.location.hash); + setTaskIdFromHash(taskId); + }; + + // Initial check on mount + handleHashChange(); + + // Listen for hash changes + window.addEventListener('hashchange', handleHashChange); + + // Listen for popstate events (back/forward navigation) + window.addEventListener('popstate', handleHashChange); + + return () => { + window.removeEventListener('hashchange', handleHashChange); + window.removeEventListener('popstate', handleHashChange); + }; + }, [parseTaskHash]); + + return { + taskIdFromHash, + setTaskHash, + clearHash, + }; +};