mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-06 08:18:39 +00:00
Use hash-based navigation for modals
This commit is contained in:
parent
c6df980faf
commit
2c5d87fe95
4 changed files with 335 additions and 3 deletions
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
146
apps/web/src/hooks/__tests__/useTaskHash.test.ts
Normal file
146
apps/web/src/hooks/__tests__/useTaskHash.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
68
apps/web/src/hooks/useTaskHash.ts
Normal file
68
apps/web/src/hooks/useTaskHash.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
Loading…
Add table
Reference in a new issue