Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
143 changes: 143 additions & 0 deletions application/ui/docs/adr/001-api-client.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
# ADR 001: API Client with openapi-fetch

## Context

We needed a type-safe way to communicate with our FastAPI backend. The API contract is defined via OpenAPI spec, and we wanted:

- Full TypeScript type safety
- Auto-generated types from OpenAPI spec
- React Query integration
- Minimal boilerplate
- Runtime validation

## Decision

We use **`openapi-fetch`** with **`openapi-react-query`** for API communication.

### Implementation

```typescript
// src/api/client.ts

import createFetchClient from 'openapi-fetch';
import createClient from 'openapi-react-query';

import type { paths } from './openapi-spec';

export const API_BASE_URL = import.meta.env.PUBLIC_API_BASE_URL || '';
export const fetchClient = createFetchClient<paths>({ baseUrl: API_BASE_URL });
export const $api = createClient(fetchClient);
```

### Usage

**Query Example:**

```typescript
const { data, isLoading } = $api.useQuery('get', '/api/projects/{project_id}', {
params: { path: { project_id: 'abc-123' } },
});
```

**Mutation Example:**

```typescript
const mutation = $api.useMutation('post', '/api/projects');
mutation.mutate({
body: {
name: 'New Project',
task: { task_type: 'detection', labels: [...] }
}
});
```

### Type Generation

Types are auto-generated from the OpenAPI spec:

```bash
# Fetch latest spec from backend
npm run build:api:download

# Generate TypeScript types
npm run build:api

# Combined command
npm run update-spec
```

This creates `src/api/openapi-spec.d.ts` with full type definitions for all endpoints.

## Alternative Considered

### Axios + Manual Types

- ❌ Requires manual type definitions
- ❌ Types can drift from API
- ✅ Popular, well-known library

## Consequences

### Positive

- ✅ **Full type safety**: Autocomplete for paths, params, body, response
- ✅ **Single source of truth**: OpenAPI spec drives types
- ✅ **React Query integration**: Built-in caching, optimistic updates, mutations
- ✅ **Low maintenance**: Types auto-update when spec changes
- ✅ **Developer experience**: Instant feedback on API changes

### Negative

- ⚠️ **Build step required**: Must run `npm run update-spec` after API changes
- ⚠️ **Backend dependency**: Need running backend to fetch spec
- ⚠️ **Learning curve**: Developers need to understand openapi-fetch patterns

### Neutral

- Type generation is fast (~1 second)
- Generated file is committed to version control (easier CI/CD)

## Implementation Notes

### Proxy Configuration

In development, we use Rsbuild proxy to avoid CORS:

```typescript
// rsbuild.config.ts
server: {
proxy: {
'/api': {
target: 'http://localhost:7860',
changeOrigin: true,
},
},
}
```

### Error Handling

```typescript
const { data, error } = $api.useQuery('get', '/api/projects');

if (error) {
// error is typed based on OpenAPI error schemas
console.error(error.message);
}
```

### Invalidation Patterns

```typescript
const mutation = $api.useMutation('post', '/api/projects', {
meta: {
invalidateQueries: [['get', '/api/projects']],
},
});
```

## References

