feat: add shared React hooks for workspace and daemon status
This commit is contained in:
parent
39f43763a2
commit
06a21ecbe1
|
|
@ -0,0 +1,31 @@
|
|||
/** @type {import('ts-jest').JestConfigWithTsJest} */
|
||||
export default {
|
||||
preset: 'ts-jest/presets/default',
|
||||
testEnvironment: 'jsdom',
|
||||
testMatch: ['**/src/**/*.test.ts', '**/src/**/*.test.tsx'],
|
||||
moduleNameMapper: {
|
||||
'^(\\.{1,2}/.*)\\.js$': '$1',
|
||||
'^react$': '<rootDir>/node_modules/react',
|
||||
'^react-dom$': '<rootDir>/node_modules/react-dom',
|
||||
},
|
||||
transform: {
|
||||
'^.+\\.tsx?$': [
|
||||
'ts-jest',
|
||||
{
|
||||
tsconfig: {
|
||||
types: ['jest'],
|
||||
jsx: 'react',
|
||||
module: 'ESNext',
|
||||
moduleResolution: 'bundler',
|
||||
allowSyntheticDefaultImports: true,
|
||||
esModuleInterop: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
transformIgnorePatterns: [
|
||||
'node_modules/(?!(react|react-dom|@tanstack/react-query|@testing-library)/)',
|
||||
],
|
||||
setupFilesAfterEnv: ['<rootDir>/jest.setup.cjs'],
|
||||
moduleDirectories: ['node_modules', '<rootDir>/node_modules'],
|
||||
};
|
||||
|
|
@ -0,0 +1 @@
|
|||
module.exports = require('@testing-library/jest-dom');
|
||||
|
|
@ -11,14 +11,19 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.0.0",
|
||||
"@types/event-source": "^3.0.0",
|
||||
"event-source": "^2.0.0",
|
||||
"eventsource": "^2.0.0",
|
||||
"zustand": "^4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@jest/globals": "^30.4.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^14.3.1",
|
||||
"@types/jest": "^29.0.0",
|
||||
"@types/react": "^18.0.0",
|
||||
"jest": "^29.0.0",
|
||||
"jest-environment-jsdom": "^30.4.1",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"typescript": "^5.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,97 @@
|
|||
// packages/shared/src/api/client.ts
|
||||
import { getPlatformBridge } from '../lib/bridge';
|
||||
|
||||
export interface RequestOptions {
|
||||
method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH';
|
||||
body?: any;
|
||||
headers?: Record<string, string>;
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
public status: number,
|
||||
public statusText: string,
|
||||
public body?: any
|
||||
) {
|
||||
super(`API Error ${status}: ${statusText}`);
|
||||
this.name = 'ApiError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ApiClient {
|
||||
private authToken?: string;
|
||||
|
||||
constructor(authToken?: string) {
|
||||
this.authToken = authToken;
|
||||
}
|
||||
|
||||
private getBaseUrl(): string {
|
||||
return getPlatformBridge().getApiBaseUrl();
|
||||
}
|
||||
|
||||
private getDefaultHeaders(): Record<string, string> {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
if (this.authToken) {
|
||||
headers['Authorization'] = `Bearer ${this.authToken}`;
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
async request<T>(path: string, options: RequestOptions = {}): Promise<T> {
|
||||
const baseUrl = this.getBaseUrl();
|
||||
const url = `${baseUrl}${path}`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: options.method || 'GET',
|
||||
headers: {
|
||||
...this.getDefaultHeaders(),
|
||||
...options.headers,
|
||||
},
|
||||
body: options.body ? JSON.stringify(options.body) : undefined,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.json().catch(() => undefined);
|
||||
throw new ApiError(response.status, response.statusText, body);
|
||||
}
|
||||
|
||||
// Handle 204 No Content
|
||||
if (response.status === 204) {
|
||||
return undefined as T;
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async get<T>(path: string, headers?: Record<string, string>): Promise<T> {
|
||||
return this.request<T>(path, { method: 'GET', headers });
|
||||
}
|
||||
|
||||
async post<T>(path: string, body: any, headers?: Record<string, string>): Promise<T> {
|
||||
return this.request<T>(path, { method: 'POST', body, headers });
|
||||
}
|
||||
|
||||
async put<T>(path: string, body: any, headers?: Record<string, string>): Promise<T> {
|
||||
return this.request<T>(path, { method: 'PUT', body, headers });
|
||||
}
|
||||
|
||||
async delete<T>(path: string, headers?: Record<string, string>): Promise<T> {
|
||||
return this.request<T>(path, { method: 'DELETE', headers });
|
||||
}
|
||||
|
||||
async patch<T>(path: string, body: any, headers?: Record<string, string>): Promise<T> {
|
||||
return this.request<T>(path, { method: 'PATCH', body, headers });
|
||||
}
|
||||
|
||||
setAuthToken(token: string): void {
|
||||
this.authToken = token;
|
||||
}
|
||||
|
||||
clearAuthToken(): void {
|
||||
this.authToken = undefined;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
// packages/shared/src/api/paths.ts
|
||||
// API path constants
|
||||
export const API_PATHS = {
|
||||
workspaces: '/api/v1/workspaces',
|
||||
sessions: '/api/v1/sessions',
|
||||
} as const;
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
// packages/shared/src/api/types.ts
|
||||
// Types will be generated from OpenAPI schema
|
||||
// This file is a placeholder for generated types
|
||||
|
||||
export interface ApiResponse<T> {
|
||||
data: T;
|
||||
}
|
||||
|
||||
export interface ApiErrorResponse {
|
||||
error: string;
|
||||
message: string;
|
||||
details?: Record<string, unknown>;
|
||||
}
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
// packages/shared/src/hooks/useDaemonStatus.ts
|
||||
import { useEffect, useState } from 'react';
|
||||
import { getPlatformBridge, DaemonStatus } from '../lib/bridge';
|
||||
|
||||
export function useDaemonStatus() {
|
||||
const [status, setStatus] = useState<DaemonStatus>({ running: false, port: 0 });
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
|
||||
const checkStatus = async () => {
|
||||
try {
|
||||
const bridge = getPlatformBridge();
|
||||
const currentStatus = await bridge.getDaemonStatus();
|
||||
if (mounted) {
|
||||
setStatus(currentStatus);
|
||||
setLoading(false);
|
||||
}
|
||||
} catch (error) {
|
||||
if (mounted) {
|
||||
setStatus({ running: false, port: 0 });
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
checkStatus();
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { status, loading };
|
||||
}
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
// packages/shared/src/hooks/useWorkspaceQuery.test.tsx
|
||||
import React from 'react';
|
||||
import { describe, it, expect, beforeEach } from '@jest/globals';
|
||||
import { renderHook, waitFor } from '@testing-library/react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { useWorkspaceQuery } from './useWorkspaceQuery';
|
||||
import { ApiClient } from '../api/client';
|
||||
import { setPlatformBridge } from '../lib/bridge';
|
||||
|
||||
describe('useWorkspaceQuery', () => {
|
||||
let queryClient: QueryClient;
|
||||
|
||||
beforeEach(() => {
|
||||
queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
},
|
||||
});
|
||||
|
||||
setPlatformBridge({
|
||||
getDaemonStatus: async () => ({ running: true, port: 3001 }),
|
||||
startDaemon: async () => {},
|
||||
stopDaemon: async () => {},
|
||||
getApiBaseUrl: () => 'http://test:3001',
|
||||
subscribeApiBaseUrl: () => () => {},
|
||||
});
|
||||
});
|
||||
|
||||
it('should fetch workspace data', async () => {
|
||||
const mockWorkspaces = [
|
||||
{ id: '1', name: 'Project A', sessions: [] },
|
||||
{ id: '2', name: 'Project B', sessions: [] },
|
||||
];
|
||||
|
||||
globalThis.fetch = globalThis.fetch || jest.fn();
|
||||
(globalThis.fetch as jest.Mock).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ workspaces: mockWorkspaces }),
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useWorkspaceQuery(), {
|
||||
wrapper: ({ children }) => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
),
|
||||
});
|
||||
|
||||
// Trigger the query and wait for it to complete
|
||||
const refetchPromise = result.current.refetch();
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||
await refetchPromise;
|
||||
|
||||
expect(result.current.data).toEqual({ workspaces: mockWorkspaces });
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
// packages/shared/src/hooks/useWorkspaceQuery.ts
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { ApiClient } from '../api/client';
|
||||
|
||||
export interface Session {
|
||||
id: string;
|
||||
projectId: string;
|
||||
agent: string;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface Project {
|
||||
id: string;
|
||||
name: string;
|
||||
sessions: Session[];
|
||||
}
|
||||
|
||||
export interface WorkspaceResponse {
|
||||
workspaces: Project[];
|
||||
}
|
||||
|
||||
const WORKSPACE_QUERY_KEY = ['workspaces'] as const;
|
||||
|
||||
export function useWorkspaceQuery(client?: ApiClient) {
|
||||
const apiClient = client || new ApiClient();
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: WORKSPACE_QUERY_KEY,
|
||||
queryFn: async () => {
|
||||
const response = await apiClient.get<WorkspaceResponse>('/api/v1/workspaces');
|
||||
return response;
|
||||
},
|
||||
enabled: false, // Manually refetch on SSE events
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
return {
|
||||
...query,
|
||||
refetch: () => query.refetch(),
|
||||
};
|
||||
}
|
||||
|
||||
export { WORKSPACE_QUERY_KEY };
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
// packages/shared/src/index.ts
|
||||
export * from './api/client';
|
||||
export * from './api/paths';
|
||||
export * from './api/types';
|
||||
export * from './lib/bridge';
|
||||
export * from './hooks/useWorkspaceQuery';
|
||||
export * from './hooks/useDaemonStatus';
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
export interface DaemonStatus {
|
||||
running: boolean;
|
||||
port: number;
|
||||
}
|
||||
|
||||
export interface PlatformBridge {
|
||||
// Daemon lifecycle
|
||||
getDaemonStatus: () => Promise<DaemonStatus>;
|
||||
startDaemon: () => Promise<void>;
|
||||
stopDaemon: () => Promise<void>;
|
||||
|
||||
// API base URL resolution
|
||||
getApiBaseUrl: () => string;
|
||||
subscribeApiBaseUrl: (callback: (url: string) => void) => () => void;
|
||||
}
|
||||
|
||||
let platformBridgeInstance: PlatformBridge | null = null;
|
||||
|
||||
export function setPlatformBridge(bridge: PlatformBridge): void {
|
||||
platformBridgeInstance = bridge;
|
||||
}
|
||||
|
||||
export function getPlatformBridge(): PlatformBridge {
|
||||
if (!platformBridgeInstance) {
|
||||
throw new Error('PlatformBridge not initialized. Call setPlatformBridge() first.');
|
||||
}
|
||||
return platformBridgeInstance;
|
||||
}
|
||||
Loading…
Reference in New Issue