Use hash-based navigation for modals

This commit is contained in:
Matt Rubens 2025-07-07 18:01:35 -04:00
parent c6df980faf
commit 2c5d87fe95
4 changed files with 335 additions and 3 deletions

View file

@ -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<TaskWithUser | null> => {
const result = await getTasks({
taskId,
orgId,
userId,
limit: 1,
allowCrossUserAccess: false,
});
return result.tasks[0] || null;
};

View file

@ -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<ViewMode>('tasks');
const [filters, setFilters] = useState<Filter[]>([]);
const [task, setTask] = useState<TaskWithUser | null>(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 = ({
<Tasks
filters={effectiveFilters}
onFilter={isMember ? () => {} : onAddFilter}
onTaskSelected={(task: TaskWithUser) => setTask(task)}
onTaskSelected={handleTaskSelect}
userRole={userRole}
currentUserId={currentUserId}
/>
@ -152,8 +238,17 @@ export const Usage = ({
<Models onFilter={onAddFilter} filters={effectiveFilters} />
)}
</div>
{/* Loading indicator for hash-based task loading */}
{isLoadingTask && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<Loading />
</div>
)}
{/* Task Modal with hash-based routing */}
{task && (
<TaskModal task={task} open={!!task} onClose={() => setTask(null)} />
<TaskModal task={task} open={isModalOpen} onClose={handleTaskClose} />
)}
</>
);

View file

@ -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');
});
});

View file

@ -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-<taskId>
*/
export const useTaskHash = (): TaskHashState => {
const [taskIdFromHash, setTaskIdFromHash] = useState<string | null>(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,
};
};