- [openapi-fetch](https://openapi-ts.pages.dev/openapi-fetch/)
- [openapi-react-query](https://openapi-ts.pages.dev/openapi-react-query/)
- [OpenAPI TypeScript](https://openapi-ts.pages.dev/)
224 changes: 224 additions & 0 deletions application/ui/docs/adr/002-state-management.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
# ADR 002: State Management with Context Providers

## Context

We needed a state management solution that:

- Handles complex domain logic (annotations, zoom, selections)
- Avoids prop drilling
- Works well with React Query for server state
- Keeps components decoupled
- Supports multiple independent state domains

## Decision

Use **React Context API with custom providers** for domain-specific state, and **React Query** for server state.

### Architecture

```
State Layer:
├── Server State (React Query)
│ └── API data, caching, mutations
└── Client State (Context Providers)
├── AnnotationActionsProvider (CRUD operations)
├── AnnotationVisibilityProvider (UI state)
├── SelectAnnotationProvider (selection state)
├── AnnotatorProvider (tool state)
└── ZoomProvider (zoom/pan state)
```

## Implementation

### Provider Pattern

```typescript
// annotation-actions-provider.component.tsx
interface AnnotationsContextValue {
annotations: Annotation[];
addAnnotations: (shapes: Shape[]) => void;
deleteAnnotations: (ids: string[]) => void;
updateAnnotations: (annotations: Annotation[]) => void;
}

const AnnotationsContext = createContext<AnnotationsContextValue | null>(null);

export const AnnotationActionsProvider = ({ children, mediaItem }) => {
const [localAnnotations, setLocalAnnotations] = useState<Annotation[]>([]);

const addAnnotations = (shapes: Shape[]) => {
setLocalAnnotations(prev => [...prev, ...shapes.map(toAnnotation)]);
};

return (
<AnnotationsContext.Provider value={{ annotations: localAnnotations, addAnnotations, ... }}>
{children}
</AnnotationsContext.Provider>
);
};

export const useAnnotationActions = () => {
const context = useContext(AnnotationsContext);
if (!context) throw new Error('Must use within provider');
return context;
};
```

### Provider Composition

```tsx
// media-preview.component.tsx
<AnnotationActionsProvider mediaItem={mediaItem}>
<ZoomProvider>
<SelectAnnotationProvider>
<AnnotationVisibilityProvider>
<AnnotatorProvider>{/* Components access any provider via hooks */}</AnnotatorProvider>
</AnnotationVisibilityProvider>
</SelectAnnotationProvider>
</ZoomProvider>
</AnnotationActionsProvider>
```

## Provider Responsibilities

### AnnotationActionsProvider

**Purpose**: Manages annotation data and CRUD operations
**State**: `localAnnotations[]`, `isDirty`
**Actions**: `addAnnotations()`, `updateAnnotations()`, `deleteAnnotations()`, `submitAnnotations()`
**Syncs with**: Server via React Query

### AnnotationVisibilityProvider

**Purpose**: UI visibility state
**State**: `isVisible`, `isFocussed`
**Actions**: `toggleVisibility()`, `toggleFocus()`
**Pure UI**: No server interaction

### SelectAnnotationProvider

**Purpose**: Selection state
**State**: `selectedAnnotations: Set<string>`
**Actions**: `setSelectedAnnotations()`, `toggleSelection()`
**Supports**: Multi-select, click handlers

### ZoomProvider

**Purpose**: Canvas zoom/pan transformations
**State**: `scale`, `translate`, `canvasSize`
**Actions**: `setZoom()`, `fitToScreen()`, `zoomIn()`, `zoomOut()`
**Math**: Transform calculations

## Alternatives Considered

### 1. Zustand

- ❌ Another dependency
- ❌ Less explicit than Context
- ✅ Simpler API than Redux
- ✅ Good performance

### 2. Props Drilling

- ❌ Unmaintainable for deep hierarchies
- ❌ Violates component boundaries
- ✅ Simple, explicit
- ✅ No magic

## Consequences

### Positive

- ✅ **Scoped state**: Each provider owns its domain
- ✅ **No prop drilling**: Components access state directly
- ✅ **Testable**: Providers can be tested in isolation
- ✅ **Composable**: Mix and match providers as needed
- ✅ **Type-safe**: Full TypeScript support
- ✅ **React-native**: Uses standard React patterns

### Negative

- ⚠️ **Provider hell**: Deep nesting can be verbose
- ⚠️ **Re-render optimization**: Need `useMemo`/`useCallback` carefully
- ⚠️ **No DevTools**: Unlike Redux, no time-travel debugging
- ⚠️ **Testing setup**: Each test needs provider wrapper

### Neutral

- Context API is built-in (no extra dependencies)
- Performance is good enough for our use case
- Can migrate to Zustand/Redux later if needed

## Best Practices

### 1. Single Responsibility

Each provider manages ONE domain:

```typescript
// ✅ Good
AnnotationVisibilityProvider; // Only visibility state

// ❌ Bad
AnnotationProvider; // Too broad, mixes concerns
```

### 2. Custom Hooks

Always provide a custom hook:

```typescript
export const useAnnotationActions = () => {
const context = useContext(AnnotationsContext);
if (!context) throw new Error('Provider missing');
return context;
};
```

### 3. Minimize Re-renders

```typescript
const value = useMemo(
() => ({
annotations,
addAnnotations,
deleteAnnotations,
}),
[annotations]
); // Only recreate if annotations change
```

### 4. Provider Placement

Place providers as close as possible to consumers:

```typescript
// ✅ Scoped to feature
<MediaPreview>
<AnnotationActionsProvider>
<AnnotatorCanvas />
</AnnotationActionsProvider>
</MediaPreview>

// ❌ Too global
<App>
<AnnotationActionsProvider> {/* Unnecessary for non-annotation pages */}
<Router />
</AnnotationActionsProvider>
</App>
```

## Migration Path

If we outgrow Context, migration path:

1. Keep provider interfaces unchanged
2. Swap Context implementation with Zustand/Redux
3. Consumers continue using custom hooks
4. No component changes needed

## References

- [React Context API](https://react.dev/reference/react/createContext)
- [Context Performance](https://react.dev/reference/react/useMemo#skipping-expensive-recalculations)
- [Composition Pattern](https://react.dev/learn/passing-data-deeply-with-context)
Loading
Loading