mirror of
https://github.com/trustgraph-ai/trustgraph.git
synced 2026-07-01 09:29:38 +02:00
Squashed 'ai-context/workbench-ui/' content from commit 32e36a5c
git-subtree-dir: ai-context/workbench-ui git-subtree-split: 32e36a5c2131e429a7081cfaf67dabad3193cda3
This commit is contained in:
commit
a8390532f7
310 changed files with 56430 additions and 0 deletions
28
.github/workflows/pull-request.yaml
vendored
Normal file
28
.github/workflows/pull-request.yaml
vendored
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
|
||||
name: Test pull request
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
|
||||
container-push:
|
||||
|
||||
name: Build
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Install Typescript support
|
||||
run: npm install
|
||||
|
||||
- name: Run tests
|
||||
run: npm test -- --run
|
||||
|
||||
- name: Build
|
||||
run: make service-package VERSION=0.0.0
|
||||
70
.github/workflows/release.yaml
vendored
Normal file
70
.github/workflows/release.yaml
vendored
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
|
||||
name: Build
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
tags:
|
||||
- v*
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
|
||||
deploy:
|
||||
|
||||
name: Build everything
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
id-token: write
|
||||
environment:
|
||||
name: release
|
||||
|
||||
steps:
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@f4ef78c080cd8ba55a85445d5b36e214a81df20a
|
||||
with:
|
||||
username: ${{ vars.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_SECRET }}
|
||||
|
||||
- name: Get version
|
||||
id: version
|
||||
run: echo VERSION=$(git describe --exact-match --tags | sed 's/^v//') >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Install Typescript support
|
||||
run: npm install
|
||||
|
||||
- name: Run tests
|
||||
run: npm test -- --run
|
||||
|
||||
- name: Extract metadata for container
|
||||
id: meta
|
||||
uses: docker/metadata-action@v4
|
||||
with:
|
||||
images: trustgraph/workbench-ui
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=pr
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=sha
|
||||
|
||||
- name: Build
|
||||
run: make service-package VERSION=${{ steps.version.outputs.VERSION }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
id: push
|
||||
uses: docker/build-push-action@3b5e8027fcad23fda98b2e3ac259d8d67585f671
|
||||
with:
|
||||
context: .
|
||||
file: ./Containerfile
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
29
.gitignore
vendored
Normal file
29
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
*~
|
||||
workbench-ui/workbench/__pycache__/
|
||||
workbench-ui/workbench/ui/
|
||||
workbench-ui/workbench/version.py
|
||||
*.egg-info/
|
||||
7
.prettierignore
Normal file
7
.prettierignore
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
*.json
|
||||
*.yaml
|
||||
*.md
|
||||
pulumi
|
||||
**/node_modules
|
||||
env
|
||||
dist
|
||||
3
.prettierrc
Normal file
3
.prettierrc
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"printWidth": 79
|
||||
}
|
||||
836
CODEBOT-INSTRUCTIONS.md
Normal file
836
CODEBOT-INSTRUCTIONS.md
Normal file
|
|
@ -0,0 +1,836 @@
|
|||
# UI Toolkits and Framework Notes
|
||||
|
||||
## Change Management and API Stability
|
||||
|
||||
**CRITICAL**: Components in `src/components/common/` are foundational to the entire application. DO NOT modify these components without explicit approval from the application design authority. Changes to common components have extensive downstream impact and can break multiple features across the application.
|
||||
|
||||
### Change Impact Assessment
|
||||
Before modifying any common component:
|
||||
1. **Document all consumers** - Search the entire codebase for usage
|
||||
2. **Assess breaking changes** - Any interface changes affect all consumers
|
||||
3. **Test extensively** - Changes can break seemingly unrelated features
|
||||
4. **Get approval** - Design authority must approve all common component changes
|
||||
|
||||
### Lessons from SelectField Issues
|
||||
Recent issues with SelectField demonstrate why common component changes are dangerous:
|
||||
- **September 2025**: Changes to SelectField to support one feature (Ontology editor) broke document submission
|
||||
- **Root cause**: Interface contract violations between array/string APIs
|
||||
- **Impact**: Multiple components across different domains affected
|
||||
- **Resolution required**: Systematic updates to 15+ components across the application
|
||||
|
||||
**Key takeaway**: Changing common components to fix one feature often breaks others. Always prefer adapter patterns or feature-specific solutions over modifying shared infrastructure.
|
||||
|
||||
## Directory Structure and Organization Rationale
|
||||
|
||||
### Core Principles
|
||||
|
||||
We follow a **domain-driven, flat structure** that avoids unnecessary nesting and keeps related code together:
|
||||
|
||||
1. **Avoid generic aggregation directories** - No `src/hooks/`, `src/constants/`, `src/utils/` that become dumping grounds
|
||||
2. **Colocate by domain** - Keep related code together in feature-specific directories
|
||||
3. **Flat when possible** - Single files don't need their own subdirectories
|
||||
4. **Clear separation of concerns** - Different types of logic go in appropriate places
|
||||
|
||||
### Directory Layout
|
||||
|
||||
```
|
||||
src/
|
||||
├── components/ # UI components organized by domain
|
||||
│ ├── schemas/ # All schema-related UI components
|
||||
│ │ ├── EditSchemaDialog.tsx # Main orchestrator component
|
||||
│ │ ├── SchemaFieldEditor.tsx # Individual field editing
|
||||
│ │ ├── SchemaFieldsList.tsx # Fields list management
|
||||
│ │ ├── SchemaTableStates.tsx # Reusable table states
|
||||
│ │ ├── useSchemaForm.ts # Form state logic (colocated)
|
||||
│ │ └── ...
|
||||
│ ├── taxonomies/ # All taxonomy-related UI components
|
||||
│ └── common/ # Truly shared/generic components
|
||||
├── model/ # Data models, types, and domain constants
|
||||
│ ├── schemas-table.tsx # Schema data models
|
||||
│ ├── schemaTypes.ts # Schema type constants
|
||||
│ └── ...
|
||||
├── state/ # Application state management
|
||||
│ ├── schemas.ts # Schema API calls and state
|
||||
│ └── ...
|
||||
├── api/ # Direct API communication
|
||||
└── utils/ # Pure utility functions (no React/UI)
|
||||
```
|
||||
|
||||
### Rationale by Directory
|
||||
|
||||
**`src/components/[domain]/`**
|
||||
- Contains ALL UI components for a specific domain (schemas, taxonomies, etc.)
|
||||
- Includes domain-specific hooks like `useSchemaForm.ts`
|
||||
- **Why**: Keeps everything needed to work on a feature in one place
|
||||
- **Avoid**: Generic `src/hooks/` that becomes a dumping ground
|
||||
|
||||
**`src/model/`**
|
||||
- Data types, interfaces, constants, and domain models
|
||||
- **Why**: Centralized data definitions that can be imported anywhere
|
||||
- **Example**: `schemaTypes.ts` contains `SCHEMA_TYPE_OPTIONS` and `DEFAULT_FIELD`
|
||||
|
||||
**`src/state/`**
|
||||
- High-level application state management
|
||||
- React Query hooks for API calls and caching
|
||||
- **Why**: Separates data fetching/caching from UI logic
|
||||
|
||||
**`src/api/`**
|
||||
- Direct API communication layer
|
||||
- WebSocket management
|
||||
- **Why**: Abstracts network concerns from business logic
|
||||
|
||||
### Benefits of This Approach
|
||||
|
||||
1. **Discoverability**: All schema-related code is in `src/components/schemas/`
|
||||
2. **Maintainability**: Changes to schema features are localized
|
||||
3. **Reusability**: Shared types in `src/model/` can be imported anywhere
|
||||
4. **Scalability**: New domains get their own component directories
|
||||
5. **Avoids Anti-patterns**: No generic directories that accumulate unrelated files
|
||||
|
||||
### Example: Schema Feature Organization
|
||||
|
||||
When working on schema-related features, everything you need is in one place:
|
||||
- UI components: `src/components/schemas/`
|
||||
- Data models: `src/model/schemas-table.tsx`, `src/model/schemaTypes.ts`
|
||||
- API/state: `src/state/schemas.ts`
|
||||
|
||||
This eliminates the need to hunt through multiple generic directories to understand or modify a feature.
|
||||
|
||||
## Icon Library
|
||||
|
||||
**CRITICAL**: Always use `lucide-react` for icons throughout the application. Do NOT use `react-icons` or any other icon library.
|
||||
|
||||
```tsx
|
||||
// ✅ Correct - Use lucide-react
|
||||
import { Plus, Save, Trash2, Edit, Settings } from "lucide-react";
|
||||
|
||||
// ❌ Wrong - Don't use react-icons
|
||||
import { FiPlus, FiSave } from "react-icons/fi";
|
||||
```
|
||||
|
||||
**Common icon mappings from react-icons to lucide-react:**
|
||||
- `FiPlus` → `Plus`
|
||||
- `FiX` → `X`
|
||||
- `FiSave` → `Save`
|
||||
- `FiTrash2` → `Trash2`
|
||||
- `FiEdit/FiEdit3` → `Edit`
|
||||
- `FiSettings` → `Settings`
|
||||
- `FiDownload` → `Download`
|
||||
- `FiUpload` → `Upload`
|
||||
- `FiMove` → `Move`
|
||||
- `FiMoreVertical` → `MoreVertical`
|
||||
- `FiList` → `List`
|
||||
|
||||
## Chakra UI Version
|
||||
|
||||
**CRITICAL**: This project uses **Chakra UI v3**, NOT v2. Always check component APIs against v3 documentation.
|
||||
|
||||
## Key Chakra v3 Migration Points
|
||||
|
||||
### Modal → Dialog
|
||||
```tsx
|
||||
// ❌ Chakra v2
|
||||
<Modal isOpen={open} onClose={onClose}>
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader>Title</ModalHeader>
|
||||
<ModalBody>Content</ModalBody>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
|
||||
// ✅ Chakra v3
|
||||
<Dialog.Root open={open} onOpenChange={(x) => onOpenChange(x.open)}>
|
||||
<Portal>
|
||||
<Dialog.Backdrop />
|
||||
<Dialog.Positioner>
|
||||
<Dialog.Content>
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>Title</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
<Dialog.Body>Content</Dialog.Body>
|
||||
</Dialog.Content>
|
||||
</Dialog.Positioner>
|
||||
</Portal>
|
||||
</Dialog.Root>
|
||||
```
|
||||
|
||||
### Toast System
|
||||
```tsx
|
||||
// ❌ Chakra v2
|
||||
const toast = useToast();
|
||||
toast({ title: "Success", status: "success" });
|
||||
|
||||
// ✅ Chakra v3
|
||||
import { toaster } from "../ui/toaster";
|
||||
toaster.create({ title: "Success", status: "success" });
|
||||
```
|
||||
|
||||
### Form Components
|
||||
```tsx
|
||||
// ❌ Chakra v2
|
||||
<FormControl>
|
||||
<FormLabel>Label</FormLabel>
|
||||
<Input />
|
||||
</FormControl>
|
||||
|
||||
// ✅ Chakra v3
|
||||
<Field.Root>
|
||||
<Field.Label>Label</Field.Label>
|
||||
<Input />
|
||||
</Field.Root>
|
||||
```
|
||||
|
||||
### Tabs Structure
|
||||
```tsx
|
||||
// ❌ Chakra v2
|
||||
<Tabs>
|
||||
<TabList>
|
||||
<Tab>Tab 1</Tab>
|
||||
</TabList>
|
||||
<TabPanels>
|
||||
<TabPanel>Content</TabPanel>
|
||||
</TabPanels>
|
||||
</Tabs>
|
||||
|
||||
// ✅ Chakra v3
|
||||
<Tabs.Root>
|
||||
<Tabs.List>
|
||||
<Tabs.Trigger value="tab1">Tab 1</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
<Tabs.Content value="tab1">Content</Tabs.Content>
|
||||
</Tabs.Root>
|
||||
```
|
||||
|
||||
### Menu Components
|
||||
```tsx
|
||||
// ❌ Chakra v2
|
||||
<Menu>
|
||||
<MenuButton>Button</MenuButton>
|
||||
<MenuList>
|
||||
<MenuItem>Item</MenuItem>
|
||||
</MenuList>
|
||||
</Menu>
|
||||
|
||||
// ✅ Chakra v3
|
||||
<Menu.Root>
|
||||
<Menu.Trigger>Button</Menu.Trigger>
|
||||
<Menu.Content>
|
||||
<Menu.Item>Item</Menu.Item>
|
||||
</Menu.Content>
|
||||
</Menu.Root>
|
||||
```
|
||||
|
||||
### Props Changes
|
||||
```tsx
|
||||
// ❌ Chakra v2 props
|
||||
colorScheme="blue"
|
||||
isDisabled={true}
|
||||
|
||||
// ✅ Chakra v3 props
|
||||
colorPalette="blue"
|
||||
disabled={true}
|
||||
```
|
||||
|
||||
### Layout Components
|
||||
```tsx
|
||||
// ❌ Chakra v2
|
||||
<Divider />
|
||||
|
||||
// ✅ Chakra v3
|
||||
<Separator />
|
||||
```
|
||||
|
||||
### Spacing Props
|
||||
```tsx
|
||||
// ❌ Old pattern
|
||||
<VStack spacing={4}>
|
||||
<HStack spacing={2}>
|
||||
|
||||
// ✅ Chakra v3
|
||||
<VStack gap={4}>
|
||||
<HStack gap={2}>
|
||||
```
|
||||
|
||||
### Button Icons
|
||||
```tsx
|
||||
// ❌ Old pattern
|
||||
<Button leftIcon={<Plus />}>Add</Button>
|
||||
<IconButton icon={<Upload />} aria-label="Upload" />
|
||||
|
||||
// ✅ Chakra v3
|
||||
<Button><Plus /> Add</Button>
|
||||
<IconButton aria-label="Upload"><Upload /></IconButton>
|
||||
```
|
||||
|
||||
### Input Groups (Simplified)
|
||||
```tsx
|
||||
// ❌ Chakra v2
|
||||
<InputGroup>
|
||||
<InputLeftElement>🔍</InputLeftElement>
|
||||
<Input placeholder="Search..." />
|
||||
</InputGroup>
|
||||
|
||||
// ✅ Chakra v3 (simplified approach)
|
||||
<Input placeholder="🔍 Search..." />
|
||||
```
|
||||
|
||||
### Avatar Structure
|
||||
```tsx
|
||||
// ❌ Chakra v2
|
||||
<Avatar name="John Doe" />
|
||||
|
||||
// ✅ Chakra v3
|
||||
<Avatar.Root>
|
||||
<Avatar.Fallback name="John Doe" />
|
||||
</Avatar.Root>
|
||||
```
|
||||
|
||||
### Alert Component
|
||||
```tsx
|
||||
// ❌ Chakra v2
|
||||
<Alert status="error">
|
||||
<AlertIcon />
|
||||
<Text>Error message</Text>
|
||||
</Alert>
|
||||
|
||||
// ✅ Chakra v3
|
||||
<Alert.Root status="error">
|
||||
<Alert.Indicator />
|
||||
<Alert.Content>
|
||||
<Alert.Description>Error message</Alert.Description>
|
||||
</Alert.Content>
|
||||
</Alert.Root>
|
||||
```
|
||||
|
||||
**Alert Status Options:**
|
||||
- `status="error"` - Red error alerts
|
||||
- `status="warning"` - Orange warning alerts
|
||||
- `status="success"` - Green success alerts
|
||||
- `status="info"` - Blue info alerts
|
||||
|
||||
**Alert with Title:**
|
||||
```tsx
|
||||
<Alert.Root status="warning">
|
||||
<Alert.Indicator />
|
||||
<Alert.Content>
|
||||
<Alert.Title>Warning Title</Alert.Title>
|
||||
<Alert.Description>Warning description text</Alert.Description>
|
||||
</Alert.Content>
|
||||
</Alert.Root>
|
||||
```
|
||||
|
||||
### Progress Component
|
||||
```tsx
|
||||
// ❌ Chakra v2
|
||||
<Progress value={60} colorScheme="blue" />
|
||||
|
||||
// ✅ Chakra v3
|
||||
<Progress.Root value={60}>
|
||||
<Progress.Track>
|
||||
<Progress.Range />
|
||||
</Progress.Track>
|
||||
<Progress.Label />
|
||||
<Progress.ValueText />
|
||||
</Progress.Root>
|
||||
```
|
||||
|
||||
**Progress with custom styling:**
|
||||
```tsx
|
||||
<Progress.Root value={75} colorPalette="green" size="sm">
|
||||
<Progress.Track>
|
||||
<Progress.Range />
|
||||
</Progress.Track>
|
||||
</Progress.Root>
|
||||
```
|
||||
|
||||
## Layout Components Still Work
|
||||
|
||||
**Important**: VStack, HStack, Box, Grid, GridItem, Text, Button, Input, etc. still work the same way in v3. The confusion around VStack/HStack causing "invalid component type" errors is usually due to **circular import dependencies**, not Chakra version issues.
|
||||
|
||||
## Common Debugging Steps
|
||||
|
||||
1. **Check imports**: Ensure all Chakra components are imported from `@chakra-ui/react`
|
||||
2. **Verify component structure**: Use the v3 nested component patterns (Component.Root, Component.Trigger, etc.)
|
||||
3. **Check props**: Use `colorPalette` instead of `colorScheme`, `disabled` instead of `isDisabled`
|
||||
4. **Circular imports**: If getting "invalid component type" errors with basic components like VStack, check for circular import dependencies
|
||||
|
||||
## Migration Verification Checklist
|
||||
|
||||
When migrating components to Chakra v3:
|
||||
- [ ] Replace `<Alert>` with `<Alert.Root>`
|
||||
- [ ] Replace `<AlertIcon />` with `<Alert.Indicator />`
|
||||
- [ ] Wrap text in `<Alert.Content><Alert.Description>...</Alert.Description></Alert.Content>`
|
||||
- [ ] Replace `<Progress>` with `<Progress.Root><Progress.Track><Progress.Range /></Progress.Track></Progress.Root>`
|
||||
- [ ] Use `<Card.Root><Card.Header /><Card.Body /></Card.Root>` structure for cards
|
||||
- [ ] Replace `<Modal>` with `<Dialog.Root>`
|
||||
- [ ] Replace `spacing` props with `gap` props
|
||||
- [ ] Replace `colorScheme` with `colorPalette`
|
||||
- [ ] Replace `isDisabled` with `disabled`
|
||||
- [ ] Test build after changes
|
||||
- [ ] Verify visual styling is preserved
|
||||
- [ ] Be systematic: search for old patterns, document the fix, then apply consistently
|
||||
|
||||
## Project-Specific Patterns
|
||||
|
||||
### Notifications
|
||||
**CRITICAL: NEVER use the toaster directly.** The `toaster` from `@chakra-ui/react` or `../ui/toaster` must NOT be imported or used directly. Always use the `useNotification` hook:
|
||||
|
||||
```tsx
|
||||
// ❌ NEVER do this - toaster is forbidden
|
||||
import { toaster } from "../ui/toaster";
|
||||
import { toaster } from "@chakra-ui/react";
|
||||
toaster.create({ title: "Success", status: "success" });
|
||||
|
||||
// ✅ ALWAYS do this instead
|
||||
import { useNotification } from "../../state/notify";
|
||||
|
||||
const notify = useNotification();
|
||||
notify.success("Operation completed successfully");
|
||||
notify.error("Something went wrong");
|
||||
notify.info("FYI: This is informational");
|
||||
```
|
||||
|
||||
**Why toaster is forbidden:**
|
||||
- Direct toaster usage bypasses the project's notification standards
|
||||
- The `useNotification` hook provides consistent error prefixing and styling
|
||||
- It maintains a unified notification interface across the application
|
||||
- Direct toaster usage can cause inconsistent user experience
|
||||
|
||||
### Common Components
|
||||
**ALWAYS** prefer using pre-built components from `src/components/common/` instead of raw Chakra components. These components handle Chakra v3 APIs correctly and reduce boilerplate:
|
||||
|
||||
```tsx
|
||||
// ❌ Don't use raw Chakra components
|
||||
<Field.Root required>
|
||||
<Field.Label>Name</Field.Label>
|
||||
<Input value={value} onChange={(e) => setValue(e.target.value)} />
|
||||
</Field.Root>
|
||||
|
||||
// ✅ Use common components instead
|
||||
<TextField
|
||||
label="Name"
|
||||
value={value}
|
||||
onValueChange={setValue}
|
||||
required
|
||||
/>
|
||||
```
|
||||
|
||||
**Available Common Components:**
|
||||
- `TextField` - Text input with label and validation
|
||||
- `TextAreaField` - Multi-line text input
|
||||
- `SelectField` - Dropdown select with rich options
|
||||
- `BasicTable` - Pre-configured Tanstack Table
|
||||
- `Card` - Consistent card layout with title/description
|
||||
- `ProgressSubmitButton` - Submit button with loading state
|
||||
- `PageHeader` - Standard page header layout
|
||||
- `StatusBadge` - Consistent status indicators
|
||||
- `CenterSpinner` - Loading spinner
|
||||
- `ChipInputField` - Tag/chip input field
|
||||
- `NumberField` - Numeric input with validation
|
||||
- `Slider` - Range slider component
|
||||
|
||||
#### SelectField Usage
|
||||
**CRITICAL**: SelectField expects array values for selection and MUST include description fields for dropdown display:
|
||||
|
||||
```tsx
|
||||
// ✅ Correct usage
|
||||
import SelectField from "../common/SelectField";
|
||||
import SelectOptionText from "../common/SelectOptionText";
|
||||
|
||||
<SelectField
|
||||
label="Select Option"
|
||||
items={[
|
||||
{
|
||||
value: 'option1',
|
||||
label: 'Option 1',
|
||||
description: (
|
||||
<SelectOptionText>
|
||||
Option 1
|
||||
</SelectOptionText>
|
||||
)
|
||||
},
|
||||
{
|
||||
value: 'option2',
|
||||
label: 'Option 2',
|
||||
description: (
|
||||
<SelectOptionText>
|
||||
Option 2
|
||||
</SelectOptionText>
|
||||
)
|
||||
}
|
||||
]}
|
||||
value={selectedValues} // array - current selection (empty array for no selection)
|
||||
onValueChange={(values) => setSelectedValues(values)} // receives array
|
||||
/>
|
||||
```
|
||||
|
||||
**Important Notes:**
|
||||
- Pass an empty array `[]` for no selection, not an empty string
|
||||
- The `value` prop should always be an array
|
||||
- The `onValueChange` callback receives an array
|
||||
- For single selection, extract the first element: `values.length > 0 ? values[0] : null`
|
||||
- **REQUIRED**: The `description` field MUST be provided using `SelectOptionText` or `SelectOption` components
|
||||
- **Missing descriptions will result in empty dropdown options**
|
||||
|
||||
**Example with single selection extraction:**
|
||||
```tsx
|
||||
const [selectedValues, setSelectedValues] = useState([]);
|
||||
|
||||
// Get the selected value (for single select behavior)
|
||||
const selectedValue = selectedValues.length > 0 ? selectedValues[0] : null;
|
||||
|
||||
// Handle submission
|
||||
const handleSubmit = () => {
|
||||
if (selectedValue) {
|
||||
onSubmit(selectedValue);
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
**Common Mistake - Missing Descriptions:**
|
||||
```tsx
|
||||
// ❌ WRONG - Will show empty dropdown options
|
||||
items={[
|
||||
{value: 'option1', label: 'Option 1'}, // Missing description!
|
||||
{value: 'option2', label: 'Option 2'} // Missing description!
|
||||
]}
|
||||
|
||||
// ✅ CORRECT - Includes required descriptions
|
||||
items={[
|
||||
{
|
||||
value: 'option1',
|
||||
label: 'Option 1',
|
||||
description: <SelectOptionText>Option 1</SelectOptionText>
|
||||
},
|
||||
{
|
||||
value: 'option2',
|
||||
label: 'Option 2',
|
||||
description: <SelectOptionText>Option 2</SelectOptionText>
|
||||
}
|
||||
]}
|
||||
```
|
||||
|
||||
### Theming and Colors
|
||||
**ALWAYS** use semantic color tokens instead of direct color palettes. The theme provides semantic tokens that automatically handle light/dark mode:
|
||||
|
||||
```tsx
|
||||
// ❌ Don't use direct color palettes
|
||||
colorPalette="blue"
|
||||
bg="gray.100"
|
||||
color="deepPlum.700"
|
||||
|
||||
// ✅ Use semantic tokens instead
|
||||
colorPalette="primary"
|
||||
bg="bg.muted"
|
||||
color="primary.fg"
|
||||
```
|
||||
|
||||
**Available Semantic Color Palettes:**
|
||||
- `primary` - Main brand color (airForceBlue)
|
||||
- `accent` - Secondary brand color (deepPlum)
|
||||
- `observing` - For observation callouts (warmNeutral)
|
||||
- `thinking` - For thinking callouts (deepPlum variants)
|
||||
- `insightful` - For answer callouts (neutralGreen)
|
||||
|
||||
**Semantic Token Structure:**
|
||||
Each palette has these variants:
|
||||
- `.solid` - Strong, high contrast (buttons, badges)
|
||||
- `.contrast` - Text on solid backgrounds
|
||||
- `.fg` - Foreground text color
|
||||
- `.muted` - Subtle backgrounds
|
||||
- `.subtle` - Light backgrounds
|
||||
- `.emphasized` - Medium emphasis backgrounds
|
||||
- `.focusRing` - Focus indicators
|
||||
|
||||
**Background/Text Tokens:**
|
||||
- `background` - Main page background
|
||||
- `text` - Main text color
|
||||
- `bg.muted` - Subtle background areas
|
||||
- `fg.muted` - Muted text
|
||||
|
||||
### Page Structure
|
||||
**ALWAYS** use consistent page structure with PageHeader:
|
||||
|
||||
```tsx
|
||||
// ❌ Don't embed headings in components
|
||||
export const MyComponent = () => {
|
||||
return (
|
||||
<VStack>
|
||||
<Heading>My Page Title</Heading>
|
||||
<Content />
|
||||
</VStack>
|
||||
);
|
||||
};
|
||||
|
||||
// ✅ Use PageHeader at the page level
|
||||
// In pages/MyPage.tsx:
|
||||
import PageHeader from "../components/common/PageHeader";
|
||||
import MyComponent from "../components/MyComponent";
|
||||
|
||||
const MyPage = () => {
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
icon={<IconName />}
|
||||
title="Page Title"
|
||||
description="Brief description of what this page does"
|
||||
/>
|
||||
<MyComponent />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
// In components/MyComponent.tsx (no heading):
|
||||
export const MyComponent = () => {
|
||||
return (
|
||||
<VStack>
|
||||
<Content />
|
||||
</VStack>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
**Page Structure Rules:**
|
||||
1. Page components go in `src/pages/` directory
|
||||
2. Always use `PageHeader` component for consistent headers
|
||||
3. Page title and description should be at page level, not component level
|
||||
4. Components should not contain their own page-level headings
|
||||
5. Use appropriate lucide-react icons for the page icon
|
||||
|
||||
### Progress Management and Loading States
|
||||
|
||||
**CRITICAL**: Always use the `useActivity` hook for loading states instead of managing spinners manually. This provides consistent loading indicators across the application.
|
||||
|
||||
```tsx
|
||||
// ❌ Don't manage loading states manually
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const handleSubmit = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await submitData();
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ✅ Use useActivity hook instead
|
||||
import { useActivity } from "../../state/activity";
|
||||
|
||||
const submitMutation = useMutation({
|
||||
mutationFn: submitData,
|
||||
});
|
||||
|
||||
// Automatically shows/hides loading indicator
|
||||
useActivity(submitMutation.isPending, "Submitting data");
|
||||
```
|
||||
|
||||
**Progress System Components:**
|
||||
|
||||
1. **`useProgressStateStore`** - Zustand store that manages global activity tracking
|
||||
- `activity: Set<string>` - Active operations being tracked
|
||||
- `error: string` - Current error state
|
||||
- `addActivity(name)` - Add a loading operation
|
||||
- `removeActivity(name)` - Remove a loading operation
|
||||
- `setError(message)` - Set/clear error state
|
||||
|
||||
2. **`useActivity(isActive, description)`** - React hook for automatic activity management
|
||||
- `isActive: boolean` - Whether the activity is currently running
|
||||
- `description: string` - User-friendly description of the activity
|
||||
- Automatically adds/removes activities based on the boolean condition
|
||||
- Handles cleanup when component unmounts or dependencies change
|
||||
|
||||
**Usage Patterns:**
|
||||
|
||||
```tsx
|
||||
// ✅ With React Query mutations
|
||||
const updateMutation = useMutation({ mutationFn: updateData });
|
||||
useActivity(updateMutation.isPending, "Updating settings");
|
||||
|
||||
// ✅ With React Query queries
|
||||
const dataQuery = useQuery({ queryKey: ['data'], queryFn: fetchData });
|
||||
useActivity(dataQuery.isLoading, "Loading data");
|
||||
|
||||
// ✅ Multiple activities for complex operations
|
||||
useActivity(settingsQuery.isLoading, "Loading settings");
|
||||
useActivity(updateSettingsMutation.isPending, "Saving settings");
|
||||
useActivity(resetSettingsMutation.isPending, "Resetting settings");
|
||||
|
||||
// ✅ Manual activity management (when useActivity isn't sufficient)
|
||||
const addActivity = useProgressStateStore((state) => state.addActivity);
|
||||
const removeActivity = useProgressStateStore((state) => state.removeActivity);
|
||||
|
||||
const handleComplexOperation = async () => {
|
||||
const activityId = "Processing complex operation";
|
||||
addActivity(activityId);
|
||||
try {
|
||||
await step1();
|
||||
await step2();
|
||||
await step3();
|
||||
} finally {
|
||||
removeActivity(activityId);
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
**Benefits of the Progress System:**
|
||||
- **Consistent UX**: All loading states are managed centrally
|
||||
- **Automatic cleanup**: Activities are removed when operations complete or components unmount
|
||||
- **Deduplication**: Multiple identical activity names are automatically deduplicated
|
||||
- **Global visibility**: The UI can show a global loading indicator when any activities are active
|
||||
- **Error handling**: Centralized error state management
|
||||
- **Zero boilerplate**: Just call `useActivity()` with a boolean and description
|
||||
|
||||
**Integration with TanStack Query:**
|
||||
The progress system integrates perfectly with TanStack Query's loading states:
|
||||
|
||||
```tsx
|
||||
// All these patterns work seamlessly together
|
||||
export const useSettings = () => {
|
||||
const settingsQuery = useQuery({
|
||||
queryKey: ["settings"],
|
||||
queryFn: fetchSettings,
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: updateSettings,
|
||||
});
|
||||
|
||||
// Automatic activity tracking
|
||||
useActivity(settingsQuery.isLoading, "Loading settings");
|
||||
useActivity(updateMutation.isPending, "Saving settings");
|
||||
|
||||
return {
|
||||
settings: settingsQuery.data,
|
||||
isLoading: settingsQuery.isLoading,
|
||||
updateSettings: updateMutation.mutate,
|
||||
isSaving: updateMutation.isPending,
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
### Table Components
|
||||
|
||||
**CRITICAL**: Always use TanStack Table with our standardized table components instead of manually implementing Chakra Table structures.
|
||||
|
||||
**Standard Table Pattern:**
|
||||
|
||||
1. **Create Model File** (`src/model/[feature]-table.tsx`):
|
||||
```tsx
|
||||
import { createColumnHelper } from "@tanstack/react-table";
|
||||
|
||||
export type MyData = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
export const columnHelper = createColumnHelper<MyData>();
|
||||
|
||||
export const columns = [
|
||||
columnHelper.accessor("id", {
|
||||
header: "ID",
|
||||
cell: (info) => info.getValue(),
|
||||
}),
|
||||
columnHelper.accessor("name", {
|
||||
header: "Name",
|
||||
cell: (info) => info.getValue(),
|
||||
}),
|
||||
columnHelper.accessor("description", {
|
||||
header: "Description",
|
||||
cell: (info) => info.getValue(),
|
||||
}),
|
||||
];
|
||||
```
|
||||
|
||||
2. **Use Common Table Components**:
|
||||
```tsx
|
||||
// ❌ Don't manually implement table structure
|
||||
<Table.Root>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.ColumnHeader>Name</Table.ColumnHeader>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{data.map(row => (
|
||||
<Table.Row key={row.id}>
|
||||
<Table.Cell>{row.name}</Table.Cell>
|
||||
</Table.Row>
|
||||
))}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
|
||||
// ✅ Use standardized components and models
|
||||
import { BasicTable } from "../common/BasicTable";
|
||||
import { columns, MyData } from "../../model/my-data-table";
|
||||
|
||||
const table = useReactTable({
|
||||
data: myData as MyData[],
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
});
|
||||
|
||||
return <BasicTable table={table} />;
|
||||
```
|
||||
|
||||
**Available Table Components:**
|
||||
- `BasicTable` - Standard table display
|
||||
- `ClickableTable` - Table with row click handlers
|
||||
- `SelectableTable` - Table with row selection checkboxes
|
||||
|
||||
**Standard Column Patterns:**
|
||||
```tsx
|
||||
// Selection column (for SelectableTable)
|
||||
columnHelper.display({
|
||||
id: "select",
|
||||
header: ({ table }) => (
|
||||
<Checkbox.Root
|
||||
checked={selectionState(table)}
|
||||
onChange={table.getToggleAllRowsSelectedHandler()}
|
||||
>
|
||||
<Checkbox.HiddenInput />
|
||||
<Checkbox.Control />
|
||||
</Checkbox.Root>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<Checkbox.Root
|
||||
checked={row.getIsSelected()}
|
||||
onChange={row.getToggleSelectedHandler()}
|
||||
>
|
||||
<Checkbox.HiddenInput />
|
||||
<Checkbox.Control />
|
||||
</Checkbox.Root>
|
||||
),
|
||||
});
|
||||
|
||||
// Data columns with custom formatting
|
||||
columnHelper.accessor("timestamp", {
|
||||
header: "Created",
|
||||
cell: (info) => timeString(info.getValue()),
|
||||
});
|
||||
|
||||
// Tags/badges column
|
||||
columnHelper.accessor("tags", {
|
||||
header: "Tags",
|
||||
cell: (info) =>
|
||||
info.getValue()?.map((tag) => (
|
||||
<Tag.Root key={tag} mr={2}>
|
||||
<Tag.Label>{tag}</Tag.Label>
|
||||
</Tag.Root>
|
||||
)),
|
||||
});
|
||||
```
|
||||
|
||||
**Benefits of Standardized Tables:**
|
||||
- ✅ Consistent behavior and styling across the application
|
||||
- ✅ Built-in sorting, selection, and interaction patterns
|
||||
- ✅ Type safety with column definitions
|
||||
- ✅ Easier testing and maintenance
|
||||
- ✅ Better performance with TanStack optimizations
|
||||
- ✅ Automatic loading state integration with progress system
|
||||
|
||||
### Other Patterns
|
||||
- Use Tanstack Query for state management with existing socket-based config API
|
||||
- Follow kebab-case naming conventions for IDs and URLs
|
||||
- Always prefer TanStack Table models over manual Chakra Table implementation
|
||||
30
Containerfile
Normal file
30
Containerfile
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
|
||||
FROM alpine:3.21 AS build
|
||||
|
||||
RUN apk add --update --no-cache --no-progress make g++ gcc linux-headers
|
||||
|
||||
RUN apk add --update --no-cache --no-progress python3 py3-pip py3-wheel \
|
||||
python3-dev
|
||||
|
||||
RUN mkdir /root/wheels
|
||||
|
||||
COPY workbench-ui /root/workbench-ui/
|
||||
|
||||
RUN (cd /root/workbench-ui && pip wheel -w /root/wheels --no-deps .)
|
||||
|
||||
FROM alpine:3.21
|
||||
|
||||
ENV PIP_BREAK_SYSTEM_PACKAGES=1
|
||||
|
||||
COPY --from=build /root/wheels /root/wheels
|
||||
|
||||
RUN apk add --update --no-cache --no-progress python3 py3-pip \
|
||||
py3-aiohttp
|
||||
|
||||
RUN pip install /root/wheels/* && \
|
||||
pip cache purge && \
|
||||
rm -rf /root/wheels
|
||||
|
||||
CMD service
|
||||
EXPOSE 8888
|
||||
|
||||
132
FLOW-CLASS-NOTES.md
Normal file
132
FLOW-CLASS-NOTES.md
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
# Flow Class Architecture Notes
|
||||
|
||||
## Overview
|
||||
|
||||
Flow Classes define distributed service mesh architectures for TrustGraph dataflow processing. They specify how processors connect through message queues to form complete data processing pipelines.
|
||||
|
||||
## Core Concepts
|
||||
|
||||
### Service Mesh Graph
|
||||
|
||||
Flow Classes describe a **service mesh graph** where:
|
||||
- **Processors** are nodes that provide and consume services
|
||||
- **Queues** are the edges that connect processors
|
||||
- **Queue names** determine connectivity - matching names create connections
|
||||
- **Template variables** control queue multiplexing strategy
|
||||
|
||||
### Service Providers vs Consumers
|
||||
|
||||
**Service Providers** implement services by listening to special queue names:
|
||||
- `input` - receives work/data to process
|
||||
- `request` - handles request/response patterns (receives requests)
|
||||
- `response` - sends back responses in request/response patterns
|
||||
|
||||
**Service Consumers** use services through all other queue names:
|
||||
- Any queue name NOT `input`/`request`/`response`
|
||||
- Represents dependencies on external services
|
||||
- Send messages TO these queues as clients
|
||||
|
||||
### Queue Multiplexing Strategy
|
||||
|
||||
**Class Processors** (`{class}` template):
|
||||
- Use **shared queues** across all flow instances of that class
|
||||
- One `service-name-{class}` queue serves ALL flows
|
||||
- Higher throughput, shared resources
|
||||
- Example: `user-auth-{class}` becomes `user-auth-nlp-chat`
|
||||
|
||||
**Flow Processors** (`{id}` template):
|
||||
- Use **dedicated queues** per individual flow instance
|
||||
- Each flow gets its own `service-name-{id}` queue
|
||||
- Isolated processing, per-flow state
|
||||
- Example: `document-store-{id}` becomes `document-store-flow123`
|
||||
|
||||
## Flow Class Structure
|
||||
|
||||
```typescript
|
||||
interface FlowClassDefinition {
|
||||
id: string;
|
||||
class: {
|
||||
[processorName: string]: {
|
||||
[queueName: string]: string; // Queue pattern with templates
|
||||
};
|
||||
};
|
||||
flow: {
|
||||
[processorName: string]: {
|
||||
[queueName: string]: string; // Queue pattern with templates
|
||||
};
|
||||
};
|
||||
interfaces: {
|
||||
[interfaceName: string]: string | {
|
||||
request: string;
|
||||
response: string;
|
||||
};
|
||||
};
|
||||
description?: string;
|
||||
tags?: string[];
|
||||
}
|
||||
```
|
||||
|
||||
## Service Graph Connections
|
||||
|
||||
Connections form when:
|
||||
1. **Processor A** has queue "service-x" (as consumer)
|
||||
2. **Processor B** implements "service-x" via `input`/`request`/`response` (as provider)
|
||||
3. This creates edge: A → B
|
||||
|
||||
Example:
|
||||
```
|
||||
nlp-processor:
|
||||
input: "nlp-service-{class}" # Provides nlp-service
|
||||
|
||||
chat-handler:
|
||||
nlp: "nlp-service-{class}" # Consumes nlp-service
|
||||
```
|
||||
Result: `chat-handler` → `nlp-processor`
|
||||
|
||||
## External API Layer
|
||||
|
||||
**Interfaces** define the **external API contract** - how external clients access internal services:
|
||||
|
||||
- **Public Service Contract**: External clients call interface endpoints
|
||||
- **Internal Routing**: Interfaces map to internal queue services
|
||||
- **Implementation Hiding**: Internal processor topology is hidden from clients
|
||||
|
||||
Interface Types:
|
||||
- **Simple**: `"api-endpoint": "internal-service-name"`
|
||||
- **Request/Response**:
|
||||
```json
|
||||
{
|
||||
"user-api": {
|
||||
"request": "user-request-{class}",
|
||||
"response": "user-response-{class}"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Architecture Layers
|
||||
|
||||
```
|
||||
┌─────────────────────────────────┐
|
||||
│ External Clients │
|
||||
│ (REST, GraphQL, etc.) │
|
||||
└─────────────────┬───────────────┘
|
||||
│
|
||||
┌─────────────────▼───────────────┐
|
||||
│ Interfaces │
|
||||
│ (Public API Contract) │
|
||||
└─────────────────┬───────────────┘
|
||||
│
|
||||
┌─────────────────▼───────────────┐
|
||||
│ Internal Service Mesh │
|
||||
│ (Class + Flow Processors) │
|
||||
│ Connected via Queue Names │
|
||||
└─────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Key Insights
|
||||
|
||||
1. **Class vs Flow is about queue sharing**, not different processor types
|
||||
2. **Queue names create the service graph** - they're the connection points
|
||||
3. **Interfaces expose internal services** to external clients
|
||||
4. **Template variables control multiplexing** - shared vs dedicated queues
|
||||
5. **Service mesh is unified** - class and flow processors all participate in the same graph
|
||||
203
LICENSE
Normal file
203
LICENSE
Normal file
|
|
@ -0,0 +1,203 @@
|
|||
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
|
||||
33
Makefile
Normal file
33
Makefile
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
|
||||
PACKAGE_VERSION=0.0.0
|
||||
VERSION=0.0.8
|
||||
|
||||
all: service-package container
|
||||
|
||||
ui:
|
||||
npm run build
|
||||
rm -rf workbench-ui/workbench/ui/
|
||||
cp -r dist/ workbench-ui/workbench/ui/
|
||||
# cp public/*.png workbench-ui/workbench/ui/
|
||||
cp public/*.svg workbench-ui/workbench/ui/
|
||||
|
||||
service-package: ui update-package-versions
|
||||
cd workbench-ui && python3 setup.py sdist --dist-dir ../pkgs/
|
||||
|
||||
update-package-versions:
|
||||
echo __version__ = \"${PACKAGE_VERSION}\" > workbench-ui/workbench/version.py
|
||||
|
||||
CONTAINER=docker.io/trustgraph/workbench-ui
|
||||
DOCKER=podman
|
||||
|
||||
container:
|
||||
${DOCKER} build -f Containerfile -t ${CONTAINER}:${VERSION} \
|
||||
--format docker
|
||||
|
||||
push:
|
||||
${DOCKER} push ${CONTAINER}:${VERSION}
|
||||
|
||||
docker-hub-login:
|
||||
cat docker-token.txt | \
|
||||
docker login -u trustgraph --password-stdin registry-1.docker.io
|
||||
|
||||
31
README.criteria
Normal file
31
README.criteria
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
● Service connections: Same connection type (kind=service) where consumer's {connection-name}-request/{connection-name}-response
|
||||
queues = provider's request/response queues
|
||||
|
||||
Flow connections: Same connection type (kind=flow) where they share the exact same queue value
|
||||
|
||||
Passive connections: Same connection type (kind=passive) where consumer's single queue value = provider's response queue value
|
||||
|
||||
|
||||
|
||||
|
||||
Interfaces...
|
||||
|
||||
1. Look up the interface name in the service map's interfaces definitions
|
||||
2. Check the kind:
|
||||
|
||||
2. If kind = "flow":
|
||||
- The flow class specifies a single queue string
|
||||
- Example: "entity-contexts-load": "persistent://tg/flow/entity-contexts-load:{id}"
|
||||
- To connect: find any processor that produces/consumes this exact queue
|
||||
|
||||
If kind = "service":
|
||||
- The flow class specifies request/response queues
|
||||
- Example: "agent": {"request": "non-persistent://tg/request/agent:{id}", "response": "non-persistent://tg/response/agent:{id}"}
|
||||
- To connect: find any processor whose request/response queues match these
|
||||
3. The interface definition provides:
|
||||
- Documentation (description)
|
||||
- Validation (ensuring correct structure based on kind)
|
||||
- Visibility hints (for UI purposes)
|
||||
|
||||
So the interfaces are essentially the "public API" of a flow class - they declare which standard connection points this flow class
|
||||
exposes, and other components can connect to these standardized interfaces without knowing the internal implementation details.
|
||||
66
README.md
Normal file
66
README.md
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
|
||||
# Test Suite for TrustGraph
|
||||
|
||||
## Setup for Python
|
||||
|
||||
```
|
||||
pip3 -m venv env
|
||||
. env/bin/activate
|
||||
pip3 install -r requirements.txt
|
||||
```
|
||||
|
||||
## Dev mode
|
||||
|
||||
```
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
This runs the application in Vite at http://localhost:5173.
|
||||
Note that UI bit works, but the generation part isn't running.
|
||||
|
||||
## Run it all locally
|
||||
|
||||
This builds the UI and the Python package:
|
||||
```
|
||||
make service-package
|
||||
```
|
||||
|
||||
Then run the Python package which serves the generator and UI:
|
||||
|
||||
```
|
||||
export PYTHONPATH=workbench-ui
|
||||
workbench-ui/scripts/service
|
||||
```
|
||||
|
||||
Generation should work
|
||||
|
||||
## Run it in a container
|
||||
|
||||
Build the container:
|
||||
```
|
||||
make service-package VERSION=0.0.0
|
||||
```
|
||||
|
||||
and run it
|
||||
|
||||
```
|
||||
podman run -i -t -p 8080:8080 localhost/workbench-ui:0.0.0
|
||||
```
|
||||
|
||||
Go to http://localhost:8080
|
||||
|
||||
## Release it
|
||||
|
||||
Deployment is Github actions, automatic to Docker Hub. Deployment kicks in
|
||||
automatically on anything with a version tag. Version tags should be of
|
||||
form v1.2.3. Convention is to have a branch name something like
|
||||
`release/vX.Y` for version tags of the form `vX.Y.Z`. So,
|
||||
version `v0.1.10` would be release on branch `release/v0.1`.
|
||||
|
||||
On release, container images are pushed to docker hub.
|
||||
|
||||
To release with TrustGraph, change the version number of the container
|
||||
in the trustgraph repo, `templates/values/images.jsonnet` and also
|
||||
in the config portal repo, same filename, `templates/values/images.jsonnet`.
|
||||
|
||||
590
TEST_STRATEGY.md
Normal file
590
TEST_STRATEGY.md
Normal file
|
|
@ -0,0 +1,590 @@
|
|||
# Test Strategy for TrustGraph UI
|
||||
|
||||
## Overview
|
||||
|
||||
This document outlines the comprehensive testing strategy for the TrustGraph UI application, a React-based knowledge graph visualization and chat interface built with TypeScript, Vite, and Chakra UI.
|
||||
|
||||
## Technology Stack
|
||||
|
||||
- **Frontend**: React 18, TypeScript, Vite
|
||||
- **UI Framework**: Chakra UI v3
|
||||
- **State Management**: Zustand
|
||||
- **Data Fetching**: TanStack Query (React Query)
|
||||
- **Routing**: React Router v7
|
||||
- **Visualization**: React Force Graph (3D), Three.js
|
||||
- **WebSocket**: Custom socket implementation for real-time communication
|
||||
- **Build Tools**: Vite, ESLint, Prettier
|
||||
|
||||
## Testing Approach
|
||||
|
||||
### 1. Unit Testing
|
||||
|
||||
#### Framework Recommendation
|
||||
- **Jest** + **React Testing Library** for component testing
|
||||
- **Vitest** (recommended for Vite projects) as Jest alternative
|
||||
- **@testing-library/jest-dom** for DOM assertions
|
||||
|
||||
#### What to Test
|
||||
- **State Management (Zustand stores)**:
|
||||
- `src/state/chat.ts` - Chat state management
|
||||
- `src/state/session.ts` - Session state
|
||||
- `src/state/workbench.ts` - Workbench state
|
||||
- `src/state/graph-query.ts` - Graph query state
|
||||
- All other state modules in `src/state/`
|
||||
|
||||
- **Utility Functions**:
|
||||
- `src/utils/knowledge-graph.ts` - Graph manipulation utilities
|
||||
- `src/utils/document-encoding.ts` - Document encoding/decoding
|
||||
- `src/utils/vector-search.ts` - Vector search utilities
|
||||
- `src/utils/time-string.ts` - Time formatting utilities
|
||||
|
||||
- **API Layer**:
|
||||
- `src/api/trustgraph/socket.ts` - WebSocket connection
|
||||
- `src/api/trustgraph/service-call.ts` - Service call utilities
|
||||
- `src/api/trustgraph/messages.ts` - Message handling
|
||||
|
||||
#### Test Structure
|
||||
```
|
||||
tests/
|
||||
├── unit/
|
||||
│ ├── components/
|
||||
│ ├── state/
|
||||
│ ├── utils/
|
||||
│ ├── api/
|
||||
│ └── model/
|
||||
├── integration/
|
||||
├── e2e/
|
||||
└── fixtures/
|
||||
```
|
||||
|
||||
### 2. Component Testing
|
||||
|
||||
#### Core Components to Test
|
||||
- **Chat Components**:
|
||||
- `src/components/chat/ChatConversation.tsx`
|
||||
- `src/components/chat/ChatMessage.tsx`
|
||||
- `src/components/chat/InputArea.tsx`
|
||||
- `src/components/chat/ChatModeSelector.tsx`
|
||||
|
||||
- **Graph Components**:
|
||||
- `src/components/graph/Graph.tsx`
|
||||
- `src/components/entity/EntityDetail.tsx`
|
||||
- `src/components/entity/EntityNode.tsx`
|
||||
|
||||
- **Common Components**:
|
||||
- `src/components/common/BasicTable.tsx`
|
||||
- `src/components/common/SelectableTable.tsx`
|
||||
- `src/components/common/TextField.tsx`
|
||||
- `src/components/common/Card.tsx`
|
||||
|
||||
- **Layout Components**:
|
||||
- `src/components/Layout.tsx`
|
||||
- `src/components/Sidebar.tsx`
|
||||
|
||||
#### Testing Strategies
|
||||
- **Snapshot Testing** for UI consistency
|
||||
- **User Interaction Testing** (click, input, navigation)
|
||||
- **Props Testing** and component behavior
|
||||
- **Error Boundary Testing**
|
||||
- **Accessibility Testing** (screen reader, keyboard navigation)
|
||||
|
||||
### 3. Integration Testing
|
||||
|
||||
#### Areas to Test
|
||||
- **WebSocket Integration**: Test real-time communication with backend
|
||||
- **State Persistence**: Test Zustand store persistence
|
||||
- **Route Navigation**: Test React Router integration
|
||||
- **API Integration**: Test service calls and data flow
|
||||
- **Component Interaction**: Test parent-child component communication
|
||||
|
||||
#### Mock Strategy
|
||||
- Mock WebSocket connections for consistent testing
|
||||
- Mock external APIs and services
|
||||
- Mock file upload/download operations
|
||||
- Mock 3D graph rendering for performance
|
||||
|
||||
### 4. End-to-End Testing
|
||||
|
||||
#### Framework Recommendation
|
||||
- **Playwright** or **Cypress** for cross-browser testing
|
||||
|
||||
#### Test Scenarios
|
||||
1. **User Authentication Flow**
|
||||
2. **Chat Interface**:
|
||||
- Send messages in different modes (graph-rag, agent, basic-llm)
|
||||
- Receive and display responses
|
||||
- Chat history persistence
|
||||
|
||||
3. **Knowledge Graph Visualization**:
|
||||
- Load and display graph data
|
||||
- Node interaction and navigation
|
||||
- Graph filtering and search
|
||||
|
||||
4. **Document Management**:
|
||||
- Upload documents
|
||||
- Process and index documents
|
||||
- Search through document library
|
||||
|
||||
5. **Flow Management**:
|
||||
- Create and edit processing flows
|
||||
- Execute flows with different parameters
|
||||
|
||||
6. **Agent Tools**:
|
||||
- Configure MCP tools
|
||||
- Test agent interactions
|
||||
- Tool execution and results
|
||||
|
||||
### 5. Performance Testing
|
||||
|
||||
#### Areas to Monitor
|
||||
- **Bundle Size**: Monitor JavaScript bundle size
|
||||
- **3D Graph Rendering**: Test performance with large graphs
|
||||
- **Memory Usage**: Monitor memory leaks in long-running sessions
|
||||
- **WebSocket Performance**: Test real-time communication under load
|
||||
|
||||
#### Tools
|
||||
- **Lighthouse** for web performance metrics
|
||||
- **Bundle Analyzer** for bundle size optimization
|
||||
- **React DevTools Profiler** for component performance
|
||||
|
||||
### 6. Accessibility Testing
|
||||
|
||||
#### Requirements
|
||||
- **WCAG 2.1 AA** compliance
|
||||
- **Screen Reader** compatibility
|
||||
- **Keyboard Navigation** support
|
||||
- **Color Contrast** validation
|
||||
|
||||
#### Tools
|
||||
- **axe-core** for automated accessibility testing
|
||||
- **React Testing Library** accessibility queries
|
||||
- **Manual testing** with screen readers
|
||||
|
||||
## Testing Infrastructure
|
||||
|
||||
### Setup Requirements
|
||||
```bash
|
||||
# Install testing dependencies
|
||||
npm install --save-dev \
|
||||
vitest \
|
||||
@testing-library/react \
|
||||
@testing-library/jest-dom \
|
||||
@testing-library/user-event \
|
||||
jsdom \
|
||||
@vitest/ui \
|
||||
playwright
|
||||
```
|
||||
|
||||
### Configuration Files
|
||||
- `vitest.config.ts` - Vitest configuration
|
||||
- `playwright.config.ts` - E2E test configuration
|
||||
- `test-setup.ts` - Global test setup
|
||||
|
||||
### CI/CD Integration
|
||||
- **GitHub Actions** workflow for automated testing
|
||||
- **Pre-commit hooks** for running tests before commits
|
||||
- **Coverage reporting** with minimum thresholds
|
||||
- **Visual regression testing** for UI components
|
||||
|
||||
## Test Data Management
|
||||
|
||||
### Mock Data Strategy
|
||||
- **Fixtures**: Static test data for consistent testing
|
||||
- **Factory Functions**: Generate test data programmatically
|
||||
- **MSW (Mock Service Worker)**: Mock API responses
|
||||
- **WebSocket Mocking**: Mock real-time communication
|
||||
|
||||
### Data Categories
|
||||
- **Graph Data**: Nodes, edges, and relationships
|
||||
- **Chat Messages**: Various message types and formats
|
||||
- **User Sessions**: Authentication and session data
|
||||
- **Document Metadata**: File information and processing status
|
||||
|
||||
## Coverage Goals
|
||||
|
||||
### Minimum Coverage Targets
|
||||
- **Unit Tests**: 80% line coverage
|
||||
- **Integration Tests**: Cover all major user flows
|
||||
- **E2E Tests**: Cover critical business paths
|
||||
- **Component Tests**: 90% of UI components
|
||||
|
||||
### Exclusions
|
||||
- Third-party library code
|
||||
- Generated type definitions
|
||||
- Development-only code
|
||||
|
||||
## Testing Best Practices
|
||||
|
||||
### General Guidelines
|
||||
1. **Test behavior, not implementation**
|
||||
2. **Use descriptive test names**
|
||||
3. **Keep tests independent and isolated**
|
||||
4. **Test error conditions and edge cases**
|
||||
5. **Maintain test data consistency**
|
||||
|
||||
### React-Specific Guidelines
|
||||
1. **Test user interactions, not internal state**
|
||||
2. **Use React Testing Library queries effectively**
|
||||
3. **Test accessibility features**
|
||||
4. **Mock external dependencies**
|
||||
5. **Test component composition**
|
||||
|
||||
## Monitoring and Maintenance
|
||||
|
||||
### Test Health
|
||||
- **Flaky Test Detection**: Monitor and fix unstable tests
|
||||
- **Test Performance**: Keep test execution time reasonable
|
||||
- **Coverage Trends**: Monitor coverage over time
|
||||
- **Test Maintenance**: Regular review and cleanup
|
||||
|
||||
### Quality Gates
|
||||
- **All tests must pass** before merging
|
||||
- **Coverage thresholds** must be maintained
|
||||
- **Performance budgets** must not be exceeded
|
||||
- **Accessibility standards** must be met
|
||||
|
||||
## Domain-Specific Testing Strategy
|
||||
|
||||
### Agents Module (`src/components/agents/`)
|
||||
|
||||
#### Components to Test:
|
||||
- **EditDialog.tsx**: Agent configuration dialog
|
||||
- Form validation for agent settings
|
||||
- Tool selection and configuration
|
||||
- Modal open/close behavior
|
||||
- **ToolsTable.tsx**: Display agent tools
|
||||
- Table rendering with tool data
|
||||
- Tool selection/filtering
|
||||
- Action buttons (edit, delete, enable/disable)
|
||||
|
||||
#### Testing Approach:
|
||||
```tsx
|
||||
// Example test structure
|
||||
describe('Agent EditDialog', () => {
|
||||
it('validates required fields', () => {
|
||||
// Test form validation
|
||||
});
|
||||
|
||||
it('handles tool selection', () => {
|
||||
// Test multi-select tool interface
|
||||
});
|
||||
|
||||
it('saves agent configuration', () => {
|
||||
// Test API calls and state updates
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
#### Mock Requirements:
|
||||
- Agent configuration API calls
|
||||
- Available tools data
|
||||
- Agent execution results
|
||||
|
||||
---
|
||||
|
||||
### Graph Module (`src/components/graph/`)
|
||||
|
||||
#### Components to Test:
|
||||
- **Graph.tsx**: 3D force graph visualization
|
||||
- Graph rendering with mock data
|
||||
- Node selection and highlighting
|
||||
- Performance with large datasets (>1000 nodes)
|
||||
- **NodeDetailsDrawer.tsx**: Entity detail panel
|
||||
- Property display formatting
|
||||
- Relationship navigation
|
||||
- Edit mode functionality
|
||||
|
||||
#### Testing Challenges:
|
||||
- **3D Rendering**: Mock Three.js and react-force-graph
|
||||
- **Performance**: Test with large graph datasets
|
||||
- **Interactions**: Node selection, drag, zoom behaviors
|
||||
|
||||
#### Testing Approach:
|
||||
```tsx
|
||||
describe('Graph Component', () => {
|
||||
beforeEach(() => {
|
||||
// Mock 3D rendering libraries
|
||||
vi.mock('react-force-graph-3d');
|
||||
});
|
||||
|
||||
it('renders nodes and links', () => {
|
||||
// Test graph data rendering
|
||||
});
|
||||
|
||||
it('handles node selection', () => {
|
||||
// Test node click events
|
||||
});
|
||||
|
||||
it('performs well with large datasets', () => {
|
||||
// Performance testing with 1000+ nodes
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### MCP Tools Module (`src/components/mcp-tools/`)
|
||||
|
||||
#### Components to Test:
|
||||
- **EditDialog.tsx**: MCP tool configuration
|
||||
- Tool parameter validation
|
||||
- API endpoint configuration
|
||||
- Authentication settings
|
||||
- **McpToolsTable.tsx**: Tools management interface
|
||||
- Tool status indicators
|
||||
- Bulk operations (enable/disable multiple tools)
|
||||
- Tool execution history
|
||||
|
||||
#### Test Data Requirements:
|
||||
```tsx
|
||||
// Mock MCP tool configurations
|
||||
const mockMcpTool = {
|
||||
id: 'test-tool-1',
|
||||
name: 'File System Tool',
|
||||
endpoint: 'http://localhost:3001',
|
||||
enabled: true,
|
||||
parameters: {
|
||||
path: '/tmp',
|
||||
permissions: 'read-write'
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Prompts Module (`src/components/prompts/`)
|
||||
|
||||
#### Components to Test:
|
||||
- **EditDialog.tsx**: Prompt template editor
|
||||
- Template syntax validation
|
||||
- Variable substitution preview
|
||||
- JSON schema validation for structured outputs
|
||||
- **PromptsTable.tsx**: Prompt management
|
||||
- Template versioning
|
||||
- Usage statistics
|
||||
- Import/export functionality
|
||||
|
||||
#### Key Test Scenarios:
|
||||
- Template variable validation: `{{variable}}` syntax
|
||||
- JSON schema validation for structured prompts
|
||||
- Prompt execution with different input types
|
||||
|
||||
---
|
||||
|
||||
### Schemas Module (`src/components/schemas/`) - Recently Modularized
|
||||
|
||||
#### Components to Test (New Modular Structure):
|
||||
- **SchemaFieldEditor.tsx**: Individual field configuration
|
||||
- Field type selection and validation
|
||||
- Enum value management
|
||||
- Required/optional field toggles
|
||||
- **useSchemaForm.ts**: Form state management hook
|
||||
- Field addition/removal
|
||||
- Form validation logic
|
||||
- Form reset functionality
|
||||
- **EnumValueManager.tsx**: Enum value editing
|
||||
- Add/remove enum values
|
||||
- Duplicate value prevention
|
||||
- Input validation
|
||||
|
||||
#### Testing Approach for Modular Components:
|
||||
```tsx
|
||||
describe('Schema Field Editor', () => {
|
||||
it('handles field type changes', () => {
|
||||
// Test type dropdown and dependent field updates
|
||||
});
|
||||
|
||||
it('manages enum values correctly', () => {
|
||||
// Test enum value addition/removal
|
||||
});
|
||||
|
||||
it('validates field configurations', () => {
|
||||
// Test field validation rules
|
||||
});
|
||||
});
|
||||
|
||||
describe('useSchemaForm hook', () => {
|
||||
it('manages form state correctly', () => {
|
||||
// Test hook state management
|
||||
});
|
||||
|
||||
it('handles field operations', () => {
|
||||
// Test add/remove/update field operations
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Taxonomies Module (`src/components/taxonomies/`) - Extensive Functionality
|
||||
|
||||
#### Priority Components to Test:
|
||||
- **TaxonomyManager.tsx**: Main taxonomy editor
|
||||
- SKOS concept hierarchy management
|
||||
- Concept relationships (broader/narrower/related)
|
||||
- Bulk concept operations
|
||||
- **ConceptEditor.tsx**: Individual concept editing
|
||||
- Concept metadata validation
|
||||
- Relationship consistency checking
|
||||
- Auto-save functionality
|
||||
- **TaxonomyValidationTab.tsx**: SKOS validation
|
||||
- Validation rule execution
|
||||
- Error reporting and suggestions
|
||||
- Auto-fix functionality
|
||||
- **SKOSDialog.tsx**: Import/export functionality
|
||||
- SKOS RDF/XML parsing
|
||||
- Format conversion (RDF ↔ Turtle ↔ JSON)
|
||||
- File upload/download
|
||||
|
||||
#### Complex Test Scenarios:
|
||||
```tsx
|
||||
describe('Taxonomy Validation', () => {
|
||||
it('detects circular references', () => {
|
||||
const invalidTaxonomy = {
|
||||
concepts: {
|
||||
'A': { broader: 'B' },
|
||||
'B': { broader: 'A' } // Circular reference
|
||||
}
|
||||
};
|
||||
// Test validation catches this error
|
||||
});
|
||||
|
||||
it('suggests auto-fixes', () => {
|
||||
// Test quality improvement suggestions
|
||||
});
|
||||
});
|
||||
|
||||
describe('SKOS Import/Export', () => {
|
||||
it('parses valid SKOS RDF/XML', () => {
|
||||
// Test XML parsing and conversion
|
||||
});
|
||||
|
||||
it('handles parsing errors gracefully', () => {
|
||||
// Test error handling for invalid SKOS
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
#### Mock Requirements:
|
||||
- SKOS validation rules
|
||||
- File upload/download operations
|
||||
- Large taxonomy datasets (100+ concepts)
|
||||
|
||||
---
|
||||
|
||||
## Implementation Roadmap (Updated)
|
||||
|
||||
### Phase 1: Foundation & Utilities (Weeks 1-2)
|
||||
- **Complete**: Set up testing framework (Vitest already configured)
|
||||
- **Complete**: Utility function tests (SKOS, time-string, encoding)
|
||||
- **Complete**: Basic common component tests
|
||||
- **New**: Test modularized schema components
|
||||
|
||||
### Phase 2: Domain-Specific Components (Weeks 3-4)
|
||||
- **Agents Module**: Test agent configuration and tools management
|
||||
- **Prompts Module**: Test prompt editing and template validation
|
||||
- **MCP Tools Module**: Test tool configuration and execution
|
||||
- **Graph Module**: Test visualization components (with mocked 3D)
|
||||
|
||||
### Phase 3: Complex Domain Logic (Weeks 5-6)
|
||||
- **Taxonomies Module**: Test SKOS validation, concept editing, import/export
|
||||
- **Schemas Module**: Test advanced schema validation and form logic
|
||||
- **Integration Tests**: Test cross-module interactions
|
||||
|
||||
### Phase 4: Advanced Testing (Weeks 7-8)
|
||||
- **Performance Testing**: Large dataset handling (1000+ graph nodes, 100+ taxonomy concepts)
|
||||
- **E2E Workflows**: Complete user journeys across modules
|
||||
- **Accessibility**: WCAG compliance for all form components
|
||||
- **Visual Regression**: Ensure UI consistency across refactoring
|
||||
|
||||
---
|
||||
|
||||
## Testing Priority Matrix
|
||||
|
||||
### High Priority (Week 3-4)
|
||||
1. **Taxonomies**: Complex SKOS logic, validation, import/export
|
||||
2. **Schemas**: Recently modularized, needs comprehensive coverage
|
||||
3. **Graph**: Core visualization functionality
|
||||
4. **Agents**: Critical for AI functionality
|
||||
|
||||
### Medium Priority (Week 5-6)
|
||||
1. **Prompts**: Template management and validation
|
||||
2. **MCP Tools**: Tool configuration and execution
|
||||
3. **Integration**: Cross-module data flow
|
||||
|
||||
### Lower Priority (Week 7-8)
|
||||
1. **Performance**: Large dataset handling
|
||||
2. **Accessibility**: WCAG compliance
|
||||
3. **Visual**: UI consistency and regression testing
|
||||
|
||||
---
|
||||
|
||||
## Mock Data Strategy (Updated)
|
||||
|
||||
### Taxonomy Test Data:
|
||||
```tsx
|
||||
const mockTaxonomy = {
|
||||
concepts: {
|
||||
'animals': {
|
||||
prefLabel: 'Animals',
|
||||
narrower: ['mammals', 'birds'],
|
||||
topConcept: true
|
||||
},
|
||||
'mammals': {
|
||||
prefLabel: 'Mammals',
|
||||
broader: 'animals',
|
||||
narrower: ['cats', 'dogs']
|
||||
}
|
||||
},
|
||||
scheme: {
|
||||
uri: 'http://example.org/taxonomy',
|
||||
hasTopConcept: ['animals']
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### Schema Test Data:
|
||||
```tsx
|
||||
const mockSchema = {
|
||||
name: 'Customer Record',
|
||||
fields: [
|
||||
{
|
||||
name: 'customer_id',
|
||||
type: 'string',
|
||||
required: true,
|
||||
primary_key: true
|
||||
},
|
||||
{
|
||||
name: 'status',
|
||||
type: 'enum',
|
||||
enum: ['active', 'inactive', 'pending']
|
||||
}
|
||||
]
|
||||
};
|
||||
```
|
||||
|
||||
### Agent Test Data:
|
||||
```tsx
|
||||
const mockAgent = {
|
||||
id: 'research-agent',
|
||||
name: 'Research Assistant',
|
||||
description: 'Helps with research tasks',
|
||||
tools: ['web-search', 'document-reader'],
|
||||
model: 'gpt-4',
|
||||
temperature: 0.7
|
||||
};
|
||||
```
|
||||
|
||||
## Success Metrics
|
||||
|
||||
- **Test Coverage**: Achieve and maintain 80%+ coverage
|
||||
- **Test Reliability**: < 1% flaky test rate
|
||||
- **Test Performance**: Test suite completes in < 5 minutes
|
||||
- **Bug Detection**: Catch 90%+ of bugs before production
|
||||
- **Developer Experience**: Tests provide clear feedback and are easy to maintain
|
||||
|
||||
## Conclusion
|
||||
|
||||
This testing strategy provides comprehensive coverage for the TrustGraph UI application, ensuring reliability, performance, and maintainability. The phased implementation approach allows for gradual adoption while maintaining development velocity.
|
||||
|
||||
Regular review and updates of this strategy will ensure it remains effective as the application evolves and new features are added.
|
||||
734
docs/tech-specs/collections.md
Normal file
734
docs/tech-specs/collections.md
Normal file
|
|
@ -0,0 +1,734 @@
|
|||
# Collections Support for TrustGraph UI
|
||||
|
||||
## Overview
|
||||
|
||||
This document specifies the implementation of collections support in the TrustGraph UI. Collections provide a way to organize and manage groups of documents with metadata including name, description, and tags. The feature adds collection management capabilities to the Library page through a tabbed interface.
|
||||
|
||||
## API Integration
|
||||
|
||||
### Backend API Summary
|
||||
|
||||
The TrustGraph API provides three collection operations via the `collection-management` endpoint:
|
||||
|
||||
#### 1. List Collections
|
||||
- **Operation**: `list-collections`
|
||||
- **Request**:
|
||||
```json
|
||||
{
|
||||
"operation": "list-collections",
|
||||
"user": "username",
|
||||
"tag_filter": ["tag1", "tag2"] // optional
|
||||
}
|
||||
```
|
||||
- **Response**: Array of collection metadata objects
|
||||
- **Fields**: user, collection, name, description, tags, created_at, updated_at
|
||||
|
||||
#### 2. Update Collection (also creates)
|
||||
- **Operation**: `update-collection`
|
||||
- **Request**:
|
||||
```json
|
||||
{
|
||||
"operation": "update-collection",
|
||||
"user": "username",
|
||||
"collection": "collection-id",
|
||||
"name": "Display Name", // optional
|
||||
"description": "Description", // optional
|
||||
"tags": ["tag1", "tag2"] // optional
|
||||
}
|
||||
```
|
||||
- **Response**: Single collection metadata object in `collections` array
|
||||
- **Note**: Creates collection if it doesn't exist; updates if it does
|
||||
|
||||
#### 3. Delete Collection
|
||||
- **Operation**: `delete-collection`
|
||||
- **Request**:
|
||||
```json
|
||||
{
|
||||
"operation": "delete-collection",
|
||||
"user": "username",
|
||||
"collection": "collection-id"
|
||||
}
|
||||
```
|
||||
- **Response**: Empty object `{}`
|
||||
|
||||
### Collection Metadata Structure
|
||||
|
||||
```typescript
|
||||
interface CollectionMetadata {
|
||||
user: string;
|
||||
collection: string; // Collection ID (unique identifier)
|
||||
name: string; // Display name
|
||||
description: string; // Description text
|
||||
tags: string[]; // Array of tags
|
||||
created_at: string; // ISO timestamp
|
||||
updated_at: string; // ISO timestamp
|
||||
}
|
||||
```
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### Phase 1: Socket Layer Integration
|
||||
|
||||
**File**: `src/api/trustgraph/trustgraph-socket.ts`
|
||||
|
||||
Add collection management methods to the socket interface:
|
||||
|
||||
```typescript
|
||||
// Add to Socket interface
|
||||
export interface Socket {
|
||||
// ... existing methods ...
|
||||
|
||||
// Collection management
|
||||
collectionManagement: () => CollectionManagement;
|
||||
}
|
||||
|
||||
// New CollectionManagement interface
|
||||
export interface CollectionManagement {
|
||||
listCollections: (
|
||||
user: string,
|
||||
tagFilter?: string[]
|
||||
) => Promise<CollectionMetadata[]>;
|
||||
|
||||
updateCollection: (
|
||||
user: string,
|
||||
collection: string,
|
||||
name?: string,
|
||||
description?: string,
|
||||
tags?: string[]
|
||||
) => Promise<CollectionMetadata>;
|
||||
|
||||
deleteCollection: (
|
||||
user: string,
|
||||
collection: string
|
||||
) => Promise<void>;
|
||||
}
|
||||
|
||||
// Implementation in BaseApi class
|
||||
class BaseApi {
|
||||
// ... existing methods ...
|
||||
|
||||
collectionManagement(): CollectionManagement {
|
||||
return {
|
||||
listCollections: async (user, tagFilter) => {
|
||||
const request = {
|
||||
operation: "list-collections",
|
||||
user,
|
||||
...(tagFilter && { tag_filter: tagFilter }),
|
||||
};
|
||||
const response = await this.request("collection-management", request);
|
||||
return response.collections || [];
|
||||
},
|
||||
|
||||
updateCollection: async (user, collection, name, description, tags) => {
|
||||
const request = {
|
||||
operation: "update-collection",
|
||||
user,
|
||||
collection,
|
||||
...(name !== undefined && { name }),
|
||||
...(description !== undefined && { description }),
|
||||
...(tags !== undefined && { tags }),
|
||||
};
|
||||
const response = await this.request("collection-management", request);
|
||||
return response.collections[0];
|
||||
},
|
||||
|
||||
deleteCollection: async (user, collection) => {
|
||||
const request = {
|
||||
operation: "delete-collection",
|
||||
user,
|
||||
collection,
|
||||
};
|
||||
await this.request("collection-management", request);
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Phase 2: State Management Hook
|
||||
|
||||
**File**: `src/state/collections.ts` (new file)
|
||||
|
||||
Create a React Query-based state management hook following the library.ts pattern:
|
||||
|
||||
```typescript
|
||||
import { useQueryClient, useQuery, useMutation } from "@tanstack/react-query";
|
||||
import { useSocket, useConnectionState } from "../api/trustgraph/socket";
|
||||
import { useNotification } from "./notify";
|
||||
import { useActivity } from "./activity";
|
||||
import { useSettings } from "./settings";
|
||||
|
||||
export interface CollectionMetadata {
|
||||
user: string;
|
||||
collection: string;
|
||||
name: string;
|
||||
description: string;
|
||||
tags: string[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export const useCollections = () => {
|
||||
const socket = useSocket();
|
||||
const connectionState = useConnectionState();
|
||||
const queryClient = useQueryClient();
|
||||
const notify = useNotification();
|
||||
const { settings } = useSettings();
|
||||
|
||||
const isSocketReady =
|
||||
connectionState?.status === "authenticated" ||
|
||||
connectionState?.status === "unauthenticated";
|
||||
|
||||
// Query for fetching all collections
|
||||
const collectionsQuery = useQuery({
|
||||
queryKey: ["collections", settings.user],
|
||||
enabled: isSocketReady && !!settings.user,
|
||||
queryFn: () => {
|
||||
return socket.collectionManagement().listCollections(settings.user);
|
||||
},
|
||||
});
|
||||
|
||||
// Mutation for creating/updating a collection
|
||||
const updateCollectionMutation = useMutation({
|
||||
mutationFn: ({ collection, name, description, tags, onSuccess }) => {
|
||||
return socket
|
||||
.collectionManagement()
|
||||
.updateCollection(
|
||||
settings.user,
|
||||
collection,
|
||||
name,
|
||||
description,
|
||||
tags
|
||||
)
|
||||
.then(() => {
|
||||
if (onSuccess) onSuccess();
|
||||
});
|
||||
},
|
||||
onError: (err) => {
|
||||
console.log("Error:", err);
|
||||
notify.error(err.toString());
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["collections"] });
|
||||
notify.success("Collection saved successfully");
|
||||
},
|
||||
});
|
||||
|
||||
// Mutation for deleting collections
|
||||
const deleteCollectionsMutation = useMutation({
|
||||
mutationFn: ({ collections, onSuccess }) => {
|
||||
return Promise.all(
|
||||
collections.map((collection) =>
|
||||
socket
|
||||
.collectionManagement()
|
||||
.deleteCollection(settings.user, collection)
|
||||
)
|
||||
).then(() => {
|
||||
if (onSuccess) onSuccess();
|
||||
});
|
||||
},
|
||||
onError: (err) => {
|
||||
console.log("Error:", err);
|
||||
notify.error(err.toString());
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["collections"] });
|
||||
notify.success("Collections deleted successfully");
|
||||
},
|
||||
});
|
||||
|
||||
// Activity indicators
|
||||
useActivity(collectionsQuery.isLoading, "Loading collections");
|
||||
useActivity(updateCollectionMutation.isPending, "Saving collection");
|
||||
useActivity(deleteCollectionsMutation.isPending, "Deleting collections");
|
||||
|
||||
return {
|
||||
// Collection data and query state
|
||||
collections: collectionsQuery.data || [],
|
||||
isLoading: collectionsQuery.isLoading,
|
||||
isError: collectionsQuery.isError,
|
||||
error: collectionsQuery.error,
|
||||
|
||||
// Update/create collection operations
|
||||
updateCollection: updateCollectionMutation.mutate,
|
||||
isUpdating: updateCollectionMutation.isPending,
|
||||
updateError: updateCollectionMutation.error,
|
||||
|
||||
// Delete collection operations
|
||||
deleteCollections: deleteCollectionsMutation.mutate,
|
||||
isDeleting: deleteCollectionsMutation.isPending,
|
||||
deleteError: deleteCollectionsMutation.error,
|
||||
|
||||
// Manual refetch
|
||||
refetch: collectionsQuery.refetch,
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
### Phase 3: Data Model
|
||||
|
||||
**File**: `src/model/collection-table.tsx` (new file)
|
||||
|
||||
Define table columns for collections display:
|
||||
|
||||
```typescript
|
||||
import { createColumnHelper } from "@tanstack/react-table";
|
||||
import { Tag } from "@chakra-ui/react";
|
||||
import { Checkbox } from "../components/ui/checkbox";
|
||||
import { selectionState } from "../components/common/SelectableTable";
|
||||
import { CollectionMetadata } from "../state/collections";
|
||||
|
||||
export const columnHelper = createColumnHelper<CollectionMetadata>();
|
||||
|
||||
export const columns = [
|
||||
// Selection column
|
||||
columnHelper.display({
|
||||
id: "select",
|
||||
header: ({ table }) => (
|
||||
<Checkbox.Root
|
||||
checked={selectionState(table)}
|
||||
onChange={table.getToggleAllRowsSelectedHandler()}
|
||||
>
|
||||
<Checkbox.HiddenInput />
|
||||
<Checkbox.Control />
|
||||
</Checkbox.Root>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<Checkbox.Root
|
||||
checked={row.getIsSelected()}
|
||||
onChange={row.getToggleSelectedHandler()}
|
||||
>
|
||||
<Checkbox.HiddenInput />
|
||||
<Checkbox.Control />
|
||||
</Checkbox.Root>
|
||||
),
|
||||
}),
|
||||
|
||||
// Collection ID column
|
||||
columnHelper.accessor("collection", {
|
||||
header: "Collection ID",
|
||||
cell: (info) => info.getValue(),
|
||||
}),
|
||||
|
||||
// Name column
|
||||
columnHelper.accessor("name", {
|
||||
header: "Name",
|
||||
cell: (info) => info.getValue(),
|
||||
}),
|
||||
|
||||
// Description column
|
||||
columnHelper.accessor("description", {
|
||||
header: "Description",
|
||||
cell: (info) => info.getValue(),
|
||||
}),
|
||||
|
||||
// Tags column
|
||||
columnHelper.accessor("tags", {
|
||||
header: "Tags",
|
||||
cell: (info) =>
|
||||
info.getValue()?.map((tag) => (
|
||||
<Tag.Root key={tag} mr={2} size="sm">
|
||||
<Tag.Label>{tag}</Tag.Label>
|
||||
</Tag.Root>
|
||||
)),
|
||||
}),
|
||||
|
||||
// Created column
|
||||
columnHelper.accessor("created_at", {
|
||||
header: "Created",
|
||||
cell: (info) => new Date(info.getValue()).toLocaleString(),
|
||||
}),
|
||||
|
||||
// Updated column
|
||||
columnHelper.accessor("updated_at", {
|
||||
header: "Updated",
|
||||
cell: (info) => new Date(info.getValue()).toLocaleString(),
|
||||
}),
|
||||
];
|
||||
```
|
||||
|
||||
### Phase 4: UI Components
|
||||
|
||||
#### 4.1 Collections Component
|
||||
|
||||
**File**: `src/components/library/Collections.tsx` (new file)
|
||||
|
||||
Main collections management component following the Documents.tsx pattern:
|
||||
|
||||
```typescript
|
||||
import React, { useState } from "react";
|
||||
import { getCoreRowModel, useReactTable } from "@tanstack/react-table";
|
||||
|
||||
import { columns } from "../../model/collection-table";
|
||||
import { useCollections } from "../../state/collections";
|
||||
import { useNotification } from "../../state/notify";
|
||||
|
||||
import CollectionActions from "./CollectionActions";
|
||||
import CollectionDialog from "./CollectionDialog";
|
||||
import SelectableTable from "../common/SelectableTable";
|
||||
import CollectionControls from "./CollectionControls";
|
||||
|
||||
const Collections = () => {
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [editingCollection, setEditingCollection] = useState(null);
|
||||
|
||||
const notify = useNotification();
|
||||
const collectionsState = useCollections();
|
||||
|
||||
const collections = collectionsState.collections || [];
|
||||
|
||||
const table = useReactTable({
|
||||
data: collections,
|
||||
columns: columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
});
|
||||
|
||||
const selected = table.getSelectedRowModel().rows.map((x) => x.original.collection);
|
||||
|
||||
const onCreateNew = () => {
|
||||
setEditingCollection(null);
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const onEdit = () => {
|
||||
if (selected.length !== 1) {
|
||||
notify.info("Please select exactly one collection to edit");
|
||||
return;
|
||||
}
|
||||
const collection = collections.find(c => c.collection === selected[0]);
|
||||
setEditingCollection(collection);
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const onDelete = () => {
|
||||
collectionsState.deleteCollections({
|
||||
collections: selected,
|
||||
onSuccess: () => {
|
||||
table.setRowSelection({});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const onSaveCollection = (collection, name, description, tags) => {
|
||||
collectionsState.updateCollection({
|
||||
collection,
|
||||
name,
|
||||
description,
|
||||
tags,
|
||||
onSuccess: () => {
|
||||
setDialogOpen(false);
|
||||
table.setRowSelection({});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<CollectionActions
|
||||
selectedCount={selected.length}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
|
||||
<CollectionDialog
|
||||
open={dialogOpen}
|
||||
onOpenChange={setDialogOpen}
|
||||
onSave={onSaveCollection}
|
||||
editingCollection={editingCollection}
|
||||
/>
|
||||
|
||||
<SelectableTable table={table} />
|
||||
|
||||
<CollectionControls onCreate={onCreateNew} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Collections;
|
||||
```
|
||||
|
||||
#### 4.2 Collection Actions Bar
|
||||
|
||||
**File**: `src/components/library/CollectionActions.tsx` (new file)
|
||||
|
||||
Action buttons for bulk operations on selected collections:
|
||||
|
||||
```typescript
|
||||
import React from "react";
|
||||
import { HStack, Button, Text } from "@chakra-ui/react";
|
||||
import { Edit, Trash2 } from "lucide-react";
|
||||
|
||||
interface CollectionActionsProps {
|
||||
selectedCount: number;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
const CollectionActions = ({ selectedCount, onEdit, onDelete }: CollectionActionsProps) => {
|
||||
if (selectedCount === 0) return null;
|
||||
|
||||
return (
|
||||
<HStack mb={4} p={4} bg="bg.muted" borderRadius="md">
|
||||
<Text flex={1}>
|
||||
{selectedCount} collection{selectedCount !== 1 ? "s" : ""} selected
|
||||
</Text>
|
||||
<Button onClick={onEdit} size="sm">
|
||||
<Edit /> Edit
|
||||
</Button>
|
||||
<Button onClick={onDelete} colorPalette="red" size="sm">
|
||||
<Trash2 /> Delete
|
||||
</Button>
|
||||
</HStack>
|
||||
);
|
||||
};
|
||||
|
||||
export default CollectionActions;
|
||||
```
|
||||
|
||||
#### 4.3 Collection Dialog
|
||||
|
||||
**File**: `src/components/library/CollectionDialog.tsx` (new file)
|
||||
|
||||
Dialog for creating/editing collections:
|
||||
|
||||
```typescript
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Dialog } from "@chakra-ui/react";
|
||||
import { Portal } from "@chakra-ui/react";
|
||||
|
||||
import TextField from "../common/TextField";
|
||||
import TextAreaField from "../common/TextAreaField";
|
||||
import ChipInputField from "../common/ChipInputField";
|
||||
import ProgressSubmitButton from "../common/ProgressSubmitButton";
|
||||
|
||||
interface CollectionDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSave: (collection: string, name: string, description: string, tags: string[]) => void;
|
||||
editingCollection?: any;
|
||||
}
|
||||
|
||||
const CollectionDialog = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
onSave,
|
||||
editingCollection,
|
||||
}: CollectionDialogProps) => {
|
||||
const [collection, setCollection] = useState("");
|
||||
const [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [tags, setTags] = useState<string[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (editingCollection) {
|
||||
setCollection(editingCollection.collection);
|
||||
setName(editingCollection.name);
|
||||
setDescription(editingCollection.description);
|
||||
setTags(editingCollection.tags || []);
|
||||
} else {
|
||||
setCollection("");
|
||||
setName("");
|
||||
setDescription("");
|
||||
setTags([]);
|
||||
}
|
||||
}, [editingCollection, open]);
|
||||
|
||||
const handleSubmit = () => {
|
||||
onSave(collection, name, description, tags);
|
||||
};
|
||||
|
||||
const isValid = collection.trim() !== "" && name.trim() !== "";
|
||||
|
||||
return (
|
||||
<Dialog.Root open={open} onOpenChange={(e) => onOpenChange(e.open)}>
|
||||
<Portal>
|
||||
<Dialog.Backdrop />
|
||||
<Dialog.Positioner>
|
||||
<Dialog.Content>
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>
|
||||
{editingCollection ? "Edit Collection" : "Create Collection"}
|
||||
</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
<Dialog.Body>
|
||||
<TextField
|
||||
label="Collection ID"
|
||||
value={collection}
|
||||
onValueChange={setCollection}
|
||||
disabled={!!editingCollection}
|
||||
required
|
||||
helperText={editingCollection ? "ID cannot be changed" : "Unique identifier for the collection"}
|
||||
/>
|
||||
<TextField
|
||||
label="Name"
|
||||
value={name}
|
||||
onValueChange={setName}
|
||||
required
|
||||
helperText="Display name for the collection"
|
||||
/>
|
||||
<TextAreaField
|
||||
label="Description"
|
||||
value={description}
|
||||
onValueChange={setDescription}
|
||||
helperText="Brief description of the collection"
|
||||
/>
|
||||
<ChipInputField
|
||||
label="Tags"
|
||||
value={tags}
|
||||
onValueChange={setTags}
|
||||
helperText="Press Enter to add tags"
|
||||
/>
|
||||
</Dialog.Body>
|
||||
<Dialog.Footer>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<ProgressSubmitButton onClick={handleSubmit} disabled={!isValid}>
|
||||
{editingCollection ? "Update" : "Create"}
|
||||
</ProgressSubmitButton>
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
</Dialog.Positioner>
|
||||
</Portal>
|
||||
</Dialog.Root>
|
||||
);
|
||||
};
|
||||
|
||||
export default CollectionDialog;
|
||||
```
|
||||
|
||||
#### 4.4 Collection Controls
|
||||
|
||||
**File**: `src/components/library/CollectionControls.tsx` (new file)
|
||||
|
||||
Control buttons for collection operations:
|
||||
|
||||
```typescript
|
||||
import React from "react";
|
||||
import { HStack, Button } from "@chakra-ui/react";
|
||||
import { Plus } from "lucide-react";
|
||||
|
||||
interface CollectionControlsProps {
|
||||
onCreate: () => void;
|
||||
}
|
||||
|
||||
const CollectionControls = ({ onCreate }: CollectionControlsProps) => {
|
||||
return (
|
||||
<HStack mt={4} justify="flex-end">
|
||||
<Button onClick={onCreate} colorPalette="primary">
|
||||
<Plus /> Create Collection
|
||||
</Button>
|
||||
</HStack>
|
||||
);
|
||||
};
|
||||
|
||||
export default CollectionControls;
|
||||
```
|
||||
|
||||
### Phase 5: Update Library Page with Tabs
|
||||
|
||||
**File**: `src/pages/LibraryPage.tsx`
|
||||
|
||||
Update the library page to use tabs for Documents and Collections:
|
||||
|
||||
```typescript
|
||||
import React from "react";
|
||||
import { LibraryBig } from "lucide-react";
|
||||
import { Tabs } from "@chakra-ui/react";
|
||||
|
||||
import PageHeader from "../components/common/PageHeader";
|
||||
import Documents from "../components/library/Documents";
|
||||
import Collections from "../components/library/Collections";
|
||||
|
||||
const LibraryPage = () => {
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
icon={<LibraryBig />}
|
||||
title="Library"
|
||||
description="Managing documents and collections"
|
||||
/>
|
||||
<Tabs.Root defaultValue="documents">
|
||||
<Tabs.List>
|
||||
<Tabs.Trigger value="documents">Documents</Tabs.Trigger>
|
||||
<Tabs.Trigger value="collections">Collections</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
<Tabs.Content value="documents">
|
||||
<Documents />
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value="collections">
|
||||
<Collections />
|
||||
</Tabs.Content>
|
||||
</Tabs.Root>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default LibraryPage;
|
||||
```
|
||||
|
||||
## Type Definitions
|
||||
|
||||
**File**: `src/api/trustgraph/messages.ts`
|
||||
|
||||
Add collection-related message types:
|
||||
|
||||
```typescript
|
||||
// Collection management request
|
||||
export interface CollectionRequest extends RequestMessage {
|
||||
operation: "list-collections" | "update-collection" | "delete-collection";
|
||||
user: string;
|
||||
collection?: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
tags?: string[];
|
||||
tag_filter?: string[];
|
||||
}
|
||||
|
||||
// Collection management response
|
||||
export interface CollectionResponse {
|
||||
collections?: Array<{
|
||||
user: string;
|
||||
collection: string;
|
||||
name: string;
|
||||
description: string;
|
||||
tags: string[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}>;
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
- [ ] List collections displays all collections for the user
|
||||
- [ ] Tag filter works when listing collections
|
||||
- [ ] Create new collection with ID, name, description, and tags
|
||||
- [ ] Edit existing collection (ID is disabled, other fields editable)
|
||||
- [ ] Delete single collection
|
||||
- [ ] Delete multiple collections
|
||||
- [ ] Selection state persists correctly
|
||||
- [ ] Loading indicators show during operations
|
||||
- [ ] Error notifications display for failures
|
||||
- [ ] Success notifications display for completed operations
|
||||
- [ ] Tab switching preserves state
|
||||
- [ ] Collections table sorts correctly
|
||||
- [ ] Empty state displays when no collections exist
|
||||
- [ ] Validation prevents creating collections with empty ID or name
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
1. **Collection Assignment**: Allow assigning documents to collections from the Documents tab
|
||||
2. **Collection Filtering**: Filter documents by collection
|
||||
3. **Bulk Collection Operations**: Move multiple documents between collections
|
||||
4. **Collection Statistics**: Show document count per collection
|
||||
5. **Collection Search**: Search collections by name, description, or tags
|
||||
6. **Collection Export**: Export collection metadata
|
||||
|
||||
## Migration Notes
|
||||
|
||||
- No breaking changes to existing functionality
|
||||
- Documents tab functionality remains unchanged
|
||||
- New collections functionality is additive
|
||||
- Follows established patterns from library.ts and Documents.tsx
|
||||
- Uses consistent Chakra v3 components and patterns
|
||||
156
docs/tech-specs/flow-class-definition.md
Normal file
156
docs/tech-specs/flow-class-definition.md
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
# Flow Class Definition Specification
|
||||
|
||||
## Overview
|
||||
|
||||
A flow class defines a complete dataflow pattern template in the TrustGraph system. When instantiated, it creates an interconnected network of processors that handle data ingestion, processing, storage, and querying as a unified system.
|
||||
|
||||
## Structure
|
||||
|
||||
A flow class definition consists of four main sections:
|
||||
|
||||
### 1. Class Section
|
||||
Defines shared service processors that are instantiated once per flow class. These processors handle requests from all flow instances of this class.
|
||||
|
||||
```json
|
||||
"class": {
|
||||
"service-name:{class}": {
|
||||
"request": "queue-pattern:{class}",
|
||||
"response": "queue-pattern:{class}"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Characteristics:**
|
||||
- Shared across all flow instances of the same class
|
||||
- Typically expensive or stateless services (LLMs, embedding models)
|
||||
- Use `{class}` template variable for queue naming
|
||||
- Examples: `embeddings:{class}`, `text-completion:{class}`, `graph-rag:{class}`
|
||||
|
||||
### 2. Flow Section
|
||||
Defines flow-specific processors that are instantiated for each individual flow instance. Each flow gets its own isolated set of these processors.
|
||||
|
||||
```json
|
||||
"flow": {
|
||||
"processor-name:{id}": {
|
||||
"input": "queue-pattern:{id}",
|
||||
"output": "queue-pattern:{id}"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Characteristics:**
|
||||
- Unique instance per flow
|
||||
- Handle flow-specific data and state
|
||||
- Use `{id}` template variable for queue naming
|
||||
- Examples: `chunker:{id}`, `pdf-decoder:{id}`, `kg-extract-relationships:{id}`
|
||||
|
||||
### 3. Interfaces Section
|
||||
Defines the entry points and interaction contracts for the flow. These form the API surface for external systems and internal component communication.
|
||||
|
||||
Interfaces can take two forms:
|
||||
|
||||
**Fire-and-Forget Pattern** (single queue):
|
||||
```json
|
||||
"interfaces": {
|
||||
"document-load": "persistent://tg/flow/document-load:{id}",
|
||||
"triples-store": "persistent://tg/flow/triples-store:{id}"
|
||||
}
|
||||
```
|
||||
|
||||
**Request/Response Pattern** (object with request/response fields):
|
||||
```json
|
||||
"interfaces": {
|
||||
"embeddings": {
|
||||
"request": "non-persistent://tg/request/embeddings:{class}",
|
||||
"response": "non-persistent://tg/response/embeddings:{class}"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Types of Interfaces:**
|
||||
- **Entry Points**: Where external systems inject data (`document-load`, `agent`)
|
||||
- **Service Interfaces**: Request/response patterns for services (`embeddings`, `text-completion`)
|
||||
- **Data Interfaces**: Fire-and-forget data flow connection points (`triples-store`, `entity-contexts-load`)
|
||||
|
||||
### 4. Metadata
|
||||
Additional information about the flow class:
|
||||
|
||||
```json
|
||||
"description": "Human-readable description",
|
||||
"tags": ["capability-1", "capability-2"]
|
||||
```
|
||||
|
||||
## Template Variables
|
||||
|
||||
### {id}
|
||||
- Replaced with the unique flow instance identifier
|
||||
- Creates isolated resources for each flow
|
||||
- Example: `flow-123`, `customer-A-flow`
|
||||
|
||||
### {class}
|
||||
- Replaced with the flow class name
|
||||
- Creates shared resources across flows of the same class
|
||||
- Example: `standard-rag`, `enterprise-rag`
|
||||
|
||||
## Queue Patterns (Pulsar)
|
||||
|
||||
Flow classes use Apache Pulsar for messaging. Queue names follow the Pulsar format:
|
||||
```
|
||||
<persistence>://<tenant>/<namespace>/<topic>
|
||||
```
|
||||
|
||||
### Components:
|
||||
- **persistence**: `persistent` or `non-persistent` (Pulsar persistence mode)
|
||||
- **tenant**: `tg` for TrustGraph-supplied flow class definitions
|
||||
- **namespace**: Indicates the messaging pattern
|
||||
- `flow`: Fire-and-forget services
|
||||
- `request`: Request portion of request/response services
|
||||
- `response`: Response portion of request/response services
|
||||
- **topic**: The specific queue/topic name with template variables
|
||||
|
||||
### Persistent Queues
|
||||
- Pattern: `persistent://tg/flow/<topic>:{id}`
|
||||
- Used for fire-and-forget services and durable data flow
|
||||
- Data persists in Pulsar storage across restarts
|
||||
- Example: `persistent://tg/flow/chunk-load:{id}`
|
||||
|
||||
### Non-Persistent Queues
|
||||
- Pattern: `non-persistent://tg/request/<topic>:{class}` or `non-persistent://tg/response/<topic>:{class}`
|
||||
- Used for request/response messaging patterns
|
||||
- Ephemeral, not persisted to disk by Pulsar
|
||||
- Lower latency, suitable for RPC-style communication
|
||||
- Example: `non-persistent://tg/request/embeddings:{class}`
|
||||
|
||||
## Dataflow Architecture
|
||||
|
||||
The flow class creates a unified dataflow where:
|
||||
|
||||
1. **Document Processing Pipeline**: Flows from ingestion through transformation to storage
|
||||
2. **Query Services**: Integrated processors that query the same data stores and services
|
||||
3. **Shared Services**: Centralized processors that all flows can utilize
|
||||
4. **Storage Writers**: Persist processed data to appropriate stores
|
||||
|
||||
All processors (both `{id}` and `{class}`) work together as a cohesive dataflow graph, not as separate systems.
|
||||
|
||||
## Example Flow Instantiation
|
||||
|
||||
Given:
|
||||
- Flow Instance ID: `customer-A-flow`
|
||||
- Flow Class: `standard-rag`
|
||||
|
||||
Template expansions:
|
||||
- `persistent://tg/flow/chunk-load:{id}` → `persistent://tg/flow/chunk-load:customer-A-flow`
|
||||
- `non-persistent://tg/request/embeddings:{class}` → `non-persistent://tg/request/embeddings:standard-rag`
|
||||
|
||||
This creates:
|
||||
- Isolated document processing pipeline for `customer-A-flow`
|
||||
- Shared embedding service for all `standard-rag` flows
|
||||
- Complete dataflow from document ingestion through querying
|
||||
|
||||
## Benefits
|
||||
|
||||
1. **Resource Efficiency**: Expensive services are shared across flows
|
||||
2. **Flow Isolation**: Each flow has its own data processing pipeline
|
||||
3. **Scalability**: Can instantiate multiple flows from the same template
|
||||
4. **Modularity**: Clear separation between shared and flow-specific components
|
||||
5. **Unified Architecture**: Query and processing are part of the same dataflow
|
||||
910
docs/tech-specs/flow-class-editor.md
Normal file
910
docs/tech-specs/flow-class-editor.md
Normal file
|
|
@ -0,0 +1,910 @@
|
|||
# Flow Class Visual Editor Technical Specification
|
||||
|
||||
## Overview
|
||||
|
||||
A React-based visual editor for creating and modifying TrustGraph flow class definitions using a node-and-edge graph interface. Built with React Flow, this component allows users to visually design dataflow patterns by dragging processors onto a canvas and connecting them with queues.
|
||||
|
||||
## Core Technologies
|
||||
|
||||
- **React Flow** - Node-based editor framework
|
||||
- **TypeScript** - Type safety for flow definitions
|
||||
- **Chakra UI v3** - UI components and theming
|
||||
- **Zustand** - Editor state management
|
||||
- **Zod** - Schema validation for flow class structure
|
||||
|
||||
## Component Architecture
|
||||
|
||||
### Directory Structure
|
||||
```
|
||||
src/components/flow-editor/
|
||||
├── FlowClassEditor.tsx # Main editor component
|
||||
├── nodes/ # Custom node components
|
||||
│ ├── ClassProcessorNode.tsx # Shared service nodes
|
||||
│ ├── FlowProcessorNode.tsx # Flow-specific nodes
|
||||
│ └── InterfaceNode.tsx # Entry/exit point nodes
|
||||
├── edges/ # Custom edge components
|
||||
│ ├── PersistentQueueEdge.tsx # Persistent queue connections
|
||||
│ └── RequestResponseEdge.tsx # Request/response pairs
|
||||
├── panels/ # Editor UI panels
|
||||
│ ├── NodePalette.tsx # Drag-and-drop processor library
|
||||
│ ├── PropertiesPanel.tsx # Node/edge configuration
|
||||
│ └── ValidationPanel.tsx # Real-time validation feedback
|
||||
├── hooks/
|
||||
│ ├── useFlowValidation.ts # Validation logic
|
||||
│ ├── useFlowExport.ts # JSON export/import
|
||||
│ └── useAutoLayout.ts # Automatic graph layout
|
||||
└── types/
|
||||
└── flowEditorTypes.ts # TypeScript definitions
|
||||
```
|
||||
|
||||
## Visual Design
|
||||
|
||||
### Node Types
|
||||
|
||||
#### 1. Class Processor Node ({class})
|
||||
```tsx
|
||||
{
|
||||
type: 'classProcessor',
|
||||
data: {
|
||||
processorName: string, // e.g., "embeddings"
|
||||
queues: {
|
||||
request?: string, // Queue pattern
|
||||
response?: string, // Queue pattern
|
||||
[key: string]: string // Additional queues
|
||||
}
|
||||
},
|
||||
style: {
|
||||
background: 'accent.subtle', // Shared service color
|
||||
border: '2px solid accent.solid',
|
||||
icon: <Share2 /> // Lucide icon indicating shared
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 2. Flow Processor Node ({id})
|
||||
```tsx
|
||||
{
|
||||
type: 'flowProcessor',
|
||||
data: {
|
||||
processorName: string, // e.g., "chunker"
|
||||
queues: {
|
||||
input?: string, // Input queue
|
||||
output?: string, // Output queue
|
||||
[key: string]: string // Additional queues
|
||||
}
|
||||
},
|
||||
style: {
|
||||
background: 'primary.subtle', // Flow-specific color
|
||||
border: '2px solid primary.solid',
|
||||
icon: <Box /> // Lucide icon for isolated
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 3. Interface Node
|
||||
```tsx
|
||||
{
|
||||
type: 'interfaceNode',
|
||||
data: {
|
||||
interfaceName: string, // e.g., "document-load"
|
||||
interfaceType: 'fire-and-forget' | 'request-response',
|
||||
queuePattern?: string, // For fire-and-forget
|
||||
request?: string, // For request-response
|
||||
response?: string // For request-response
|
||||
},
|
||||
style: {
|
||||
background: 'bg.muted',
|
||||
border: '2px dashed border.muted',
|
||||
icon: <Plug /> // Entry/exit point indicator
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Edge Types
|
||||
|
||||
#### 1. Persistent Queue Edge
|
||||
- **Visual**: Solid line with arrow
|
||||
- **Color**: Based on namespace (flow: green, request: blue, response: purple)
|
||||
- **Label**: Queue name displayed on hover
|
||||
- **Validation**: Source/target compatibility checking
|
||||
|
||||
#### 2. Non-Persistent Queue Edge
|
||||
- **Visual**: Dashed line with arrow
|
||||
- **Color**: Lighter variant of namespace colors
|
||||
- **Label**: Queue name with non-persistent indicator
|
||||
- **Validation**: Request/response pairing validation
|
||||
|
||||
## User Edit Operations
|
||||
|
||||
### 1. Processor Management
|
||||
|
||||
#### Add Processor
|
||||
- **Drag from palette**: Drag processor type from categorized library
|
||||
- **Double-click canvas**: Quick-add with processor type selector
|
||||
- **Context menu**: Right-click → Add Processor → Select type
|
||||
- **Keyboard shortcut**: `A` key opens add processor dialog
|
||||
|
||||
#### Configure Processor
|
||||
- **Rename**: Click processor name to edit inline
|
||||
- **Change type**: Toggle between `{class}` and `{id}` via properties panel
|
||||
- **Queue management**:
|
||||
```tsx
|
||||
// Add new queue to processor
|
||||
addQueue(processorId, {
|
||||
name: "custom-queue",
|
||||
direction: "input" | "output" | "bidirectional",
|
||||
pattern: "persistent://tg/flow/custom:{id}"
|
||||
});
|
||||
|
||||
// Remove queue
|
||||
removeQueue(processorId, queueName);
|
||||
|
||||
// Edit queue pattern
|
||||
updateQueue(processorId, queueName, newPattern);
|
||||
```
|
||||
|
||||
#### Delete Processor
|
||||
- **Single**: Select + Delete key
|
||||
- **Multiple**: Multi-select + Delete key
|
||||
- **Context menu**: Right-click → Delete
|
||||
- **Validation**: Warn if processor has connections
|
||||
|
||||
### 2. Connection Management
|
||||
|
||||
#### Create Connection
|
||||
- **Drag connection**: From output handle to input handle
|
||||
- **Validation rules**:
|
||||
- Persistence compatibility (persistent ↔ persistent preferred)
|
||||
- Namespace compatibility (flow/request/response)
|
||||
- Template variable consistency ({class} ↔ {class}, {id} ↔ {id})
|
||||
- No self-connections
|
||||
- No duplicate connections
|
||||
|
||||
#### Configure Connection
|
||||
- **Auto-naming**: Generate queue name from source/target processors
|
||||
- **Custom naming**: Override auto-generated queue name
|
||||
- **Persistence mode**: Toggle persistent/non-persistent
|
||||
- **Queue pattern template**:
|
||||
```tsx
|
||||
generateQueuePattern({
|
||||
persistence: "persistent" | "non-persistent",
|
||||
tenant: "tg",
|
||||
namespace: "flow" | "request" | "response",
|
||||
topic: "document-embeddings",
|
||||
template: "{id}" | "{class}"
|
||||
});
|
||||
// Result: "persistent://tg/flow/document-embeddings:{id}"
|
||||
```
|
||||
|
||||
#### Delete Connection
|
||||
- **Click to select** + Delete key
|
||||
- **Context menu** on edge
|
||||
- **Disconnect handle**: Drag connection away from handle
|
||||
|
||||
### 3. Interface Operations
|
||||
|
||||
#### Add Interface
|
||||
- **Entry points**: Document load, text input, etc.
|
||||
- **Exit points**: Response outputs, storage endpoints
|
||||
- **Service interfaces**: Request/response pairs
|
||||
|
||||
#### Configure Interface Type
|
||||
```tsx
|
||||
// Fire-and-forget pattern
|
||||
interface FireAndForgetInterface {
|
||||
type: "fire-and-forget";
|
||||
queue: string; // Single queue pattern
|
||||
}
|
||||
|
||||
// Request/response pattern
|
||||
interface RequestResponseInterface {
|
||||
type: "request-response";
|
||||
request: string; // Request queue pattern
|
||||
response: string; // Response queue pattern
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Bulk Operations
|
||||
|
||||
#### Multi-select Actions
|
||||
- **Box select**: Click and drag to select multiple nodes
|
||||
- **Shift-click**: Add to selection
|
||||
- **Cmd-click**: Toggle selection
|
||||
- **Select all**: Cmd+A
|
||||
|
||||
#### Group Operations
|
||||
- **Move together**: Drag any selected node moves all
|
||||
- **Delete together**: Delete key removes all selected
|
||||
- **Duplicate**: Cmd+D duplicates selection
|
||||
- **Copy/paste**: Cmd+C/Cmd+V for cross-flow copying
|
||||
|
||||
### 5. Layout Operations
|
||||
|
||||
#### Auto-layout
|
||||
```tsx
|
||||
const layoutStrategies = {
|
||||
hierarchical: {
|
||||
direction: "LR" | "TB", // Left-right or top-bottom
|
||||
nodeSpacing: 150,
|
||||
levelSpacing: 200
|
||||
},
|
||||
force: {
|
||||
strength: -1000,
|
||||
distance: 150
|
||||
},
|
||||
circular: {
|
||||
radius: 300,
|
||||
startAngle: 0
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
#### Manual Arrangement
|
||||
- **Snap to grid**: Optional grid snapping (toggle with G key)
|
||||
- **Alignment tools**: Align selected nodes (top/bottom/left/right/center)
|
||||
- **Distribution**: Distribute nodes evenly (horizontal/vertical)
|
||||
|
||||
### 6. Template Operations
|
||||
|
||||
#### Apply Template
|
||||
```tsx
|
||||
const templates = {
|
||||
"document-rag": {
|
||||
description: "Document processing with RAG",
|
||||
processors: [
|
||||
{ type: "pdf-decoder", id: "{id}" },
|
||||
{ type: "chunker", id: "{id}" },
|
||||
{ type: "embeddings", id: "{class}" },
|
||||
{ type: "de-write", id: "{id}" }
|
||||
],
|
||||
connections: [
|
||||
{ from: "pdf-decoder.output", to: "chunker.input" },
|
||||
{ from: "chunker.output", to: "embeddings.input" },
|
||||
{ from: "embeddings.output", to: "de-write.input" }
|
||||
]
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
#### Create Template
|
||||
- Select nodes/edges → Right-click → "Save as template"
|
||||
- Provide template name and description
|
||||
- Template saved to library for reuse
|
||||
|
||||
### 7. Validation Operations
|
||||
|
||||
#### Real-time Validation
|
||||
```tsx
|
||||
interface ValidationRule {
|
||||
id: string;
|
||||
severity: "error" | "warning" | "info";
|
||||
check: (flowClass: FlowClass) => ValidationResult;
|
||||
}
|
||||
|
||||
const validationRules = [
|
||||
{
|
||||
id: "no-orphans",
|
||||
severity: "warning",
|
||||
check: (flow) => findOrphanedNodes(flow)
|
||||
},
|
||||
{
|
||||
id: "queue-consistency",
|
||||
severity: "error",
|
||||
check: (flow) => validateQueuePatterns(flow)
|
||||
},
|
||||
{
|
||||
id: "template-consistency",
|
||||
severity: "error",
|
||||
check: (flow) => validateTemplateVariables(flow)
|
||||
}
|
||||
];
|
||||
```
|
||||
|
||||
#### Fix Suggestions
|
||||
- **Auto-fix**: One-click fixes for common issues
|
||||
- **Quick actions**: Context-aware suggestions
|
||||
- **Validation overlay**: Visual indicators on invalid elements
|
||||
|
||||
### 8. History Management
|
||||
|
||||
#### Undo/Redo Stack
|
||||
```tsx
|
||||
interface HistoryAction {
|
||||
type: "add" | "delete" | "update" | "connect" | "disconnect";
|
||||
before: FlowState;
|
||||
after: FlowState;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
const historyStack: HistoryAction[] = [];
|
||||
const redoStack: HistoryAction[] = [];
|
||||
|
||||
// Track all operations
|
||||
const executeOperation = (operation: Operation) => {
|
||||
const before = getCurrentState();
|
||||
performOperation(operation);
|
||||
const after = getCurrentState();
|
||||
|
||||
historyStack.push({
|
||||
type: operation.type,
|
||||
before,
|
||||
after,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
|
||||
redoStack.length = 0; // Clear redo on new operation
|
||||
};
|
||||
```
|
||||
|
||||
### 9. Import/Export Operations
|
||||
|
||||
#### Import Flow Class
|
||||
- **From JSON file**: Upload or paste JSON
|
||||
- **From Config API**: Select from existing flow classes
|
||||
- **Validation**: Verify structure before import
|
||||
- **Merge options**: Replace or merge with existing
|
||||
|
||||
#### Export Flow Class
|
||||
- **To JSON**: Download as .json file
|
||||
- **To Config API**: Save directly to backend
|
||||
- **To clipboard**: Copy JSON for sharing
|
||||
- **Format options**: Minified or pretty-printed
|
||||
|
||||
### 10. Metadata Operations
|
||||
|
||||
#### Edit Flow Properties
|
||||
```tsx
|
||||
interface FlowMetadata {
|
||||
id: string; // Kebab-case identifier
|
||||
name: string; // Human-readable name
|
||||
description: string; // Detailed description
|
||||
tags: string[]; // Categorization tags
|
||||
version: string; // Semantic version
|
||||
author: string; // Creator identity
|
||||
created: Date; // Creation timestamp
|
||||
modified: Date; // Last modification
|
||||
}
|
||||
```
|
||||
|
||||
#### Tag Management
|
||||
- **Add tags**: Type or select from existing
|
||||
- **Remove tags**: Click X on tag chips
|
||||
- **Tag suggestions**: Based on processors used
|
||||
- **Tag categories**: System tags vs user tags
|
||||
|
||||
## Core Features
|
||||
|
||||
### 1. Auto-Layout
|
||||
```tsx
|
||||
const handleAutoLayout = () => {
|
||||
const layoutedElements = getLayoutedElements(nodes, edges, {
|
||||
direction: 'LR', // Left to right
|
||||
nodeSpacing: 150,
|
||||
levelSpacing: 200,
|
||||
animate: true
|
||||
});
|
||||
setNodes(layoutedElements.nodes);
|
||||
setEdges(layoutedElements.edges);
|
||||
};
|
||||
```
|
||||
|
||||
### 2. Import/Export
|
||||
```tsx
|
||||
// Export to flow class JSON
|
||||
const exportFlowClass = () => {
|
||||
const flowClass = {
|
||||
class: extractClassProcessors(nodes),
|
||||
flow: extractFlowProcessors(nodes),
|
||||
interfaces: extractInterfaces(nodes),
|
||||
description: metadata.description,
|
||||
tags: metadata.tags
|
||||
};
|
||||
return JSON.stringify(flowClass, null, 2);
|
||||
};
|
||||
|
||||
// Import from JSON
|
||||
const importFlowClass = (json: string) => {
|
||||
const flowClass = JSON.parse(json);
|
||||
const { nodes, edges } = convertToReactFlow(flowClass);
|
||||
setNodes(nodes);
|
||||
setEdges(edges);
|
||||
};
|
||||
```
|
||||
|
||||
### 3. Connection Validation
|
||||
```tsx
|
||||
const isValidConnection = (connection: Connection) => {
|
||||
const sourceNode = getNode(connection.source);
|
||||
const targetNode = getNode(connection.target);
|
||||
|
||||
// Validate queue compatibility
|
||||
if (!areQueuesCompatible(sourceNode, targetNode)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Prevent circular dependencies
|
||||
if (createsCircularDependency(connection)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
```
|
||||
|
||||
### 4. Smart Templates
|
||||
Pre-built flow patterns users can instantiate:
|
||||
- **Document RAG Pipeline**: PDF → Chunker → Embeddings → Storage
|
||||
- **Graph RAG Pipeline**: Knowledge extraction → Graph embeddings → Query
|
||||
- **Simple Q&A**: Prompt → LLM → Response
|
||||
- **Custom Template**: User-defined reusable patterns
|
||||
|
||||
## State Management
|
||||
|
||||
```tsx
|
||||
interface FlowEditorState {
|
||||
// React Flow state
|
||||
nodes: Node[];
|
||||
edges: Edge[];
|
||||
|
||||
// Editor state
|
||||
selectedElement: Node | Edge | null;
|
||||
validationErrors: ValidationError[];
|
||||
isDirty: boolean;
|
||||
|
||||
// Metadata
|
||||
flowClassName: string;
|
||||
description: string;
|
||||
tags: string[];
|
||||
|
||||
// Actions
|
||||
addNode: (type: NodeType, position: XYPosition) => void;
|
||||
updateNode: (nodeId: string, data: NodeData) => void;
|
||||
deleteNode: (nodeId: string) => void;
|
||||
addEdge: (edge: Edge) => void;
|
||||
deleteEdge: (edgeId: string) => void;
|
||||
validateFlow: () => ValidationResult;
|
||||
exportFlow: () => FlowClassDefinition;
|
||||
importFlow: (definition: FlowClassDefinition) => void;
|
||||
}
|
||||
```
|
||||
|
||||
## Visual Indicators
|
||||
|
||||
### Node States
|
||||
- **Normal**: Default appearance
|
||||
- **Selected**: Blue glow/border
|
||||
- **Invalid**: Red border with error icon
|
||||
- **Connecting**: Pulse animation on handles
|
||||
- **Hover**: Slight scale increase
|
||||
|
||||
### Edge States
|
||||
- **Normal**: Default appearance
|
||||
- **Selected**: Highlighted with thicker stroke
|
||||
- **Invalid**: Red dashed line
|
||||
- **Animated**: Flow animation for active connections
|
||||
|
||||
### Queue Handle Types
|
||||
- **Input**: Left side of node, inward arrow
|
||||
- **Output**: Right side of node, outward arrow
|
||||
- **Bidirectional**: Both sides, for request/response
|
||||
|
||||
## Keyboard Shortcuts
|
||||
|
||||
- `Delete` - Delete selected elements
|
||||
- `Cmd+Z` - Undo
|
||||
- `Cmd+Shift+Z` - Redo
|
||||
- `Cmd+S` - Save flow class
|
||||
- `Cmd+O` - Open flow class
|
||||
- `Cmd+E` - Export to JSON
|
||||
- `Space` - Pan mode
|
||||
- `Cmd+A` - Select all nodes
|
||||
- `Cmd+D` - Duplicate selected nodes
|
||||
|
||||
## Integration Points
|
||||
|
||||
### Config API Integration
|
||||
|
||||
The flow class editor uses the existing Config API for all flow class operations:
|
||||
|
||||
#### State Management Hook
|
||||
```tsx
|
||||
// src/state/flow-classes.ts
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useSocket } from "./socket";
|
||||
|
||||
export const useFlowClasses = () => {
|
||||
const socket = useSocket();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ["flow-classes"],
|
||||
queryFn: async () => {
|
||||
const response = await socket.request({
|
||||
operation: "get-config",
|
||||
path: "flow-classes"
|
||||
});
|
||||
return response.configuration as FlowClassDefinition[];
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const useFlowClass = (flowClassId: string) => {
|
||||
const socket = useSocket();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ["flow-class", flowClassId],
|
||||
queryFn: async () => {
|
||||
const response = await socket.request({
|
||||
operation: "get-config",
|
||||
path: `flow-classes/${flowClassId}`
|
||||
});
|
||||
return response.configuration as FlowClassDefinition;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const useUpdateFlowClass = () => {
|
||||
const socket = useSocket();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ id, flowClass }: {
|
||||
id: string;
|
||||
flowClass: FlowClassDefinition
|
||||
}) => {
|
||||
return await socket.request({
|
||||
operation: "set-config",
|
||||
path: `flow-classes/${id}`,
|
||||
configuration: flowClass
|
||||
});
|
||||
},
|
||||
onSuccess: (_, variables) => {
|
||||
queryClient.invalidateQueries(["flow-class", variables.id]);
|
||||
queryClient.invalidateQueries(["flow-classes"]);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const useDeleteFlowClass = () => {
|
||||
const socket = useSocket();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (id: string) => {
|
||||
return await socket.request({
|
||||
operation: "delete-config",
|
||||
path: `flow-classes/${id}`
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries(["flow-classes"]);
|
||||
}
|
||||
});
|
||||
};
|
||||
```
|
||||
|
||||
#### Editor Component Integration
|
||||
```tsx
|
||||
// src/components/flow-editor/FlowClassEditor.tsx
|
||||
import { useFlowClass, useUpdateFlowClass } from "../../state/flow-classes";
|
||||
import { useActivity } from "../../state/activity";
|
||||
import { useNotification } from "../../state/notify";
|
||||
|
||||
export const FlowClassEditor = ({ flowClassId }: { flowClassId?: string }) => {
|
||||
const notify = useNotification();
|
||||
|
||||
// Load existing flow class if ID provided
|
||||
const { data: flowClass, isLoading } = useFlowClass(flowClassId);
|
||||
const updateMutation = useUpdateFlowClass();
|
||||
|
||||
// Track loading state
|
||||
useActivity(isLoading, "Loading flow class");
|
||||
useActivity(updateMutation.isPending, "Saving flow class");
|
||||
|
||||
// Initialize React Flow with loaded data
|
||||
useEffect(() => {
|
||||
if (flowClass) {
|
||||
const { nodes, edges } = convertFlowClassToReactFlow(flowClass);
|
||||
setNodes(nodes);
|
||||
setEdges(edges);
|
||||
setMetadata({
|
||||
description: flowClass.description,
|
||||
tags: flowClass.tags
|
||||
});
|
||||
}
|
||||
}, [flowClass]);
|
||||
|
||||
// Save handler
|
||||
const handleSave = async () => {
|
||||
const flowClassData = exportFlowClass();
|
||||
|
||||
try {
|
||||
await updateMutation.mutateAsync({
|
||||
id: flowClassId || generateFlowClassId(),
|
||||
flowClass: flowClassData
|
||||
});
|
||||
notify.success("Flow class saved successfully");
|
||||
} catch (error) {
|
||||
notify.error("Failed to save flow class");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
// ... rest of React Flow config
|
||||
/>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
#### Flow Class List Integration
|
||||
```tsx
|
||||
// src/components/flow-editor/FlowClassList.tsx
|
||||
import { useFlowClasses, useDeleteFlowClass } from "../../state/flow-classes";
|
||||
|
||||
export const FlowClassList = () => {
|
||||
const { data: flowClasses, isLoading } = useFlowClasses();
|
||||
const deleteMutation = useDeleteFlowClass();
|
||||
|
||||
useActivity(isLoading, "Loading flow classes");
|
||||
useActivity(deleteMutation.isPending, "Deleting flow class");
|
||||
|
||||
return (
|
||||
<VStack>
|
||||
{flowClasses?.map(flowClass => (
|
||||
<Card key={flowClass.id}>
|
||||
<HStack>
|
||||
<Text>{flowClass.description}</Text>
|
||||
<Button onClick={() => openEditor(flowClass.id)}>
|
||||
Edit
|
||||
</Button>
|
||||
<Button onClick={() => deleteMutation.mutate(flowClass.id)}>
|
||||
Delete
|
||||
</Button>
|
||||
</HStack>
|
||||
</Card>
|
||||
))}
|
||||
</VStack>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
#### Config API Request/Response Format
|
||||
```typescript
|
||||
// Request to get all flow classes
|
||||
{
|
||||
operation: "get-config",
|
||||
path: "flow-classes"
|
||||
}
|
||||
|
||||
// Response
|
||||
{
|
||||
configuration: [
|
||||
{
|
||||
id: "document-rag-flow",
|
||||
class: { /* class processors */ },
|
||||
flow: { /* flow processors */ },
|
||||
interfaces: { /* interfaces */ },
|
||||
description: "Document RAG pipeline",
|
||||
tags: ["rag", "documents"]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
// Request to update flow class
|
||||
{
|
||||
operation: "set-config",
|
||||
path: "flow-classes/document-rag-flow",
|
||||
configuration: {
|
||||
class: { /* updated class processors */ },
|
||||
flow: { /* updated flow processors */ },
|
||||
interfaces: { /* updated interfaces */ },
|
||||
description: "Updated description",
|
||||
tags: ["rag", "documents", "v2"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Real-time Updates via WebSocket
|
||||
|
||||
The editor subscribes to configuration changes to handle external updates:
|
||||
|
||||
```tsx
|
||||
useEffect(() => {
|
||||
const subscription = socket.subscribe(
|
||||
`config/flow-classes/${flowClassId}`,
|
||||
(update) => {
|
||||
// Handle external updates to the flow class
|
||||
if (update.source !== currentSessionId) {
|
||||
notify.warning("Flow class updated externally. Refreshing...");
|
||||
queryClient.invalidateQueries(["flow-class", flowClassId]);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
return () => subscription.unsubscribe();
|
||||
}, [flowClassId]);
|
||||
```
|
||||
|
||||
### With Existing UI
|
||||
|
||||
#### Page Integration
|
||||
The Flow Class Editor is a separate page in the workbench, controlled by a feature toggle:
|
||||
|
||||
```tsx
|
||||
// src/components/settings/FeatureSwitchesSection.tsx
|
||||
// Add to existing feature switches:
|
||||
<HStack justify="space-between" align="center">
|
||||
<VStack gap={1} align="start">
|
||||
<Text fontWeight="medium">Flow Class Editor</Text>
|
||||
<HStack gap={2} align="center">
|
||||
<Text fontSize="sm" color="fg.muted">
|
||||
Enable the visual flow class editor for creating and modifying dataflow patterns
|
||||
</Text>
|
||||
<Tag.Root colorPalette="accent" size="sm">
|
||||
<Tag.Label>experimental</Tag.Label>
|
||||
</Tag.Root>
|
||||
</HStack>
|
||||
</VStack>
|
||||
<Switch.Root
|
||||
checked={flowClassEditor}
|
||||
onCheckedChange={(details) =>
|
||||
onFlowClassEditorChange(details.checked)
|
||||
}
|
||||
>
|
||||
<Switch.HiddenInput />
|
||||
<Switch.Control>
|
||||
<Switch.Thumb />
|
||||
</Switch.Control>
|
||||
</Switch.Root>
|
||||
</HStack>
|
||||
```
|
||||
|
||||
#### Sidebar Navigation
|
||||
```tsx
|
||||
// src/components/Sidebar.tsx
|
||||
// Add conditional menu item based on feature switch:
|
||||
{settings.featureSwitches.flowClassEditor && (
|
||||
<SidebarItem
|
||||
icon={<GitBranch />}
|
||||
label="Flow Class Editor"
|
||||
path="/flow-class-editor"
|
||||
isActive={location.pathname === "/flow-class-editor"}
|
||||
/>
|
||||
)}
|
||||
```
|
||||
|
||||
#### Route Configuration
|
||||
```tsx
|
||||
// src/App.tsx
|
||||
// Add route for the editor page:
|
||||
{settings.featureSwitches.flowClassEditor && (
|
||||
<Route path="/flow-class-editor" element={<FlowClassEditorPage />} />
|
||||
)}
|
||||
```
|
||||
|
||||
#### Page Component
|
||||
```tsx
|
||||
// src/pages/FlowClassEditorPage.tsx
|
||||
import React from "react";
|
||||
import PageHeader from "../components/common/PageHeader";
|
||||
import FlowClassEditor from "../components/flow-editor/FlowClassEditor";
|
||||
import { GitBranch } from "lucide-react";
|
||||
|
||||
const FlowClassEditorPage: React.FC = () => {
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
icon={<GitBranch />}
|
||||
title="Flow Class Editor"
|
||||
description="Visual editor for creating and modifying TrustGraph dataflow patterns"
|
||||
/>
|
||||
<FlowClassEditor />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default FlowClassEditorPage;
|
||||
```
|
||||
|
||||
#### Settings State Update
|
||||
```tsx
|
||||
// src/state/settings.ts
|
||||
interface FeatureSwitches {
|
||||
ontologyEditor: boolean;
|
||||
submissions: boolean;
|
||||
agentTools: boolean;
|
||||
mcpTools: boolean;
|
||||
schemas: boolean;
|
||||
tokenCost: boolean;
|
||||
flowClasses: boolean; // Existing flow classes management
|
||||
flowClassEditor: boolean; // New visual editor
|
||||
structuredQuery: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
#### Integration Features
|
||||
- Uses consistent Chakra UI theming
|
||||
- Integrates with notification system via `useNotification`
|
||||
- Progress indicators via `useActivity`
|
||||
- Follows existing Config API patterns
|
||||
- Respects user's feature toggle preferences
|
||||
|
||||
## Responsive Design
|
||||
|
||||
### Desktop (Primary)
|
||||
- Full editor with all panels visible
|
||||
- Optimal canvas size for complex flows
|
||||
- Properties panel as sidebar
|
||||
|
||||
### Tablet
|
||||
- Collapsible panels to maximize canvas
|
||||
- Touch-friendly node manipulation
|
||||
- Simplified toolbar
|
||||
|
||||
### Mobile (View-only)
|
||||
- Read-only flow visualization
|
||||
- Pan and zoom only
|
||||
- Export functionality retained
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
### Optimizations
|
||||
- **Virtualization** for large flows (100+ nodes)
|
||||
- **Debounced validation** during editing
|
||||
- **Memoized node/edge components**
|
||||
- **Lazy loading** of processor templates
|
||||
- **Web Workers** for layout calculations
|
||||
|
||||
### Limits
|
||||
- Max 500 nodes per flow class
|
||||
- Max 1000 edges per flow class
|
||||
- Auto-layout for flows under 100 nodes
|
||||
- Real-time validation for flows under 50 nodes
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Validation Errors
|
||||
- Inline error indicators on invalid nodes/edges
|
||||
- Validation panel with detailed error list
|
||||
- Prevent export/save when errors exist
|
||||
|
||||
### Runtime Errors
|
||||
- Connection rejection with toast notification
|
||||
- Import failure with detailed parsing errors
|
||||
- Auto-save recovery for browser crashes
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
### Phase 2
|
||||
- **Processor library management** - Add custom processors
|
||||
- **Collaborative editing** - Real-time multi-user support
|
||||
- **Version control** - Flow class versioning and diff view
|
||||
- **Simulation mode** - Visualize data flow through the graph
|
||||
|
||||
### Phase 3
|
||||
- **AI assistance** - Suggest connections and optimizations
|
||||
- **Performance profiling** - Visualize bottlenecks
|
||||
- **Template marketplace** - Share flow patterns
|
||||
- **Code generation** - Generate processor stubs from flow
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Unit Tests
|
||||
- Node/edge component rendering
|
||||
- Validation logic
|
||||
- Import/export transformations
|
||||
- State management actions
|
||||
|
||||
### Integration Tests
|
||||
- Full editor workflow
|
||||
- Save/load operations
|
||||
- Template instantiation
|
||||
- Keyboard shortcuts
|
||||
|
||||
### E2E Tests
|
||||
- Create flow from scratch
|
||||
- Import and modify existing flow
|
||||
- Export and validate JSON
|
||||
- Deploy flow instance
|
||||
1048
docs/tech-specs/flow-configurable-parameters-client.md
Normal file
1048
docs/tech-specs/flow-configurable-parameters-client.md
Normal file
File diff suppressed because it is too large
Load diff
373
docs/tech-specs/gateway-auth.md
Normal file
373
docs/tech-specs/gateway-auth.md
Normal file
|
|
@ -0,0 +1,373 @@
|
|||
# Gateway Authentication for TrustGraph UI
|
||||
|
||||
## Overview
|
||||
|
||||
This document specifies the implementation of gateway authentication for the TrustGraph UI application. The gateway authentication system will provide secure communication between the UI and the TrustGraph backend services through an authentication token mechanism.
|
||||
|
||||
## Requirements
|
||||
|
||||
The gateway authentication system should provide:
|
||||
|
||||
1. **Authentication Modes**
|
||||
- **Unauthenticated Mode**: When no API key is entered (empty string), the system operates without authentication
|
||||
- **Authenticated Mode**: When an API key is specified in settings, all communications include the authentication token
|
||||
|
||||
2. **Token Management**
|
||||
- Secure storage and retrieval of authentication credentials via settings system
|
||||
- Authentication is determined by presence/absence of API key
|
||||
- Token persists across sessions (stored in localStorage via settings)
|
||||
- Token should be masked in UI displays
|
||||
|
||||
3. **Integration Points**
|
||||
- **WebSocket Connections**: Append `?token=<apiToken>` to WebSocket connection URL
|
||||
- **REST API Calls**: Include token as Bearer token in Authorization header
|
||||
- Settings page for token configuration
|
||||
- Error handling for 401/403 responses
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### Settings Integration
|
||||
|
||||
**Authentication Settings** (already exists in settings-types.ts):
|
||||
```typescript
|
||||
authentication: {
|
||||
apiKey: string; // Gateway authentication token/secret
|
||||
}
|
||||
```
|
||||
|
||||
### Socket Layer Integration
|
||||
|
||||
**WebSocket Authentication**:
|
||||
```typescript
|
||||
// In createTrustGraphSocket or SocketProvider
|
||||
const wsUrl = settings.authentication.apiKey
|
||||
? `/api/socket?token=${settings.authentication.apiKey}`
|
||||
: `/api/socket`;
|
||||
```
|
||||
|
||||
**REST API Authentication**:
|
||||
```typescript
|
||||
// For any REST endpoints (if used)
|
||||
const headers = settings.authentication.apiKey
|
||||
? { 'Authorization': `Bearer ${settings.authentication.apiKey}` }
|
||||
: {};
|
||||
```
|
||||
|
||||
**Current State**:
|
||||
- `useSocket()` hook now has access to settings via `useSettings()`
|
||||
- Socket context created once at app initialization
|
||||
- Settings changes require socket reconnection for auth updates
|
||||
|
||||
**Required Changes**:
|
||||
1. Modify `createTrustGraphSocket` to accept optional token parameter
|
||||
2. Update socket initialization to append token to WebSocket URL when present
|
||||
3. Add Bearer token to any REST API calls (if applicable)
|
||||
4. Handle authentication errors (401/403) gracefully
|
||||
5. Consider socket reconnection when authentication settings change
|
||||
|
||||
### User Interface
|
||||
|
||||
**Settings Page**:
|
||||
- Password input field for gateway secret
|
||||
- Show/hide toggle for secret visibility
|
||||
- Clear button to remove authentication
|
||||
- Save confirmation with success/error feedback
|
||||
|
||||
**Authentication Status**:
|
||||
- Optional status indicator in header/sidebar
|
||||
- Error notifications for auth failures
|
||||
- Redirect to settings on 401/403 errors
|
||||
|
||||
## Socket Initialization Timing
|
||||
|
||||
### Critical Requirements
|
||||
1. **The socket MUST NOT be created until settings have been loaded from localStorage/backend**. Creating the socket too early will result in incorrect authentication state.
|
||||
2. **The socket MUST reconnect when the API key changes**. This ensures authentication state stays synchronized with user settings.
|
||||
|
||||
### Initialization Scenarios
|
||||
|
||||
1. **Scenario 1: Token Already Configured**
|
||||
- User has previously saved an API key in settings
|
||||
- Settings load from localStorage → contains `apiKey: "token123"`
|
||||
- Socket creation MUST wait for settings load
|
||||
- Socket connects with `?token=token123` appended to URL
|
||||
- **Risk if socket created early**: Connects without auth, requires reconnection
|
||||
|
||||
2. **Scenario 2: Explicitly Unauthenticated**
|
||||
- User has explicitly chosen no authentication (saved empty token)
|
||||
- Settings load from localStorage → contains `apiKey: ""`
|
||||
- Socket creation MUST wait for settings load
|
||||
- Socket connects WITHOUT token parameter
|
||||
- **Risk if socket created early**: Might use stale token from previous session
|
||||
|
||||
3. **Scenario 3: First-Time User / No Settings**
|
||||
- No settings have been saved yet
|
||||
- Settings system returns defaults (empty apiKey)
|
||||
- **Options**:
|
||||
a. Wait for settings to initialize with defaults, then create socket (safest)
|
||||
b. Create socket immediately without auth (assumes unauthenticated default)
|
||||
c. Show setup wizard requiring auth decision before socket creation
|
||||
- **Recommendation**: Option (a) - always wait for settings initialization
|
||||
|
||||
### Socket Reconnection Requirements
|
||||
|
||||
When the API key changes (user updates settings), the socket must:
|
||||
|
||||
1. **Detect the Change**
|
||||
- Monitor `settings.authentication.apiKey` for changes
|
||||
- Triggered when user saves new API key in settings
|
||||
- Also triggered when user clears API key (switches to unauthenticated)
|
||||
|
||||
2. **Clean Disconnect**
|
||||
- Close existing WebSocket connection gracefully
|
||||
- Cancel any pending requests/subscriptions
|
||||
- Clear any auth-related state
|
||||
|
||||
3. **Reconnect with New Auth**
|
||||
- Create new socket with updated token (or no token)
|
||||
- Re-establish WebSocket connection
|
||||
- Show brief loading/reconnecting state to user
|
||||
|
||||
4. **Handle Edge Cases**
|
||||
- API key changes from `""` to `"token123"` (unauthenticated → authenticated)
|
||||
- API key changes from `"token123"` to `"token456"` (change tokens)
|
||||
- API key changes from `"token123"` to `""` (authenticated → unauthenticated)
|
||||
- Rapid API key changes (debounce or queue reconnections)
|
||||
|
||||
### Implementation Strategy
|
||||
|
||||
```typescript
|
||||
// BAD - Socket created immediately, no reconnection
|
||||
const socket = createTrustGraphSocket(); // ❌ No access to settings yet
|
||||
export const SocketContext = createContext(socket);
|
||||
|
||||
// GOOD - Socket created after settings load, reconnects on auth change
|
||||
const SocketProvider = ({ children }) => {
|
||||
const { settings, isLoaded } = useSettings();
|
||||
const [socket, setSocket] = useState(null);
|
||||
const [isReconnecting, setIsReconnecting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoaded) return; // Wait for settings
|
||||
|
||||
// Show reconnecting state during transitions
|
||||
setIsReconnecting(true);
|
||||
|
||||
// Clean up old socket if it exists
|
||||
if (socket) {
|
||||
console.log("Closing existing socket for reconnection...");
|
||||
socket.close();
|
||||
}
|
||||
|
||||
// Create new socket with current auth settings
|
||||
const newSocket = createTrustGraphSocket(settings.authentication.apiKey);
|
||||
|
||||
// Wait for connection to establish
|
||||
newSocket.addEventListener('open', () => {
|
||||
console.log("Socket connected with auth:",
|
||||
settings.authentication.apiKey ? 'enabled' : 'disabled');
|
||||
setIsReconnecting(false);
|
||||
});
|
||||
|
||||
setSocket(newSocket);
|
||||
|
||||
return () => newSocket?.close();
|
||||
}, [isLoaded, settings.authentication.apiKey]); // Re-run when apiKey changes
|
||||
|
||||
if (!socket || isReconnecting) {
|
||||
return (
|
||||
<Box>
|
||||
<LoadingSpinner />
|
||||
<Text>{isReconnecting ? 'Reconnecting...' : 'Initializing...'}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SocketContext.Provider value={socket}>
|
||||
{children}
|
||||
</SocketContext.Provider>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
## Technical Approach
|
||||
|
||||
### Phase 1: Deferred Socket Initialization
|
||||
1. Convert static socket creation to dynamic SocketProvider
|
||||
2. Wait for settings to load before creating socket
|
||||
3. Show loading state while settings/socket initialize
|
||||
4. Pass loaded auth token to socket creation
|
||||
|
||||
### Phase 2: Basic Authentication
|
||||
1. Append `?token=<token>` to WebSocket URL if token exists
|
||||
2. Add Bearer token to REST API headers if token exists
|
||||
3. Handle basic auth success/failure
|
||||
|
||||
### Phase 3: Socket Reconnection on Auth Change
|
||||
1. Detect when authentication settings change
|
||||
2. Close existing socket connection gracefully
|
||||
3. Create new socket with updated authentication
|
||||
4. Handle in-flight requests during reconnection
|
||||
5. Restore any active subscriptions/state (if needed)
|
||||
|
||||
### Phase 4: Enhanced Features (Future)
|
||||
1. Token validation endpoint
|
||||
2. Authentication status indicator
|
||||
3. Auto-retry with exponential backoff on auth failures
|
||||
4. Better error messages for authentication issues
|
||||
|
||||
## Security Considerations
|
||||
|
||||
1. **Token Storage**:
|
||||
- Stored in localStorage via settings system
|
||||
- Never logged to console in production
|
||||
- Masked in UI displays
|
||||
|
||||
2. **Token Transmission**:
|
||||
- Sent via secure headers
|
||||
- HTTPS required in production
|
||||
- No token in URL parameters
|
||||
|
||||
3. **Error Handling**:
|
||||
- Generic error messages to users
|
||||
- Detailed errors only in development mode
|
||||
- Rate limiting on failed attempts
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
1. **Unit Tests**:
|
||||
- Settings storage and retrieval
|
||||
- Header injection logic
|
||||
- Error handling paths
|
||||
|
||||
2. **Integration Tests**:
|
||||
- Full authentication flow
|
||||
- Token persistence across sessions
|
||||
- Error recovery scenarios
|
||||
|
||||
3. **Manual Testing**:
|
||||
- UI interaction flows
|
||||
- Network failure scenarios
|
||||
- Token expiration handling
|
||||
|
||||
## Migration Path
|
||||
|
||||
1. **Backwards Compatibility**:
|
||||
- Support unauthenticated mode (empty token)
|
||||
- Graceful degradation for older backends
|
||||
- Feature detection for auth requirements
|
||||
|
||||
2. **Rollout Strategy**:
|
||||
- Deploy with auth disabled by default
|
||||
- Enable per-user via settings
|
||||
- Monitor error rates during rollout
|
||||
|
||||
## Implementation Example
|
||||
|
||||
```typescript
|
||||
// In trustgraph-socket.ts
|
||||
export const createTrustGraphSocket = (token?: string) => {
|
||||
// Use relative URL for WebSocket connection
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const host = window.location.host;
|
||||
const baseUrl = `${protocol}//${host}/api/socket`;
|
||||
const wsUrl = token ? `${baseUrl}?token=${token}` : baseUrl;
|
||||
|
||||
console.log(`Creating socket with auth: ${token ? 'enabled' : 'disabled'}`);
|
||||
|
||||
// Create WebSocket connection with authentication
|
||||
const socket = new WebSocket(wsUrl);
|
||||
|
||||
// ... rest of socket implementation
|
||||
};
|
||||
|
||||
// In SocketProvider.tsx (NEW FILE)
|
||||
export const SocketProvider = ({ children }) => {
|
||||
const { settings, isLoaded } = useSettings();
|
||||
const [socket, setSocket] = useState(null);
|
||||
const [isSocketReady, setIsSocketReady] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// CRITICAL: Wait for settings to load
|
||||
if (!isLoaded) {
|
||||
console.log("Waiting for settings to load before creating socket...");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("Settings loaded, creating socket with auth:",
|
||||
settings.authentication.apiKey ? 'enabled' : 'disabled');
|
||||
|
||||
// Clean up existing socket before creating new one
|
||||
if (socket) {
|
||||
console.log("API key changed, closing existing socket...");
|
||||
socket.close();
|
||||
setIsSocketReady(false);
|
||||
}
|
||||
|
||||
// Create socket with current auth settings
|
||||
const newSocket = createTrustGraphSocket(settings.authentication.apiKey);
|
||||
|
||||
// Mark socket as ready when connection opens
|
||||
newSocket.addEventListener('open', () => {
|
||||
console.log("Socket connected successfully");
|
||||
setIsSocketReady(true);
|
||||
});
|
||||
|
||||
setSocket(newSocket);
|
||||
|
||||
return () => {
|
||||
newSocket?.close();
|
||||
setIsSocketReady(false);
|
||||
};
|
||||
}, [isLoaded, settings.authentication.apiKey]); // Reconnects when API key changes
|
||||
|
||||
// Show loading state until both settings and socket are ready
|
||||
if (!isSocketReady) {
|
||||
return (
|
||||
<Box>
|
||||
<CenterSpinner />
|
||||
<Text>Initializing connection...</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SocketContext.Provider value={socket}>
|
||||
{children}
|
||||
</SocketContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
// In App.tsx or index.tsx
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<SocketProvider> {/* Now waits for settings before creating socket */}
|
||||
<ChakraProvider>
|
||||
<App />
|
||||
</ChakraProvider>
|
||||
</SocketProvider>
|
||||
</QueryClientProvider>
|
||||
```
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. **Socket Reconnection Strategy**:
|
||||
- Should socket reconnect automatically when auth settings change?
|
||||
- How to handle in-flight requests during reconnection?
|
||||
- Should we show a loading state during reconnection?
|
||||
|
||||
2. **Error Handling**:
|
||||
- How does the backend communicate auth failures (401 vs 403)?
|
||||
- Should we automatically redirect to settings on auth failure?
|
||||
- How to differentiate between network errors and auth errors?
|
||||
|
||||
3. **Token Security**:
|
||||
- Should we support token rotation/refresh?
|
||||
- How long should tokens be valid?
|
||||
- Should we add CSRF protection for REST calls?
|
||||
|
||||
## References
|
||||
|
||||
- [Settings System Documentation](./SETTINGS.md)
|
||||
- [Socket Implementation](../../src/api/trustgraph/socket.ts)
|
||||
- [TrustGraph Socket API](../../src/api/trustgraph/trustgraph-socket.ts)
|
||||
185
docs/tech-specs/llm-models-editor.md
Normal file
185
docs/tech-specs/llm-models-editor.md
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
# LLM Models Editor Technical Specification
|
||||
|
||||
## Overview
|
||||
|
||||
This specification describes the LLM Models Editor in the TrustGraph UI. This feature allows administrators to manage the `llm-model` parameter type - the list of available LLM models that appear in dropdown menus when launching flows.
|
||||
|
||||
The LLM model list is stored as a parameter type definition in the configuration system with type `"parameter-types"` and key `"llm-model"`. The editor provides a simple table interface for managing model options (ID, Description, Default).
|
||||
|
||||
## Background
|
||||
|
||||
The `llm-model` parameter controls which models are available when configuring flows. It's stored as a parameter type definition with an `enum` field containing model options.
|
||||
|
||||
### Current State
|
||||
|
||||
- The llm-model parameter can be modified through direct config API calls or CLI commands
|
||||
- The parameter type with its `enum` array renders as a dropdown in flow dialogs
|
||||
- The default value determines which model is pre-selected
|
||||
|
||||
### Feature Switch
|
||||
|
||||
This feature is controlled by a feature switch in Settings:
|
||||
- **Setting Name**: `llmModels`
|
||||
- **Display Label**: "LLM Models"
|
||||
- **Default**: `false` (off by default)
|
||||
- **Location**: Settings page → Feature Switches section
|
||||
|
||||
## Goals
|
||||
|
||||
- **Simple Table Editor**: Editable table with ID, Description, and Default columns
|
||||
- **Direct Editing**: Edit model options directly in table cells
|
||||
- **Add/Delete Rows**: Add new models or delete existing ones
|
||||
- **Default Selection**: Radio button to mark one model as default
|
||||
- **Save Changes**: Manual save with "Save Changes" button
|
||||
- **Auto-defaults**: First model automatically selected as default when adding to empty table
|
||||
|
||||
## Technical Design
|
||||
|
||||
### Architecture
|
||||
|
||||
Following CODEBOT-INSTRUCTIONS.md patterns:
|
||||
|
||||
**Component Structure:**
|
||||
```
|
||||
src/
|
||||
├── pages/
|
||||
│ └── LLMModelsPage.tsx # Main page with PageHeader
|
||||
├── components/
|
||||
│ └── llm-models/ # Domain-specific directory
|
||||
│ ├── LLMModels.tsx # Container component
|
||||
│ ├── ParameterTypeSelector.tsx # (unused - kept for future)
|
||||
│ └── ModelsTable.tsx # Editable table with save
|
||||
├── state/
|
||||
│ └── llm-models.ts # API hooks
|
||||
└── model/
|
||||
└── llm-models.ts # TypeScript types
|
||||
```
|
||||
|
||||
### Data Models
|
||||
|
||||
#### EnumOption (Model Option)
|
||||
|
||||
```typescript
|
||||
interface EnumOption {
|
||||
id: string; // Model ID (e.g., "gemini-2.5-flash")
|
||||
description: string; // Display text (e.g., "Gemini 2.5 Flash")
|
||||
}
|
||||
```
|
||||
|
||||
#### LLMModelParameter
|
||||
|
||||
```typescript
|
||||
interface LLMModelParameter {
|
||||
name: string; // Parameter type key (always "llm-model")
|
||||
type: string; // Always "string"
|
||||
description: string; // Read-only (e.g., "LLM model to use")
|
||||
default: string; // Default model ID
|
||||
enum: EnumOption[]; // List of models
|
||||
required: boolean; // Read-only
|
||||
}
|
||||
```
|
||||
|
||||
### Implementation Details
|
||||
|
||||
**Key Behavior:**
|
||||
1. Page only handles the single `llm-model` parameter type
|
||||
2. Table edits are local until "Save Changes" is clicked
|
||||
3. Radio buttons use native HTML inputs (Chakra RadioGroup had issues in tables)
|
||||
4. When adding first model to empty table, it's auto-selected as default
|
||||
5. When editing ID of default model, default value updates to track changes
|
||||
6. When deleting default model, first remaining model becomes default
|
||||
7. Empty ID fields are allowed but disabled for default selection
|
||||
|
||||
**State Management:**
|
||||
- Uses `getConfig([{type: "parameter-types", key: "llm-model"}])` to fetch single param
|
||||
- Uses `putConfig()` to save changes, preserving read-only fields
|
||||
- React Query handles caching and invalidation
|
||||
|
||||
### Routing and Navigation
|
||||
|
||||
#### Route (`src/App.tsx`)
|
||||
```typescript
|
||||
<Route path="/llm-models" element={<LLMModelsPage />} />
|
||||
```
|
||||
|
||||
#### Sidebar Navigation (`src/components/Sidebar.tsx`)
|
||||
```typescript
|
||||
{settings.featureSwitches.llmModels && (
|
||||
<NavItem to="/llm-models" icon={Bot} label="LLM Models" />
|
||||
)}
|
||||
```
|
||||
|
||||
### Feature Switch Integration
|
||||
|
||||
#### Settings Types (`src/model/settings-types.ts`)
|
||||
```typescript
|
||||
featureSwitches: {
|
||||
llmModels: boolean; // Default: false
|
||||
}
|
||||
```
|
||||
|
||||
#### Feature Switches Section (`src/components/settings/FeatureSwitchesSection.tsx`)
|
||||
Adds toggle UI with prop `llmModels` and handler `onLlmModelsChange`
|
||||
|
||||
## User Workflows
|
||||
|
||||
### Editing Model Options
|
||||
|
||||
1. Enable feature in Settings → Feature Switches → LLM Models
|
||||
2. Navigate to LLM Models page from sidebar
|
||||
3. View current models in table
|
||||
4. Edit ID or Description fields directly
|
||||
5. Click "Save Changes" to persist
|
||||
6. Notification confirms success
|
||||
|
||||
### Setting Default Model
|
||||
|
||||
1. View models table
|
||||
2. Click radio button in "Default" column for desired model
|
||||
3. Click "Save Changes" to persist
|
||||
|
||||
### Adding New Model
|
||||
|
||||
1. Click "Add Model" button
|
||||
2. New empty row appears
|
||||
3. Enter Model ID and Description
|
||||
4. If it's the only model, radio button is auto-selected
|
||||
5. Click "Save Changes" to persist
|
||||
|
||||
### Deleting Model
|
||||
|
||||
1. Click trash icon next to model
|
||||
2. Row is removed from local state
|
||||
3. If deleted model was default, first remaining model becomes default
|
||||
4. Click "Save Changes" to persist
|
||||
|
||||
## Implementation Checklist
|
||||
|
||||
- [x] Update `src/model/settings-types.ts` - Add `llmModels` feature switch
|
||||
- [x] Update `src/components/settings/FeatureSwitchesSection.tsx` - Add LLM Models toggle
|
||||
- [x] Update `src/components/settings/Settings.tsx` - Wire up llmModels prop
|
||||
- [x] Create `src/model/llm-models.ts` - Type definitions
|
||||
- [x] Create `src/state/llm-models.ts` - useLLMModels hook
|
||||
- [x] Create `src/components/llm-models/LLMModels.tsx` - Container
|
||||
- [x] Create `src/components/llm-models/ParameterTypeSelector.tsx` - (Created but unused)
|
||||
- [x] Create `src/components/llm-models/ModelsTable.tsx` - Editable table with save
|
||||
- [x] Create `src/pages/LLMModelsPage.tsx` - Main page with PageHeader
|
||||
- [x] Update `src/App.tsx` - Add route
|
||||
- [x] Update `src/components/Sidebar.tsx` - Add navigation item with Bot icon
|
||||
- [x] Test CRUD operations
|
||||
- [x] Test feature switch toggle
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
1. **Multiple Parameter Types**: Support editing other parameter types with enum arrays (llm-rag-model, etc.)
|
||||
2. **Import/Export**: Bulk import/export model lists from JSON
|
||||
3. **Templates**: Pre-configured model lists for common providers
|
||||
4. **Model Metadata**: Additional fields like context length, cost per token
|
||||
5. **Reordering**: Drag-and-drop or up/down arrows to reorder models
|
||||
|
||||
## References
|
||||
|
||||
- Flow Configurable Parameters: `docs/tech-specs/flow-configurable-parameters.md`
|
||||
- Parameter Inputs Component: `src/components/flows/ParameterInputs.tsx`
|
||||
- Settings Feature Switches: `src/components/settings/FeatureSwitchesSection.tsx`
|
||||
- CODEBOT Instructions: `CODEBOT-INSTRUCTIONS.md`
|
||||
1029
docs/tech-specs/ontology.md
Normal file
1029
docs/tech-specs/ontology.md
Normal file
File diff suppressed because it is too large
Load diff
150
docs/tech-specs/schema.md
Normal file
150
docs/tech-specs/schema.md
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
# Schema Support for TrustGraph UI
|
||||
|
||||
## Overview
|
||||
|
||||
This document specifies the UI work needed to support structured data schemas in TrustGraph. Schemas enable the system to work with structured data (rows in tables/objects) alongside unstructured data processing.
|
||||
|
||||
## Schema Representation
|
||||
|
||||
Based on the STRUCTURED_DATA.md specification, schemas are stored in TrustGraph's configuration system with:
|
||||
|
||||
- **Type**: `schema` (fixed value for all structured data schemas)
|
||||
- **Key**: Unique schema identifier (e.g., `customer_records`, `transaction_log`)
|
||||
- **Value**: JSON schema definition
|
||||
|
||||
### Schema Structure Example:
|
||||
```json
|
||||
{
|
||||
"name": "customer_records",
|
||||
"description": "Customer information table",
|
||||
"fields": [
|
||||
{
|
||||
"name": "customer_id",
|
||||
"type": "string",
|
||||
"primary_key": true
|
||||
},
|
||||
{
|
||||
"name": "name",
|
||||
"type": "string",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "email",
|
||||
"type": "string",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "registration_date",
|
||||
"type": "timestamp"
|
||||
},
|
||||
{
|
||||
"name": "status",
|
||||
"type": "string",
|
||||
"enum": ["active", "inactive", "suspended"]
|
||||
}
|
||||
],
|
||||
"indexes": ["email", "registration_date"]
|
||||
}
|
||||
```
|
||||
|
||||
### Field Types Supported:
|
||||
- `string`
|
||||
- `integer`
|
||||
- `float`
|
||||
- `boolean`
|
||||
- `timestamp`
|
||||
- `enum` (with predefined values)
|
||||
|
||||
### Field Properties:
|
||||
- `name`: Field identifier
|
||||
- `type`: Data type
|
||||
- `primary_key`: Boolean flag for primary key fields
|
||||
- `required`: Boolean flag for required fields
|
||||
- `enum`: Array of allowed values for enum types
|
||||
|
||||
## Requirements
|
||||
|
||||
Based on the Prompts page implementation pattern, the Schema UI should provide:
|
||||
|
||||
1. **Schema Management Page**
|
||||
- List all schemas in a table view
|
||||
- Create new schemas via modal dialog
|
||||
- Edit existing schemas
|
||||
- Delete schemas with confirmation
|
||||
- View schema details in a readable format
|
||||
|
||||
2. **UI Components Needed**
|
||||
- Main schemas page with table listing
|
||||
- Create/Edit schema dialog with form validation
|
||||
- Schema field editor (add/remove/edit fields)
|
||||
- Field type selector with appropriate options
|
||||
- Primary key and index configuration
|
||||
- Schema preview/viewer component
|
||||
|
||||
3. **State Management**
|
||||
- Use React Query for data fetching and mutations
|
||||
- Implement CRUD operations following the prompts pattern
|
||||
- Handle loading states and error notifications
|
||||
- Cache management and invalidation
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### API Integration Pattern (from Prompts example)
|
||||
|
||||
1. **Configuration Keys**
|
||||
- Individual schemas: `{ type: "schema", key: "{schema_id}" }`
|
||||
- List all schemas by querying all keys with `type: "schema"`
|
||||
|
||||
2. **State Management Hook** (`useSchemas`)
|
||||
- `getValues("schema")` to list all schemas (returns array of {key, value} objects)
|
||||
- `putConfig()` to create/update schemas
|
||||
- `deleteConfig()` to remove schemas
|
||||
- No need for separate index management
|
||||
|
||||
3. **Component Structure**
|
||||
- `SchemasPage.tsx` - Main page component
|
||||
- `components/schemas/Schemas.tsx` - Container component
|
||||
- `components/schemas/SchemasTable.tsx` - List view
|
||||
- `components/schemas/SchemaControls.tsx` - Action buttons
|
||||
- `components/schemas/EditSchemaDialog.tsx` - Create/Edit form
|
||||
- `components/schemas/SchemaViewer.tsx` - Read-only schema display
|
||||
- `state/schemas.ts` - React Query hooks
|
||||
- `model/schemas-table.tsx` - TypeScript definitions
|
||||
|
||||
4. **Field Editor Requirements**
|
||||
- Dynamic field list with add/remove capabilities
|
||||
- Field property editors:
|
||||
- Name (text input)
|
||||
- Type (dropdown: string, integer, float, boolean, timestamp, enum)
|
||||
- Primary key (checkbox)
|
||||
- Required (checkbox)
|
||||
- Enum values (list editor, shown only for enum type)
|
||||
- Index configuration (multi-select from available fields)
|
||||
|
||||
5. **Validation Rules**
|
||||
- Schema name: Required, unique
|
||||
- At least one field required
|
||||
- At least one primary key field
|
||||
- Field names must be unique within schema
|
||||
- Enum type requires at least one enum value
|
||||
|
||||
## Tasks
|
||||
|
||||
1. Create schema state management hook (`useSchemas`)
|
||||
2. Implement SchemasPage and routing
|
||||
3. Build SchemasTable component with sorting/filtering
|
||||
4. Create EditSchemaDialog with field editor
|
||||
5. Add schema validation logic
|
||||
6. Implement schema viewer component
|
||||
7. Add TypeScript models and table configurations
|
||||
8. Integration testing with backend API
|
||||
|
||||
## Notes
|
||||
|
||||
- Follow the existing Prompts page pattern for consistency
|
||||
- Use Chakra UI components matching current design system
|
||||
- Implement proper error handling and user feedback
|
||||
- Consider adding import/export functionality for schemas
|
||||
- May need to handle schema versioning in the future
|
||||
- Implementation is simpler than prompts since we use `getValues("schema")` instead of maintaining a separate index
|
||||
- Reference the agent-tools implementation pattern which also uses `getValues()` directly
|
||||
232
docs/tech-specs/settings.md
Normal file
232
docs/tech-specs/settings.md
Normal file
|
|
@ -0,0 +1,232 @@
|
|||
# Settings Page for TrustGraph UI
|
||||
|
||||
## Overview
|
||||
|
||||
This document specifies the implementation of a Settings page for the TrustGraph UI application. The Settings page will provide a centralized interface for configuring application preferences, user settings, and system-wide configurations.
|
||||
|
||||
## Requirements
|
||||
|
||||
The Settings page should provide:
|
||||
|
||||
1. **Settings Management Interface**
|
||||
- Centralized location for all user and system settings
|
||||
- Organized into logical sections/categories using visual grouping
|
||||
- Real-time save functionality with visual feedback
|
||||
- Reset to defaults capability
|
||||
- Import/export settings configuration
|
||||
|
||||
2. **Settings Categories**
|
||||
- **Authentication**: API key configuration for TrustGraph socket authentication
|
||||
- **GraphRAG Configuration**: Entity limits, triple limits, and graph traversal settings
|
||||
- **Feature Switches**: Toggle switches for advanced/experimental functionality
|
||||
|
||||
3. **Specific Settings**
|
||||
|
||||
**Authentication Section**:
|
||||
- **API Key**: Text input field (password type for security)
|
||||
- Default: empty string (no authentication)
|
||||
- When set: used for TrustGraph socket authentication
|
||||
- Should mask the key value when displayed
|
||||
|
||||
**GraphRAG Settings Section**:
|
||||
- **Entity Limit**: Number input (default: 50)
|
||||
- **Triple Limit**: Number input (default: 30)
|
||||
- **Max Subgraph Size**: Number input (default: 1000)
|
||||
- **Path Length**: Number input (default: 2)
|
||||
|
||||
**Feature Switches Section**:
|
||||
- **Taxonomy Editor**: Boolean toggle (default: false)
|
||||
- **Submissions**: Boolean toggle (default: false)
|
||||
|
||||
3. **Navigation Integration**
|
||||
- Add settings route at the end of the sidebar navigation
|
||||
- Use Settings icon from lucide-react
|
||||
- Standard page structure with PageHeader
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### Routing Integration
|
||||
|
||||
**Sidebar Addition** (src/components/Sidebar.tsx):
|
||||
- Add import: `Settings` from lucide-react
|
||||
- Add NavItem at end of VStack: `<NavItem to="/settings" icon={Settings} label="Settings" />`
|
||||
|
||||
**Route Configuration**:
|
||||
- Add route in main router configuration
|
||||
- Path: `/settings`
|
||||
- Component: `SettingsPage`
|
||||
|
||||
### Component Structure
|
||||
|
||||
Following the established patterns from UI-TOOLKITS.md:
|
||||
|
||||
```
|
||||
src/
|
||||
├── pages/
|
||||
│ └── SettingsPage.tsx # Main page with PageHeader
|
||||
├── components/
|
||||
│ └── settings/
|
||||
│ ├── Settings.tsx # Main container component
|
||||
│ ├── SettingsForm.tsx # Settings form management
|
||||
│ ├── AuthenticationSection.tsx # API key configuration
|
||||
│ ├── GraphRagSection.tsx # GraphRAG settings
|
||||
│ ├── FeatureSwitchesSection.tsx # Feature toggles
|
||||
│ └── SettingsControls.tsx # Action buttons (save, reset, import/export)
|
||||
├── state/
|
||||
│ └── settings.ts # Settings state management with localStorage
|
||||
└── model/
|
||||
└── settings-types.ts # TypeScript definitions for settings
|
||||
```
|
||||
|
||||
### UI Framework Considerations
|
||||
|
||||
Based on UI-TOOLKITS.md guidelines:
|
||||
|
||||
**Chakra UI v3 Components**:
|
||||
- Use `Field.Root` and `Field.Label` for form inputs
|
||||
- Use common components: `TextField`, `SelectField`, `Card`
|
||||
- Use `Alert.Root` for validation feedback
|
||||
- Follow semantic color tokens (`primary`, `accent`, etc.)
|
||||
|
||||
**Icons**:
|
||||
- Use `Settings` from lucide-react (already established pattern)
|
||||
- Other icons as needed: `Save`, `RotateCcw`, `Download`, `Upload`
|
||||
|
||||
**Notifications**:
|
||||
- Use `useNotification` hook (NOT direct toaster)
|
||||
- Provide success/error feedback for save operations
|
||||
|
||||
### State Management Pattern
|
||||
|
||||
Following the established React Query pattern:
|
||||
|
||||
**Settings State Hook** (`useSettings`):
|
||||
- `getSettings()` to retrieve current settings from localStorage
|
||||
- `updateSetting()` to modify individual settings and persist to localStorage
|
||||
- `resetSettings()` to restore defaults and clear localStorage
|
||||
- `exportSettings()` and `importSettings()` for configuration management
|
||||
- Handle localStorage serialization/deserialization
|
||||
- Provide default values when localStorage is empty
|
||||
|
||||
**Data Storage**:
|
||||
- **Browser localStorage**: All settings stored in browser's localStorage
|
||||
- Settings persist across browser sessions
|
||||
- Settings are client-side only (no server synchronization)
|
||||
- Use structured key naming for organized storage
|
||||
|
||||
### Testing Strategy
|
||||
|
||||
Based on TEST_STRATEGY.md:
|
||||
|
||||
**Component Tests**:
|
||||
- SettingsForm validation and state management
|
||||
- Settings section rendering and interaction
|
||||
- Import/export functionality
|
||||
- Reset to defaults behavior
|
||||
|
||||
**Integration Tests**:
|
||||
- Settings persistence across sessions
|
||||
- Settings application to other components
|
||||
- Route navigation and sidebar integration
|
||||
|
||||
**Test Data**:
|
||||
```tsx
|
||||
const mockSettings = {
|
||||
authentication: {
|
||||
apiKey: '' // Empty by default
|
||||
},
|
||||
graphrag: {
|
||||
entityLimit: 50,
|
||||
tripleLimit: 30,
|
||||
maxSubgraphSize: 1000,
|
||||
pathLength: 2
|
||||
},
|
||||
featureSwitches: {
|
||||
taxonomyEditor: false,
|
||||
submissions: false
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
## Tasks
|
||||
|
||||
1. **Foundation Setup**
|
||||
- Create SettingsPage component with PageHeader
|
||||
- Add routing integration and sidebar navigation
|
||||
- Set up basic component structure
|
||||
|
||||
2. **State Management**
|
||||
- Implement settings state hook with localStorage integration
|
||||
- Define settings data model with typed interfaces
|
||||
- Create default settings configuration
|
||||
- Handle localStorage persistence and retrieval
|
||||
|
||||
3. **UI Implementation**
|
||||
- Build AuthenticationSection with masked API key input
|
||||
- Create GraphRagSection with NumberField components for limits
|
||||
- Implement FeatureSwitchesSection with toggle switches
|
||||
- Add visual grouping with Card components for each section
|
||||
- Implement form validation and submission
|
||||
- Add import/export functionality
|
||||
- Create reset to defaults mechanism
|
||||
|
||||
4. **Integration & Testing**
|
||||
- Add route configuration
|
||||
- Implement component tests
|
||||
- Add integration tests for settings persistence
|
||||
- Verify UI consistency with design system
|
||||
|
||||
## Data Model
|
||||
|
||||
### Settings Structure
|
||||
```tsx
|
||||
interface Settings {
|
||||
authentication: {
|
||||
apiKey: string; // Default: ''
|
||||
};
|
||||
graphrag: {
|
||||
entityLimit: number; // Default: 50
|
||||
tripleLimit: number; // Default: 30
|
||||
maxSubgraphSize: number; // Default: 1000
|
||||
pathLength: number; // Default: 2
|
||||
};
|
||||
featureSwitches: {
|
||||
taxonomyEditor: boolean; // Default: false
|
||||
submissions: boolean; // Default: false
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### LocalStorage Keys
|
||||
- Main settings: `trustgraph-settings`
|
||||
- Backup/versioning: Consider `trustgraph-settings-backup` for import/export
|
||||
|
||||
## Integration Points
|
||||
|
||||
### API Key Integration
|
||||
- Settings API key should be used by TrustGraph socket authentication
|
||||
- When API key is empty, no authentication is used
|
||||
- When API key has value, it's passed to socket connection for authentication
|
||||
|
||||
### Feature Switches Integration
|
||||
- **Taxonomy Editor**: Controls visibility of taxonomy-related routes/components
|
||||
- **Submissions**: Controls visibility of submissions/processing routes/components
|
||||
- Features should be conditionally rendered based on these settings
|
||||
|
||||
## Notes
|
||||
|
||||
- **Security**: API key should be masked in UI but stored as plaintext in localStorage
|
||||
- **Visual Grouping**: Use Card components to separate the three main sections
|
||||
- **Real-time Updates**: Settings changes should be immediately persisted to localStorage
|
||||
- **Validation**: Number inputs should have min/max constraints and validation
|
||||
- **Accessibility**: Ensure full keyboard navigation and screen reader support
|
||||
- **Responsive**: Settings should work well on mobile and desktop layouts
|
||||
|
||||
## Future Considerations
|
||||
|
||||
- User-specific vs. system-wide settings
|
||||
- Settings synchronization across devices
|
||||
- Advanced settings with warnings/confirmations
|
||||
- Settings search/filter capability
|
||||
- Bulk settings operations
|
||||
- Settings versioning and migration
|
||||
289
docs/tech-specs/socket-reliability.md
Normal file
289
docs/tech-specs/socket-reliability.md
Normal file
|
|
@ -0,0 +1,289 @@
|
|||
# Socket Reliability Refactor
|
||||
|
||||
## Overview
|
||||
|
||||
This document outlines a comprehensive refactor to address critical issues in the TrustGraph UI WebSocket connection handling that are causing exponential retry storms and excessive logging.
|
||||
|
||||
## Current Problems
|
||||
|
||||
### Issue #1: Dual Retry System Conflict ⚠️ CRITICAL
|
||||
|
||||
**Problem**: Two independent retry mechanisms create multiplicative retry storms:
|
||||
|
||||
1. **BaseApi Socket-Level Reconnection** (`trustgraph-socket.ts`)
|
||||
- Triggers on `onClose()` events
|
||||
- 10 attempts with exponential backoff (2-60 seconds)
|
||||
- Handles socket-level connection failures
|
||||
|
||||
2. **ServiceCall Request-Level Retries** (`service-call.ts`)
|
||||
- Triggers on send failures and timeouts
|
||||
- 3 retries per request with backoff
|
||||
- **Calls `socket.reopen()` which triggers BaseApi reconnection**
|
||||
|
||||
**Result**: Single connection failure → 3 request retries × 10 socket reconnections = **30+ retry attempts**
|
||||
|
||||
```typescript
|
||||
// service-call.ts:160, 174 - PROBLEM LINES
|
||||
console.log("Reopen...");
|
||||
this.socket.reopen(); // ← Triggers BaseApi reconnection
|
||||
```
|
||||
|
||||
### Issue #2: SocketProvider Dependency Loop ✅ FIXED
|
||||
**Status**: Resolved by removing `socket` from dependency array
|
||||
|
||||
### Issue #3: Inconsistent Request Retry Backoff ⚠️ MEDIUM
|
||||
**Location**: `service-call.ts:170`
|
||||
|
||||
```typescript
|
||||
// Inconsistent retry strategies:
|
||||
setTimeout(this.attempt.bind(this), backoffDelay); // Exponential backoff ✅
|
||||
setTimeout(this.attempt.bind(this), 500); // Fixed 500ms ❌ (spams)
|
||||
setTimeout(this.attempt.bind(this), backoffDelay); // Exponential backoff ✅
|
||||
```
|
||||
|
||||
### Issue #4: Concurrent Socket Reopen Calls ⚠️ MEDIUM
|
||||
**Problem**: Multiple failed requests simultaneously call `socket.reopen()`:
|
||||
- No coordination between ServiceCalls
|
||||
- Redundant reconnection attempts
|
||||
- Race conditions in connection state
|
||||
|
||||
## Proposed Solution: Centralized Retry Strategy
|
||||
|
||||
### Architectural Decision
|
||||
|
||||
**Adopt Option A: Let BaseApi handle ALL reconnection logic**
|
||||
|
||||
**Rationale**:
|
||||
- ✅ Single source of truth for connection state
|
||||
- ✅ BaseApi already has robust exponential backoff
|
||||
- ✅ Eliminates retry system conflicts
|
||||
- ✅ Cleaner separation of concerns
|
||||
- ✅ Minimal code changes required
|
||||
|
||||
### Implementation Plan
|
||||
|
||||
#### Phase 1: Remove ServiceCall Reconnection Triggers
|
||||
|
||||
**File**: `src/api/trustgraph/service-call.ts`
|
||||
|
||||
**Changes**:
|
||||
1. Remove `this.socket.reopen()` calls (lines 160, 174)
|
||||
2. Replace with passive waiting for socket reconnection
|
||||
3. Standardize backoff for all retry paths
|
||||
|
||||
```typescript
|
||||
// BEFORE (service-call.ts:156-161)
|
||||
console.log("Reopen...");
|
||||
this.socket.reopen(); // ← REMOVE THIS
|
||||
|
||||
// AFTER
|
||||
console.log("Message send failure, waiting for socket reconnection...");
|
||||
// Let BaseApi handle reconnection, just retry the request
|
||||
```
|
||||
|
||||
#### Phase 2: Improve Request Queueing Strategy
|
||||
|
||||
**Current Behavior**: ServiceCall attempts fail when socket is not ready
|
||||
|
||||
**New Behavior**: ServiceCall waits for socket to become available
|
||||
|
||||
```typescript
|
||||
// Enhanced attempt() method logic
|
||||
attempt() {
|
||||
if (this.complete) return;
|
||||
|
||||
this.retries--;
|
||||
if (this.retries < 0) {
|
||||
// Give up after retries exhausted
|
||||
this.error("Ran out of retries");
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.socket.ws && this.socket.ws.readyState === WebSocket.OPEN) {
|
||||
// Socket ready - send message
|
||||
try {
|
||||
this.socket.ws.send(JSON.stringify(this.msg));
|
||||
this.timeoutId = setTimeout(this.onTimeout.bind(this), this.timeout);
|
||||
} catch (e) {
|
||||
// Send failed - wait and retry (no socket reopen)
|
||||
setTimeout(this.attempt.bind(this), this.calculateBackoff());
|
||||
}
|
||||
} else {
|
||||
// Socket not ready - wait for BaseApi to reconnect
|
||||
console.log("Request", this.mid, "waiting for socket reconnection...");
|
||||
setTimeout(this.attempt.bind(this), this.calculateBackoff());
|
||||
}
|
||||
}
|
||||
|
||||
calculateBackoff() {
|
||||
return Math.min(
|
||||
SOCKET_RECONNECTION_TIMEOUT * Math.pow(2, 3 - this.retries) + Math.random() * 1000,
|
||||
30000
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
#### Phase 3: Enhanced BaseApi Connection Management
|
||||
|
||||
**File**: `src/api/trustgraph/trustgraph-socket.ts`
|
||||
|
||||
**Improvements**:
|
||||
1. Add connection state tracking
|
||||
2. Prevent redundant reconnection attempts
|
||||
3. Improve logging for debugging
|
||||
|
||||
```typescript
|
||||
class BaseApi {
|
||||
reconnectionState: 'idle' | 'reconnecting' | 'failed' = 'idle';
|
||||
|
||||
scheduleReconnect() {
|
||||
// Prevent concurrent reconnection attempts
|
||||
if (this.reconnectionState === 'reconnecting') {
|
||||
console.log("[socket] Reconnection already in progress, skipping");
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.reconnectTimer) return;
|
||||
|
||||
this.reconnectionState = 'reconnecting';
|
||||
// ... existing logic
|
||||
}
|
||||
|
||||
onOpen() {
|
||||
console.log("[socket open]");
|
||||
this.reconnectAttempts = 0;
|
||||
this.reconnectionState = 'idle'; // Reset state
|
||||
|
||||
// Clear any pending reconnect timer
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer);
|
||||
this.reconnectTimer = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Expected Benefits
|
||||
|
||||
### Immediate Impact
|
||||
- **80-90% reduction in retry attempts** - eliminates dual retry system
|
||||
- **Cleaner logs** - single source of reconnection messages
|
||||
- **Predictable behavior** - one retry algorithm instead of two
|
||||
|
||||
### Log Message Changes
|
||||
```
|
||||
// BEFORE: Chaotic dual retry messages
|
||||
[socket] Reconnecting in 2000ms (attempt 1)
|
||||
Request test-123 timed out
|
||||
Message send failure, retry...
|
||||
Reopen...
|
||||
[socket] Reconnecting in 4000ms (attempt 2)
|
||||
Request test-123 ran out of retries
|
||||
Request test-456 timed out
|
||||
Reopen...
|
||||
[socket] Reconnecting in 8000ms (attempt 3)
|
||||
|
||||
// AFTER: Clean, coordinated messages
|
||||
[socket] Reconnecting in 2000ms (attempt 1)
|
||||
Request test-123 waiting for socket reconnection...
|
||||
Request test-456 waiting for socket reconnection...
|
||||
[socket open]
|
||||
Request test-123 sent successfully
|
||||
Request test-456 sent successfully
|
||||
```
|
||||
|
||||
### Performance Improvements
|
||||
- **Reduced CPU usage** - fewer concurrent timers and retry loops
|
||||
- **Less network spam** - coordinated reconnection attempts
|
||||
- **Better user experience** - faster recovery from connection issues
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
### Low Risk Changes
|
||||
- ✅ Removing `socket.reopen()` calls from ServiceCall
|
||||
- ✅ Standardizing backoff calculations
|
||||
- ✅ Adding connection state tracking
|
||||
|
||||
### Potential Issues
|
||||
- ⚠️ **Request timeout behavior may change** - requests may take longer to fail
|
||||
- ⚠️ **Need to test edge cases** - rapid API key changes, server restarts
|
||||
- ⚠️ **Verify inflight request cleanup** - ensure requests don't hang indefinitely
|
||||
|
||||
### Mitigation Strategies
|
||||
1. **Preserve existing timeout behavior** - requests should still timeout appropriately
|
||||
2. **Add circuit breaker** - stop retrying after socket reconnection gives up
|
||||
3. **Comprehensive testing** - test connection failure scenarios
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Unit Tests
|
||||
- Mock WebSocket state transitions
|
||||
- Verify ServiceCall doesn't trigger socket reopens
|
||||
- Test backoff calculations are consistent
|
||||
|
||||
### Integration Tests
|
||||
- Test connection failure and recovery scenarios
|
||||
- Verify request queueing during reconnection
|
||||
- Test concurrent request handling
|
||||
|
||||
### Manual Testing Scenarios
|
||||
1. **Server shutdown** - verify clean reconnection behavior
|
||||
2. **Network interruption** - test mobile/wifi scenarios
|
||||
3. **API key changes** - ensure proper socket recreation
|
||||
4. **High load** - multiple concurrent requests during connection issues
|
||||
|
||||
## Implementation Timeline
|
||||
|
||||
### Phase 1: Core Fixes (1-2 hours)
|
||||
- Remove `socket.reopen()` calls from ServiceCall
|
||||
- Standardize ServiceCall backoff calculations
|
||||
- Add basic connection state tracking
|
||||
|
||||
### Phase 2: Enhanced Reliability (2-3 hours)
|
||||
- Implement request queueing improvements
|
||||
- Add comprehensive logging
|
||||
- Enhanced error handling
|
||||
|
||||
### Phase 3: Testing & Validation (2-4 hours)
|
||||
- Unit test coverage
|
||||
- Integration testing
|
||||
- Performance validation
|
||||
|
||||
**Total Estimated Effort**: 5-9 hours
|
||||
|
||||
## Success Metrics
|
||||
|
||||
### Quantitative Goals
|
||||
- **Reduce retry attempts by 80%+** during connection failures
|
||||
- **Eliminate concurrent socket reopen calls**
|
||||
- **Standardize all retry backoff to exponential**
|
||||
|
||||
### Qualitative Goals
|
||||
- **Cleaner, more understandable logs**
|
||||
- **Predictable connection recovery behavior**
|
||||
- **Better separation of concerns in codebase**
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
### Potential Improvements (Out of Scope)
|
||||
1. **Request prioritization** - critical requests retry faster
|
||||
2. **Connection health monitoring** - proactive reconnection
|
||||
3. **Metrics collection** - track connection reliability
|
||||
4. **Advanced queueing** - persist important requests across sessions
|
||||
|
||||
### Monitoring Additions
|
||||
```typescript
|
||||
// Connection reliability metrics
|
||||
interface SocketMetrics {
|
||||
connectionAttempts: number;
|
||||
successfulConnections: number;
|
||||
averageReconnectionTime: number;
|
||||
requestsLostDuringReconnection: number;
|
||||
}
|
||||
```
|
||||
|
||||
## Conclusion
|
||||
|
||||
This refactor addresses the root cause of socket retry storms by establishing BaseApi as the single authority for connection management. The changes are surgical and low-risk, focusing on removing the problematic dual retry system while preserving all existing functionality.
|
||||
|
||||
**Next Steps**: Implement Phase 1 changes and validate that retry storms are eliminated before proceeding with enhanced features.
|
||||
29
eslint.config.js
Normal file
29
eslint.config.js
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import js from "@eslint/js";
|
||||
import globals from "globals";
|
||||
import reactHooks from "eslint-plugin-react-hooks";
|
||||
import reactRefresh from "eslint-plugin-react-refresh";
|
||||
import tseslint from "typescript-eslint";
|
||||
|
||||
export default tseslint.config(
|
||||
{ ignores: ["dist"] },
|
||||
{
|
||||
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||
files: ["**/*.{ts,tsx}"],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
plugins: {
|
||||
"react-hooks": reactHooks,
|
||||
"react-refresh": reactRefresh,
|
||||
},
|
||||
rules: {
|
||||
...reactHooks.configs.recommended.rules,
|
||||
"react-refresh/only-export-components": "off",
|
||||
"@typescript-eslint/no-unused-vars": [
|
||||
"error",
|
||||
{ argsIgnorePattern: "^_" },
|
||||
],
|
||||
},
|
||||
},
|
||||
);
|
||||
16
index.html
Normal file
16
index.html
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/tg.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>TrustGraph</title>
|
||||
<script src="//unpkg.com/aframe@1.7.1"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
<!-- This is needed for react-force-graph since 1.47.0 -->
|
||||
<script type="module" src="/src/index.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
9485
package-lock.json
generated
Normal file
9485
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
67
package.json
Normal file
67
package.json
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
{
|
||||
"name": "vite-ts",
|
||||
"private": true,
|
||||
"version": "1.0.2",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint .",
|
||||
"prettify": "prettier src --write",
|
||||
"test": "vitest run",
|
||||
"test:ui": "vitest --ui",
|
||||
"test:run": "vitest",
|
||||
"test:coverage": "vitest run --coverage"
|
||||
},
|
||||
"dependencies": {
|
||||
"@chakra-ui/react": "^3.19.1",
|
||||
"@emotion/styled": "^11.13.0",
|
||||
"@msgpack/msgpack": "^3.1.1",
|
||||
"@tanstack/react-query": "^5.80.3",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@trustgraph/client": "github:trustgraph-ai/trustgraph-client#master",
|
||||
"@trustgraph/react-provider": "github:trustgraph-ai/trustgraph-react-provider#master",
|
||||
"@trustgraph/react-state": "github:trustgraph-ai/trustgraph-react-state#d24cdec0",
|
||||
"@types/dagre": "^0.7.53",
|
||||
"compute-cosine-similarity": "^1.1.0",
|
||||
"dagre": "^0.8.5",
|
||||
"jszip": "^3.10.1",
|
||||
"lucide-react": "^0.511.0",
|
||||
"n3": "^1.26.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-force-graph": ">=1.47.0",
|
||||
"react-hotkeys-hook": "^5.1.0",
|
||||
"react-icons": "^5.5.0",
|
||||
"react-markdown-it": "^1.0.2",
|
||||
"react-resize-detector": "^12.0.2",
|
||||
"react-router": "^7.6.0",
|
||||
"reactflow": "^11.11.4",
|
||||
"streamsaver": "^2.0.6",
|
||||
"three-spritetext": "^1.9.3",
|
||||
"uuid": "^11.0.3",
|
||||
"zustand": "^5.0.0-rc.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.9.0",
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"eslint": "^9.9.0",
|
||||
"eslint-plugin-react-hooks": "^5.1.0-rc.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.9",
|
||||
"globals": "^15.9.0",
|
||||
"jsdom": "^26.1.0",
|
||||
"prettier": "3.5.3",
|
||||
"sass-embedded": "^1.77.8",
|
||||
"typescript": "^5.5.3",
|
||||
"typescript-eslint": "^8.0.1",
|
||||
"vite": "^6.3.4",
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
}
|
||||
8
palette.svg
Normal file
8
palette.svg
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Exported from Coolors.co - https://coolors.co/250219-83b7ce-417094-3f1d44-dac0ec -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" viewBox="0 0 500 250" xml:space="preserve">
|
||||
<rect fill="#250219" x="0" y="0" width="100" height="220"/>,<rect fill="#83B7CE" x="100" y="0" width="100" height="220"/>,<rect fill="#417094" x="200" y="0" width="100" height="220"/>,<rect fill="#3F1D44" x="300" y="0" width="100" height="220"/>,<rect fill="#DAC0EC" x="400" y="0" width="100" height="220"/>
|
||||
<text x="10" y="235" font-family="Arial" font-size="6" alignment-baseline="middle">Exported from Coolors.co</text>
|
||||
<text x="490" y="235" font-family="Arial" font-size="6" alignment-baseline="middle" text-anchor="end">https://coolors.co/250219-83b7ce-417094-3f1d44-dac0ec</text>
|
||||
</svg>
|
||||
|
||||
|
After Width: | Height: | Size: 939 B |
83
public/tg.svg
Normal file
83
public/tg.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 13 KiB |
14
pulumi/Pulumi.dev.yaml
Normal file
14
pulumi/Pulumi.dev.yaml
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
encryptionsalt: v1:vQGk98eEeYI=:v1:tHg+f1b66tEydgA9:J1RGVNI0FssyjSXVhcKU7bfBofNFTg==
|
||||
config:
|
||||
config-ui:artifact-name: config-ui-dev
|
||||
config-ui:artifact-repo: us-central1-docker.pkg.dev/trustgraph-demo/config-ui-dev
|
||||
config-ui:artifact-repo-region: us-central1
|
||||
config-ui:cloud-run-region: us-central1
|
||||
config-ui:domain: demo.trustgraph.ai
|
||||
config-ui:environment: dev
|
||||
config-ui:gcp-project: trustgraph-demo
|
||||
config-ui:gcp-region: us-central1
|
||||
config-ui:hostname: dev.config-ui.demo.trustgraph.ai
|
||||
config-ui:managed-zone: demo
|
||||
config-ui:max-scale: "2"
|
||||
config-ui:min-scale: "0"
|
||||
14
pulumi/Pulumi.prod.yaml
Normal file
14
pulumi/Pulumi.prod.yaml
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
encryptionsalt: v1:vQGk98eEeYI=:v1:tHg+f1b66tEydgA9:J1RGVNI0FssyjSXVhcKU7bfBofNFTg==
|
||||
config:
|
||||
config-ui:artifact-name: config-ui-prod
|
||||
config-ui:artifact-repo: us-central1-docker.pkg.dev/trustgraph-demo/config-ui-prod
|
||||
config-ui:artifact-repo-region: us-central1
|
||||
config-ui:cloud-run-region: us-central1
|
||||
config-ui:domain: demo.trustgraph.ai
|
||||
config-ui:environment: prod
|
||||
config-ui:gcp-project: trustgraph-demo
|
||||
config-ui:gcp-region: us-central1
|
||||
config-ui:hostname: config-ui.demo.trustgraph.ai
|
||||
config-ui:managed-zone: demo
|
||||
config-ui:max-scale: "2"
|
||||
config-ui:min-scale: "0"
|
||||
3
pulumi/Pulumi.yaml
Normal file
3
pulumi/Pulumi.yaml
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
name: config-ui
|
||||
runtime: nodejs
|
||||
description: Config UI
|
||||
348
pulumi/index.ts
Normal file
348
pulumi/index.ts
Normal file
|
|
@ -0,0 +1,348 @@
|
|||
|
||||
import * as pulumi from "@pulumi/pulumi";
|
||||
import * as gcp from "@pulumi/gcp";
|
||||
import { local } from "@pulumi/command";
|
||||
//import * as fs from 'fs';
|
||||
|
||||
const cfg = new pulumi.Config();
|
||||
|
||||
function get(tag : string) {
|
||||
|
||||
const val = cfg.get(tag);
|
||||
|
||||
if (!val) {
|
||||
console.log("ERROR: The '" + tag + "' config is mandatory");
|
||||
throw "The '" + tag + "' config is mandatory";
|
||||
}
|
||||
|
||||
return val;
|
||||
|
||||
}
|
||||
|
||||
const imageVersion = process.env.IMAGE_VERSION;
|
||||
if (!imageVersion)
|
||||
throw Error("IMAGE_VERSION not defined");
|
||||
|
||||
const repo = get("artifact-repo");
|
||||
const artifactRepoRegion = get("artifact-repo-region");
|
||||
const artifactName = get("artifact-name");
|
||||
const hostname = get("hostname");
|
||||
const managedZone = get("managed-zone");
|
||||
const project = get("gcp-project");
|
||||
const region = get("gcp-region");
|
||||
const cloudRunRegion = get("cloud-run-region");
|
||||
const environment = get("environment");
|
||||
//const domain = get("domain");
|
||||
const minScale = get("min-scale");
|
||||
const maxScale = get("max-scale");
|
||||
|
||||
const provider = new gcp.Provider(
|
||||
"gcp",
|
||||
{
|
||||
project: project,
|
||||
region: region,
|
||||
}
|
||||
);
|
||||
|
||||
const artifactRepo = new gcp.artifactregistry.Repository(
|
||||
"artifact-repo",
|
||||
{
|
||||
description: "repository for " + environment,
|
||||
format: "DOCKER",
|
||||
location: artifactRepoRegion,
|
||||
repositoryId: artifactName,
|
||||
cleanupPolicies: [
|
||||
{
|
||||
id: "keep-minimum-versions",
|
||||
action: "KEEP",
|
||||
mostRecentVersions: {
|
||||
keepCount: 5,
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
provider: provider,
|
||||
}
|
||||
);
|
||||
|
||||
const localImageName = "localhost/config-ui:" + imageVersion;
|
||||
|
||||
const imageName = repo + "/config-ui:" + imageVersion;
|
||||
|
||||
const taggedImage = new local.Command(
|
||||
"podman-tag-command",
|
||||
{
|
||||
create: "podman tag " + localImageName + " " + imageName,
|
||||
}
|
||||
);
|
||||
|
||||
const image = new local.Command(
|
||||
"podman-push-command",
|
||||
{
|
||||
create: "podman push " + imageName,
|
||||
},
|
||||
{
|
||||
dependsOn: [taggedImage, artifactRepo],
|
||||
}
|
||||
);
|
||||
|
||||
const svcAccount = new gcp.serviceaccount.Account(
|
||||
"service-account",
|
||||
{
|
||||
accountId: "config-ui-" + environment,
|
||||
displayName: "Config UI",
|
||||
description: "Config UI",
|
||||
},
|
||||
{
|
||||
provider: provider,
|
||||
}
|
||||
);
|
||||
|
||||
/*
|
||||
|
||||
const vertexAiUserMember = new gcp.projects.IAMMember(
|
||||
"vertexai-user-role",
|
||||
{
|
||||
member: svcAccount.email.apply(x => "serviceAccount:" + x),
|
||||
project: project,
|
||||
role: "roles/aiplatform.admin",
|
||||
},
|
||||
{
|
||||
provider: provider,
|
||||
}
|
||||
);
|
||||
|
||||
*/
|
||||
|
||||
const service = new gcp.cloudrun.Service(
|
||||
"service",
|
||||
{
|
||||
name: "config-ui-" + environment,
|
||||
location: cloudRunRegion,
|
||||
template: {
|
||||
metadata: {
|
||||
labels: {
|
||||
version: "v" + imageVersion.replace(/\./g, "-"),
|
||||
},
|
||||
annotations: {
|
||||
|
||||
// Scale attributes
|
||||
"autoscaling.knative.dev/minScale": minScale,
|
||||
"autoscaling.knative.dev/maxScale": maxScale,
|
||||
|
||||
// 2nd generation. Need to specify at least 512MB RAM.
|
||||
// Going back to gen1 because faster cold starts
|
||||
"run.googleapis.com/execution-environment": "gen1",
|
||||
|
||||
}
|
||||
},
|
||||
spec: {
|
||||
containerConcurrency: 100,
|
||||
timeoutSeconds: 300,
|
||||
serviceAccountName: svcAccount.email,
|
||||
containers: [
|
||||
{
|
||||
image: imageName,
|
||||
// commands: [
|
||||
// "config-ui"
|
||||
// ],
|
||||
ports: [
|
||||
{
|
||||
"name": "http1", // Must be http1 or h2c.
|
||||
"containerPort": 8080,
|
||||
}
|
||||
],
|
||||
resources: {
|
||||
limits: {
|
||||
cpu: "1000m",
|
||||
memory: "512Mi",
|
||||
}
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
provider: provider,
|
||||
dependsOn: [image],
|
||||
}
|
||||
);
|
||||
|
||||
const allUsersPolicy = gcp.organizations.getIAMPolicy(
|
||||
{
|
||||
bindings: [{
|
||||
role: "roles/run.invoker",
|
||||
members: ["allUsers"],
|
||||
}],
|
||||
},
|
||||
{
|
||||
provider: provider,
|
||||
}
|
||||
);
|
||||
|
||||
/*const _noAuthPolicy =*/ new gcp.cloudrun.IamPolicy(
|
||||
"no-auth-policy",
|
||||
{
|
||||
location: service.location,
|
||||
project: service.project,
|
||||
service: service.name,
|
||||
policyData: allUsersPolicy.then(pol => pol.policyData),
|
||||
},
|
||||
{
|
||||
provider: provider,
|
||||
}
|
||||
);
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
const domainMapping = new gcp.cloudrun.DomainMapping(
|
||||
"domain-mapping",
|
||||
{
|
||||
name: hostname,
|
||||
location: cloudRunRegion,
|
||||
metadata: {
|
||||
namespace: project,
|
||||
},
|
||||
spec: {
|
||||
routeName: service.name,
|
||||
}
|
||||
},
|
||||
{
|
||||
provider: provider
|
||||
}
|
||||
);
|
||||
|
||||
const zone = gcp.dns.getManagedZoneOutput(
|
||||
{
|
||||
name: managedZone,
|
||||
},
|
||||
{
|
||||
provider: provider,
|
||||
}
|
||||
);
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
domainMapping.statuses.apply(
|
||||
ss => ss[0].resourceRecords
|
||||
).apply(
|
||||
rrs => {
|
||||
if (rrs) {
|
||||
|
||||
let mapping : { [k : string] : string[] } = {};
|
||||
|
||||
for(let i = 0; i < rrs.length; i++) {
|
||||
if (rrs[i].rrdata) {
|
||||
|
||||
const rr = rrs[i].rrdata;
|
||||
const tp = rrs[i].type;
|
||||
|
||||
if (!rr || !tp) continue;
|
||||
|
||||
if (mapping[tp])
|
||||
mapping = {
|
||||
...mapping,
|
||||
[tp]: [...mapping[tp], rr],
|
||||
};
|
||||
else
|
||||
mapping = {
|
||||
...mapping,
|
||||
[tp]: [rr],
|
||||
};
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
for (const tp in mapping) {
|
||||
|
||||
new gcp.dns.RecordSet(
|
||||
"resource-record-" + tp,
|
||||
{
|
||||
name: hostname + ".",
|
||||
managedZone: zone.name,
|
||||
type: tp,
|
||||
ttl: 300,
|
||||
rrdatas: mapping[tp],
|
||||
},
|
||||
{
|
||||
provider: provider,
|
||||
}
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
);
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
const serviceMon = new gcp.monitoring.GenericService(
|
||||
"service-monitoring",
|
||||
{
|
||||
basicService: {
|
||||
serviceLabels: {
|
||||
service_name: service.name,
|
||||
location: cloudRunRegion,
|
||||
},
|
||||
serviceType: "CLOUD_RUN",
|
||||
},
|
||||
displayName: "Config UI service (" + environment + ")",
|
||||
serviceId: "config-ui-service-" + environment + "-mon",
|
||||
userLabels: {
|
||||
"service": service.name,
|
||||
"application": "config-ui",
|
||||
"environment": environment,
|
||||
},
|
||||
},
|
||||
{
|
||||
provider: provider,
|
||||
}
|
||||
);
|
||||
|
||||
new gcp.monitoring.Slo(
|
||||
"latency-slo",
|
||||
{
|
||||
service: serviceMon.serviceId,
|
||||
sloId: "config-ui-service-" + environment + "-latency-slo",
|
||||
displayName: "Config UI latency (" + environment + ")",
|
||||
goal: 0.95,
|
||||
rollingPeriodDays: 5,
|
||||
basicSli: {
|
||||
latency: {
|
||||
threshold: "2s"
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
provider: provider,
|
||||
}
|
||||
);
|
||||
|
||||
new gcp.monitoring.Slo(
|
||||
"availability-slo",
|
||||
{
|
||||
service: serviceMon.serviceId,
|
||||
sloId: "config-ui-service-" + environment + "-availability-slo",
|
||||
displayName: "Config UI availability (" + environment + ")",
|
||||
goal: 0.95,
|
||||
rollingPeriodDays: 5,
|
||||
windowsBasedSli: {
|
||||
windowPeriod: "3600s",
|
||||
goodTotalRatioThreshold: {
|
||||
basicSliPerformance: {
|
||||
availability: {
|
||||
}
|
||||
},
|
||||
threshold: 0.9,
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
provider: provider,
|
||||
}
|
||||
);
|
||||
|
||||
3625
pulumi/package-lock.json
generated
Normal file
3625
pulumi/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
12
pulumi/package.json
Normal file
12
pulumi/package.json
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"name": "safety-ai",
|
||||
"main": "index.ts",
|
||||
"devDependencies": {
|
||||
"@types/node": "^16"
|
||||
},
|
||||
"dependencies": {
|
||||
"@pulumi/command": "^0.7.2",
|
||||
"@pulumi/gcp": "^6.58.0",
|
||||
"@pulumi/pulumi": "^3.0.0"
|
||||
}
|
||||
}
|
||||
18
pulumi/tsconfig.json
Normal file
18
pulumi/tsconfig.json
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"outDir": "bin",
|
||||
"target": "es2016",
|
||||
"module": "commonjs",
|
||||
"moduleResolution": "node",
|
||||
"sourceMap": true,
|
||||
"experimentalDecorators": true,
|
||||
"pretty": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noImplicitReturns": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
},
|
||||
"files": [
|
||||
"index.ts"
|
||||
]
|
||||
}
|
||||
3
requirements.txt
Normal file
3
requirements.txt
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
aiohttp
|
||||
pyyaml
|
||||
wheel
|
||||
16
src/App.scss
Normal file
16
src/App.scss
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
#root {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
}
|
||||
|
||||
/*
|
||||
|
||||
body {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
*/
|
||||
202
src/App.tsx
Normal file
202
src/App.tsx
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
import { useEffect, lazy, Suspense } from "react";
|
||||
|
||||
import { Box } from "@chakra-ui/react";
|
||||
import { BrowserRouter as Router, Routes, Route } from "react-router";
|
||||
|
||||
import Layout from "./components/Layout";
|
||||
|
||||
import ChatPage from "./pages/ChatPage";
|
||||
import SearchPage from "./pages/SearchPage";
|
||||
import EntityPage from "./pages/EntityPage";
|
||||
|
||||
// Lazy load GraphPage since it includes heavy 3D visualization library (react-force-graph/Three.js)
|
||||
const GraphPage = lazy(() => import("./pages/GraphPage"));
|
||||
// Lazy load FlowClassesPage since it includes reactflow library
|
||||
const FlowClassesPage = lazy(() => import("./pages/FlowClassesPage"));
|
||||
// Lazy load less frequently used pages
|
||||
const OntologiesPage = lazy(() => import("./pages/OntologiesPage"));
|
||||
const StructuredQueryPage = lazy(() => import("./pages/StructuredQueryPage"));
|
||||
const SettingsPage = lazy(() => import("./pages/SettingsPage"));
|
||||
const SchemasPage = lazy(() => import("./pages/SchemasPage"));
|
||||
const LLMModelsPage = lazy(() => import("./pages/LLMModelsPage"));
|
||||
const McpToolsPage = lazy(() => import("./pages/McpToolsPage"));
|
||||
|
||||
import FlowsPage from "./pages/FlowsPage";
|
||||
import LibraryPage from "./pages/LibraryPage";
|
||||
import KnowledgeCoresPage from "./pages/KnowledgeCoresPage";
|
||||
import ProcessingPage from "./pages/ProcessingPage";
|
||||
import TokenCostPage from "./pages/TokenCostPage";
|
||||
import PromptsPage from "./pages/PromptsPage";
|
||||
import ToolsPage from "./pages/ToolsPage";
|
||||
|
||||
import CenterSpinner from "./components/common/CenterSpinner";
|
||||
import Progress from "./components/common/Progress";
|
||||
import { Toaster } from "./components/ui/ToasterComponent";
|
||||
|
||||
import { useSocket, useConnectionState } from "@trustgraph/react-provider";
|
||||
import {
|
||||
useProgressStateStore,
|
||||
useSessionStore,
|
||||
} from "@trustgraph/react-state";
|
||||
|
||||
const App = () => {
|
||||
const socket = useSocket();
|
||||
const connectionState = useConnectionState();
|
||||
|
||||
const addActivity = useProgressStateStore((state) => state.addActivity);
|
||||
const removeActivity = useProgressStateStore(
|
||||
(state) => state.removeActivity,
|
||||
);
|
||||
|
||||
const setFlowId = useSessionStore((state) => state.setFlowId);
|
||||
const setFlow = useSessionStore((state) => state.setFlow);
|
||||
|
||||
useEffect(() => {
|
||||
// Wait for socket connection to be established before loading flows
|
||||
if (
|
||||
!connectionState ||
|
||||
(connectionState.status !== "connected" &&
|
||||
connectionState.status !== "authenticated" &&
|
||||
connectionState.status !== "unauthenticated")
|
||||
) {
|
||||
console.log(
|
||||
"App: Waiting for socket connection...",
|
||||
connectionState?.status,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("App: Socket connected, loading flows...");
|
||||
const act = "Load flows";
|
||||
addActivity(act);
|
||||
socket
|
||||
.flows()
|
||||
.getFlows()
|
||||
.then((ids) => {
|
||||
return Promise.all(
|
||||
ids.map((id) =>
|
||||
socket
|
||||
.flows()
|
||||
.getFlow(id)
|
||||
.then((x) => [id, x]),
|
||||
),
|
||||
);
|
||||
})
|
||||
.then((flows) => {
|
||||
removeActivity(act);
|
||||
|
||||
const flowIds = flows.map((fl) => fl[0]);
|
||||
if (flowIds.includes("default")) {
|
||||
setFlowId("default");
|
||||
const flow = flows.filter((fl) => fl[0] == "default")[0][1];
|
||||
setFlow(flow);
|
||||
} else {
|
||||
// No default flow, just pick first in the list.
|
||||
setFlowId(flows[0][0]);
|
||||
setFlow(flows[0][1]);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
removeActivity(act);
|
||||
console.log("Error:", err);
|
||||
});
|
||||
}, [
|
||||
socket,
|
||||
connectionState,
|
||||
addActivity,
|
||||
removeActivity,
|
||||
setFlow,
|
||||
setFlowId,
|
||||
]);
|
||||
|
||||
return (
|
||||
<Box width="100%" minHeight="100vh" bg="colors.background">
|
||||
<Router>
|
||||
<Layout>
|
||||
<Routes>
|
||||
<Route path="/" element={<ChatPage />} />
|
||||
<Route path="/chat" element={<ChatPage />} />
|
||||
<Route path="/search" element={<SearchPage />} />
|
||||
<Route path="/entity" element={<EntityPage />} />
|
||||
<Route
|
||||
path="/graph"
|
||||
element={
|
||||
<Suspense fallback={<CenterSpinner />}>
|
||||
<GraphPage />
|
||||
</Suspense>
|
||||
}
|
||||
/>
|
||||
<Route path="/flows" element={<FlowsPage />} />
|
||||
<Route
|
||||
path="/flow-classes"
|
||||
element={
|
||||
<Suspense fallback={<CenterSpinner />}>
|
||||
<FlowClassesPage />
|
||||
</Suspense>
|
||||
}
|
||||
/>
|
||||
<Route path="/library" element={<LibraryPage />} />
|
||||
<Route path="/kc" element={<KnowledgeCoresPage />} />
|
||||
<Route path="/procs" element={<ProcessingPage />} />
|
||||
<Route path="/tokencost" element={<TokenCostPage />} />
|
||||
<Route path="/prompts" element={<PromptsPage />} />
|
||||
<Route
|
||||
path="/schemas"
|
||||
element={
|
||||
<Suspense fallback={<CenterSpinner />}>
|
||||
<SchemasPage />
|
||||
</Suspense>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/ontologies"
|
||||
element={
|
||||
<Suspense fallback={<CenterSpinner />}>
|
||||
<OntologiesPage />
|
||||
</Suspense>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/structured-query"
|
||||
element={
|
||||
<Suspense fallback={<CenterSpinner />}>
|
||||
<StructuredQueryPage />
|
||||
</Suspense>
|
||||
}
|
||||
/>
|
||||
<Route path="/agents" element={<ToolsPage />} />
|
||||
<Route
|
||||
path="/mcp-tools"
|
||||
element={
|
||||
<Suspense fallback={<CenterSpinner />}>
|
||||
<McpToolsPage />
|
||||
</Suspense>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/llm-models"
|
||||
element={
|
||||
<Suspense fallback={<CenterSpinner />}>
|
||||
<LLMModelsPage />
|
||||
</Suspense>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/settings"
|
||||
element={
|
||||
<Suspense fallback={<CenterSpinner />}>
|
||||
<SettingsPage />
|
||||
</Suspense>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</Layout>
|
||||
</Router>
|
||||
<Progress />
|
||||
<CenterSpinner />
|
||||
<Toaster />
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
41
src/api/authenticated-fetch.ts
Normal file
41
src/api/authenticated-fetch.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
/**
|
||||
* Authenticated fetch utility
|
||||
*
|
||||
* Provides fetch functions that automatically include Bearer token authentication
|
||||
* when an API key is configured in settings.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Creates an authenticated fetch function that includes Bearer token when available
|
||||
* @param apiKey - Optional API key for authentication
|
||||
* @returns Fetch function with automatic auth headers
|
||||
*/
|
||||
export const createAuthenticatedFetch = (apiKey?: string) => {
|
||||
return (url: string, options: RequestInit = {}) => {
|
||||
const headers: HeadersInit = {
|
||||
...options.headers,
|
||||
};
|
||||
|
||||
// Add Bearer token if API key is present
|
||||
if (apiKey) {
|
||||
headers["Authorization"] = `Bearer ${apiKey}`;
|
||||
}
|
||||
|
||||
return fetch(url, {
|
||||
...options,
|
||||
headers,
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook-based authenticated fetch that uses current settings
|
||||
* This is a React hook that must be called from within a component
|
||||
*/
|
||||
export const useAuthenticatedFetch = () => {
|
||||
// Note: This will be implemented when we need it in components
|
||||
// For now, we'll use the createAuthenticatedFetch directly with settings
|
||||
throw new Error(
|
||||
"useAuthenticatedFetch not yet implemented - use createAuthenticatedFetch with settings",
|
||||
);
|
||||
};
|
||||
20
src/components/Layout.tsx
Normal file
20
src/components/Layout.tsx
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import React from "react";
|
||||
import { Box, Flex } from "@chakra-ui/react";
|
||||
import Sidebar from "./Sidebar";
|
||||
|
||||
interface LayoutProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const Layout: React.FC<LayoutProps> = ({ children }) => {
|
||||
return (
|
||||
<Flex width="100%" minHeight="100vh">
|
||||
<Sidebar />
|
||||
<Box flex="1" p={6} overflowY="auto" maxHeight="100vh">
|
||||
{children}
|
||||
</Box>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default Layout;
|
||||
162
src/components/Sidebar.tsx
Normal file
162
src/components/Sidebar.tsx
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
import React from "react";
|
||||
|
||||
import {
|
||||
Box,
|
||||
Flex,
|
||||
VStack,
|
||||
Text,
|
||||
Icon,
|
||||
Heading,
|
||||
Separator,
|
||||
chakra,
|
||||
} from "@chakra-ui/react";
|
||||
|
||||
import { NavLink as ReactRouterNavLink } from "react-router";
|
||||
import { useSettings } from "@trustgraph/react-state";
|
||||
|
||||
const ChakraNavLink = chakra(ReactRouterNavLink);
|
||||
|
||||
import {
|
||||
TestTube2,
|
||||
Hammer,
|
||||
Plug,
|
||||
Bot,
|
||||
MessageSquareText,
|
||||
Search,
|
||||
Waypoints,
|
||||
Rotate3d,
|
||||
// FileUp,
|
||||
Workflow,
|
||||
ScrollText,
|
||||
LibraryBig,
|
||||
BrainCircuit,
|
||||
CircleArrowRight,
|
||||
HandCoins,
|
||||
MessageCircleCode,
|
||||
Database,
|
||||
Network,
|
||||
FileSearch,
|
||||
Settings,
|
||||
} from "lucide-react";
|
||||
|
||||
interface NavItemProps {
|
||||
to: string;
|
||||
icon: React.ElementType;
|
||||
label: string;
|
||||
}
|
||||
|
||||
const NavItem: React.FC<NavItemProps> = ({ to, icon, label }) => {
|
||||
return (
|
||||
<ChakraNavLink to={to} width="100%">
|
||||
{({ isActive }: { isActive: boolean }) => (
|
||||
<Flex
|
||||
align="center"
|
||||
p={3}
|
||||
mx={3}
|
||||
borderRadius="lg"
|
||||
role="group"
|
||||
cursor="pointer"
|
||||
bg={isActive ? "{colors.primary.solid}" : "transparent"}
|
||||
color={isActive ? "colors.primary.solid" : "gray.500"}
|
||||
_hover={{ bg: isActive ? "colors.primary.contrast" : "gray.200" }}
|
||||
transition="all 0.2s"
|
||||
>
|
||||
<Icon as={icon} mr={4} fontSize="16" />
|
||||
<Text fontWeight="medium">{label}</Text>
|
||||
</Flex>
|
||||
)}
|
||||
</ChakraNavLink>
|
||||
);
|
||||
};
|
||||
|
||||
const Sidebar = () => {
|
||||
const { settings } = useSettings();
|
||||
|
||||
return (
|
||||
<Box
|
||||
bg="colors.background"
|
||||
borderRight="1px"
|
||||
borderRightColor="gray.200"
|
||||
width={{ base: "70px", md: "250px" }}
|
||||
position="sticky"
|
||||
top="0"
|
||||
height="100vh"
|
||||
boxShadow="sm"
|
||||
>
|
||||
<Flex h="20" alignItems="center" mx="8" justifyContent="space-between">
|
||||
<Box color="{colors.primary.fg}">
|
||||
<TestTube2 />
|
||||
</Box>
|
||||
<Heading
|
||||
fontSize="2xl"
|
||||
fontWeight="bold"
|
||||
color="primary.solid"
|
||||
display={{
|
||||
base: "none",
|
||||
md: "block",
|
||||
}}
|
||||
>
|
||||
TrustGraph
|
||||
</Heading>
|
||||
<Box
|
||||
display={{
|
||||
base: "block",
|
||||
md: "none",
|
||||
}}
|
||||
fontSize="2xl"
|
||||
fontWeight="bold"
|
||||
color="#5285ed"
|
||||
>
|
||||
TG
|
||||
</Box>
|
||||
</Flex>
|
||||
|
||||
<Separator />
|
||||
|
||||
<VStack gap={1} align="stretch" mt={5}>
|
||||
<NavItem to="/search" icon={Search} label="Vector Search" />
|
||||
<NavItem to="/chat" icon={MessageSquareText} label="Assistant" />
|
||||
<NavItem to="/entity" icon={Waypoints} label="Relationships" />
|
||||
<NavItem to="/graph" icon={Rotate3d} label="Graph Visualizer" />
|
||||
<NavItem to="/library" icon={LibraryBig} label="Library" />
|
||||
{settings.featureSwitches.flowClasses && (
|
||||
<NavItem to="/flow-classes" icon={ScrollText} label="Flow Classes" />
|
||||
)}
|
||||
<NavItem to="/flows" icon={Workflow} label="Flows" />
|
||||
<NavItem to="/kc" icon={BrainCircuit} label="Knowledge Cores" />
|
||||
{settings.featureSwitches.submissions && (
|
||||
<NavItem to="/procs" icon={CircleArrowRight} label="Submissions" />
|
||||
)}
|
||||
{settings.featureSwitches.tokenCost && (
|
||||
<NavItem to="/tokencost" icon={HandCoins} label="Token Cost" />
|
||||
)}
|
||||
<NavItem to="/prompts" icon={MessageCircleCode} label="Prompts" />
|
||||
{settings.featureSwitches.schemas && (
|
||||
<NavItem to="/schemas" icon={Database} label="Schemas" />
|
||||
)}
|
||||
{settings.featureSwitches.structuredQuery && (
|
||||
<NavItem
|
||||
to="/structured-query"
|
||||
icon={FileSearch}
|
||||
label="Structured Query"
|
||||
/>
|
||||
)}
|
||||
{settings.featureSwitches.ontologyEditor && (
|
||||
<NavItem to="/ontologies" icon={Network} label="Ontologies" />
|
||||
)}
|
||||
{settings.featureSwitches.agentTools && (
|
||||
<NavItem to="/agents" icon={Hammer} label="Agent Tools" />
|
||||
)}
|
||||
{settings.featureSwitches.mcpTools && (
|
||||
<NavItem to="/mcp-tools" icon={Plug} label="MCP Tools" />
|
||||
)}
|
||||
{settings.featureSwitches.llmModels && (
|
||||
<NavItem to="/llm-models" icon={Bot} label="LLM Models" />
|
||||
)}
|
||||
<NavItem to="/settings" icon={Settings} label="Settings" />
|
||||
</VStack>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default Sidebar;
|
||||
38
src/components/agents/Controls.tsx
Normal file
38
src/components/agents/Controls.tsx
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import React, { useState } from "react";
|
||||
|
||||
import { Plus } from "lucide-react";
|
||||
|
||||
import { Button, Box } from "@chakra-ui/react";
|
||||
|
||||
import EditDialog from "./EditDialog";
|
||||
|
||||
const Controls = () => {
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
|
||||
const onComplete = () => {
|
||||
setCreateOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Button
|
||||
mt={5}
|
||||
ml={5}
|
||||
mb={5}
|
||||
variant="solid"
|
||||
colorPalette="primary"
|
||||
onClick={() => setCreateOpen(true)}
|
||||
>
|
||||
<Plus /> Create Tool
|
||||
</Button>
|
||||
<EditDialog
|
||||
open={createOpen}
|
||||
onOpenChange={setCreateOpen}
|
||||
create={true}
|
||||
onComplete={() => onComplete()}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default Controls;
|
||||
340
src/components/agents/EditDialog.tsx
Normal file
340
src/components/agents/EditDialog.tsx
Normal file
|
|
@ -0,0 +1,340 @@
|
|||
import React, { useEffect, useState, useRef } from "react";
|
||||
|
||||
import { Trash, SendHorizontal, Plus } from "lucide-react";
|
||||
|
||||
import { Portal, Button, Dialog, Box, CloseButton } from "@chakra-ui/react";
|
||||
|
||||
import { useSocket } from "@trustgraph/react-provider";
|
||||
import { useAgentTools } from "@trustgraph/react-state";
|
||||
import { useMcpTools } from "@trustgraph/react-state";
|
||||
import { usePrompts } from "@trustgraph/react-state";
|
||||
import SelectField from "../common/SelectField";
|
||||
import TextAreaField from "../common/TextAreaField";
|
||||
import TextField from "../common/TextField";
|
||||
import ChipInputField from "../common/ChipInputField";
|
||||
import { toaster } from "../ui/toaster";
|
||||
import EditableArgumentsTable from "./EditableArgumentsTable";
|
||||
|
||||
const EditDialog = ({ open, onOpenChange, onComplete, id, create }) => {
|
||||
const socket = useSocket();
|
||||
const { updateTool, createTool, deleteTool } = useAgentTools();
|
||||
const { tools: mcpTools } = useMcpTools();
|
||||
const { prompts } = usePrompts();
|
||||
|
||||
const [newId, setNewId] = useState("");
|
||||
const [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [type, setType] = useState("knowledge-query");
|
||||
const [args, setArgs] = useState([]);
|
||||
const [templateId, setTemplateId] = useState("");
|
||||
const [mcpToolId, setMcpToolId] = useState("");
|
||||
const [collection, setCollection] = useState("");
|
||||
const [group, setGroup] = useState([]);
|
||||
const [state, setState] = useState("");
|
||||
const [applicableStates, setApplicableStates] = useState([]);
|
||||
|
||||
const [editArgIx, setEditArgIx] = useState(-1);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
|
||||
socket
|
||||
.config()
|
||||
.getConfig([{ type: "tool", key: id }])
|
||||
.then((x) => {
|
||||
return JSON.parse(x.values[0].value);
|
||||
})
|
||||
.then((x) => {
|
||||
// Store flow information
|
||||
setName(x.name || "");
|
||||
setDescription(x.description);
|
||||
setType(x.type);
|
||||
setArgs(x.arguments || []);
|
||||
// Handle both old 'template' and new 'template_id' attributes
|
||||
setTemplateId(x.template_id || x.template || "");
|
||||
// Handle both old 'mcp-tool' and new 'mcp_tool_id' attributes
|
||||
setMcpToolId(x.mcp_tool_id || x["mcp-tool"] || "");
|
||||
// Handle collection attribute for knowledge-query tools
|
||||
setCollection(x.collection || "");
|
||||
// Handle new optional fields
|
||||
setGroup(x.group || []);
|
||||
setState(x.state || "");
|
||||
setApplicableStates(x["applicable-states"] || []);
|
||||
})
|
||||
.catch((e) => {
|
||||
console.log("Error:", e);
|
||||
toaster.create({
|
||||
title: "Error: " + e.toString(),
|
||||
type: "error",
|
||||
});
|
||||
});
|
||||
}, [id, create, socket]);
|
||||
|
||||
const typeOptions = [
|
||||
{
|
||||
value: "text-completion",
|
||||
label: "Text completion",
|
||||
description: "Consults an LLM for a response with no further knowledge",
|
||||
},
|
||||
{
|
||||
value: "knowledge-query",
|
||||
label: "Knowledge query",
|
||||
description: "Uses the GraphRAG service for knowledge",
|
||||
},
|
||||
{
|
||||
value: "structured-query",
|
||||
label: "Structured Query",
|
||||
description:
|
||||
"Execute natural language questions against records in a structured data / object store",
|
||||
},
|
||||
{
|
||||
value: "mcp-tool",
|
||||
label: "MCP Tool",
|
||||
description: "Uses the mcp-tool service to access a remote MCP tool",
|
||||
},
|
||||
{
|
||||
value: "prompt",
|
||||
label: "Prompt Template",
|
||||
description: "Executes a prompt template with variables",
|
||||
},
|
||||
];
|
||||
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Create options for MCP tools select menu
|
||||
const mcpToolOptions = mcpTools.map(([id]) => ({
|
||||
value: id,
|
||||
label: id,
|
||||
description: id,
|
||||
}));
|
||||
|
||||
// Create options for prompt templates select menu
|
||||
const promptTemplateOptions = prompts.map(([id]) => ({
|
||||
value: id,
|
||||
label: id,
|
||||
description: id,
|
||||
}));
|
||||
|
||||
const onEdit = () => {
|
||||
// Build the tool structure
|
||||
const toolStruct = {
|
||||
id: create ? newId : id,
|
||||
name: name,
|
||||
description: description,
|
||||
type: type,
|
||||
arguments: args,
|
||||
...(type === "prompt" && templateId && { template: templateId }),
|
||||
...(type === "mcp-tool" && mcpToolId && { "mcp-tool": mcpToolId }),
|
||||
...((type === "knowledge-query" || type === "structured-query") &&
|
||||
collection && { collection: collection }),
|
||||
...(group && group.length > 0 && { group: group }),
|
||||
...(state && { state: state }),
|
||||
...(applicableStates &&
|
||||
applicableStates.length > 0 && {
|
||||
"applicable-states": applicableStates,
|
||||
}),
|
||||
};
|
||||
|
||||
if (create) {
|
||||
createTool({ id: newId, tool: toolStruct, onSuccess: onComplete });
|
||||
} else {
|
||||
updateTool({ id, tool: toolStruct, onSuccess: onComplete });
|
||||
}
|
||||
};
|
||||
|
||||
const addArgument = () => {
|
||||
setArgs((x) => [
|
||||
...x,
|
||||
{
|
||||
name: "argname",
|
||||
description: "???",
|
||||
type: "string",
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
const deleteArgument = (index) => {
|
||||
setArgs((x) => x.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const setArgAttr = (id, key, value) => {
|
||||
const newArgs = args.map((arg, ix) => {
|
||||
if (id == ix) {
|
||||
return {
|
||||
...arg,
|
||||
[key]: value,
|
||||
};
|
||||
} else {
|
||||
return arg;
|
||||
}
|
||||
});
|
||||
setArgs(newArgs);
|
||||
};
|
||||
|
||||
const onDelete = () => {
|
||||
if (create) return;
|
||||
deleteTool({ id, onSuccess: onComplete });
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog.Root
|
||||
placement="center"
|
||||
size="xl"
|
||||
open={open}
|
||||
onOpenChange={(x) => {
|
||||
onOpenChange(x.open);
|
||||
}}
|
||||
>
|
||||
<Portal>
|
||||
<Dialog.Backdrop />
|
||||
<Dialog.Positioner>
|
||||
<Dialog.Content ref={contentRef}>
|
||||
<Dialog.Header>
|
||||
{create && <Dialog.Title>Create tool</Dialog.Title>}
|
||||
|
||||
{!create && (
|
||||
<Dialog.Title>
|
||||
Edit tool: <code>{id}</code>
|
||||
</Dialog.Title>
|
||||
)}
|
||||
</Dialog.Header>
|
||||
<Dialog.Body>
|
||||
{create && (
|
||||
<TextField
|
||||
label="Tool ID"
|
||||
placeholder="Enter a unique tool ID"
|
||||
value={newId}
|
||||
onValueChange={(v) => setNewId(v)}
|
||||
required={true}
|
||||
/>
|
||||
)}
|
||||
|
||||
<TextField
|
||||
label="Tool Name"
|
||||
placeholder="Enter a human-readable name for the tool"
|
||||
value={name}
|
||||
onValueChange={(v) => setName(v)}
|
||||
required={true}
|
||||
/>
|
||||
|
||||
<TextAreaField
|
||||
label="Description of the tool"
|
||||
placeholder="Description"
|
||||
value={description}
|
||||
onValueChange={(v) => setDescription(v)}
|
||||
required={true}
|
||||
/>
|
||||
|
||||
<SelectField
|
||||
label="Tool type"
|
||||
items={typeOptions}
|
||||
value={type ? [type] : []}
|
||||
onValueChange={(v) => setType(v[0])}
|
||||
contentRef={contentRef}
|
||||
/>
|
||||
|
||||
{type === "prompt" && (
|
||||
<SelectField
|
||||
label="Template ID"
|
||||
items={promptTemplateOptions}
|
||||
value={templateId ? [templateId] : []}
|
||||
onValueChange={(v) => setTemplateId(v[0] || "")}
|
||||
contentRef={contentRef}
|
||||
/>
|
||||
)}
|
||||
|
||||
{type === "mcp-tool" && (
|
||||
<SelectField
|
||||
label="MCP Tool ID"
|
||||
items={mcpToolOptions}
|
||||
value={mcpToolId ? [mcpToolId] : []}
|
||||
onValueChange={(v) => setMcpToolId(v[0] || "")}
|
||||
contentRef={contentRef}
|
||||
/>
|
||||
)}
|
||||
|
||||
{(type === "knowledge-query" || type === "structured-query") && (
|
||||
<TextField
|
||||
label="Collection"
|
||||
placeholder="Enter the knowledge collection (optional)"
|
||||
value={collection}
|
||||
onValueChange={(v) => setCollection(v)}
|
||||
required={false}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ChipInputField
|
||||
label="Groups"
|
||||
values={group}
|
||||
onValuesChange={setGroup}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
label="Next State"
|
||||
placeholder="Optional: Specify which state the agent should move to after successfully using this tool. Used to create multi-step workflows."
|
||||
value={state}
|
||||
onValueChange={(v) => setState(v)}
|
||||
required={false}
|
||||
/>
|
||||
|
||||
<ChipInputField
|
||||
label="Applicable States"
|
||||
values={applicableStates}
|
||||
onValuesChange={setApplicableStates}
|
||||
/>
|
||||
|
||||
{(type === "prompt" || type === "mcp-tool") && (
|
||||
<>
|
||||
<EditableArgumentsTable
|
||||
args={args}
|
||||
editArgIx={editArgIx}
|
||||
setEditArgIx={setEditArgIx}
|
||||
setArgAttr={setArgAttr}
|
||||
deleteArg={deleteArgument}
|
||||
/>
|
||||
|
||||
<Box mt={5}>
|
||||
<Button
|
||||
variant="solid"
|
||||
onClick={() => addArgument()}
|
||||
colorPalette="primary"
|
||||
size="xs"
|
||||
>
|
||||
<Plus /> add argument
|
||||
</Button>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</Dialog.Body>
|
||||
<Dialog.Footer>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
{
|
||||
// If a 'create' operation, there's nothing to delete, only
|
||||
// present if an existing tool exists
|
||||
}
|
||||
{!create && (
|
||||
<Button
|
||||
variant="solid"
|
||||
onClick={() => onDelete()}
|
||||
colorPalette="red"
|
||||
>
|
||||
<Trash /> Delete
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={() => onEdit()} colorPalette="primary">
|
||||
<SendHorizontal /> Submit
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
<Dialog.CloseTrigger asChild>
|
||||
<CloseButton size="sm" />
|
||||
</Dialog.CloseTrigger>
|
||||
</Dialog.Content>
|
||||
</Dialog.Positioner>
|
||||
</Portal>
|
||||
</Dialog.Root>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditDialog;
|
||||
243
src/components/agents/EditableArgumentsTable.tsx
Normal file
243
src/components/agents/EditableArgumentsTable.tsx
Normal file
|
|
@ -0,0 +1,243 @@
|
|||
import React, { useMemo, useCallback, useRef, useEffect } from "react";
|
||||
import {
|
||||
Table,
|
||||
Editable,
|
||||
Popover,
|
||||
Text,
|
||||
RadioGroup,
|
||||
Stack,
|
||||
Box,
|
||||
IconButton,
|
||||
} from "@chakra-ui/react";
|
||||
import { Trash } from "lucide-react";
|
||||
import {
|
||||
createColumnHelper,
|
||||
useReactTable,
|
||||
getCoreRowModel,
|
||||
} from "@tanstack/react-table";
|
||||
import { flexRender } from "@tanstack/react-table";
|
||||
|
||||
interface Argument {
|
||||
name: string;
|
||||
description: string;
|
||||
type: "string" | "number";
|
||||
}
|
||||
|
||||
interface EditableArgumentsTableProps {
|
||||
args: Argument[];
|
||||
editArgIx: number;
|
||||
setEditArgIx: (ix: number) => void;
|
||||
setArgAttr: (ix: number, attr: keyof Argument, value: string) => void;
|
||||
deleteArg: (ix: number) => void;
|
||||
}
|
||||
|
||||
const columnHelper = createColumnHelper<Argument>();
|
||||
|
||||
export const EditableArgumentsTable: React.FC<EditableArgumentsTableProps> = ({
|
||||
args,
|
||||
editArgIx,
|
||||
setEditArgIx,
|
||||
setArgAttr,
|
||||
deleteArg,
|
||||
}) => {
|
||||
// Store latest function references to avoid stale closures
|
||||
const setArgAttrRef = useRef(setArgAttr);
|
||||
const setEditArgIxRef = useRef(setEditArgIx);
|
||||
const deleteArgRef = useRef(deleteArg);
|
||||
|
||||
useEffect(() => {
|
||||
setArgAttrRef.current = setArgAttr;
|
||||
setEditArgIxRef.current = setEditArgIx;
|
||||
deleteArgRef.current = deleteArg;
|
||||
});
|
||||
|
||||
// Create truly stable callback functions that never change reference
|
||||
const handleNameChange = useCallback((index: number, value: string) => {
|
||||
setArgAttrRef.current(index, "name", value);
|
||||
}, []);
|
||||
|
||||
const handleDescriptionChange = useCallback(
|
||||
(index: number, value: string) => {
|
||||
setArgAttrRef.current(index, "description", value);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleTypeChange = useCallback((index: number, value: string) => {
|
||||
setArgAttrRef.current(index, "type", value);
|
||||
setEditArgIxRef.current(-1); // Close popover after selection
|
||||
}, []);
|
||||
|
||||
const handleDelete = useCallback((index: number) => {
|
||||
deleteArgRef.current(index);
|
||||
}, []);
|
||||
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
columnHelper.display({
|
||||
id: "name",
|
||||
header: "Name",
|
||||
size: 20,
|
||||
cell: ({ row }) => (
|
||||
<Editable.Root
|
||||
autoResize={false}
|
||||
value={row.original.name}
|
||||
onValueChange={(v) => handleNameChange(row.index, v.value)}
|
||||
>
|
||||
<Editable.Preview />
|
||||
<Editable.Input />
|
||||
</Editable.Root>
|
||||
),
|
||||
}),
|
||||
columnHelper.display({
|
||||
id: "description",
|
||||
header: "Description",
|
||||
size: 50,
|
||||
cell: ({ row }) => (
|
||||
<Editable.Root
|
||||
value={row.original.description}
|
||||
onValueChange={(v) => handleDescriptionChange(row.index, v.value)}
|
||||
>
|
||||
<Editable.Preview />
|
||||
<Editable.Input />
|
||||
</Editable.Root>
|
||||
),
|
||||
}),
|
||||
columnHelper.display({
|
||||
id: "type",
|
||||
header: "Type",
|
||||
size: 30,
|
||||
cell: ({ row }) => (
|
||||
<div onClick={() => setEditArgIx(row.index)}>
|
||||
{editArgIx === row.index && (
|
||||
<Popover.Root
|
||||
open={editArgIx === row.index}
|
||||
onOpenChange={(e) => {
|
||||
// Close popover when selection changes
|
||||
if (!e.open) setEditArgIx(-1);
|
||||
}}
|
||||
>
|
||||
<Popover.Trigger asChild>
|
||||
<Text cursor="pointer">{row.original.type}</Text>
|
||||
</Popover.Trigger>
|
||||
<Popover.Positioner>
|
||||
<Popover.Content>
|
||||
<Popover.Arrow />
|
||||
<Popover.Body>
|
||||
<RadioGroup.Root
|
||||
value={row.original.type}
|
||||
onValueChange={(v) => {
|
||||
handleTypeChange(row.index, v.value);
|
||||
}}
|
||||
>
|
||||
<Stack gap="6">
|
||||
<RadioGroup.Item value="string">
|
||||
<RadioGroup.ItemHiddenInput />
|
||||
<RadioGroup.ItemIndicator />
|
||||
<RadioGroup.ItemText>string</RadioGroup.ItemText>
|
||||
</RadioGroup.Item>
|
||||
<RadioGroup.Item value="number">
|
||||
<RadioGroup.ItemHiddenInput />
|
||||
<RadioGroup.ItemIndicator />
|
||||
<RadioGroup.ItemText>number</RadioGroup.ItemText>
|
||||
</RadioGroup.Item>
|
||||
</Stack>
|
||||
</RadioGroup.Root>
|
||||
</Popover.Body>
|
||||
</Popover.Content>
|
||||
</Popover.Positioner>
|
||||
</Popover.Root>
|
||||
)}
|
||||
{editArgIx !== row.index && (
|
||||
<Text cursor="pointer">{row.original.type}</Text>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
}),
|
||||
columnHelper.display({
|
||||
id: "delete",
|
||||
header: "",
|
||||
size: 10,
|
||||
cell: ({ row }) => (
|
||||
<IconButton
|
||||
aria-label="Delete argument"
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
colorPalette="red"
|
||||
onClick={() => handleDelete(row.index)}
|
||||
>
|
||||
<Trash />
|
||||
</IconButton>
|
||||
),
|
||||
}),
|
||||
],
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[editArgIx], // Only editArgIx changes, callbacks are stable
|
||||
);
|
||||
|
||||
const table = useReactTable({
|
||||
data: args,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
});
|
||||
|
||||
// Show helpful message if no arguments yet
|
||||
if (args.length === 0) {
|
||||
return (
|
||||
<Box
|
||||
p={4}
|
||||
borderWidth="1px"
|
||||
borderRadius="md"
|
||||
borderStyle="dashed"
|
||||
borderColor="border.default"
|
||||
color="fg.muted"
|
||||
textAlign="center"
|
||||
fontSize="sm"
|
||||
>
|
||||
No arguments defined yet. Click "add argument" below to create template
|
||||
variables.
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Table.Root interactive size="xs">
|
||||
<Table.Header>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<Table.Row key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<Table.ColumnHeader
|
||||
key={header.id}
|
||||
width={
|
||||
header.column.columnDef.size
|
||||
? `${header.column.columnDef.size}%`
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext(),
|
||||
)}
|
||||
</Table.ColumnHeader>
|
||||
))}
|
||||
</Table.Row>
|
||||
))}
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{table.getRowModel().rows.map((row) => (
|
||||
<Table.Row key={row.id}>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<Table.Cell key={cell.id}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</Table.Cell>
|
||||
))}
|
||||
</Table.Row>
|
||||
))}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditableArgumentsTable;
|
||||
35
src/components/agents/Tools.tsx
Normal file
35
src/components/agents/Tools.tsx
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import React, { useState } from "react";
|
||||
|
||||
import { useAgentTools } from "@trustgraph/react-state";
|
||||
import EditDialog from "./EditDialog";
|
||||
import Controls from "./Controls";
|
||||
import ToolsTable from "./ToolsTable";
|
||||
|
||||
const Tools = () => {
|
||||
const toolsState = useAgentTools();
|
||||
const [selected, setSelected] = useState("");
|
||||
|
||||
const onComplete = () => {
|
||||
setSelected("");
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<EditDialog
|
||||
open={selected != ""}
|
||||
onOpenChange={() => setSelected("")}
|
||||
onComplete={() => onComplete()}
|
||||
create={false}
|
||||
id={selected}
|
||||
/>
|
||||
<ToolsTable
|
||||
selected={selected}
|
||||
setSelected={setSelected}
|
||||
tools={toolsState.tools}
|
||||
/>
|
||||
<Controls />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Tools;
|
||||
32
src/components/agents/ToolsTable.tsx
Normal file
32
src/components/agents/ToolsTable.tsx
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import { useMemo } from "react";
|
||||
import { getCoreRowModel, useReactTable } from "@tanstack/react-table";
|
||||
|
||||
import { columns, type AgentTool } from "../../model/agent-tools-table";
|
||||
import ClickableTable from "../common/ClickableTable";
|
||||
|
||||
const ToolsTable = ({ setSelected, tools }) => {
|
||||
// Transform the raw tools data to match our table structure
|
||||
const tableData: AgentTool[] = useMemo(() => {
|
||||
return tools.map(([id, config]) => ({
|
||||
id,
|
||||
name: config?.name || "",
|
||||
description: config?.description || "",
|
||||
type: config?.type || "",
|
||||
}));
|
||||
}, [tools]);
|
||||
|
||||
// Initialize React Table with tool data and column configuration
|
||||
const table = useReactTable({
|
||||
data: tableData,
|
||||
columns: columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
});
|
||||
|
||||
const onSelect = (row) => {
|
||||
setSelected(row.original.id);
|
||||
};
|
||||
|
||||
return <ClickableTable table={table} onClick={onSelect} />;
|
||||
};
|
||||
|
||||
export default ToolsTable;
|
||||
28
src/components/chat/ChatConversation.tsx
Normal file
28
src/components/chat/ChatConversation.tsx
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import React from "react";
|
||||
|
||||
import ChatHistory from "./ChatHistory";
|
||||
import InputArea from "./InputArea";
|
||||
import EntityList from "../common/EntityList";
|
||||
|
||||
import { useConversation, useChat } from "@trustgraph/react-state";
|
||||
|
||||
const ChatConversation = () => {
|
||||
const input = useConversation((state) => state.input);
|
||||
const { submitMessage } = useChat();
|
||||
|
||||
const submit = () => {
|
||||
if (input.trim()) {
|
||||
submitMessage({ input });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<ChatHistory />
|
||||
<InputArea onSubmit={() => submit()} />
|
||||
<EntityList />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChatConversation;
|
||||
34
src/components/chat/ChatHelp.tsx
Normal file
34
src/components/chat/ChatHelp.tsx
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import React from "react";
|
||||
|
||||
import { Popover, Text, IconButton, Portal } from "@chakra-ui/react";
|
||||
import { CircleHelp } from "lucide-react";
|
||||
|
||||
const ChatHelp = () => {
|
||||
return (
|
||||
<Popover.Root size="md" variant="outline">
|
||||
<Popover.Trigger asChild>
|
||||
<IconButton size="lg" ml={10}>
|
||||
<CircleHelp />
|
||||
</IconButton>
|
||||
</Popover.Trigger>
|
||||
<Portal>
|
||||
<Popover.Positioner>
|
||||
<Popover.Content w="25rem">
|
||||
<Popover.Arrow />
|
||||
<Popover.Body p={5}>
|
||||
<Popover.Title fontWeight="medium">Chat assistant</Popover.Title>
|
||||
<Text m={2}>
|
||||
The Chat assistant lets you converse with the assistant in
|
||||
natural language. The assistant has access to all of the
|
||||
information in the knowledge graph and will use the knowledge
|
||||
graph to provide information to you.
|
||||
</Text>
|
||||
</Popover.Body>
|
||||
</Popover.Content>
|
||||
</Popover.Positioner>
|
||||
</Portal>
|
||||
</Popover.Root>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChatHelp;
|
||||
63
src/components/chat/ChatHistory.tsx
Normal file
63
src/components/chat/ChatHistory.tsx
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import React, { useEffect, useRef } from "react";
|
||||
|
||||
import { Box, Text, VStack, HStack, Avatar, Spacer } from "@chakra-ui/react";
|
||||
|
||||
import { useConversation } from "@trustgraph/react-state";
|
||||
import ChatMessage from "./ChatMessage";
|
||||
import ChatModeSelector from "./ChatModeSelector";
|
||||
|
||||
const ChatHistory = () => {
|
||||
const messages = useConversation((state) => state.messages);
|
||||
|
||||
const scrollRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const scrollToElement = () => {
|
||||
const { current } = scrollRef;
|
||||
if (current !== null) {
|
||||
current.scrollIntoView({ behavior: "smooth" });
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(scrollToElement, [messages]);
|
||||
|
||||
return (
|
||||
<VStack
|
||||
spacing={4}
|
||||
align="stretch"
|
||||
borderWidth="1px"
|
||||
borderRadius="lg"
|
||||
overflowY="auto"
|
||||
maxH="calc(90% - 10rem)"
|
||||
>
|
||||
<Box bg="bg.emphasized" p={4} borderBottomWidth="1px">
|
||||
<HStack>
|
||||
<Avatar.Root size="sm" colorPalette="accent" mr={4}>
|
||||
<Avatar.Fallback name="Bot" />
|
||||
</Avatar.Root>
|
||||
<Text fontWeight="bold">Assistant</Text>
|
||||
<ChatModeSelector />
|
||||
<Spacer />
|
||||
<Text fontSize="sm" color="fg.muted">
|
||||
Online
|
||||
</Text>
|
||||
</HStack>
|
||||
</Box>
|
||||
|
||||
<VStack
|
||||
flex={1}
|
||||
spacing={4}
|
||||
maxH="100%"
|
||||
overflowY="scroll"
|
||||
p={4}
|
||||
align="stretch"
|
||||
>
|
||||
{messages.map((message, ix) => (
|
||||
<ChatMessage key={ix} message={message} />
|
||||
))}
|
||||
<div ref={scrollRef}></div>
|
||||
</VStack>
|
||||
</VStack>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChatHistory;
|
||||
95
src/components/chat/ChatMessage.tsx
Normal file
95
src/components/chat/ChatMessage.tsx
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
import { Box, Flex, Avatar, Badge } from "@chakra-ui/react";
|
||||
import { Brain, Eye, CheckCircle } from "lucide-react";
|
||||
import Markdown from "react-markdown-it";
|
||||
|
||||
const ChatMessage = ({ message }) => {
|
||||
const isUser = message.role === "human";
|
||||
const messageType = message.type || "normal";
|
||||
|
||||
// Define styles and icons for different message types
|
||||
const getTypeStyles = () => {
|
||||
switch (messageType) {
|
||||
case "thinking":
|
||||
return {
|
||||
bg: "thinking.contrast",
|
||||
borderColor: "thinking.muted",
|
||||
borderWidth: "1px",
|
||||
icon: <Brain size={14} />,
|
||||
badge: "Thinking",
|
||||
badgeColor: "thinking",
|
||||
color: "collout1.fg",
|
||||
};
|
||||
case "observation":
|
||||
return {
|
||||
bg: "observing.contrast",
|
||||
borderColor: "observing.muted",
|
||||
borderWidth: "1px",
|
||||
icon: <Eye size={14} />,
|
||||
badge: "Observation",
|
||||
badgeColor: "observing",
|
||||
color: "observing.fg",
|
||||
};
|
||||
case "answer":
|
||||
return {
|
||||
bg: "insightful.contrast",
|
||||
borderColor: "insightful.muted",
|
||||
borderWidth: "1px",
|
||||
icon: <CheckCircle size={14} />,
|
||||
badge: "Answer",
|
||||
badgeColor: "insightful",
|
||||
color: "insightful.fg",
|
||||
};
|
||||
default:
|
||||
return {
|
||||
bg: isUser ? "primary.solid" : "bg",
|
||||
color: isUser ? "fg.inverted" : "fg",
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const typeStyles = getTypeStyles();
|
||||
|
||||
return (
|
||||
<Flex w="100%" justify={isUser ? "flex-end" : "flex-start"} mb={2}>
|
||||
{!isUser && (
|
||||
<Avatar.Root size="sm" colorPalette="accent" mr={3}>
|
||||
<Avatar.Fallback name="Bot" />
|
||||
</Avatar.Root>
|
||||
)}
|
||||
|
||||
<Box
|
||||
maxW="70%"
|
||||
bg={typeStyles.bg}
|
||||
color={typeStyles.color || (isUser ? "fg.inverted" : "fg")}
|
||||
borderRadius="lg"
|
||||
borderColor={typeStyles.borderColor}
|
||||
borderWidth={typeStyles.borderWidth}
|
||||
px={4}
|
||||
py={2}
|
||||
>
|
||||
{typeStyles.badge && (
|
||||
<Flex align="center" mb={2}>
|
||||
{typeStyles.icon}
|
||||
<Badge
|
||||
ml={2}
|
||||
size="sm"
|
||||
colorPalette={typeStyles.badgeColor}
|
||||
variant="subtle"
|
||||
>
|
||||
{typeStyles.badge}
|
||||
</Badge>
|
||||
</Flex>
|
||||
)}
|
||||
<Markdown>{message.text}</Markdown>
|
||||
</Box>
|
||||
|
||||
{isUser && (
|
||||
<Avatar.Root size="sm" colorPalette="primary" ml={3}>
|
||||
<Avatar.Fallback name="User" />
|
||||
</Avatar.Root>
|
||||
)}
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChatMessage;
|
||||
50
src/components/chat/ChatModeSelector.tsx
Normal file
50
src/components/chat/ChatModeSelector.tsx
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import React from "react";
|
||||
import { Select, Portal, createListCollection } from "@chakra-ui/react";
|
||||
import { useConversation, ChatMode } from "@trustgraph/react-state";
|
||||
|
||||
const ChatModeSelector = () => {
|
||||
const chatMode = useConversation((state) => state.chatMode);
|
||||
const setChatMode = useConversation((state) => state.setChatMode);
|
||||
|
||||
const chatModes = [
|
||||
{ value: "graph-rag", label: "Graph RAG" },
|
||||
{ value: "agent", label: "Agent" },
|
||||
{ value: "basic-llm", label: "Basic LLM" },
|
||||
];
|
||||
|
||||
const collection = createListCollection({ items: chatModes });
|
||||
|
||||
return (
|
||||
<Select.Root
|
||||
collection={collection}
|
||||
value={[chatMode]}
|
||||
onValueChange={(e) => setChatMode(e.value[0] as ChatMode)}
|
||||
size="sm"
|
||||
width="150px"
|
||||
>
|
||||
<Select.HiddenSelect />
|
||||
<Select.Control>
|
||||
<Select.Trigger>
|
||||
<Select.ValueText />
|
||||
</Select.Trigger>
|
||||
<Select.IndicatorGroup>
|
||||
<Select.Indicator />
|
||||
</Select.IndicatorGroup>
|
||||
</Select.Control>
|
||||
<Portal>
|
||||
<Select.Positioner>
|
||||
<Select.Content>
|
||||
{chatModes.map((mode) => (
|
||||
<Select.Item item={mode} key={mode.value}>
|
||||
<Select.ItemText>{mode.label}</Select.ItemText>
|
||||
<Select.ItemIndicator />
|
||||
</Select.Item>
|
||||
))}
|
||||
</Select.Content>
|
||||
</Select.Positioner>
|
||||
</Portal>
|
||||
</Select.Root>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChatModeSelector;
|
||||
61
src/components/chat/InputArea.tsx
Normal file
61
src/components/chat/InputArea.tsx
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import React, { useRef } from "react";
|
||||
|
||||
import { Input, HStack } from "@chakra-ui/react";
|
||||
|
||||
import {
|
||||
useProgressStateStore,
|
||||
useConversation,
|
||||
} from "@trustgraph/react-state";
|
||||
import ChatHelp from "./ChatHelp";
|
||||
import ProgressSubmitButton from "../common/ProgressSubmitButton";
|
||||
|
||||
interface InputAreaProps {
|
||||
onSubmit: () => void;
|
||||
}
|
||||
|
||||
const InputArea: React.FC<InputAreaProps> = ({ onSubmit }) => {
|
||||
const input = useConversation((state) => state.input);
|
||||
const setInput = useConversation((state) => state.setInput);
|
||||
const activity = useProgressStateStore((state) => state.activity);
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const submit = () => {
|
||||
onSubmit();
|
||||
if (inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
}
|
||||
};
|
||||
|
||||
const onKeyDown = (event) => {
|
||||
if (event.key == "Enter") {
|
||||
onSubmit();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<HStack mt={4}>
|
||||
<Input
|
||||
w="full"
|
||||
variant="outlined"
|
||||
placeholder="Describe a Graph RAG request..."
|
||||
value={input}
|
||||
ref={inputRef}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
/>
|
||||
|
||||
<ProgressSubmitButton
|
||||
disabled={activity.size > 0}
|
||||
working={activity.size > 0}
|
||||
onClick={() => submit()}
|
||||
/>
|
||||
|
||||
<ChatHelp />
|
||||
</HStack>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default InputArea;
|
||||
353
src/components/chat/__tests__/ChatMessage.test.tsx
Normal file
353
src/components/chat/__tests__/ChatMessage.test.tsx
Normal file
|
|
@ -0,0 +1,353 @@
|
|||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import ChatMessage from "../ChatMessage";
|
||||
|
||||
// Helper function to filter out Chakra UI props
|
||||
const filterChakraProps = (props: Record<string, unknown>) => {
|
||||
const chakraProps = [
|
||||
"alignItems",
|
||||
"justifyContent",
|
||||
"direction",
|
||||
"gap",
|
||||
"p",
|
||||
"px",
|
||||
"py",
|
||||
"pt",
|
||||
"pb",
|
||||
"pl",
|
||||
"pr",
|
||||
"m",
|
||||
"mx",
|
||||
"my",
|
||||
"mt",
|
||||
"mb",
|
||||
"ml",
|
||||
"mr",
|
||||
"w",
|
||||
"h",
|
||||
"maxW",
|
||||
"maxH",
|
||||
"minW",
|
||||
"minH",
|
||||
"bg",
|
||||
"color",
|
||||
"borderRadius",
|
||||
"borderWidth",
|
||||
"borderColor",
|
||||
"borderStyle",
|
||||
"boxShadow",
|
||||
"display",
|
||||
"position",
|
||||
"top",
|
||||
"right",
|
||||
"bottom",
|
||||
"left",
|
||||
"zIndex",
|
||||
"overflow",
|
||||
"textAlign",
|
||||
"fontSize",
|
||||
"fontWeight",
|
||||
"lineHeight",
|
||||
"letterSpacing",
|
||||
"textTransform",
|
||||
"textDecoration",
|
||||
"opacity",
|
||||
"visibility",
|
||||
"cursor",
|
||||
"pointerEvents",
|
||||
"userSelect",
|
||||
"resize",
|
||||
"outline",
|
||||
"transform",
|
||||
"transformOrigin",
|
||||
"transition",
|
||||
"animation",
|
||||
"colorPalette",
|
||||
"variant",
|
||||
"size",
|
||||
"loading",
|
||||
"disabled",
|
||||
"checked",
|
||||
"selected",
|
||||
"active",
|
||||
"focus",
|
||||
"hover",
|
||||
"flexDirection",
|
||||
"flexWrap",
|
||||
"flex",
|
||||
"flexGrow",
|
||||
"flexShrink",
|
||||
"flexBasis",
|
||||
"alignSelf",
|
||||
"justifySelf",
|
||||
"order",
|
||||
"gridColumn",
|
||||
"gridRow",
|
||||
"gridArea",
|
||||
"gridTemplateColumns",
|
||||
"gridTemplateRows",
|
||||
"gridGap",
|
||||
"rowGap",
|
||||
"columnGap",
|
||||
"placeItems",
|
||||
"placeContent",
|
||||
"placeSelf",
|
||||
"area",
|
||||
"colSpan",
|
||||
"rowSpan",
|
||||
"start",
|
||||
"end",
|
||||
];
|
||||
const filtered = { ...props };
|
||||
chakraProps.forEach((prop) => delete filtered[prop]);
|
||||
return filtered;
|
||||
};
|
||||
|
||||
// Mock Chakra UI components
|
||||
vi.mock("@chakra-ui/react", () => ({
|
||||
Box: ({
|
||||
children,
|
||||
...props
|
||||
}: React.PropsWithChildren<Record<string, unknown>>) => (
|
||||
<div data-testid="box" {...filterChakraProps(props)}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
Flex: ({
|
||||
children,
|
||||
...props
|
||||
}: React.PropsWithChildren<Record<string, unknown>>) => (
|
||||
<div data-testid="flex" {...filterChakraProps(props)}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
Text: ({
|
||||
children,
|
||||
...props
|
||||
}: React.PropsWithChildren<Record<string, unknown>>) => (
|
||||
<p data-testid="text" {...filterChakraProps(props)}>
|
||||
{children}
|
||||
</p>
|
||||
),
|
||||
Avatar: {
|
||||
Root: ({
|
||||
children,
|
||||
...props
|
||||
}: React.PropsWithChildren<Record<string, unknown>>) => (
|
||||
<div data-testid="avatar-root" {...filterChakraProps(props)}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
Fallback: ({
|
||||
name,
|
||||
...props
|
||||
}: { name?: string } & Record<string, unknown>) => (
|
||||
<div data-testid="avatar-fallback" {...filterChakraProps(props)}>
|
||||
{name}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
Badge: ({
|
||||
children,
|
||||
...props
|
||||
}: React.PropsWithChildren<Record<string, unknown>>) => (
|
||||
<span data-testid="badge" {...filterChakraProps(props)}>
|
||||
{children}
|
||||
</span>
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock react-markdown-it
|
||||
vi.mock("react-markdown-it", () => ({
|
||||
default: ({ children }: React.PropsWithChildren) => (
|
||||
<div data-testid="markdown">{children}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock lucide-react icons
|
||||
vi.mock("lucide-react", () => ({
|
||||
Brain: () => <div data-testid="brain-icon">Brain</div>,
|
||||
Eye: () => <div data-testid="eye-icon">Eye</div>,
|
||||
CheckCircle: () => <div data-testid="check-circle-icon">CheckCircle</div>,
|
||||
}));
|
||||
|
||||
describe("ChatMessage", () => {
|
||||
it("should render user message with correct styling", () => {
|
||||
const message = {
|
||||
role: "human",
|
||||
text: "Hello, how are you?",
|
||||
type: "normal",
|
||||
};
|
||||
|
||||
render(<ChatMessage message={message} />);
|
||||
|
||||
expect(screen.getByTestId("markdown")).toHaveTextContent(
|
||||
"Hello, how are you?",
|
||||
);
|
||||
expect(screen.getByTestId("avatar-fallback")).toHaveTextContent("User");
|
||||
});
|
||||
|
||||
it("should render AI message with correct styling", () => {
|
||||
const message = {
|
||||
role: "ai",
|
||||
text: "I am doing well, thank you!",
|
||||
type: "normal",
|
||||
};
|
||||
|
||||
render(<ChatMessage message={message} />);
|
||||
|
||||
expect(screen.getByTestId("markdown")).toHaveTextContent(
|
||||
"I am doing well, thank you!",
|
||||
);
|
||||
expect(screen.getByTestId("avatar-fallback")).toHaveTextContent("Bot");
|
||||
});
|
||||
|
||||
it("should render thinking message with correct badge and icon", () => {
|
||||
const message = {
|
||||
role: "ai",
|
||||
text: "Let me think about this...",
|
||||
type: "thinking",
|
||||
};
|
||||
|
||||
render(<ChatMessage message={message} />);
|
||||
|
||||
expect(screen.getByTestId("brain-icon")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("badge")).toHaveTextContent("Thinking");
|
||||
expect(screen.getByTestId("markdown")).toHaveTextContent(
|
||||
"Let me think about this...",
|
||||
);
|
||||
});
|
||||
|
||||
it("should render observation message with correct badge and icon", () => {
|
||||
const message = {
|
||||
role: "ai",
|
||||
text: "I observe that...",
|
||||
type: "observation",
|
||||
};
|
||||
|
||||
render(<ChatMessage message={message} />);
|
||||
|
||||
expect(screen.getByTestId("eye-icon")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("badge")).toHaveTextContent("Observation");
|
||||
expect(screen.getByTestId("markdown")).toHaveTextContent(
|
||||
"I observe that...",
|
||||
);
|
||||
});
|
||||
|
||||
it("should render answer message with correct badge and icon", () => {
|
||||
const message = {
|
||||
role: "ai",
|
||||
text: "The answer is 42.",
|
||||
type: "answer",
|
||||
};
|
||||
|
||||
render(<ChatMessage message={message} />);
|
||||
|
||||
expect(screen.getByTestId("check-circle-icon")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("badge")).toHaveTextContent("Answer");
|
||||
expect(screen.getByTestId("markdown")).toHaveTextContent(
|
||||
"The answer is 42.",
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle message without type (defaults to normal)", () => {
|
||||
const message = {
|
||||
role: "ai",
|
||||
text: "Regular message",
|
||||
};
|
||||
|
||||
render(<ChatMessage message={message} />);
|
||||
|
||||
expect(screen.getByTestId("markdown")).toHaveTextContent(
|
||||
"Regular message",
|
||||
);
|
||||
expect(screen.queryByTestId("badge")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should handle empty message text", () => {
|
||||
const message = {
|
||||
role: "human",
|
||||
text: "",
|
||||
type: "normal",
|
||||
};
|
||||
|
||||
render(<ChatMessage message={message} />);
|
||||
|
||||
expect(screen.getByTestId("markdown")).toHaveTextContent("");
|
||||
});
|
||||
|
||||
it("should handle markdown content", () => {
|
||||
const message = {
|
||||
role: "ai",
|
||||
text: "**Bold text** and *italic text*",
|
||||
type: "normal",
|
||||
};
|
||||
|
||||
render(<ChatMessage message={message} />);
|
||||
|
||||
expect(screen.getByTestId("markdown")).toHaveTextContent(
|
||||
"**Bold text** and *italic text*",
|
||||
);
|
||||
});
|
||||
|
||||
it("should differentiate between user and AI avatar placement", () => {
|
||||
const userMessage = {
|
||||
role: "human",
|
||||
text: "User message",
|
||||
type: "normal",
|
||||
};
|
||||
|
||||
const { rerender } = render(<ChatMessage message={userMessage} />);
|
||||
|
||||
expect(screen.getByTestId("avatar-fallback")).toHaveTextContent("User");
|
||||
|
||||
const aiMessage = {
|
||||
role: "ai",
|
||||
text: "AI message",
|
||||
type: "normal",
|
||||
};
|
||||
|
||||
rerender(<ChatMessage message={aiMessage} />);
|
||||
|
||||
expect(screen.getByTestId("avatar-fallback")).toHaveTextContent("Bot");
|
||||
});
|
||||
|
||||
it("should handle all message types with correct styling", () => {
|
||||
const messageTypes = ["normal", "thinking", "observation", "answer"];
|
||||
|
||||
messageTypes.forEach((type) => {
|
||||
const message = {
|
||||
role: "ai",
|
||||
text: `Message of type ${type}`,
|
||||
type: type,
|
||||
};
|
||||
|
||||
const { unmount } = render(<ChatMessage message={message} />);
|
||||
|
||||
expect(screen.getByTestId("markdown")).toHaveTextContent(
|
||||
`Message of type ${type}`,
|
||||
);
|
||||
|
||||
if (type !== "normal") {
|
||||
expect(screen.getByTestId("badge")).toBeInTheDocument();
|
||||
}
|
||||
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle unknown message type (falls back to normal)", () => {
|
||||
const message = {
|
||||
role: "ai",
|
||||
text: "Unknown type message",
|
||||
type: "unknown",
|
||||
};
|
||||
|
||||
render(<ChatMessage message={message} />);
|
||||
|
||||
expect(screen.getByTestId("markdown")).toHaveTextContent(
|
||||
"Unknown type message",
|
||||
);
|
||||
expect(screen.queryByTestId("badge")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
17
src/components/color-mode-toggle.tsx
Normal file
17
src/components/color-mode-toggle.tsx
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { IconButton } from "@chakra-ui/react";
|
||||
import { useTheme } from "next-themes";
|
||||
import { LuMoon, LuSun } from "react-icons/lu";
|
||||
|
||||
const ColorModeToggle = () => {
|
||||
const { theme, setTheme } = useTheme();
|
||||
const toggleColorMode = () => {
|
||||
setTheme(theme === "light" ? "dark" : "light");
|
||||
};
|
||||
return (
|
||||
<IconButton aria-label="toggle color mode" onClick={toggleColorMode}>
|
||||
{theme === "light" ? <LuMoon /> : <LuSun />}
|
||||
</IconButton>
|
||||
);
|
||||
};
|
||||
|
||||
export default ColorModeToggle;
|
||||
39
src/components/common/AltCard.tsx
Normal file
39
src/components/common/AltCard.tsx
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import React from "react";
|
||||
import { Box, Flex, Heading, Text } from "@chakra-ui/react";
|
||||
|
||||
interface AltCardProps {
|
||||
title: string;
|
||||
description?: string;
|
||||
icon?: React.ReactNode;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
const AltCard: React.FC<AltCardProps> = ({
|
||||
title,
|
||||
description,
|
||||
icon,
|
||||
children,
|
||||
}) => {
|
||||
return (
|
||||
<Box borderRadius="lg" boxShadow="sm" p={5} border="1px" height="100%">
|
||||
<Flex alignItems="center" mb={description ? 2 : 4}>
|
||||
{icon && (
|
||||
<Box mr={3} color="accent.solid">
|
||||
{icon}
|
||||
</Box>
|
||||
)}
|
||||
<Heading as="h3" size="md" fontWeight="semibold">
|
||||
{title}
|
||||
</Heading>
|
||||
</Flex>
|
||||
{description && (
|
||||
<Text mb={4} fontSize="sm">
|
||||
{description}
|
||||
</Text>
|
||||
)}
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default AltCard;
|
||||
40
src/components/common/BasicTable.tsx
Normal file
40
src/components/common/BasicTable.tsx
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import { Table } from "@chakra-ui/react";
|
||||
import { flexRender } from "@tanstack/react-table";
|
||||
|
||||
const BasicTable = ({ table }) => {
|
||||
return (
|
||||
<>
|
||||
<Table.Root>
|
||||
<Table.Header>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<Table.Row key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<Table.ColumnHeader key={header.id}>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext(),
|
||||
)}
|
||||
</Table.ColumnHeader>
|
||||
))}
|
||||
</Table.Row>
|
||||
))}
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{table.getRowModel().rows.map((row) => (
|
||||
<Table.Row key={row.id}>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<Table.Cell key={cell.id}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</Table.Cell>
|
||||
))}
|
||||
</Table.Row>
|
||||
))}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default BasicTable;
|
||||
34
src/components/common/Card.tsx
Normal file
34
src/components/common/Card.tsx
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import React from "react";
|
||||
import { Box, Flex, Heading, Text } from "@chakra-ui/react";
|
||||
|
||||
interface CardProps {
|
||||
title: string;
|
||||
description?: string;
|
||||
icon?: React.ReactNode;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
const Card: React.FC<CardProps> = ({ title, description, icon, children }) => {
|
||||
return (
|
||||
<Box borderRadius="lg" boxShadow="sm" p={5} border="1px" height="100%">
|
||||
<Flex alignItems="center" mb={description ? 2 : 4}>
|
||||
{icon && (
|
||||
<Box mr={3} color="primary.solid">
|
||||
{icon}
|
||||
</Box>
|
||||
)}
|
||||
<Heading as="h3" size="md" fontWeight="semibold">
|
||||
{title}
|
||||
</Heading>
|
||||
</Flex>
|
||||
{description && (
|
||||
<Text mb={4} fontSize="sm">
|
||||
{description}
|
||||
</Text>
|
||||
)}
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default Card;
|
||||
28
src/components/common/CenterSpinner.tsx
Normal file
28
src/components/common/CenterSpinner.tsx
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import React from "react";
|
||||
|
||||
import { Box, Spinner } from "@chakra-ui/react";
|
||||
|
||||
import { useProgressStateStore } from "@trustgraph/react-state";
|
||||
|
||||
const CenterSpinner: React.FC = () => {
|
||||
const activity = useProgressStateStore((state) => state.activity);
|
||||
|
||||
if (activity.size < 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
position="absolute"
|
||||
top="calc(50% - 3rem)"
|
||||
left="calc(50% - 3rem)"
|
||||
zIndex="999"
|
||||
margin="0"
|
||||
padding="0"
|
||||
>
|
||||
<Spinner size="xl" />
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default CenterSpinner;
|
||||
118
src/components/common/ChipInputField.tsx
Normal file
118
src/components/common/ChipInputField.tsx
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
import React, { useState } from "react";
|
||||
|
||||
import { Input, Tag, Wrap, Field } from "@chakra-ui/react";
|
||||
|
||||
// Represents a label added to the list. Highlighted with a close button for
|
||||
// removal.
|
||||
const Chip = ({ label, onCloseClick }) => (
|
||||
<Tag.Root
|
||||
key={label}
|
||||
borderRadius="full"
|
||||
variant="solid"
|
||||
colorScheme="green"
|
||||
>
|
||||
<Tag.Label>{label}</Tag.Label>
|
||||
<Tag.EndElement>
|
||||
<Tag.CloseTrigger
|
||||
onClick={() => {
|
||||
onCloseClick(label);
|
||||
}}
|
||||
/>
|
||||
</Tag.EndElement>
|
||||
</Tag.Root>
|
||||
);
|
||||
|
||||
// A horizontal stack of chips. Like a Pringles can on its side.
|
||||
const ChipList = ({ items = [], onCloseClick }) => (
|
||||
<Wrap spacing={1} mb={3}>
|
||||
{items.map((item) => (
|
||||
<Chip label={item} key={item} onCloseClick={onCloseClick} />
|
||||
))}
|
||||
</Wrap>
|
||||
);
|
||||
|
||||
// Form field wrapper.
|
||||
const ChipInput = ({ ...rest }) => <Input {...rest} />;
|
||||
|
||||
// Field wrapping chip list and input
|
||||
const ChipInputField: React.FC<{
|
||||
values: string[];
|
||||
onValuesChange: (v: string[]) => void;
|
||||
label: string;
|
||||
}> = ({ values, onValuesChange, label }) => {
|
||||
const [inputValue, setInputValue] = useState("");
|
||||
|
||||
// Checks whether we've added this item already.
|
||||
const itemChipExists = (item) => values.includes(item);
|
||||
|
||||
// Add an item to the list, if it's valid and isn't already there.
|
||||
const addItems = (itemsToAdd) => {
|
||||
const validatedItems = itemsToAdd
|
||||
.map((e) => e.trim())
|
||||
.filter((item) => !itemChipExists(item));
|
||||
|
||||
const newItems = [...values, ...validatedItems];
|
||||
|
||||
onValuesChange(newItems);
|
||||
setInputValue("");
|
||||
};
|
||||
|
||||
// Remove an item from the list.
|
||||
const removeItem = (item) => {
|
||||
const index = values.findIndex((e) => e === item);
|
||||
if (index !== -1) {
|
||||
const newItems = [...values];
|
||||
newItems.splice(index, 1);
|
||||
onValuesChange(newItems);
|
||||
}
|
||||
};
|
||||
|
||||
// Save input field contents in state when changed.
|
||||
const handleChange = (e) => {
|
||||
setInputValue(e.target.value);
|
||||
};
|
||||
|
||||
// Validate and add the item if we press tab, enter or comma.
|
||||
const handleKeyDown = (e) => {
|
||||
if (["Enter", "Tab", ","].includes(e.key)) {
|
||||
e.preventDefault();
|
||||
addItems([inputValue]);
|
||||
}
|
||||
};
|
||||
|
||||
// Split and add items when pasting.
|
||||
const handlePaste = (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const pastedData = e.clipboardData.getData("text");
|
||||
const pastedItems = pastedData.split(",");
|
||||
addItems(pastedItems);
|
||||
};
|
||||
|
||||
const handleCloseClick = (item) => {
|
||||
removeItem(item);
|
||||
};
|
||||
|
||||
const required = false;
|
||||
|
||||
return (
|
||||
<Field.Root mb={4} required={required}>
|
||||
<Field.Label>
|
||||
{label} {required && <Field.RequiredIndicator />}
|
||||
</Field.Label>
|
||||
|
||||
<ChipList items={values} onCloseClick={handleCloseClick} />
|
||||
|
||||
<ChipInput
|
||||
placeholder="enter items"
|
||||
onPaste={handlePaste}
|
||||
onKeyDown={handleKeyDown}
|
||||
onChange={handleChange}
|
||||
value={inputValue}
|
||||
variant="subtle"
|
||||
/>
|
||||
</Field.Root>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChipInputField;
|
||||
40
src/components/common/ClickableTable.tsx
Normal file
40
src/components/common/ClickableTable.tsx
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import { Table } from "@chakra-ui/react";
|
||||
import { flexRender } from "@tanstack/react-table";
|
||||
|
||||
const ClickableTable = ({ table, onClick, ...tableProps }) => {
|
||||
return (
|
||||
<>
|
||||
<Table.Root interactive {...tableProps}>
|
||||
<Table.Header>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<Table.Row key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<Table.ColumnHeader key={header.id}>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext(),
|
||||
)}
|
||||
</Table.ColumnHeader>
|
||||
))}
|
||||
</Table.Row>
|
||||
))}
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{table.getRowModel().rows.map((row) => (
|
||||
<Table.Row key={row.id} onClick={() => onClick(row)}>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<Table.Cell key={cell.id}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</Table.Cell>
|
||||
))}
|
||||
</Table.Row>
|
||||
))}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ClickableTable;
|
||||
139
src/components/common/ConfirmDialog.tsx
Normal file
139
src/components/common/ConfirmDialog.tsx
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
import React from "react";
|
||||
import { Box, VStack, HStack, Text, Button } from "@chakra-ui/react";
|
||||
import { AlertTriangle, X } from "lucide-react";
|
||||
|
||||
interface ConfirmDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
title: string;
|
||||
message: string;
|
||||
confirmText?: string;
|
||||
cancelText?: string;
|
||||
variant?: "danger" | "warning" | "info";
|
||||
}
|
||||
|
||||
export const ConfirmDialog: React.FC<ConfirmDialogProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onConfirm,
|
||||
title,
|
||||
message,
|
||||
confirmText = "Confirm",
|
||||
cancelText = "Cancel",
|
||||
variant = "warning",
|
||||
}) => {
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleConfirm = () => {
|
||||
onConfirm();
|
||||
onClose();
|
||||
};
|
||||
|
||||
const getVariantColors = () => {
|
||||
switch (variant) {
|
||||
case "danger":
|
||||
return {
|
||||
icon: "red.500",
|
||||
confirmButton: "red",
|
||||
bg: "red.50",
|
||||
border: "red.200",
|
||||
};
|
||||
case "warning":
|
||||
return {
|
||||
icon: "orange.500",
|
||||
confirmButton: "orange",
|
||||
bg: "orange.50",
|
||||
border: "orange.200",
|
||||
};
|
||||
case "info":
|
||||
return {
|
||||
icon: "blue.500",
|
||||
confirmButton: "blue",
|
||||
bg: "blue.50",
|
||||
border: "blue.200",
|
||||
};
|
||||
default:
|
||||
return {
|
||||
icon: "orange.500",
|
||||
confirmButton: "orange",
|
||||
bg: "orange.50",
|
||||
border: "orange.200",
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const colors = getVariantColors();
|
||||
|
||||
return (
|
||||
<Box
|
||||
position="fixed"
|
||||
top="0"
|
||||
left="0"
|
||||
right="0"
|
||||
bottom="0"
|
||||
bg="blackAlpha.600"
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
zIndex="modal"
|
||||
>
|
||||
<Box
|
||||
bg="white"
|
||||
borderRadius="lg"
|
||||
boxShadow="xl"
|
||||
w="500px"
|
||||
maxW="90vw"
|
||||
maxH="90vh"
|
||||
overflow="auto"
|
||||
>
|
||||
{/* Header */}
|
||||
<Box p={6} borderBottomWidth="1px">
|
||||
<HStack justify="space-between" align="center">
|
||||
<HStack>
|
||||
<AlertTriangle size={20} color={colors.icon} />
|
||||
<Text fontSize="lg" fontWeight="semibold">
|
||||
{title}
|
||||
</Text>
|
||||
</HStack>
|
||||
<Button variant="ghost" size="sm" onClick={onClose}>
|
||||
<X size={16} />
|
||||
</Button>
|
||||
</HStack>
|
||||
</Box>
|
||||
|
||||
{/* Content */}
|
||||
<Box p={6}>
|
||||
<VStack align="stretch" spacing={4}>
|
||||
<Box
|
||||
p={4}
|
||||
bg={colors.bg}
|
||||
borderRadius="md"
|
||||
borderWidth="1px"
|
||||
borderColor={colors.border}
|
||||
>
|
||||
<Text fontSize="sm" color="gray.700" whiteSpace="pre-line">
|
||||
{message}
|
||||
</Text>
|
||||
</Box>
|
||||
</VStack>
|
||||
</Box>
|
||||
|
||||
{/* Footer */}
|
||||
<Box p={6} borderTopWidth="1px" bg="gray.50">
|
||||
<HStack justify="flex-end" spacing={3}>
|
||||
<Button variant="ghost" onClick={onClose}>
|
||||
{cancelText}
|
||||
</Button>
|
||||
<Button
|
||||
colorPalette={colors.confirmButton}
|
||||
onClick={handleConfirm}
|
||||
>
|
||||
{confirmText}
|
||||
</Button>
|
||||
</HStack>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
117
src/components/common/ConnectionStatus.tsx
Normal file
117
src/components/common/ConnectionStatus.tsx
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
import React from "react";
|
||||
import { Box, HStack, Text, Tooltip } from "@chakra-ui/react";
|
||||
import { Info, Clock, Wifi, WifiOff, Shield, ShieldOff } from "lucide-react";
|
||||
import { useConnectionState } from "@trustgraph/react-provider";
|
||||
import type { ConnectionState } from "@trustgraph/client";
|
||||
|
||||
interface ConnectionStatusProps {
|
||||
showDetails?: boolean;
|
||||
size?: "sm" | "md" | "lg";
|
||||
}
|
||||
|
||||
const getStatusDisplay = (state: ConnectionState) => {
|
||||
switch (state.status) {
|
||||
case "connecting":
|
||||
return {
|
||||
icon: Clock,
|
||||
color: "yellow.500",
|
||||
text: "Connecting...",
|
||||
tooltip: "Establishing connection to server",
|
||||
};
|
||||
|
||||
case "connected":
|
||||
return {
|
||||
icon: Wifi,
|
||||
color: "green.500",
|
||||
text: "Connected",
|
||||
tooltip: "Connected to server",
|
||||
};
|
||||
|
||||
case "authenticated":
|
||||
return {
|
||||
icon: Shield,
|
||||
color: "green.500",
|
||||
text: "Authenticated",
|
||||
tooltip: "Connected with API key authentication",
|
||||
};
|
||||
|
||||
case "unauthenticated":
|
||||
return {
|
||||
icon: ShieldOff,
|
||||
color: "blue.500",
|
||||
text: "Unauthenticated",
|
||||
tooltip: "Connected but no API key provided (limited functionality)",
|
||||
};
|
||||
|
||||
case "reconnecting":
|
||||
return {
|
||||
icon: Clock,
|
||||
color: "orange.500",
|
||||
text: `Reconnecting... (${state.reconnectAttempt}/${state.maxAttempts})`,
|
||||
tooltip: `Attempting to reconnect. Try ${state.reconnectAttempt} of ${state.maxAttempts}`,
|
||||
};
|
||||
|
||||
case "failed":
|
||||
return {
|
||||
icon: WifiOff,
|
||||
color: "red.500",
|
||||
text: "Connection Failed",
|
||||
tooltip:
|
||||
state.lastError || "Connection failed after maximum retry attempts",
|
||||
};
|
||||
|
||||
default:
|
||||
return {
|
||||
icon: Info,
|
||||
color: "gray.500",
|
||||
text: "Unknown",
|
||||
tooltip: "Unknown connection state",
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export const ConnectionStatus: React.FC<ConnectionStatusProps> = ({
|
||||
showDetails = false,
|
||||
size = "md",
|
||||
}) => {
|
||||
const connectionState = useConnectionState();
|
||||
|
||||
if (!connectionState) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const {
|
||||
icon: StatusIcon,
|
||||
color,
|
||||
text,
|
||||
tooltip,
|
||||
} = getStatusDisplay(connectionState);
|
||||
|
||||
const iconSize = size === "sm" ? 16 : size === "lg" ? 24 : 20;
|
||||
const fontSize = size === "sm" ? "xs" : size === "lg" ? "md" : "sm";
|
||||
|
||||
return (
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<HStack spacing={2}>
|
||||
<Box color={color}>
|
||||
<StatusIcon size={iconSize} />
|
||||
</Box>
|
||||
<Text fontSize={fontSize} color="fg.default">
|
||||
{showDetails ? text : connectionState.status}
|
||||
</Text>
|
||||
{showDetails && connectionState.hasApiKey && (
|
||||
<Text fontSize="xs" color="fg.muted">
|
||||
(API Key)
|
||||
</Text>
|
||||
)}
|
||||
</HStack>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Positioner>
|
||||
<Tooltip.Content>{tooltip}</Tooltip.Content>
|
||||
</Tooltip.Positioner>
|
||||
</Tooltip.Root>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConnectionStatus;
|
||||
39
src/components/common/EntityList.tsx
Normal file
39
src/components/common/EntityList.tsx
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import { useNavigate } from "react-router";
|
||||
|
||||
import { HStack, Tag } from "@chakra-ui/react";
|
||||
|
||||
import { Entity } from "@trustgraph/react-state";
|
||||
import { useWorkbenchStateStore } from "@trustgraph/react-state";
|
||||
|
||||
const EntityList = () => {
|
||||
const entities = useWorkbenchStateStore((state) => state.entities);
|
||||
const setSelected = useWorkbenchStateStore((state) => state.setSelected);
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const onSelect = (x: Entity) => {
|
||||
setSelected(x);
|
||||
navigate("/entity");
|
||||
};
|
||||
|
||||
return (
|
||||
<HStack mt={8}>
|
||||
{entities.slice(0, 8).map((entity, ix) => (
|
||||
<Tag.Root
|
||||
asChild
|
||||
size="sm"
|
||||
key={ix}
|
||||
color="primary.solid"
|
||||
bgColor="bg"
|
||||
variant="surface"
|
||||
>
|
||||
<button onClick={() => onSelect(entity)}>
|
||||
<Tag.Label>{entity.label}</Tag.Label>
|
||||
</button>
|
||||
</Tag.Root>
|
||||
))}
|
||||
</HStack>
|
||||
);
|
||||
};
|
||||
|
||||
export default EntityList;
|
||||
17
src/components/common/ExternalDocs.tsx
Normal file
17
src/components/common/ExternalDocs.tsx
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import React, { PropsWithChildren } from "react";
|
||||
|
||||
import { Link } from "@chakra-ui/react";
|
||||
|
||||
const ExternalDocs: React.FC<
|
||||
PropsWithChildren<{
|
||||
href: string;
|
||||
}>
|
||||
> = ({ href, children }) => {
|
||||
return (
|
||||
<Link href={href} target="_blank" colorPalette="accent">
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
||||
export default ExternalDocs;
|
||||
255
src/components/common/FlowSelector.tsx
Normal file
255
src/components/common/FlowSelector.tsx
Normal file
|
|
@ -0,0 +1,255 @@
|
|||
import { useState } from "react";
|
||||
|
||||
import { Text, Box, Stack, HStack, Popover, Portal } from "@chakra-ui/react";
|
||||
|
||||
import { Database, Workflow } from "lucide-react";
|
||||
|
||||
import { useSessionStore } from "@trustgraph/react-state";
|
||||
import { useFlows } from "@trustgraph/react-state";
|
||||
import { useSettings } from "@trustgraph/react-state";
|
||||
import { useCollections } from "@trustgraph/react-state";
|
||||
|
||||
const FlowSelector = () => {
|
||||
const flowState = useFlows();
|
||||
const flows = flowState.flows ? flowState.flows : [];
|
||||
|
||||
const collectionsState = useCollections();
|
||||
const collections = collectionsState.collections || [];
|
||||
|
||||
const flowId = useSessionStore((state) => state.flowId);
|
||||
|
||||
const setFlowId = useSessionStore((state) => state.setFlowId);
|
||||
const setFlow = useSessionStore((state) => state.setFlow);
|
||||
|
||||
const { settings, updateSetting } = useSettings();
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<Popover.Root
|
||||
open={open}
|
||||
onOpenChange={(e) => setOpen(e.open)}
|
||||
size="xl"
|
||||
positioning={{ placement: "bottom-end" }}
|
||||
>
|
||||
<Popover.Trigger asChild>
|
||||
<Stack
|
||||
p={3}
|
||||
gap={2}
|
||||
borderWidth="1px"
|
||||
borderRadius="8px"
|
||||
borderColor="border.inverted/20"
|
||||
color="fg.muted"
|
||||
backgroundColor="primary.bg"
|
||||
_hover={{
|
||||
backgroundColor: "bg.emphasized",
|
||||
borderColor: "border.inverted",
|
||||
color: "fg",
|
||||
}}
|
||||
onClick={() => setOpen(true)}
|
||||
cursor="pointer"
|
||||
>
|
||||
<HStack gap={2} align="center">
|
||||
<Database size={14} />
|
||||
<Text fontSize="xs" fontWeight="medium">
|
||||
{settings.collection}
|
||||
</Text>
|
||||
</HStack>
|
||||
|
||||
<HStack gap={2} align="center">
|
||||
<Workflow size={14} />
|
||||
<Text fontSize="xs" fontWeight="medium">
|
||||
{flowId || "<none>"}
|
||||
</Text>
|
||||
</HStack>
|
||||
</Stack>
|
||||
</Popover.Trigger>
|
||||
<Portal>
|
||||
<Popover.Positioner>
|
||||
<Popover.Content>
|
||||
<Popover.Arrow />
|
||||
<Popover.Body>
|
||||
<Stack gap={4} p={4}>
|
||||
{/* Collection Selection */}
|
||||
<Stack gap={3}>
|
||||
<Text
|
||||
fontSize="sm"
|
||||
fontWeight="semibold"
|
||||
color="fg.muted"
|
||||
mb={2}
|
||||
>
|
||||
Select Collection
|
||||
</Text>
|
||||
<Stack gap="1">
|
||||
{collections.map((collection) => {
|
||||
const isSelected =
|
||||
settings.collection === collection.collection;
|
||||
return (
|
||||
<Box
|
||||
key={collection.collection}
|
||||
p={3}
|
||||
borderRadius="md"
|
||||
borderWidth="1px"
|
||||
borderColor={
|
||||
isSelected ? "primary.500" : "border.subtle"
|
||||
}
|
||||
backgroundColor={
|
||||
isSelected ? "primary.50" : "transparent"
|
||||
}
|
||||
_hover={{
|
||||
borderColor: "primary.300",
|
||||
backgroundColor: isSelected
|
||||
? "primary.100"
|
||||
: "bg.subtle",
|
||||
}}
|
||||
cursor="pointer"
|
||||
onClick={() => {
|
||||
updateSetting("collection", collection.collection);
|
||||
}}
|
||||
>
|
||||
<HStack gap={3} align="start">
|
||||
<Box
|
||||
w={4}
|
||||
h={4}
|
||||
borderRadius="full"
|
||||
borderWidth="2px"
|
||||
borderColor={
|
||||
isSelected
|
||||
? "colorPalette.500"
|
||||
: "border.emphasized"
|
||||
}
|
||||
backgroundColor={
|
||||
isSelected ? "colorPalette.500" : "transparent"
|
||||
}
|
||||
mt={0.5}
|
||||
flexShrink={0}
|
||||
position="relative"
|
||||
>
|
||||
{isSelected && (
|
||||
<Box
|
||||
w="6px"
|
||||
h="6px"
|
||||
borderRadius="full"
|
||||
backgroundColor="bg"
|
||||
position="absolute"
|
||||
top="50%"
|
||||
left="50%"
|
||||
transform="translate(-50%, -50%)"
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
<Box flex="1">
|
||||
<Text fontWeight="semibold" fontSize="sm" mb={1}>
|
||||
{collection.name}
|
||||
</Text>
|
||||
<Text
|
||||
fontSize="xs"
|
||||
color="fg.muted"
|
||||
lineHeight="1.4"
|
||||
>
|
||||
{collection.description}
|
||||
</Text>
|
||||
</Box>
|
||||
</HStack>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
{/* Flow Selection */}
|
||||
<Box borderTopWidth="1px" borderColor="border.subtle" pt={4}>
|
||||
<Text
|
||||
fontSize="sm"
|
||||
fontWeight="semibold"
|
||||
color="fg.muted"
|
||||
mb={3}
|
||||
>
|
||||
Select Flow
|
||||
</Text>
|
||||
<Stack gap="1">
|
||||
{flows.map((flow) => {
|
||||
const isSelected = flowId === flow.id;
|
||||
return (
|
||||
<Box
|
||||
key={flow.id}
|
||||
p={3}
|
||||
borderRadius="md"
|
||||
borderWidth="1px"
|
||||
borderColor={
|
||||
isSelected ? "primary.500" : "border.subtle"
|
||||
}
|
||||
backgroundColor={
|
||||
isSelected ? "primary.50" : "transparent"
|
||||
}
|
||||
_hover={{
|
||||
borderColor: "primary.300",
|
||||
backgroundColor: isSelected
|
||||
? "primary.100"
|
||||
: "bg.subtle",
|
||||
}}
|
||||
cursor="pointer"
|
||||
onClick={() => {
|
||||
setFlowId(flow.id);
|
||||
setFlow(flow);
|
||||
}}
|
||||
>
|
||||
<HStack gap={3} align="start">
|
||||
<Box
|
||||
w={4}
|
||||
h={4}
|
||||
borderRadius="full"
|
||||
borderWidth="2px"
|
||||
borderColor={
|
||||
isSelected
|
||||
? "colorPalette.500"
|
||||
: "border.emphasized"
|
||||
}
|
||||
backgroundColor={
|
||||
isSelected ? "colorPalette.500" : "transparent"
|
||||
}
|
||||
mt={0.5}
|
||||
flexShrink={0}
|
||||
position="relative"
|
||||
>
|
||||
{isSelected && (
|
||||
<Box
|
||||
w="6px"
|
||||
h="6px"
|
||||
borderRadius="full"
|
||||
backgroundColor="bg"
|
||||
position="absolute"
|
||||
top="50%"
|
||||
left="50%"
|
||||
transform="translate(-50%, -50%)"
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
<Box flex="1">
|
||||
<Text fontWeight="semibold" fontSize="sm" mb={1}>
|
||||
{flow.id}
|
||||
</Text>
|
||||
<Text
|
||||
fontSize="xs"
|
||||
color="fg.muted"
|
||||
lineHeight="1.4"
|
||||
>
|
||||
{flow.description}
|
||||
</Text>
|
||||
</Box>
|
||||
</HStack>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Popover.Body>
|
||||
</Popover.Content>
|
||||
</Popover.Positioner>
|
||||
</Portal>
|
||||
</Popover.Root>
|
||||
);
|
||||
};
|
||||
|
||||
export default FlowSelector;
|
||||
45
src/components/common/NumberField.tsx
Normal file
45
src/components/common/NumberField.tsx
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import React from "react";
|
||||
import { Field, NumberInput } from "@chakra-ui/react";
|
||||
|
||||
interface NumberFieldProps {
|
||||
label: string;
|
||||
|
||||
minValue: number;
|
||||
maxValue: number;
|
||||
value: number;
|
||||
onValueChange: (x: number) => void;
|
||||
}
|
||||
|
||||
const NumberField: React.FC<NumberFieldProps> = ({
|
||||
label,
|
||||
minValue,
|
||||
maxValue,
|
||||
value,
|
||||
onValueChange,
|
||||
}) => {
|
||||
return (
|
||||
<Field.Root mb={4}>
|
||||
<Field.Label fontWeight="medium">{label}</Field.Label>
|
||||
<NumberInput.Root
|
||||
min={minValue}
|
||||
max={maxValue}
|
||||
value={value.toString()}
|
||||
onValueChange={(e) => {
|
||||
const numValue =
|
||||
e.value === "" || e.value == null ? 0 : Number(e.value);
|
||||
if (!isNaN(numValue)) {
|
||||
onValueChange(numValue);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<NumberInput.Input />
|
||||
<NumberInput.Control>
|
||||
<NumberInput.IncrementTrigger />
|
||||
<NumberInput.DecrementTrigger />
|
||||
</NumberInput.Control>
|
||||
</NumberInput.Root>
|
||||
</Field.Root>
|
||||
);
|
||||
};
|
||||
|
||||
export default NumberField;
|
||||
42
src/components/common/OptionWithImage.tsx
Normal file
42
src/components/common/OptionWithImage.tsx
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import React from "react";
|
||||
import {
|
||||
Box,
|
||||
Stack,
|
||||
Image,
|
||||
Flex,
|
||||
Heading,
|
||||
Text,
|
||||
Center,
|
||||
} from "@chakra-ui/react";
|
||||
|
||||
const OptionWithImage: React.FC<{
|
||||
image: string;
|
||||
title: string;
|
||||
description?: string | React.ReactNode;
|
||||
badge?: React.ReactNode;
|
||||
}> = ({ description, title, image, badge }) => {
|
||||
return (
|
||||
<Stack>
|
||||
<Flex alignItems="center">
|
||||
<Box mr={4} minWidth="5rem" width="5rem">
|
||||
<Center>
|
||||
<Image rounded="md" src={image} alt={title} />
|
||||
</Center>
|
||||
</Box>
|
||||
<Box>
|
||||
<Flex alignItems="center">
|
||||
<Heading as="h1" size="md" color="fg" fontWeight="bold" mr={2}>
|
||||
{title}
|
||||
</Heading>
|
||||
{badge && badge}
|
||||
</Flex>
|
||||
<Text mt={1} textStyle="xs" color="fg.muted">
|
||||
{description}
|
||||
</Text>
|
||||
</Box>
|
||||
</Flex>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default OptionWithImage;
|
||||
64
src/components/common/PageHeader.tsx
Normal file
64
src/components/common/PageHeader.tsx
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import React from "react";
|
||||
|
||||
import { Flex, Text, Box, HStack, VStack, Heading } from "@chakra-ui/react";
|
||||
|
||||
import ColorModeToggle from "../color-mode-toggle";
|
||||
import FlowSelector from "./FlowSelector";
|
||||
import ConnectionStatus from "./ConnectionStatus";
|
||||
import UserDisplay from "./UserDisplay";
|
||||
|
||||
interface PageHeaderProps {
|
||||
title: string;
|
||||
description: string;
|
||||
icon?: React.ReactNode;
|
||||
}
|
||||
|
||||
const PageHeader: React.FC<PageHeaderProps> = ({
|
||||
title,
|
||||
description,
|
||||
icon,
|
||||
}) => {
|
||||
return (
|
||||
<Flex
|
||||
mb={8}
|
||||
alignItems="center"
|
||||
justifyContent="space-between"
|
||||
width="100%"
|
||||
px={1}
|
||||
py={1}
|
||||
>
|
||||
<Flex alignItems="center">
|
||||
{icon && (
|
||||
<Box mr={4} color="{colors.primary.fg}" fontSize="xl">
|
||||
{icon}
|
||||
</Box>
|
||||
)}
|
||||
<Box>
|
||||
<Heading
|
||||
as="h1"
|
||||
size="xl"
|
||||
color="{colors.primary.fg}"
|
||||
fontWeight="bold"
|
||||
>
|
||||
{title}
|
||||
</Heading>
|
||||
<Text mt={1} fontSize="md" color="{colors.primary.emphasized}">
|
||||
{description}
|
||||
</Text>
|
||||
</Box>
|
||||
</Flex>
|
||||
<Box>
|
||||
<HStack gap={6} align="center">
|
||||
<VStack gap={1} align="end">
|
||||
<ConnectionStatus showDetails={true} size="sm" />
|
||||
<UserDisplay />
|
||||
</VStack>
|
||||
<FlowSelector />
|
||||
<ColorModeToggle />
|
||||
</HStack>
|
||||
</Box>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default PageHeader;
|
||||
41
src/components/common/Progress.tsx
Normal file
41
src/components/common/Progress.tsx
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import React from "react";
|
||||
|
||||
import { Box, Text } from "@chakra-ui/react";
|
||||
|
||||
import { useProgressStateStore } from "@trustgraph/react-state";
|
||||
|
||||
const Progress: React.FC = () => {
|
||||
const activity = useProgressStateStore((state) => state.activity);
|
||||
|
||||
return (
|
||||
<>
|
||||
{activity.size > 0 && (
|
||||
<Box
|
||||
position="absolute"
|
||||
bottom="1rem"
|
||||
left="1rem"
|
||||
zIndex="999"
|
||||
pt="0.8rem"
|
||||
pb="0.8rem"
|
||||
pl="1.2rem"
|
||||
pr="1.2rem"
|
||||
backgroundColor="bg.emphasized/40"
|
||||
borderWidth="1px"
|
||||
borderColor="border.inverted/40"
|
||||
borderRadius="5px"
|
||||
width="25rem"
|
||||
>
|
||||
{Array.from(activity)
|
||||
.slice(0, 4)
|
||||
.map((a, ix) => (
|
||||
<Box key={ix} color="fg/40" truncate>
|
||||
<Text textStyle="sm">{a}...</Text>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Progress;
|
||||
33
src/components/common/ProgressSubmitButton.tsx
Normal file
33
src/components/common/ProgressSubmitButton.tsx
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import React from "react";
|
||||
|
||||
import { SendHorizontal } from "lucide-react";
|
||||
|
||||
import { Box, Button } from "@chakra-ui/react";
|
||||
|
||||
interface ProgressSubmitButtonProps {
|
||||
disabled: boolean;
|
||||
working: boolean;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
const ProgressSubmitButton: React.FC<ProgressSubmitButtonProps> = ({
|
||||
disabled,
|
||||
working,
|
||||
onClick,
|
||||
}) => {
|
||||
return (
|
||||
<Box>
|
||||
<Button
|
||||
variant="subtle"
|
||||
disabled={disabled}
|
||||
loading={working}
|
||||
color="primary"
|
||||
onClick={() => onClick()}
|
||||
>
|
||||
Send <SendHorizontal />
|
||||
</Button>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProgressSubmitButton;
|
||||
11
src/components/common/RecommendedBadge.tsx
Normal file
11
src/components/common/RecommendedBadge.tsx
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { Badge } from "@chakra-ui/react";
|
||||
|
||||
const RecommendedBadge = () => {
|
||||
return (
|
||||
<Badge colorPalette="green" size="sm">
|
||||
recommended
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
export default RecommendedBadge;
|
||||
92
src/components/common/SelectField.tsx
Normal file
92
src/components/common/SelectField.tsx
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
/*
|
||||
* CRITICAL: DO NOT MODIFY THIS COMPONENT WITHOUT DESIGN AUTHORITY APPROVAL
|
||||
*
|
||||
* This SelectField component is used throughout the application by 15+ components
|
||||
* across multiple domains (ontologies, flows, documents, etc.). Any changes to
|
||||
* this component's interface or behavior will have extensive downstream impact.
|
||||
*
|
||||
* Changes to this component in September 2025 broke multiple features and required
|
||||
* systematic updates across the entire application. Always prefer adapter patterns
|
||||
* or feature-specific solutions over modifying this shared infrastructure.
|
||||
*
|
||||
* Required API contract:
|
||||
* - value: string[] (arrays only)
|
||||
* - onValueChange: (values: string[]) => void
|
||||
* - items must include description fields with SelectOptionText/SelectOption
|
||||
*/
|
||||
|
||||
import React, { useMemo } from "react";
|
||||
import {
|
||||
Field,
|
||||
Select,
|
||||
Portal,
|
||||
Stack,
|
||||
createListCollection,
|
||||
} from "@chakra-ui/react";
|
||||
|
||||
export interface SelectFieldValue {
|
||||
value: string;
|
||||
label: string;
|
||||
description?: string | React.ReactElement;
|
||||
}
|
||||
|
||||
interface SelectFieldProps {
|
||||
label: string;
|
||||
|
||||
items: SelectFieldValue[];
|
||||
|
||||
value: string[];
|
||||
onValueChange: (x: string[]) => void;
|
||||
|
||||
contentRef?;
|
||||
}
|
||||
|
||||
const SelectField: React.FC<SelectFieldProps> = ({
|
||||
label,
|
||||
items,
|
||||
value,
|
||||
onValueChange,
|
||||
contentRef,
|
||||
}) => {
|
||||
// Only create new collection when items actually change
|
||||
const collection = useMemo(
|
||||
() => createListCollection({ items: items }),
|
||||
[items],
|
||||
);
|
||||
|
||||
return (
|
||||
<Field.Root mb={4}>
|
||||
<Field.Label fontWeight="medium">{label}</Field.Label>
|
||||
|
||||
<Select.Root
|
||||
collection={collection}
|
||||
value={value}
|
||||
onValueChange={(e) => onValueChange(e.value)}
|
||||
>
|
||||
<Select.HiddenSelect />
|
||||
<Select.Control>
|
||||
<Select.Trigger>
|
||||
<Select.ValueText placeholder={label} />
|
||||
</Select.Trigger>
|
||||
<Select.IndicatorGroup>
|
||||
<Select.Indicator />
|
||||
</Select.IndicatorGroup>
|
||||
</Select.Control>
|
||||
<Portal container={contentRef}>
|
||||
<Select.Positioner>
|
||||
<Select.Content>
|
||||
{items.map((v) => (
|
||||
<Select.Item item={v.value} key={v.value}>
|
||||
<Stack>{v.description && v.description}</Stack>
|
||||
<Select.ItemIndicator />
|
||||
</Select.Item>
|
||||
))}
|
||||
</Select.Content>
|
||||
</Select.Positioner>
|
||||
</Portal>
|
||||
</Select.Root>
|
||||
</Field.Root>
|
||||
);
|
||||
};
|
||||
|
||||
export default SelectField;
|
||||
34
src/components/common/SelectOption.tsx
Normal file
34
src/components/common/SelectOption.tsx
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
/*
|
||||
* CRITICAL: DO NOT MODIFY THIS COMPONENT WITHOUT DESIGN AUTHORITY APPROVAL
|
||||
*
|
||||
* This SelectOption component is used by SelectField throughout the application.
|
||||
* Changes to this component's interface or styling will affect all dropdown
|
||||
* options across multiple domains. Any modifications require extensive testing
|
||||
* and approval from the application design authority.
|
||||
*/
|
||||
|
||||
import React, { PropsWithChildren } from "react";
|
||||
import { Box, Flex, Heading, Text } from "@chakra-ui/react";
|
||||
|
||||
const SelectOption: React.FC<
|
||||
PropsWithChildren<{
|
||||
title: string;
|
||||
badge?: React.ReactNode;
|
||||
}>
|
||||
> = ({ title, badge, children }) => {
|
||||
return (
|
||||
<Box>
|
||||
<Flex alignItems="center">
|
||||
<Heading as="h1" size="sm" color="fg" fontWeight="bold">
|
||||
{title}
|
||||
</Heading>
|
||||
{badge && badge}
|
||||
</Flex>
|
||||
<Text mt={1} textStyle="xs" color="fg.muted">
|
||||
{children}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default SelectOption;
|
||||
23
src/components/common/SelectOptionText.tsx
Normal file
23
src/components/common/SelectOptionText.tsx
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
/*
|
||||
* CRITICAL: DO NOT MODIFY THIS COMPONENT WITHOUT DESIGN AUTHORITY APPROVAL
|
||||
*
|
||||
* This SelectOptionText component is used by SelectField throughout the application.
|
||||
* Changes to this component's interface or styling will affect all dropdown
|
||||
* options across multiple domains. Any modifications require extensive testing
|
||||
* and approval from the application design authority.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { Text } from "@chakra-ui/react";
|
||||
|
||||
const SelectOptionText: React.FC<{
|
||||
children: React.ReactNode;
|
||||
}> = ({ children }) => {
|
||||
return (
|
||||
<Text mt={1} textStyle="xs" color="fg.muted">
|
||||
{children}
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
|
||||
export default SelectOptionText;
|
||||
40
src/components/common/SelectableTable.tsx
Normal file
40
src/components/common/SelectableTable.tsx
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import { Table } from "@chakra-ui/react";
|
||||
import { flexRender } from "@tanstack/react-table";
|
||||
|
||||
const SelectableTable = ({ table }) => {
|
||||
return (
|
||||
<>
|
||||
<Table.Root interactive>
|
||||
<Table.Header>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<Table.Row key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<Table.ColumnHeader key={header.id}>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext(),
|
||||
)}
|
||||
</Table.ColumnHeader>
|
||||
))}
|
||||
</Table.Row>
|
||||
))}
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{table.getRowModel().rows.map((row) => (
|
||||
<Table.Row key={row.id} onClick={row.getToggleSelectedHandler()}>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<Table.Cell key={cell.id}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</Table.Cell>
|
||||
))}
|
||||
</Table.Row>
|
||||
))}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default SelectableTable;
|
||||
45
src/components/common/SimplePage.tsx
Normal file
45
src/components/common/SimplePage.tsx
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import React, { PropsWithChildren } from "react";
|
||||
|
||||
import { Box, Container, Flex, Heading, Stack } from "@chakra-ui/react";
|
||||
|
||||
import UnauthedHeader from "./UnauthedHeader";
|
||||
|
||||
const SimplePage: React.FC<
|
||||
PropsWithChildren<{
|
||||
title: string;
|
||||
}>
|
||||
> = ({ title, children }) => {
|
||||
return (
|
||||
<>
|
||||
<UnauthedHeader />
|
||||
<Flex minH="100vh" align="center" justify="center" bg="primary.900">
|
||||
<Container maxW="md" py={12}>
|
||||
<Box
|
||||
bg="primary.800"
|
||||
p={8}
|
||||
borderRadius="md"
|
||||
boxShadow="lg"
|
||||
borderWidth="1px"
|
||||
borderColor="primary.muted"
|
||||
>
|
||||
<Stack spacing={6}>
|
||||
<Heading
|
||||
as="h1"
|
||||
fontSize="2xl"
|
||||
textAlign="center"
|
||||
color="primary.400"
|
||||
mb={2}
|
||||
>
|
||||
{title}
|
||||
</Heading>
|
||||
|
||||
{children}
|
||||
</Stack>
|
||||
</Box>
|
||||
</Container>
|
||||
</Flex>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default SimplePage;
|
||||
45
src/components/common/Slider.tsx
Normal file
45
src/components/common/Slider.tsx
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import React from "react";
|
||||
|
||||
import { Field, Slider as ChakraSlider } from "@chakra-ui/react";
|
||||
|
||||
interface SliderProps {
|
||||
label: string;
|
||||
minValue: number;
|
||||
maxValue: number;
|
||||
value: number;
|
||||
step: number;
|
||||
onValueChange: (x: number) => void;
|
||||
}
|
||||
|
||||
const Slider: React.FC<SliderProps> = ({
|
||||
label,
|
||||
minValue,
|
||||
maxValue,
|
||||
value,
|
||||
onValueChange,
|
||||
step,
|
||||
}) => {
|
||||
return (
|
||||
<Field.Root mb={4}>
|
||||
<Field.Label fontWeight="medium">{label}</Field.Label>
|
||||
<ChakraSlider.Root
|
||||
min={minValue}
|
||||
max={maxValue}
|
||||
step={step}
|
||||
value={[value]}
|
||||
onValueChange={(e) => onValueChange(e.value[0])}
|
||||
width="100%"
|
||||
>
|
||||
<ChakraSlider.ValueText />
|
||||
<ChakraSlider.Control>
|
||||
<ChakraSlider.Track bg="{colors.primary.muted}">
|
||||
<ChakraSlider.Range bg="{colors.primary.solid}" />
|
||||
</ChakraSlider.Track>
|
||||
<ChakraSlider.Thumbs rounded={11} />
|
||||
</ChakraSlider.Control>
|
||||
</ChakraSlider.Root>
|
||||
</Field.Root>
|
||||
);
|
||||
};
|
||||
|
||||
export default Slider;
|
||||
35
src/components/common/StatusBadge.tsx
Normal file
35
src/components/common/StatusBadge.tsx
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import React from "react";
|
||||
import { Badge, BadgeProps } from "@chakra-ui/react";
|
||||
type StatusType = "success" | "warning" | "error" | "info" | "default";
|
||||
interface StatusBadgeProps extends Omit<BadgeProps, "colorScheme"> {
|
||||
status: StatusType;
|
||||
label: string;
|
||||
}
|
||||
const StatusBadge: React.FC<StatusBadgeProps> = ({
|
||||
status,
|
||||
label,
|
||||
...rest
|
||||
}) => {
|
||||
const colorSchemes: Record<StatusType, string> = {
|
||||
success: "#65c97a",
|
||||
warning: "orange",
|
||||
error: "red",
|
||||
info: "#5285ed",
|
||||
default: "gray",
|
||||
};
|
||||
return (
|
||||
<Badge
|
||||
px={2}
|
||||
py={1}
|
||||
borderRadius="md"
|
||||
bg={colorSchemes[status]}
|
||||
color={status === "default" ? "gray.800" : "white"}
|
||||
fontWeight="medium"
|
||||
fontSize="xs"
|
||||
{...rest}
|
||||
>
|
||||
{label}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
export default StatusBadge;
|
||||
36
src/components/common/TableStates.tsx
Normal file
36
src/components/common/TableStates.tsx
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import React from "react";
|
||||
import { Box, Text, Center } from "@chakra-ui/react";
|
||||
|
||||
interface ErrorStateProps {
|
||||
error: Error | unknown;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export const ErrorState: React.FC<ErrorStateProps> = ({
|
||||
error,
|
||||
title = "Error loading data",
|
||||
}) => (
|
||||
<Box
|
||||
p={4}
|
||||
borderWidth="1px"
|
||||
borderColor="red.500"
|
||||
borderRadius="md"
|
||||
bg="red.50"
|
||||
>
|
||||
<Text color="red.700">
|
||||
{title}: {error?.toString()}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
|
||||
interface EmptyStateProps {
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export const EmptyState: React.FC<EmptyStateProps> = ({
|
||||
message = "No data found.",
|
||||
}) => (
|
||||
<Center h="200px">
|
||||
<Text color="fg.muted">{message}</Text>
|
||||
</Center>
|
||||
);
|
||||
55
src/components/common/TableWithStates.tsx
Normal file
55
src/components/common/TableWithStates.tsx
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import React from "react";
|
||||
import { Box } from "@chakra-ui/react";
|
||||
import { Table } from "@tanstack/react-table";
|
||||
import { ErrorState, EmptyState } from "./TableStates";
|
||||
import BasicTable from "./BasicTable";
|
||||
import ClickableTable from "./ClickableTable";
|
||||
|
||||
interface TableWithStatesProps<T> {
|
||||
table: Table<T>;
|
||||
data: T[];
|
||||
error?: Error | unknown;
|
||||
onClick?: (row: T) => void;
|
||||
emptyMessage?: string;
|
||||
errorTitle?: string;
|
||||
bordered?: boolean;
|
||||
}
|
||||
|
||||
const TableWithStates = <T,>({
|
||||
table,
|
||||
data,
|
||||
error,
|
||||
onClick,
|
||||
emptyMessage = "No data found.",
|
||||
errorTitle = "Error loading data",
|
||||
bordered = true,
|
||||
}: TableWithStatesProps<T>) => {
|
||||
// Handle error state
|
||||
if (error) {
|
||||
return <ErrorState error={error} title={errorTitle} />;
|
||||
}
|
||||
|
||||
// Handle empty state
|
||||
if (data.length === 0) {
|
||||
return <EmptyState message={emptyMessage} />;
|
||||
}
|
||||
|
||||
// Render table with optional border wrapper
|
||||
const TableComponent = onClick ? (
|
||||
<ClickableTable table={table} onClick={(row) => onClick(row.original)} />
|
||||
) : (
|
||||
<BasicTable table={table} />
|
||||
);
|
||||
|
||||
if (bordered) {
|
||||
return (
|
||||
<Box overflowX="auto" borderWidth="1px" borderRadius="lg">
|
||||
{TableComponent}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return TableComponent;
|
||||
};
|
||||
|
||||
export default TableWithStates;
|
||||
40
src/components/common/TextAreaField.tsx
Normal file
40
src/components/common/TextAreaField.tsx
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import React from "react";
|
||||
|
||||
import { Field, Textarea } from "@chakra-ui/react";
|
||||
|
||||
interface TextFieldProps {
|
||||
label: string;
|
||||
placeholder?: string;
|
||||
value: string;
|
||||
onValueChange: (x: string) => void;
|
||||
required?: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const TextAreaField: React.FC<TextFieldProps> = ({
|
||||
label,
|
||||
placeholder,
|
||||
value,
|
||||
onValueChange,
|
||||
required,
|
||||
disabled,
|
||||
}) => {
|
||||
return (
|
||||
<Field.Root mb={4} required={required}>
|
||||
<Field.Label>
|
||||
{label} {required && <Field.RequiredIndicator />}
|
||||
</Field.Label>
|
||||
<Textarea
|
||||
placeholder={placeholder}
|
||||
variant="subtle"
|
||||
value={value}
|
||||
onChange={(e) => onValueChange(e.target.value)}
|
||||
maxH="30lh"
|
||||
h="10lh"
|
||||
disabled={disabled}
|
||||
/>
|
||||
</Field.Root>
|
||||
);
|
||||
};
|
||||
|
||||
export default TextAreaField;
|
||||
44
src/components/common/TextField.tsx
Normal file
44
src/components/common/TextField.tsx
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import React from "react";
|
||||
|
||||
import { Field, Input } from "@chakra-ui/react";
|
||||
|
||||
interface TextFieldProps {
|
||||
label: string;
|
||||
placeholder?: string;
|
||||
value: string;
|
||||
onValueChange: (x: string) => void;
|
||||
required?: boolean;
|
||||
helperText?: string;
|
||||
disabled?: boolean;
|
||||
type?: string;
|
||||
}
|
||||
|
||||
const TextField: React.FC<TextFieldProps> = ({
|
||||
label,
|
||||
placeholder,
|
||||
value,
|
||||
onValueChange,
|
||||
required,
|
||||
helperText,
|
||||
disabled,
|
||||
type = "text",
|
||||
}) => {
|
||||
return (
|
||||
<Field.Root mb={4} required={required}>
|
||||
<Field.Label>
|
||||
{label} {required && <Field.RequiredIndicator />}
|
||||
</Field.Label>
|
||||
<Input
|
||||
type={type}
|
||||
value={value}
|
||||
onChange={(e) => onValueChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
variant="subtle"
|
||||
disabled={disabled}
|
||||
/>
|
||||
{helperText && <Field.HelperText>{helperText}</Field.HelperText>}
|
||||
</Field.Root>
|
||||
);
|
||||
};
|
||||
|
||||
export default TextField;
|
||||
25
src/components/common/UnauthedHeader.tsx
Normal file
25
src/components/common/UnauthedHeader.tsx
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import { Box, Flex } from "@chakra-ui/react";
|
||||
|
||||
import ColorModeToggle from "../color-mode-toggle";
|
||||
|
||||
const UnauthedHeader = () => {
|
||||
return (
|
||||
<>
|
||||
<Flex
|
||||
mb={2}
|
||||
alignItems="center"
|
||||
justifyContent="space-between"
|
||||
width="100%"
|
||||
px={4}
|
||||
py={2}
|
||||
>
|
||||
<Flex mb={2} alignItems="center"></Flex>
|
||||
<Box>
|
||||
<ColorModeToggle />
|
||||
</Box>
|
||||
</Flex>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default UnauthedHeader;
|
||||
19
src/components/common/UserDisplay.tsx
Normal file
19
src/components/common/UserDisplay.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import React from "react";
|
||||
import { HStack, Text } from "@chakra-ui/react";
|
||||
import { User } from "lucide-react";
|
||||
import { useSettings } from "@trustgraph/react-state";
|
||||
|
||||
const UserDisplay: React.FC = () => {
|
||||
const { settings } = useSettings();
|
||||
|
||||
return (
|
||||
<HStack gap={2} align="center">
|
||||
<User size={14} color="currentColor" />
|
||||
<Text fontSize="xs" fontWeight="medium" color="fg.muted">
|
||||
{settings.user}
|
||||
</Text>
|
||||
</HStack>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserDisplay;
|
||||
408
src/components/common/__tests__/BasicTable.test.tsx
Normal file
408
src/components/common/__tests__/BasicTable.test.tsx
Normal file
|
|
@ -0,0 +1,408 @@
|
|||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import BasicTable from "../BasicTable";
|
||||
|
||||
// Mock Chakra UI components
|
||||
vi.mock("@chakra-ui/react", () => ({
|
||||
Table: {
|
||||
Root: ({ children }: React.PropsWithChildren) => (
|
||||
<table data-testid="table-root">{children}</table>
|
||||
),
|
||||
Header: ({ children }: React.PropsWithChildren) => (
|
||||
<thead data-testid="table-header">{children}</thead>
|
||||
),
|
||||
Body: ({ children }: React.PropsWithChildren) => (
|
||||
<tbody data-testid="table-body">{children}</tbody>
|
||||
),
|
||||
Row: ({ children }: React.PropsWithChildren) => (
|
||||
<tr data-testid="table-row">{children}</tr>
|
||||
),
|
||||
ColumnHeader: ({ children }: React.PropsWithChildren) => (
|
||||
<th data-testid="table-column-header">{children}</th>
|
||||
),
|
||||
Cell: ({ children }: React.PropsWithChildren) => (
|
||||
<td data-testid="table-cell">{children}</td>
|
||||
),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock TanStack React Table
|
||||
vi.mock("@tanstack/react-table", () => ({
|
||||
flexRender: vi.fn((content) => content),
|
||||
}));
|
||||
|
||||
describe("BasicTable", () => {
|
||||
const mockTable = {
|
||||
getHeaderGroups: vi.fn(),
|
||||
getRowModel: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should render table structure", () => {
|
||||
mockTable.getHeaderGroups.mockReturnValue([]);
|
||||
mockTable.getRowModel.mockReturnValue({ rows: [] });
|
||||
|
||||
render(<BasicTable table={mockTable} />);
|
||||
|
||||
expect(screen.getByTestId("table-root")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("table-header")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("table-body")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render header groups", () => {
|
||||
const mockHeaderGroups = [
|
||||
{
|
||||
id: "header-group-1",
|
||||
headers: [
|
||||
{
|
||||
id: "header-1",
|
||||
isPlaceholder: false,
|
||||
column: {
|
||||
columnDef: {
|
||||
header: "Column 1",
|
||||
},
|
||||
},
|
||||
getContext: () => ({}),
|
||||
},
|
||||
{
|
||||
id: "header-2",
|
||||
isPlaceholder: false,
|
||||
column: {
|
||||
columnDef: {
|
||||
header: "Column 2",
|
||||
},
|
||||
},
|
||||
getContext: () => ({}),
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
mockTable.getHeaderGroups.mockReturnValue(mockHeaderGroups);
|
||||
mockTable.getRowModel.mockReturnValue({ rows: [] });
|
||||
|
||||
render(<BasicTable table={mockTable} />);
|
||||
|
||||
expect(screen.getByText("Column 1")).toBeInTheDocument();
|
||||
expect(screen.getByText("Column 2")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not render placeholder headers", () => {
|
||||
const mockHeaderGroups = [
|
||||
{
|
||||
id: "header-group-1",
|
||||
headers: [
|
||||
{
|
||||
id: "header-1",
|
||||
isPlaceholder: true,
|
||||
column: {
|
||||
columnDef: {
|
||||
header: "Placeholder Column",
|
||||
},
|
||||
},
|
||||
getContext: () => ({}),
|
||||
},
|
||||
{
|
||||
id: "header-2",
|
||||
isPlaceholder: false,
|
||||
column: {
|
||||
columnDef: {
|
||||
header: "Visible Column",
|
||||
},
|
||||
},
|
||||
getContext: () => ({}),
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
mockTable.getHeaderGroups.mockReturnValue(mockHeaderGroups);
|
||||
mockTable.getRowModel.mockReturnValue({ rows: [] });
|
||||
|
||||
render(<BasicTable table={mockTable} />);
|
||||
|
||||
expect(screen.queryByText("Placeholder Column")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("Visible Column")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render table rows", () => {
|
||||
const mockRows = [
|
||||
{
|
||||
id: "row-1",
|
||||
getVisibleCells: () => [
|
||||
{
|
||||
id: "cell-1",
|
||||
column: {
|
||||
columnDef: {
|
||||
cell: "Cell 1",
|
||||
},
|
||||
},
|
||||
getContext: () => ({}),
|
||||
},
|
||||
{
|
||||
id: "cell-2",
|
||||
column: {
|
||||
columnDef: {
|
||||
cell: "Cell 2",
|
||||
},
|
||||
},
|
||||
getContext: () => ({}),
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
mockTable.getHeaderGroups.mockReturnValue([]);
|
||||
mockTable.getRowModel.mockReturnValue({ rows: mockRows });
|
||||
|
||||
render(<BasicTable table={mockTable} />);
|
||||
|
||||
expect(screen.getByText("Cell 1")).toBeInTheDocument();
|
||||
expect(screen.getByText("Cell 2")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render multiple rows", () => {
|
||||
const mockRows = [
|
||||
{
|
||||
id: "row-1",
|
||||
getVisibleCells: () => [
|
||||
{
|
||||
id: "cell-1-1",
|
||||
column: {
|
||||
columnDef: {
|
||||
cell: "Row 1 Cell 1",
|
||||
},
|
||||
},
|
||||
getContext: () => ({}),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "row-2",
|
||||
getVisibleCells: () => [
|
||||
{
|
||||
id: "cell-2-1",
|
||||
column: {
|
||||
columnDef: {
|
||||
cell: "Row 2 Cell 1",
|
||||
},
|
||||
},
|
||||
getContext: () => ({}),
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
mockTable.getHeaderGroups.mockReturnValue([]);
|
||||
mockTable.getRowModel.mockReturnValue({ rows: mockRows });
|
||||
|
||||
render(<BasicTable table={mockTable} />);
|
||||
|
||||
expect(screen.getByText("Row 1 Cell 1")).toBeInTheDocument();
|
||||
expect(screen.getByText("Row 2 Cell 1")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render complete table with headers and rows", () => {
|
||||
const mockHeaderGroups = [
|
||||
{
|
||||
id: "header-group-1",
|
||||
headers: [
|
||||
{
|
||||
id: "header-1",
|
||||
isPlaceholder: false,
|
||||
column: {
|
||||
columnDef: {
|
||||
header: "Name",
|
||||
},
|
||||
},
|
||||
getContext: () => ({}),
|
||||
},
|
||||
{
|
||||
id: "header-2",
|
||||
isPlaceholder: false,
|
||||
column: {
|
||||
columnDef: {
|
||||
header: "Age",
|
||||
},
|
||||
},
|
||||
getContext: () => ({}),
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const mockRows = [
|
||||
{
|
||||
id: "row-1",
|
||||
getVisibleCells: () => [
|
||||
{
|
||||
id: "cell-1-1",
|
||||
column: {
|
||||
columnDef: {
|
||||
cell: "John",
|
||||
},
|
||||
},
|
||||
getContext: () => ({}),
|
||||
},
|
||||
{
|
||||
id: "cell-1-2",
|
||||
column: {
|
||||
columnDef: {
|
||||
cell: "25",
|
||||
},
|
||||
},
|
||||
getContext: () => ({}),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "row-2",
|
||||
getVisibleCells: () => [
|
||||
{
|
||||
id: "cell-2-1",
|
||||
column: {
|
||||
columnDef: {
|
||||
cell: "Jane",
|
||||
},
|
||||
},
|
||||
getContext: () => ({}),
|
||||
},
|
||||
{
|
||||
id: "cell-2-2",
|
||||
column: {
|
||||
columnDef: {
|
||||
cell: "30",
|
||||
},
|
||||
},
|
||||
getContext: () => ({}),
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
mockTable.getHeaderGroups.mockReturnValue(mockHeaderGroups);
|
||||
mockTable.getRowModel.mockReturnValue({ rows: mockRows });
|
||||
|
||||
render(<BasicTable table={mockTable} />);
|
||||
|
||||
// Check headers
|
||||
expect(screen.getByText("Name")).toBeInTheDocument();
|
||||
expect(screen.getByText("Age")).toBeInTheDocument();
|
||||
|
||||
// Check rows
|
||||
expect(screen.getByText("John")).toBeInTheDocument();
|
||||
expect(screen.getByText("25")).toBeInTheDocument();
|
||||
expect(screen.getByText("Jane")).toBeInTheDocument();
|
||||
expect(screen.getByText("30")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should handle empty table", () => {
|
||||
mockTable.getHeaderGroups.mockReturnValue([]);
|
||||
mockTable.getRowModel.mockReturnValue({ rows: [] });
|
||||
|
||||
render(<BasicTable table={mockTable} />);
|
||||
|
||||
expect(screen.getByTestId("table-root")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("table-header")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("table-body")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should handle table with headers but no rows", () => {
|
||||
const mockHeaderGroups = [
|
||||
{
|
||||
id: "header-group-1",
|
||||
headers: [
|
||||
{
|
||||
id: "header-1",
|
||||
isPlaceholder: false,
|
||||
column: {
|
||||
columnDef: {
|
||||
header: "Empty Table Header",
|
||||
},
|
||||
},
|
||||
getContext: () => ({}),
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
mockTable.getHeaderGroups.mockReturnValue(mockHeaderGroups);
|
||||
mockTable.getRowModel.mockReturnValue({ rows: [] });
|
||||
|
||||
render(<BasicTable table={mockTable} />);
|
||||
|
||||
expect(screen.getByText("Empty Table Header")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should handle table with rows but no headers", () => {
|
||||
const mockRows = [
|
||||
{
|
||||
id: "row-1",
|
||||
getVisibleCells: () => [
|
||||
{
|
||||
id: "cell-1",
|
||||
column: {
|
||||
columnDef: {
|
||||
cell: "Orphan Cell",
|
||||
},
|
||||
},
|
||||
getContext: () => ({}),
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
mockTable.getHeaderGroups.mockReturnValue([]);
|
||||
mockTable.getRowModel.mockReturnValue({ rows: mockRows });
|
||||
|
||||
render(<BasicTable table={mockTable} />);
|
||||
|
||||
expect(screen.getByText("Orphan Cell")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should handle multiple header groups", () => {
|
||||
const mockHeaderGroups = [
|
||||
{
|
||||
id: "header-group-1",
|
||||
headers: [
|
||||
{
|
||||
id: "header-1",
|
||||
isPlaceholder: false,
|
||||
column: {
|
||||
columnDef: {
|
||||
header: "Group 1 Header",
|
||||
},
|
||||
},
|
||||
getContext: () => ({}),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "header-group-2",
|
||||
headers: [
|
||||
{
|
||||
id: "header-2",
|
||||
isPlaceholder: false,
|
||||
column: {
|
||||
columnDef: {
|
||||
header: "Group 2 Header",
|
||||
},
|
||||
},
|
||||
getContext: () => ({}),
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
mockTable.getHeaderGroups.mockReturnValue(mockHeaderGroups);
|
||||
mockTable.getRowModel.mockReturnValue({ rows: [] });
|
||||
|
||||
render(<BasicTable table={mockTable} />);
|
||||
|
||||
expect(screen.getByText("Group 1 Header")).toBeInTheDocument();
|
||||
expect(screen.getByText("Group 2 Header")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
327
src/components/common/__tests__/Card.test.tsx
Normal file
327
src/components/common/__tests__/Card.test.tsx
Normal file
|
|
@ -0,0 +1,327 @@
|
|||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import Card from "../Card";
|
||||
|
||||
// Helper function to filter out Chakra UI props
|
||||
const filterChakraProps = (props: Record<string, unknown>) => {
|
||||
const chakraProps = [
|
||||
"alignItems",
|
||||
"justifyContent",
|
||||
"direction",
|
||||
"gap",
|
||||
"p",
|
||||
"px",
|
||||
"py",
|
||||
"pt",
|
||||
"pb",
|
||||
"pl",
|
||||
"pr",
|
||||
"m",
|
||||
"mx",
|
||||
"my",
|
||||
"mt",
|
||||
"mb",
|
||||
"ml",
|
||||
"mr",
|
||||
"w",
|
||||
"h",
|
||||
"maxW",
|
||||
"maxH",
|
||||
"minW",
|
||||
"minH",
|
||||
"bg",
|
||||
"color",
|
||||
"borderRadius",
|
||||
"borderWidth",
|
||||
"borderColor",
|
||||
"borderStyle",
|
||||
"boxShadow",
|
||||
"display",
|
||||
"position",
|
||||
"top",
|
||||
"right",
|
||||
"bottom",
|
||||
"left",
|
||||
"zIndex",
|
||||
"overflow",
|
||||
"textAlign",
|
||||
"fontSize",
|
||||
"fontWeight",
|
||||
"lineHeight",
|
||||
"letterSpacing",
|
||||
"textTransform",
|
||||
"textDecoration",
|
||||
"opacity",
|
||||
"visibility",
|
||||
"cursor",
|
||||
"pointerEvents",
|
||||
"userSelect",
|
||||
"resize",
|
||||
"outline",
|
||||
"transform",
|
||||
"transformOrigin",
|
||||
"transition",
|
||||
"animation",
|
||||
"colorPalette",
|
||||
"variant",
|
||||
"size",
|
||||
"loading",
|
||||
"disabled",
|
||||
"checked",
|
||||
"selected",
|
||||
"active",
|
||||
"focus",
|
||||
"hover",
|
||||
"flexDirection",
|
||||
"flexWrap",
|
||||
"flex",
|
||||
"flexGrow",
|
||||
"flexShrink",
|
||||
"flexBasis",
|
||||
"alignSelf",
|
||||
"justifySelf",
|
||||
"order",
|
||||
"gridColumn",
|
||||
"gridRow",
|
||||
"gridArea",
|
||||
"gridTemplateColumns",
|
||||
"gridTemplateRows",
|
||||
"gridGap",
|
||||
"rowGap",
|
||||
"columnGap",
|
||||
"placeItems",
|
||||
"placeContent",
|
||||
"placeSelf",
|
||||
"area",
|
||||
"colSpan",
|
||||
"rowSpan",
|
||||
"start",
|
||||
"end",
|
||||
];
|
||||
const filtered = { ...props };
|
||||
chakraProps.forEach((prop) => delete filtered[prop]);
|
||||
return filtered;
|
||||
};
|
||||
|
||||
// Mock Chakra UI components
|
||||
vi.mock("@chakra-ui/react", () => ({
|
||||
Box: ({
|
||||
children,
|
||||
...props
|
||||
}: React.PropsWithChildren<Record<string, unknown>>) => (
|
||||
<div data-testid="box" {...filterChakraProps(props)}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
Flex: ({
|
||||
children,
|
||||
...props
|
||||
}: React.PropsWithChildren<Record<string, unknown>>) => (
|
||||
<div data-testid="flex" {...filterChakraProps(props)}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
Heading: ({
|
||||
children,
|
||||
...props
|
||||
}: React.PropsWithChildren<Record<string, unknown>>) => (
|
||||
<h3 data-testid="heading" {...filterChakraProps(props)}>
|
||||
{children}
|
||||
</h3>
|
||||
),
|
||||
Text: ({
|
||||
children,
|
||||
...props
|
||||
}: React.PropsWithChildren<Record<string, unknown>>) => (
|
||||
<p data-testid="text" {...filterChakraProps(props)}>
|
||||
{children}
|
||||
</p>
|
||||
),
|
||||
}));
|
||||
|
||||
describe("Card", () => {
|
||||
it("should render with title", () => {
|
||||
render(<Card title="Test Title" />);
|
||||
|
||||
expect(screen.getByTestId("heading")).toBeInTheDocument();
|
||||
expect(screen.getByText("Test Title")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render with description when provided", () => {
|
||||
render(<Card title="Test Title" description="Test description" />);
|
||||
|
||||
expect(screen.getByTestId("text")).toBeInTheDocument();
|
||||
expect(screen.getByText("Test description")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not render description when not provided", () => {
|
||||
render(<Card title="Test Title" />);
|
||||
|
||||
expect(screen.queryByTestId("text")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render icon when provided", () => {
|
||||
const TestIcon = () => <span data-testid="test-icon">🎯</span>;
|
||||
render(<Card title="Test Title" icon={<TestIcon />} />);
|
||||
|
||||
expect(screen.getByTestId("test-icon")).toBeInTheDocument();
|
||||
expect(screen.getByText("🎯")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not render icon when not provided", () => {
|
||||
render(<Card title="Test Title" />);
|
||||
|
||||
expect(screen.queryByTestId("test-icon")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render children when provided", () => {
|
||||
render(
|
||||
<Card title="Test Title">
|
||||
<div data-testid="child-content">Child content</div>
|
||||
</Card>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("child-content")).toBeInTheDocument();
|
||||
expect(screen.getByText("Child content")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not render children when not provided", () => {
|
||||
render(<Card title="Test Title" />);
|
||||
|
||||
expect(screen.queryByTestId("child-content")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render with all props together", () => {
|
||||
const TestIcon = () => <span data-testid="test-icon">⭐</span>;
|
||||
render(
|
||||
<Card
|
||||
title="Complete Card"
|
||||
description="This is a complete card with all props"
|
||||
icon={<TestIcon />}
|
||||
>
|
||||
<div data-testid="child-content">Children content</div>
|
||||
</Card>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Complete Card")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("This is a complete card with all props"),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByTestId("test-icon")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("child-content")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should handle empty title", () => {
|
||||
render(<Card title="" />);
|
||||
|
||||
expect(screen.getByTestId("heading")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("heading")).toHaveTextContent("");
|
||||
});
|
||||
|
||||
it("should handle empty description", () => {
|
||||
render(<Card title="Test Title" description="" />);
|
||||
|
||||
// Empty description should not render the text element
|
||||
expect(screen.queryByTestId("text")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should handle long title", () => {
|
||||
const longTitle = "a".repeat(100);
|
||||
render(<Card title={longTitle} />);
|
||||
|
||||
expect(screen.getByText(longTitle)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should handle long description", () => {
|
||||
const longDescription = "b".repeat(500);
|
||||
render(<Card title="Test Title" description={longDescription} />);
|
||||
|
||||
expect(screen.getByText(longDescription)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should handle special characters in title", () => {
|
||||
const specialTitle = "!@#$%^&*()_+{}[]|\\:;\"'<>,.?/~`";
|
||||
render(<Card title={specialTitle} />);
|
||||
|
||||
expect(screen.getByText(specialTitle)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should handle special characters in description", () => {
|
||||
const specialDescription = "!@#$%^&*()_+{}[]|\\:;\"'<>,.?/~`";
|
||||
render(<Card title="Test Title" description={specialDescription} />);
|
||||
|
||||
expect(screen.getByText(specialDescription)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should handle multiline title", () => {
|
||||
const multilineTitle = "Line 1\nLine 2\nLine 3";
|
||||
render(<Card title={multilineTitle} />);
|
||||
|
||||
// Check that content is rendered (newlines may be normalized)
|
||||
expect(screen.getByTestId("heading")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("heading").textContent).toContain("Line 1");
|
||||
expect(screen.getByTestId("heading").textContent).toContain("Line 2");
|
||||
expect(screen.getByTestId("heading").textContent).toContain("Line 3");
|
||||
});
|
||||
|
||||
it("should handle multiline description", () => {
|
||||
const multilineDescription = "Line 1\nLine 2\nLine 3";
|
||||
render(<Card title="Test Title" description={multilineDescription} />);
|
||||
|
||||
// Check that content is rendered (newlines may be normalized)
|
||||
expect(screen.getByTestId("text")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("text").textContent).toContain("Line 1");
|
||||
expect(screen.getByTestId("text").textContent).toContain("Line 2");
|
||||
expect(screen.getByTestId("text").textContent).toContain("Line 3");
|
||||
});
|
||||
|
||||
it("should handle complex icon component", () => {
|
||||
const ComplexIcon = () => (
|
||||
<div data-testid="complex-icon">
|
||||
<span>🎯</span>
|
||||
<span>Complex</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
render(<Card title="Test Title" icon={<ComplexIcon />} />);
|
||||
|
||||
expect(screen.getByTestId("complex-icon")).toBeInTheDocument();
|
||||
expect(screen.getByText("🎯")).toBeInTheDocument();
|
||||
expect(screen.getByText("Complex")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should handle complex children", () => {
|
||||
render(
|
||||
<Card title="Test Title">
|
||||
<div data-testid="complex-child">
|
||||
<button>Click me</button>
|
||||
<input placeholder="Enter text" />
|
||||
<ul>
|
||||
<li>Item 1</li>
|
||||
<li>Item 2</li>
|
||||
</ul>
|
||||
</div>
|
||||
</Card>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("complex-child")).toBeInTheDocument();
|
||||
expect(screen.getByText("Click me")).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText("Enter text")).toBeInTheDocument();
|
||||
expect(screen.getByText("Item 1")).toBeInTheDocument();
|
||||
expect(screen.getByText("Item 2")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should handle null icon", () => {
|
||||
render(<Card title="Test Title" icon={null} />);
|
||||
|
||||
expect(screen.getByText("Test Title")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("test-icon")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should handle undefined icon", () => {
|
||||
render(<Card title="Test Title" icon={undefined} />);
|
||||
|
||||
expect(screen.getByText("Test Title")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("test-icon")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
461
src/components/common/__tests__/ProgressSubmitButton.test.tsx
Normal file
461
src/components/common/__tests__/ProgressSubmitButton.test.tsx
Normal file
|
|
@ -0,0 +1,461 @@
|
|||
import React from "react";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import ProgressSubmitButton from "../ProgressSubmitButton";
|
||||
|
||||
// Helper function to filter out Chakra UI props
|
||||
const filterChakraProps = (props: Record<string, unknown>) => {
|
||||
const chakraProps = [
|
||||
"alignItems",
|
||||
"justifyContent",
|
||||
"direction",
|
||||
"gap",
|
||||
"p",
|
||||
"px",
|
||||
"py",
|
||||
"pt",
|
||||
"pb",
|
||||
"pl",
|
||||
"pr",
|
||||
"m",
|
||||
"mx",
|
||||
"my",
|
||||
"mt",
|
||||
"mb",
|
||||
"ml",
|
||||
"mr",
|
||||
"w",
|
||||
"h",
|
||||
"maxW",
|
||||
"maxH",
|
||||
"minW",
|
||||
"minH",
|
||||
"bg",
|
||||
"color",
|
||||
"borderRadius",
|
||||
"borderWidth",
|
||||
"borderColor",
|
||||
"borderStyle",
|
||||
"boxShadow",
|
||||
"display",
|
||||
"position",
|
||||
"top",
|
||||
"right",
|
||||
"bottom",
|
||||
"left",
|
||||
"zIndex",
|
||||
"overflow",
|
||||
"textAlign",
|
||||
"fontSize",
|
||||
"fontWeight",
|
||||
"lineHeight",
|
||||
"letterSpacing",
|
||||
"textTransform",
|
||||
"textDecoration",
|
||||
"opacity",
|
||||
"visibility",
|
||||
"cursor",
|
||||
"pointerEvents",
|
||||
"userSelect",
|
||||
"resize",
|
||||
"outline",
|
||||
"transform",
|
||||
"transformOrigin",
|
||||
"transition",
|
||||
"animation",
|
||||
"colorPalette",
|
||||
"variant",
|
||||
"size",
|
||||
"loading",
|
||||
"disabled",
|
||||
"checked",
|
||||
"selected",
|
||||
"active",
|
||||
"focus",
|
||||
"hover",
|
||||
"flexDirection",
|
||||
"flexWrap",
|
||||
"flex",
|
||||
"flexGrow",
|
||||
"flexShrink",
|
||||
"flexBasis",
|
||||
"alignSelf",
|
||||
"justifySelf",
|
||||
"order",
|
||||
"gridColumn",
|
||||
"gridRow",
|
||||
"gridArea",
|
||||
"gridTemplateColumns",
|
||||
"gridTemplateRows",
|
||||
"gridGap",
|
||||
"rowGap",
|
||||
"columnGap",
|
||||
"placeItems",
|
||||
"placeContent",
|
||||
"placeSelf",
|
||||
"area",
|
||||
"colSpan",
|
||||
"rowSpan",
|
||||
"start",
|
||||
"end",
|
||||
];
|
||||
const filtered = { ...props };
|
||||
chakraProps.forEach((prop) => delete filtered[prop]);
|
||||
return filtered;
|
||||
};
|
||||
|
||||
// Mock Chakra UI components
|
||||
vi.mock("@chakra-ui/react", () => ({
|
||||
Box: ({
|
||||
children,
|
||||
...props
|
||||
}: React.PropsWithChildren<Record<string, unknown>>) => (
|
||||
<div data-testid="box" {...filterChakraProps(props)}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
Button: ({
|
||||
children,
|
||||
onClick,
|
||||
disabled,
|
||||
loading,
|
||||
...props
|
||||
}: React.PropsWithChildren<
|
||||
{
|
||||
onClick?: React.MouseEventHandler<HTMLButtonElement>;
|
||||
disabled?: boolean;
|
||||
loading?: boolean;
|
||||
} & Record<string, unknown>
|
||||
>) => (
|
||||
<button
|
||||
data-testid="progress-button"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
data-loading={loading}
|
||||
{...filterChakraProps(props)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock lucide-react icon
|
||||
vi.mock("lucide-react", () => ({
|
||||
SendHorizontal: () => <div data-testid="send-icon">Send</div>,
|
||||
}));
|
||||
|
||||
describe("ProgressSubmitButton", () => {
|
||||
const mockOnClick = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should render button with correct content", () => {
|
||||
render(
|
||||
<ProgressSubmitButton
|
||||
disabled={false}
|
||||
working={false}
|
||||
onClick={mockOnClick}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("progress-button")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("progress-button")).toHaveTextContent("Send");
|
||||
expect(screen.getByTestId("send-icon")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render within Box wrapper", () => {
|
||||
render(
|
||||
<ProgressSubmitButton
|
||||
disabled={false}
|
||||
working={false}
|
||||
onClick={mockOnClick}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("box")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("box")).toContainElement(
|
||||
screen.getByTestId("progress-button"),
|
||||
);
|
||||
});
|
||||
|
||||
it("should call onClick when button is clicked", () => {
|
||||
render(
|
||||
<ProgressSubmitButton
|
||||
disabled={false}
|
||||
working={false}
|
||||
onClick={mockOnClick}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId("progress-button"));
|
||||
expect(mockOnClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should be disabled when disabled prop is true", () => {
|
||||
render(
|
||||
<ProgressSubmitButton
|
||||
disabled={true}
|
||||
working={false}
|
||||
onClick={mockOnClick}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("progress-button")).toBeDisabled();
|
||||
});
|
||||
|
||||
it("should not be disabled when disabled prop is false", () => {
|
||||
render(
|
||||
<ProgressSubmitButton
|
||||
disabled={false}
|
||||
working={false}
|
||||
onClick={mockOnClick}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("progress-button")).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it("should have loading state when working prop is true", () => {
|
||||
render(
|
||||
<ProgressSubmitButton
|
||||
disabled={false}
|
||||
working={true}
|
||||
onClick={mockOnClick}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("progress-button")).toHaveAttribute(
|
||||
"data-loading",
|
||||
"true",
|
||||
);
|
||||
});
|
||||
|
||||
it("should not have loading state when working prop is false", () => {
|
||||
render(
|
||||
<ProgressSubmitButton
|
||||
disabled={false}
|
||||
working={false}
|
||||
onClick={mockOnClick}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId("progress-button")).toHaveAttribute(
|
||||
"data-loading",
|
||||
"false",
|
||||
);
|
||||
});
|
||||
|
||||
it("should render button with correct structure", () => {
|
||||
render(
|
||||
<ProgressSubmitButton
|
||||
disabled={false}
|
||||
working={false}
|
||||
onClick={mockOnClick}
|
||||
/>,
|
||||
);
|
||||
|
||||
const button = screen.getByTestId("progress-button");
|
||||
expect(button).toBeInTheDocument();
|
||||
expect(button.tagName).toBe("BUTTON");
|
||||
});
|
||||
|
||||
it("should handle both disabled and working states", () => {
|
||||
render(
|
||||
<ProgressSubmitButton
|
||||
disabled={true}
|
||||
working={true}
|
||||
onClick={mockOnClick}
|
||||
/>,
|
||||
);
|
||||
|
||||
const button = screen.getByTestId("progress-button");
|
||||
expect(button).toBeDisabled();
|
||||
expect(button).toHaveAttribute("data-loading", "true");
|
||||
});
|
||||
|
||||
it("should not call onClick when disabled", () => {
|
||||
render(
|
||||
<ProgressSubmitButton
|
||||
disabled={true}
|
||||
working={false}
|
||||
onClick={mockOnClick}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId("progress-button"));
|
||||
expect(mockOnClick).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should handle multiple clicks when enabled", () => {
|
||||
render(
|
||||
<ProgressSubmitButton
|
||||
disabled={false}
|
||||
working={false}
|
||||
onClick={mockOnClick}
|
||||
/>,
|
||||
);
|
||||
|
||||
const button = screen.getByTestId("progress-button");
|
||||
fireEvent.click(button);
|
||||
fireEvent.click(button);
|
||||
fireEvent.click(button);
|
||||
|
||||
expect(mockOnClick).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("should maintain consistent state when props change", () => {
|
||||
const { rerender } = render(
|
||||
<ProgressSubmitButton
|
||||
disabled={false}
|
||||
working={false}
|
||||
onClick={mockOnClick}
|
||||
/>,
|
||||
);
|
||||
|
||||
let button = screen.getByTestId("progress-button");
|
||||
expect(button).not.toBeDisabled();
|
||||
expect(button).toHaveAttribute("data-loading", "false");
|
||||
|
||||
// Change to working state
|
||||
rerender(
|
||||
<ProgressSubmitButton
|
||||
disabled={false}
|
||||
working={true}
|
||||
onClick={mockOnClick}
|
||||
/>,
|
||||
);
|
||||
|
||||
button = screen.getByTestId("progress-button");
|
||||
expect(button).not.toBeDisabled();
|
||||
expect(button).toHaveAttribute("data-loading", "true");
|
||||
|
||||
// Change to disabled state
|
||||
rerender(
|
||||
<ProgressSubmitButton
|
||||
disabled={true}
|
||||
working={false}
|
||||
onClick={mockOnClick}
|
||||
/>,
|
||||
);
|
||||
|
||||
button = screen.getByTestId("progress-button");
|
||||
expect(button).toBeDisabled();
|
||||
expect(button).toHaveAttribute("data-loading", "false");
|
||||
});
|
||||
|
||||
it("should handle rapid state changes", () => {
|
||||
const { rerender } = render(
|
||||
<ProgressSubmitButton
|
||||
disabled={false}
|
||||
working={false}
|
||||
onClick={mockOnClick}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Rapidly change states
|
||||
rerender(
|
||||
<ProgressSubmitButton
|
||||
disabled={true}
|
||||
working={true}
|
||||
onClick={mockOnClick}
|
||||
/>,
|
||||
);
|
||||
|
||||
rerender(
|
||||
<ProgressSubmitButton
|
||||
disabled={false}
|
||||
working={false}
|
||||
onClick={mockOnClick}
|
||||
/>,
|
||||
);
|
||||
|
||||
const button = screen.getByTestId("progress-button");
|
||||
expect(button).not.toBeDisabled();
|
||||
expect(button).toHaveAttribute("data-loading", "false");
|
||||
});
|
||||
|
||||
it("should handle onClick function changes", () => {
|
||||
const newOnClick = vi.fn();
|
||||
|
||||
const { rerender } = render(
|
||||
<ProgressSubmitButton
|
||||
disabled={false}
|
||||
working={false}
|
||||
onClick={mockOnClick}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId("progress-button"));
|
||||
expect(mockOnClick).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Change onClick function
|
||||
rerender(
|
||||
<ProgressSubmitButton
|
||||
disabled={false}
|
||||
working={false}
|
||||
onClick={newOnClick}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId("progress-button"));
|
||||
expect(newOnClick).toHaveBeenCalledTimes(1);
|
||||
expect(mockOnClick).toHaveBeenCalledTimes(1); // Should not be called again
|
||||
});
|
||||
|
||||
it("should render icon alongside text", () => {
|
||||
render(
|
||||
<ProgressSubmitButton
|
||||
disabled={false}
|
||||
working={false}
|
||||
onClick={mockOnClick}
|
||||
/>,
|
||||
);
|
||||
|
||||
const button = screen.getByTestId("progress-button");
|
||||
expect(button).toHaveTextContent("Send");
|
||||
expect(screen.getByTestId("send-icon")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should handle edge case combinations", () => {
|
||||
// Test disabled=true, working=false
|
||||
const { rerender } = render(
|
||||
<ProgressSubmitButton
|
||||
disabled={true}
|
||||
working={false}
|
||||
onClick={mockOnClick}
|
||||
/>,
|
||||
);
|
||||
|
||||
let button = screen.getByTestId("progress-button");
|
||||
expect(button).toBeDisabled();
|
||||
expect(button).toHaveAttribute("data-loading", "false");
|
||||
|
||||
// Test disabled=false, working=true
|
||||
rerender(
|
||||
<ProgressSubmitButton
|
||||
disabled={false}
|
||||
working={true}
|
||||
onClick={mockOnClick}
|
||||
/>,
|
||||
);
|
||||
|
||||
button = screen.getByTestId("progress-button");
|
||||
expect(button).not.toBeDisabled();
|
||||
expect(button).toHaveAttribute("data-loading", "true");
|
||||
|
||||
// Test disabled=true, working=true
|
||||
rerender(
|
||||
<ProgressSubmitButton
|
||||
disabled={true}
|
||||
working={true}
|
||||
onClick={mockOnClick}
|
||||
/>,
|
||||
);
|
||||
|
||||
button = screen.getByTestId("progress-button");
|
||||
expect(button).toBeDisabled();
|
||||
expect(button).toHaveAttribute("data-loading", "true");
|
||||
});
|
||||
});
|
||||
329
src/components/common/__tests__/TextField.test.tsx
Normal file
329
src/components/common/__tests__/TextField.test.tsx
Normal file
|
|
@ -0,0 +1,329 @@
|
|||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen, fireEvent } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import TextField from "../TextField";
|
||||
|
||||
// Helper function to filter out Chakra UI props
|
||||
const filterChakraProps = (props: Record<string, unknown>) => {
|
||||
const chakraProps = [
|
||||
"alignItems",
|
||||
"justifyContent",
|
||||
"direction",
|
||||
"gap",
|
||||
"p",
|
||||
"px",
|
||||
"py",
|
||||
"pt",
|
||||
"pb",
|
||||
"pl",
|
||||
"pr",
|
||||
"m",
|
||||
"mx",
|
||||
"my",
|
||||
"mt",
|
||||
"mb",
|
||||
"ml",
|
||||
"mr",
|
||||
"w",
|
||||
"h",
|
||||
"maxW",
|
||||
"maxH",
|
||||
"minW",
|
||||
"minH",
|
||||
"bg",
|
||||
"color",
|
||||
"borderRadius",
|
||||
"borderWidth",
|
||||
"borderColor",
|
||||
"borderStyle",
|
||||
"boxShadow",
|
||||
"display",
|
||||
"position",
|
||||
"top",
|
||||
"right",
|
||||
"bottom",
|
||||
"left",
|
||||
"zIndex",
|
||||
"overflow",
|
||||
"textAlign",
|
||||
"fontSize",
|
||||
"fontWeight",
|
||||
"lineHeight",
|
||||
"letterSpacing",
|
||||
"textTransform",
|
||||
"textDecoration",
|
||||
"opacity",
|
||||
"visibility",
|
||||
"cursor",
|
||||
"pointerEvents",
|
||||
"userSelect",
|
||||
"resize",
|
||||
"outline",
|
||||
"transform",
|
||||
"transformOrigin",
|
||||
"transition",
|
||||
"animation",
|
||||
"colorPalette",
|
||||
"variant",
|
||||
"size",
|
||||
"loading",
|
||||
"disabled",
|
||||
"checked",
|
||||
"selected",
|
||||
"active",
|
||||
"focus",
|
||||
"hover",
|
||||
"flexDirection",
|
||||
"flexWrap",
|
||||
"flex",
|
||||
"flexGrow",
|
||||
"flexShrink",
|
||||
"flexBasis",
|
||||
"alignSelf",
|
||||
"justifySelf",
|
||||
"order",
|
||||
"gridColumn",
|
||||
"gridRow",
|
||||
"gridArea",
|
||||
"gridTemplateColumns",
|
||||
"gridTemplateRows",
|
||||
"gridGap",
|
||||
"rowGap",
|
||||
"columnGap",
|
||||
"placeItems",
|
||||
"placeContent",
|
||||
"placeSelf",
|
||||
"area",
|
||||
"colSpan",
|
||||
"rowSpan",
|
||||
"start",
|
||||
"end",
|
||||
"required",
|
||||
];
|
||||
const filtered = { ...props };
|
||||
chakraProps.forEach((prop) => delete filtered[prop]);
|
||||
return filtered;
|
||||
};
|
||||
|
||||
// Mock Chakra UI components
|
||||
vi.mock("@chakra-ui/react", () => ({
|
||||
Field: {
|
||||
Root: ({
|
||||
children,
|
||||
...props
|
||||
}: React.PropsWithChildren<Record<string, unknown>>) => (
|
||||
<div data-testid="field-root" {...filterChakraProps(props)}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
Label: ({
|
||||
children,
|
||||
...props
|
||||
}: React.PropsWithChildren<Record<string, unknown>>) => (
|
||||
<label data-testid="field-label" {...filterChakraProps(props)}>
|
||||
{children}
|
||||
</label>
|
||||
),
|
||||
RequiredIndicator: ({ ...props }: Record<string, unknown>) => (
|
||||
<span data-testid="required-indicator" {...filterChakraProps(props)}>
|
||||
*
|
||||
</span>
|
||||
),
|
||||
HelperText: ({
|
||||
children,
|
||||
...props
|
||||
}: React.PropsWithChildren<Record<string, unknown>>) => (
|
||||
<div data-testid="helper-text" {...filterChakraProps(props)}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
Input: ({
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
...props
|
||||
}: {
|
||||
value?: string;
|
||||
onChange?: React.ChangeEventHandler<HTMLInputElement>;
|
||||
placeholder?: string;
|
||||
} & Record<string, unknown>) => (
|
||||
<input
|
||||
data-testid="text-input"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
placeholder={placeholder}
|
||||
{...filterChakraProps(props)}
|
||||
/>
|
||||
),
|
||||
}));
|
||||
|
||||
describe("TextField", () => {
|
||||
const defaultProps = {
|
||||
label: "Test Label",
|
||||
value: "",
|
||||
onValueChange: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should render with label and input", () => {
|
||||
render(<TextField {...defaultProps} />);
|
||||
|
||||
expect(screen.getByTestId("field-label")).toBeInTheDocument();
|
||||
expect(screen.getByText("Test Label")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("text-input")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display the current value", () => {
|
||||
render(<TextField {...defaultProps} value="test value" />);
|
||||
|
||||
const input = screen.getByTestId("text-input");
|
||||
expect(input).toHaveValue("test value");
|
||||
});
|
||||
|
||||
it("should call onValueChange when input value changes", async () => {
|
||||
const user = userEvent.setup();
|
||||
const mockOnValueChange = vi.fn();
|
||||
|
||||
render(<TextField {...defaultProps} onValueChange={mockOnValueChange} />);
|
||||
|
||||
const input = screen.getByTestId("text-input");
|
||||
await user.type(input, "new value");
|
||||
|
||||
expect(mockOnValueChange).toHaveBeenCalledWith("n");
|
||||
expect(mockOnValueChange).toHaveBeenCalledWith("e");
|
||||
expect(mockOnValueChange).toHaveBeenCalledWith("w");
|
||||
expect(mockOnValueChange).toHaveBeenCalledWith(" ");
|
||||
expect(mockOnValueChange).toHaveBeenCalledWith("v");
|
||||
expect(mockOnValueChange).toHaveBeenCalledWith("a");
|
||||
expect(mockOnValueChange).toHaveBeenCalledWith("l");
|
||||
expect(mockOnValueChange).toHaveBeenCalledWith("u");
|
||||
expect(mockOnValueChange).toHaveBeenCalledWith("e");
|
||||
});
|
||||
|
||||
it("should handle onChange event correctly", () => {
|
||||
const mockOnValueChange = vi.fn();
|
||||
|
||||
render(<TextField {...defaultProps} onValueChange={mockOnValueChange} />);
|
||||
|
||||
const input = screen.getByTestId("text-input");
|
||||
fireEvent.change(input, { target: { value: "test input" } });
|
||||
|
||||
expect(mockOnValueChange).toHaveBeenCalledWith("test input");
|
||||
});
|
||||
|
||||
it("should display placeholder when provided", () => {
|
||||
render(<TextField {...defaultProps} placeholder="Enter text here" />);
|
||||
|
||||
const input = screen.getByTestId("text-input");
|
||||
expect(input).toHaveAttribute("placeholder", "Enter text here");
|
||||
});
|
||||
|
||||
it("should show required indicator when required is true", () => {
|
||||
render(<TextField {...defaultProps} required />);
|
||||
|
||||
expect(screen.getByTestId("required-indicator")).toBeInTheDocument();
|
||||
expect(screen.getByText("*")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not show required indicator when required is false", () => {
|
||||
render(<TextField {...defaultProps} required={false} />);
|
||||
|
||||
expect(screen.queryByTestId("required-indicator")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not show required indicator when required is undefined", () => {
|
||||
render(<TextField {...defaultProps} />);
|
||||
|
||||
expect(screen.queryByTestId("required-indicator")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should display helper text when provided", () => {
|
||||
render(<TextField {...defaultProps} helperText="This is helper text" />);
|
||||
|
||||
expect(screen.getByTestId("helper-text")).toBeInTheDocument();
|
||||
expect(screen.getByText("This is helper text")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should not display helper text when not provided", () => {
|
||||
render(<TextField {...defaultProps} />);
|
||||
|
||||
expect(screen.queryByTestId("helper-text")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should handle empty string value", () => {
|
||||
render(<TextField {...defaultProps} value="" />);
|
||||
|
||||
const input = screen.getByTestId("text-input");
|
||||
expect(input).toHaveValue("");
|
||||
});
|
||||
|
||||
it("should handle special characters in value", () => {
|
||||
const specialValue = "!@#$%^&*()_+{}[]|\\:;\"'<>,.?/~`";
|
||||
render(<TextField {...defaultProps} value={specialValue} />);
|
||||
|
||||
const input = screen.getByTestId("text-input");
|
||||
expect(input).toHaveValue(specialValue);
|
||||
});
|
||||
|
||||
it("should handle multiline text in value", () => {
|
||||
const multilineValue = "Line 1\nLine 2\nLine 3";
|
||||
render(<TextField {...defaultProps} value={multilineValue} />);
|
||||
|
||||
const input = screen.getByTestId("text-input");
|
||||
// Check that the value contains the expected content
|
||||
expect(input).toBeInTheDocument();
|
||||
expect(input.getAttribute("value")).toContain("Line 1");
|
||||
expect(input.getAttribute("value")).toContain("Line 2");
|
||||
expect(input.getAttribute("value")).toContain("Line 3");
|
||||
});
|
||||
|
||||
it("should render field root structure", () => {
|
||||
render(<TextField {...defaultProps} required />);
|
||||
|
||||
const fieldRoot = screen.getByTestId("field-root");
|
||||
expect(fieldRoot).toBeInTheDocument();
|
||||
expect(fieldRoot).toContainElement(screen.getByTestId("field-label"));
|
||||
expect(fieldRoot).toContainElement(screen.getByTestId("text-input"));
|
||||
});
|
||||
|
||||
it("should handle long text values", () => {
|
||||
const longValue = "a".repeat(1000);
|
||||
render(<TextField {...defaultProps} value={longValue} />);
|
||||
|
||||
const input = screen.getByTestId("text-input");
|
||||
expect(input).toHaveValue(longValue);
|
||||
});
|
||||
|
||||
it("should handle rapid input changes", async () => {
|
||||
const user = userEvent.setup();
|
||||
const mockOnValueChange = vi.fn();
|
||||
|
||||
render(<TextField {...defaultProps} onValueChange={mockOnValueChange} />);
|
||||
|
||||
const input = screen.getByTestId("text-input");
|
||||
await user.type(input, "abc", { delay: 1 });
|
||||
|
||||
expect(mockOnValueChange).toHaveBeenCalledTimes(3);
|
||||
expect(mockOnValueChange).toHaveBeenNthCalledWith(1, "a");
|
||||
expect(mockOnValueChange).toHaveBeenNthCalledWith(2, "b");
|
||||
expect(mockOnValueChange).toHaveBeenNthCalledWith(3, "c");
|
||||
});
|
||||
|
||||
it("should clear input when value changes to empty string", () => {
|
||||
const { rerender } = render(
|
||||
<TextField {...defaultProps} value="initial value" />,
|
||||
);
|
||||
|
||||
let input = screen.getByTestId("text-input");
|
||||
expect(input).toHaveValue("initial value");
|
||||
|
||||
rerender(<TextField {...defaultProps} value="" />);
|
||||
|
||||
input = screen.getByTestId("text-input");
|
||||
expect(input).toHaveValue("");
|
||||
});
|
||||
});
|
||||
20
src/components/entity/ElementNode.tsx
Normal file
20
src/components/entity/ElementNode.tsx
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import React from "react";
|
||||
|
||||
import { Value } from "@trustgraph/react-state";
|
||||
import { Entity } from "@trustgraph/react-state";
|
||||
import LiteralNode from "./LiteralNode";
|
||||
import EntityNode from "./EntityNode";
|
||||
import SelectedNode from "./SelectedNode";
|
||||
|
||||
const ElementNode: React.FC<{ value: Value; selected: Entity }> = ({
|
||||
value,
|
||||
selected,
|
||||
}) => {
|
||||
if (value.e)
|
||||
if (selected && value.v == selected.uri)
|
||||
return <SelectedNode value={value} />;
|
||||
else return <EntityNode value={value} />;
|
||||
else return <LiteralNode value={value} />;
|
||||
};
|
||||
|
||||
export default ElementNode;
|
||||
115
src/components/entity/EntityDetail.tsx
Normal file
115
src/components/entity/EntityDetail.tsx
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
import React from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
import { Rotate3d, ArrowBigRight } from "lucide-react";
|
||||
|
||||
import { Box, Alert, Button, Stack, Heading, HStack } from "@chakra-ui/react";
|
||||
|
||||
import {
|
||||
useWorkbenchStateStore,
|
||||
useSessionStore,
|
||||
useEntityDetail,
|
||||
useSettings,
|
||||
} from "@trustgraph/react-state";
|
||||
|
||||
import EntityHelp from "./EntityHelp";
|
||||
import ElementNode from "./ElementNode";
|
||||
|
||||
const EntityDetail = () => {
|
||||
const navigate = useNavigate();
|
||||
const flowId = useSessionStore((state) => state.flowId);
|
||||
const selected = useWorkbenchStateStore((state) => state.selected);
|
||||
const { settings, isLoaded: settingsLoaded } = useSettings();
|
||||
|
||||
// Use the new Tanstack Query hook for entity details
|
||||
const { detail, isLoading, isError } = useEntityDetail(
|
||||
selected?.uri,
|
||||
flowId,
|
||||
settings?.collection || "default",
|
||||
);
|
||||
|
||||
if (!settingsLoaded) {
|
||||
return (
|
||||
<Box>
|
||||
<Alert.Root status="info" variant="outline">
|
||||
<Alert.Indicator />
|
||||
<Alert.Title>Loading settings...</Alert.Title>
|
||||
</Alert.Root>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (!selected) {
|
||||
return (
|
||||
<Box>
|
||||
<Alert.Root severity="info" variant="outlined">
|
||||
<Alert.Indicator />
|
||||
<Alert.Title>
|
||||
No data to view. Try Chat or Search to find data.
|
||||
</Alert.Title>
|
||||
</Alert.Root>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading || !detail)
|
||||
return (
|
||||
<Box>
|
||||
<Alert.Root status="info" variant="outline">
|
||||
<Alert.Indicator />
|
||||
<Alert.Title>
|
||||
{isLoading
|
||||
? "Loading entity details..."
|
||||
: "No data to view. Try Chat or Search to find data."}
|
||||
</Alert.Title>
|
||||
</Alert.Root>
|
||||
</Box>
|
||||
);
|
||||
|
||||
if (isError)
|
||||
return (
|
||||
<Box>
|
||||
<Alert.Root status="error" variant="outline">
|
||||
<Alert.Indicator />
|
||||
<Alert.Title>Error loading entity details.</Alert.Title>
|
||||
</Alert.Root>
|
||||
</Box>
|
||||
);
|
||||
|
||||
const graphView = () => {
|
||||
navigate("/graph");
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<HStack mb={8}>
|
||||
<Heading>{selected.label}</Heading>
|
||||
|
||||
<Box ml={8}>
|
||||
<Button size="md" variant="solid" onClick={() => graphView()}>
|
||||
<Rotate3d /> Graph view
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<EntityHelp />
|
||||
</HStack>
|
||||
|
||||
<Box>
|
||||
{detail.triples.map((t) => {
|
||||
return (
|
||||
<Box key={t.s.v + "//" + t.p.v + "//" + t.o.v} mb={2}>
|
||||
<Stack direction="row" alignItems="center" gap={0}>
|
||||
<ElementNode value={t.s} selected={selected} />
|
||||
<ArrowBigRight />
|
||||
<ElementNode value={t.p} selected={selected} />
|
||||
<ArrowBigRight />
|
||||
<ElementNode value={t.o} selected={selected} />
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default EntityDetail;
|
||||
38
src/components/entity/EntityHelp.tsx
Normal file
38
src/components/entity/EntityHelp.tsx
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import React from "react";
|
||||
|
||||
import { Popover, Text, IconButton, Portal } from "@chakra-ui/react";
|
||||
import { CircleHelp } from "lucide-react";
|
||||
|
||||
const EntityHelp = () => {
|
||||
return (
|
||||
<Popover.Root size="md" variant="outline">
|
||||
<Popover.Trigger asChild>
|
||||
<IconButton size="lg" ml={10}>
|
||||
<CircleHelp />
|
||||
</IconButton>
|
||||
</Popover.Trigger>
|
||||
<Portal>
|
||||
<Popover.Positioner>
|
||||
<Popover.Content w="25rem">
|
||||
<Popover.Arrow />
|
||||
<Popover.Body p={5}>
|
||||
<Popover.Title fontWeight="medium">Explore</Popover.Title>
|
||||
<Text m={2}>
|
||||
The Explore page shows properties and relationships of entities
|
||||
in the knowledge graph. On this page, you can navigate by
|
||||
selecting other knowledge graph entities and seeing the
|
||||
properties and relationships related to those entities.
|
||||
</Text>
|
||||
<Text>
|
||||
Selecting the Graph View button shows you the same information,
|
||||
but presented in a 3D graphical form.
|
||||
</Text>
|
||||
</Popover.Body>
|
||||
</Popover.Content>
|
||||
</Popover.Positioner>
|
||||
</Portal>
|
||||
</Popover.Root>
|
||||
);
|
||||
};
|
||||
|
||||
export default EntityHelp;
|
||||
28
src/components/entity/EntityNode.tsx
Normal file
28
src/components/entity/EntityNode.tsx
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import React from "react";
|
||||
|
||||
import { Button } from "@chakra-ui/react";
|
||||
|
||||
import { useWorkbenchStateStore } from "@trustgraph/react-state";
|
||||
import { Value } from "@trustgraph/react-state";
|
||||
|
||||
const EntityNode: React.FC<{ value: Value }> = ({ value }) => {
|
||||
const setSelected = useWorkbenchStateStore((state) => state.setSelected);
|
||||
|
||||
return (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
colorPalette="blue"
|
||||
onClick={() =>
|
||||
setSelected({
|
||||
uri: value.v,
|
||||
label: value.label ? value.label : value.v,
|
||||
})
|
||||
}
|
||||
>
|
||||
{value.label}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export default EntityNode;
|
||||
11
src/components/entity/LiteralNode.tsx
Normal file
11
src/components/entity/LiteralNode.tsx
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import React from "react";
|
||||
|
||||
import { Text } from "@chakra-ui/react";
|
||||
|
||||
import { Value } from "@trustgraph/react-state";
|
||||
|
||||
const LiteralNode: React.FC<{ value: Value }> = ({ value }) => {
|
||||
return <Text>{value.label}</Text>;
|
||||
};
|
||||
|
||||
export default LiteralNode;
|
||||
15
src/components/entity/SelectedNode.tsx
Normal file
15
src/components/entity/SelectedNode.tsx
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import React from "react";
|
||||
|
||||
import { Tag } from "@chakra-ui/react";
|
||||
|
||||
import { Value } from "@trustgraph/react-state";
|
||||
|
||||
const SelectedNode: React.FC<{ value: Value }> = ({ value }) => {
|
||||
return (
|
||||
<Tag.Root variant="surface" color="gray.50" backgroundColor="gray.600">
|
||||
<Tag.Label>{value.label}</Tag.Label>
|
||||
</Tag.Root>
|
||||
);
|
||||
};
|
||||
|
||||
export default SelectedNode;
|
||||
71
src/components/flow-classes/FlowClassActions.tsx
Normal file
71
src/components/flow-classes/FlowClassActions.tsx
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import React from "react";
|
||||
import { Check, Trash, Eye } from "lucide-react";
|
||||
import { ActionBar, Portal, Button } from "@chakra-ui/react";
|
||||
|
||||
interface FlowClassActionsProps {
|
||||
selectedCount: number;
|
||||
onEdit?: () => void;
|
||||
onDelete?: () => void;
|
||||
}
|
||||
|
||||
const FlowClassActions: React.FC<FlowClassActionsProps> = ({
|
||||
selectedCount,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}) => {
|
||||
return (
|
||||
<ActionBar.Root open={selectedCount > 0} colorPalette="blue">
|
||||
<Portal>
|
||||
<ActionBar.Positioner>
|
||||
<ActionBar.Content
|
||||
background="{colors.bg.muted}"
|
||||
color="fg"
|
||||
colorPalette="primary"
|
||||
>
|
||||
<ActionBar.SelectionTrigger>
|
||||
<Check /> {selectedCount} selected
|
||||
</ActionBar.SelectionTrigger>
|
||||
<ActionBar.Separator />
|
||||
|
||||
{selectedCount === 1 && onEdit && (
|
||||
<Button
|
||||
variant="outline"
|
||||
colorPalette="blue"
|
||||
size="sm"
|
||||
onClick={onEdit}
|
||||
>
|
||||
<Eye /> View
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Temporarily disabled this because it doesn't work
|
||||
{selectedCount === 1 && onDuplicate && (
|
||||
<Button
|
||||
variant="outline"
|
||||
colorPalette="green"
|
||||
size="sm"
|
||||
onClick={onDuplicate}
|
||||
>
|
||||
<Copy /> Duplicate
|
||||
</Button>
|
||||
)}
|
||||
*/}
|
||||
|
||||
{onDelete && (
|
||||
<Button
|
||||
variant="outline"
|
||||
colorPalette="red"
|
||||
size="sm"
|
||||
onClick={onDelete}
|
||||
>
|
||||
<Trash /> Delete
|
||||
</Button>
|
||||
)}
|
||||
</ActionBar.Content>
|
||||
</ActionBar.Positioner>
|
||||
</Portal>
|
||||
</ActionBar.Root>
|
||||
);
|
||||
};
|
||||
|
||||
export default FlowClassActions;
|
||||
32
src/components/flow-classes/FlowClassControls.tsx
Normal file
32
src/components/flow-classes/FlowClassControls.tsx
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import React from "react";
|
||||
import { Plus } from "lucide-react";
|
||||
import { Button, Box } from "@chakra-ui/react";
|
||||
import { generateFlowClassId } from "@trustgraph/react-state";
|
||||
|
||||
interface FlowClassControlsProps {
|
||||
onNew?: (id: string) => void;
|
||||
}
|
||||
|
||||
const FlowClassControls: React.FC<FlowClassControlsProps> = ({ onNew }) => {
|
||||
const handleCreate = () => {
|
||||
const newId = generateFlowClassId("flow-class");
|
||||
onNew?.(newId);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Button
|
||||
mt={5}
|
||||
ml={5}
|
||||
mb={5}
|
||||
variant="solid"
|
||||
colorPalette="primary"
|
||||
onClick={handleCreate}
|
||||
>
|
||||
<Plus /> Create Flow Class
|
||||
</Button>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default FlowClassControls;
|
||||
206
src/components/flow-classes/FlowClassEditPanel.tsx
Normal file
206
src/components/flow-classes/FlowClassEditPanel.tsx
Normal file
|
|
@ -0,0 +1,206 @@
|
|||
import React, { useState, useEffect } from "react";
|
||||
import {
|
||||
Box,
|
||||
VStack,
|
||||
HStack,
|
||||
Text,
|
||||
Input,
|
||||
Textarea,
|
||||
Button,
|
||||
Badge,
|
||||
Separator,
|
||||
Fieldset,
|
||||
} from "@chakra-ui/react";
|
||||
import { Save, X, FileCode } from "lucide-react";
|
||||
import { FlowClassDefinition } from "@trustgraph/react-state";
|
||||
|
||||
interface FlowClassEditPanelProps {
|
||||
flowClass: FlowClassDefinition;
|
||||
onSave?: (flowClass: FlowClassDefinition) => void;
|
||||
onCancel?: () => void;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
const FlowClassEditPanel: React.FC<FlowClassEditPanelProps> = ({
|
||||
flowClass,
|
||||
onSave,
|
||||
onCancel,
|
||||
isLoading = false,
|
||||
}) => {
|
||||
const [description, setDescription] = useState(flowClass.description || "");
|
||||
const [tags, setTags] = useState((flowClass.tags || []).join(", "));
|
||||
|
||||
useEffect(() => {
|
||||
setDescription(flowClass.description || "");
|
||||
setTags((flowClass.tags || []).join(", "));
|
||||
}, [flowClass]);
|
||||
|
||||
const handleSave = () => {
|
||||
const updatedFlowClass: FlowClassDefinition = {
|
||||
...flowClass,
|
||||
description: description.trim() || undefined,
|
||||
tags: tags
|
||||
.split(",")
|
||||
.map((tag) => tag.trim())
|
||||
.filter((tag) => tag.length > 0)
|
||||
.slice(0, 10), // Limit to 10 tags
|
||||
};
|
||||
|
||||
onSave?.(updatedFlowClass);
|
||||
};
|
||||
|
||||
const hasChanges =
|
||||
description !== (flowClass.description || "") ||
|
||||
tags !== (flowClass.tags || []).join(", ");
|
||||
|
||||
const classCount = Object.keys(flowClass.class || {}).length;
|
||||
const flowCount = Object.keys(flowClass.flow || {}).length;
|
||||
const interfaceCount = Object.keys(flowClass.interfaces || {}).length;
|
||||
|
||||
return (
|
||||
<Box
|
||||
position="fixed"
|
||||
bottom={0}
|
||||
left={0}
|
||||
right={0}
|
||||
bg="bg.default"
|
||||
borderTop="1px solid"
|
||||
borderColor="border.muted"
|
||||
boxShadow="0 -4px 6px -1px rgba(0, 0, 0, 0.1)"
|
||||
zIndex={50}
|
||||
p={6}
|
||||
maxH="50vh"
|
||||
overflowY="auto"
|
||||
>
|
||||
<VStack gap={4} align="stretch" maxW="1200px" mx="auto">
|
||||
{/* Header */}
|
||||
<HStack justify="space-between" align="center">
|
||||
<HStack gap={3}>
|
||||
<FileCode size={20} />
|
||||
<Text fontSize="lg" fontWeight="semibold">
|
||||
Edit Flow Class: {flowClass.id}
|
||||
</Text>
|
||||
</HStack>
|
||||
<HStack gap={2}>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onCancel}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<X size={16} />
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="solid"
|
||||
colorPalette="blue"
|
||||
size="sm"
|
||||
onClick={handleSave}
|
||||
disabled={!hasChanges || isLoading}
|
||||
loading={isLoading}
|
||||
>
|
||||
<Save size={16} />
|
||||
Save Changes
|
||||
</Button>
|
||||
</HStack>
|
||||
</HStack>
|
||||
|
||||
<Separator />
|
||||
|
||||
<HStack gap={6} align="stretch">
|
||||
{/* Left Column - Basic Info */}
|
||||
<VStack gap={4} align="stretch" flex={1}>
|
||||
<Fieldset.Root>
|
||||
<Fieldset.Legend>Basic Information</Fieldset.Legend>
|
||||
<Fieldset.Content>
|
||||
<VStack gap={3} align="stretch">
|
||||
<Box>
|
||||
<Text fontSize="sm" fontWeight="medium" mb={2}>
|
||||
Description
|
||||
</Text>
|
||||
<Textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Enter flow class description..."
|
||||
rows={3}
|
||||
resize="none"
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Text fontSize="sm" fontWeight="medium" mb={2}>
|
||||
Tags{" "}
|
||||
<Text as="span" color="fg.muted">
|
||||
(comma-separated)
|
||||
</Text>
|
||||
</Text>
|
||||
<Input
|
||||
value={tags}
|
||||
onChange={(e) => setTags(e.target.value)}
|
||||
placeholder="rag, document-processing, llm..."
|
||||
/>
|
||||
</Box>
|
||||
</VStack>
|
||||
</Fieldset.Content>
|
||||
</Fieldset.Root>
|
||||
</VStack>
|
||||
|
||||
{/* Right Column - Statistics */}
|
||||
<VStack gap={4} align="stretch" minW="300px">
|
||||
<Fieldset.Root>
|
||||
<Fieldset.Legend>Flow Class Statistics</Fieldset.Legend>
|
||||
<Fieldset.Content>
|
||||
<VStack gap={3} align="stretch">
|
||||
<HStack justify="space-between">
|
||||
<Text fontSize="sm">Class Processors:</Text>
|
||||
<Badge colorPalette="blue">{classCount}</Badge>
|
||||
</HStack>
|
||||
|
||||
<HStack justify="space-between">
|
||||
<Text fontSize="sm">Flow Processors:</Text>
|
||||
<Badge colorPalette="green">{flowCount}</Badge>
|
||||
</HStack>
|
||||
|
||||
<HStack justify="space-between">
|
||||
<Text fontSize="sm">Interfaces:</Text>
|
||||
<Badge colorPalette="purple">{interfaceCount}</Badge>
|
||||
</HStack>
|
||||
|
||||
<HStack justify="space-between">
|
||||
<Text fontSize="sm">Total Components:</Text>
|
||||
<Badge colorPalette="gray">
|
||||
{classCount + flowCount + interfaceCount}
|
||||
</Badge>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</Fieldset.Content>
|
||||
</Fieldset.Root>
|
||||
|
||||
{/* Preview of current tags */}
|
||||
{flowClass.tags && flowClass.tags.length > 0 && (
|
||||
<Box>
|
||||
<Text fontSize="sm" fontWeight="medium" mb={2}>
|
||||
Current Tags
|
||||
</Text>
|
||||
<HStack gap={1} flexWrap="wrap">
|
||||
{flowClass.tags.map((tag, index) => (
|
||||
<Badge
|
||||
key={index}
|
||||
colorPalette="gray"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</HStack>
|
||||
</Box>
|
||||
)}
|
||||
</VStack>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default FlowClassEditPanel;
|
||||
827
src/components/flow-classes/FlowClassEditorView.tsx
Normal file
827
src/components/flow-classes/FlowClassEditorView.tsx
Normal file
|
|
@ -0,0 +1,827 @@
|
|||
import React, { useCallback, useMemo, useEffect } from "react";
|
||||
import {
|
||||
Box,
|
||||
VStack,
|
||||
HStack,
|
||||
Heading,
|
||||
Text,
|
||||
Button,
|
||||
Separator,
|
||||
} from "@chakra-ui/react";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import ReactFlow, {
|
||||
Background,
|
||||
Controls,
|
||||
MiniMap,
|
||||
Node,
|
||||
Edge,
|
||||
useNodesState,
|
||||
useEdgesState,
|
||||
addEdge,
|
||||
Connection,
|
||||
ConnectionMode,
|
||||
Handle,
|
||||
Position,
|
||||
} from "reactflow";
|
||||
import dagre from "dagre";
|
||||
import "reactflow/dist/style.css";
|
||||
import { useFlowClasses } from "@trustgraph/react-state";
|
||||
import serviceMap from "../../data/service-map.json";
|
||||
|
||||
interface FlowClassEditorViewProps {
|
||||
flowClassId: string;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
interface ProcessorInfo {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
// Custom node component - use role for connections, direction for positioning
|
||||
const CustomNode = ({
|
||||
data,
|
||||
}: {
|
||||
data: {
|
||||
label: string;
|
||||
type?: string;
|
||||
provides?: string[];
|
||||
consumes?: string[];
|
||||
processorInfo?: ProcessorInfo;
|
||||
};
|
||||
}) => {
|
||||
const borderColor = data.type === "class" ? "#2563eb" : "#16a34a"; // blue for class, green for flow
|
||||
const backgroundColor = data.type === "class" ? "#eff6ff" : "#f0fdf4";
|
||||
|
||||
const provides = data.provides || [];
|
||||
const consumes = data.consumes || [];
|
||||
const processorInfo = data.processorInfo || { connections: [] };
|
||||
|
||||
// Group connections by direction for positioning
|
||||
const leftConnections: string[] = [];
|
||||
const rightConnections: string[] = [];
|
||||
|
||||
interface Connection {
|
||||
name: string;
|
||||
role: string;
|
||||
direction?: string;
|
||||
}
|
||||
|
||||
// Add provides connections to left or right based on direction
|
||||
provides.forEach((connectionName) => {
|
||||
const conn = (processorInfo.connections as Connection[] | undefined)?.find(
|
||||
(c) => c.name === connectionName && c.role === "provides",
|
||||
);
|
||||
if (conn?.direction === "in") {
|
||||
leftConnections.push(connectionName);
|
||||
} else {
|
||||
rightConnections.push(connectionName);
|
||||
}
|
||||
});
|
||||
|
||||
// Add consumes connections to left or right based on direction
|
||||
consumes.forEach((connectionName) => {
|
||||
const conn = (processorInfo.connections as Connection[] | undefined)?.find(
|
||||
(c) => c.name === connectionName && c.role === "consumes",
|
||||
);
|
||||
if (conn?.direction === "in") {
|
||||
leftConnections.push(connectionName);
|
||||
} else {
|
||||
rightConnections.push(connectionName);
|
||||
}
|
||||
});
|
||||
|
||||
const nodeHeight = Math.max(
|
||||
50,
|
||||
Math.max(leftConnections.length, rightConnections.length) * 25 + 30,
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
padding: "10px 20px",
|
||||
border: `2px solid ${borderColor}`,
|
||||
borderRadius: "6px",
|
||||
background: backgroundColor,
|
||||
fontSize: "14px",
|
||||
fontWeight: "500",
|
||||
position: "relative",
|
||||
minWidth: "150px",
|
||||
minHeight: `${nodeHeight}px`,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
{/* LEFT side connections */}
|
||||
{leftConnections.map((connection, index) => {
|
||||
const conn = (
|
||||
processorInfo.connections as Connection[] | undefined
|
||||
)?.find((c) => c.name === connection);
|
||||
const isProvides = conn?.role === "provides";
|
||||
return (
|
||||
<React.Fragment
|
||||
key={`${isProvides ? "provide" : "consume"}-${connection}`}
|
||||
>
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Left}
|
||||
id={`${isProvides ? "provide" : "consume"}-${connection}`}
|
||||
style={{
|
||||
background: isProvides ? "#16a34a" : "#dc2626",
|
||||
top: `${((index + 1) / (leftConnections.length + 1)) * 100}%`,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
right: `calc(100% + 15px)`,
|
||||
top: `calc(${((index + 1) / (leftConnections.length + 1)) * 100}% - 8px)`,
|
||||
transform: "translateY(-50%)",
|
||||
fontSize: "9px",
|
||||
color: isProvides ? "#16a34a" : "#dc2626",
|
||||
fontWeight: "normal",
|
||||
whiteSpace: "nowrap",
|
||||
textAlign: "right",
|
||||
}}
|
||||
>
|
||||
{connection}
|
||||
</div>
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* RIGHT side connections */}
|
||||
{rightConnections.map((connection, index) => {
|
||||
const conn = (
|
||||
processorInfo.connections as Connection[] | undefined
|
||||
)?.find((c) => c.name === connection);
|
||||
const isProvides = conn?.role === "provides";
|
||||
return (
|
||||
<React.Fragment
|
||||
key={`${isProvides ? "provide" : "consume"}-${connection}`}
|
||||
>
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Right}
|
||||
id={`${isProvides ? "provide" : "consume"}-${connection}`}
|
||||
style={{
|
||||
background: isProvides ? "#16a34a" : "#dc2626",
|
||||
top: `${((index + 1) / (rightConnections.length + 1)) * 100}%`,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: `calc(100% + 15px)`,
|
||||
top: `calc(${((index + 1) / (rightConnections.length + 1)) * 100}% - 8px)`,
|
||||
transform: "translateY(-50%)",
|
||||
fontSize: "9px",
|
||||
color: isProvides ? "#16a34a" : "#dc2626",
|
||||
fontWeight: "normal",
|
||||
whiteSpace: "nowrap",
|
||||
textAlign: "left",
|
||||
}}
|
||||
>
|
||||
{connection}
|
||||
</div>
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
|
||||
<div style={{ fontSize: "12px", fontWeight: "600" }}>{data.label}</div>
|
||||
{data.type && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: "10px",
|
||||
color: borderColor,
|
||||
fontWeight: "normal",
|
||||
marginTop: "2px",
|
||||
}}
|
||||
>
|
||||
{data.type}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Interface node component - visually distinct from processors
|
||||
const InterfaceNode = ({
|
||||
data,
|
||||
}: {
|
||||
data: {
|
||||
label: string;
|
||||
interfaceKind?: string;
|
||||
description?: string;
|
||||
visible?: boolean;
|
||||
queues?: Record<string, unknown>;
|
||||
};
|
||||
}) => {
|
||||
const borderColor = data.interfaceKind === "service" ? "#8b5cf6" : "#ec4899"; // purple for service, pink for flow
|
||||
const backgroundColor =
|
||||
data.interfaceKind === "service" ? "#f3e8ff" : "#fce7f3";
|
||||
const icon = data.interfaceKind === "service" ? "⚡" : "📦";
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
padding: "12px 20px",
|
||||
border: `2px dashed ${borderColor}`,
|
||||
borderRadius: "12px",
|
||||
background: backgroundColor,
|
||||
fontSize: "14px",
|
||||
fontWeight: "500",
|
||||
minWidth: "180px",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
gap: "4px",
|
||||
boxShadow: "0 4px 6px rgba(0, 0, 0, 0.1)",
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
{/* Connection handle on the right side */}
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Right}
|
||||
id={`interface-${data.label}`}
|
||||
style={{
|
||||
background: borderColor,
|
||||
width: "12px",
|
||||
height: "12px",
|
||||
border: "2px solid white",
|
||||
right: "-6px",
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "8px",
|
||||
fontSize: "16px",
|
||||
fontWeight: "600",
|
||||
}}
|
||||
>
|
||||
<span>{icon}</span>
|
||||
<span>{data.label}</span>
|
||||
</div>
|
||||
{data.description && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: "11px",
|
||||
color: "#6b7280",
|
||||
fontStyle: "italic",
|
||||
textAlign: "center",
|
||||
maxWidth: "200px",
|
||||
}}
|
||||
>
|
||||
{data.description}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
style={{
|
||||
fontSize: "10px",
|
||||
color: borderColor,
|
||||
fontWeight: "bold",
|
||||
textTransform: "uppercase",
|
||||
marginTop: "4px",
|
||||
}}
|
||||
>
|
||||
{data.interfaceKind} interface
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Register custom node types
|
||||
const nodeTypes = {
|
||||
custom: CustomNode,
|
||||
interface: InterfaceNode,
|
||||
};
|
||||
|
||||
interface FlowClass {
|
||||
class?: Record<string, unknown>;
|
||||
flow?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
// Generate nodes from flow class processors
|
||||
const generateNodesFromFlowClass = (flowClass: FlowClass): Node[] => {
|
||||
const nodes: Node[] = [];
|
||||
|
||||
// Add class processors
|
||||
Object.keys(flowClass.class || {}).forEach((processorName) => {
|
||||
// Strip template suffix to get base processor name for service map lookup
|
||||
const baseProcessorName = processorName.replace(/:\{[^}]+\}$/, "");
|
||||
|
||||
// Get connection info from service map - use role for connections, direction for positioning
|
||||
const processorInfo = serviceMap.processors[baseProcessorName] || {
|
||||
connections: [],
|
||||
};
|
||||
const provides =
|
||||
processorInfo.connections
|
||||
?.filter((conn) => conn.role === "provides")
|
||||
.map((conn) => conn.name) || [];
|
||||
const consumes =
|
||||
processorInfo.connections
|
||||
?.filter((conn) => conn.role === "consumes")
|
||||
.map((conn) => conn.name) || [];
|
||||
|
||||
nodes.push({
|
||||
id: `class-${processorName}`,
|
||||
position: { x: 0, y: 0 }, // Will be calculated by dagre
|
||||
data: {
|
||||
label: processorName,
|
||||
type: "class",
|
||||
provides: provides,
|
||||
consumes: consumes,
|
||||
processorInfo: processorInfo, // Pass full processor info for direction lookup
|
||||
},
|
||||
type: "custom",
|
||||
});
|
||||
});
|
||||
|
||||
// Add flow processors
|
||||
Object.keys(flowClass.flow || {}).forEach((processorName) => {
|
||||
// Strip template suffix to get base processor name for service map lookup
|
||||
const baseProcessorName = processorName.replace(/:\{[^}]+\}$/, "");
|
||||
|
||||
// Get connection info from service map - use role for connections, direction for positioning
|
||||
const processorInfo = serviceMap.processors[baseProcessorName] || {
|
||||
connections: [],
|
||||
};
|
||||
const provides =
|
||||
processorInfo.connections
|
||||
?.filter((conn) => conn.role === "provides")
|
||||
.map((conn) => conn.name) || [];
|
||||
const consumes =
|
||||
processorInfo.connections
|
||||
?.filter((conn) => conn.role === "consumes")
|
||||
.map((conn) => conn.name) || [];
|
||||
|
||||
nodes.push({
|
||||
id: `flow-${processorName}`,
|
||||
position: { x: 0, y: 0 }, // Will be calculated by dagre
|
||||
data: {
|
||||
label: processorName,
|
||||
type: "flow",
|
||||
provides: provides,
|
||||
consumes: consumes,
|
||||
processorInfo: processorInfo, // Pass full processor info for direction lookup
|
||||
},
|
||||
type: "custom",
|
||||
});
|
||||
});
|
||||
|
||||
// Add interface nodes
|
||||
Object.entries(flowClass.interfaces || {}).forEach(
|
||||
([interfaceName, interfaceQueues]) => {
|
||||
// Look up interface definition in service map
|
||||
const interfaceDefinition = serviceMap.interfaces?.[interfaceName];
|
||||
|
||||
nodes.push({
|
||||
id: `interface-${interfaceName}`,
|
||||
position: { x: 0, y: 0 }, // Will be calculated by dagre
|
||||
data: {
|
||||
label: interfaceName,
|
||||
type: "interface",
|
||||
interfaceKind: interfaceDefinition?.kind || "unknown",
|
||||
description: interfaceDefinition?.description || "",
|
||||
visible: interfaceDefinition?.visible,
|
||||
queues: interfaceQueues,
|
||||
},
|
||||
type: "interface", // Use a different node type for interfaces
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
return nodes;
|
||||
};
|
||||
|
||||
// Apply dagre layout to nodes and edges for better positioning
|
||||
const applyDagreLayout = (nodes: Node[], edges: Edge[]): Node[] => {
|
||||
const nodeWidth = 200;
|
||||
const nodeHeight = 120; // Increased for interface nodes
|
||||
|
||||
// Create a new directed graph
|
||||
const dagreGraph = new dagre.graphlib.Graph();
|
||||
dagreGraph.setDefaultEdgeLabel(() => ({}));
|
||||
dagreGraph.setGraph({
|
||||
rankdir: "LR", // Left to right layout
|
||||
nodesep: 80, // Increased horizontal spacing between nodes
|
||||
ranksep: 500, // Extra 50% left-right spacing between ranks
|
||||
marginx: 40, // Increased margins
|
||||
marginy: 40,
|
||||
align: "UL", // Align ranks upward-left for better interface positioning
|
||||
acyclicer: "greedy", // Better cycle removal
|
||||
ranker: "tight-tree", // Better ranking algorithm
|
||||
});
|
||||
|
||||
// Add nodes to dagre graph
|
||||
nodes.forEach((node) => {
|
||||
dagreGraph.setNode(node.id, { width: nodeWidth, height: nodeHeight });
|
||||
});
|
||||
|
||||
// Add edges to dagre graph
|
||||
edges.forEach((edge) => {
|
||||
dagreGraph.setEdge(edge.source, edge.target);
|
||||
});
|
||||
|
||||
// Calculate the layout
|
||||
dagre.layout(dagreGraph);
|
||||
|
||||
// Apply the calculated positions back to the nodes
|
||||
return nodes.map((node) => {
|
||||
const nodeWithPosition = dagreGraph.node(node.id);
|
||||
return {
|
||||
...node,
|
||||
position: {
|
||||
x: nodeWithPosition.x - nodeWidth / 2,
|
||||
y: nodeWithPosition.y - nodeHeight / 2,
|
||||
},
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
// Generate edges from flow class connections using three-way matching algorithm
|
||||
const generateEdgesFromFlowClass = (flowClass: FlowClass): Edge[] => {
|
||||
const edges: Edge[] = [];
|
||||
let edgeIndex = 0;
|
||||
|
||||
// Build maps of providers and consumers by connection type
|
||||
const providersByType = new Map<
|
||||
string,
|
||||
Array<{
|
||||
processorId: string;
|
||||
processorName: string;
|
||||
connectionName: string;
|
||||
queues: Record<string, unknown>;
|
||||
}>
|
||||
>();
|
||||
const consumersByType = new Map<
|
||||
string,
|
||||
Array<{
|
||||
processorId: string;
|
||||
processorName: string;
|
||||
connectionName: string;
|
||||
queues: Record<string, unknown>;
|
||||
}>
|
||||
>();
|
||||
|
||||
// Collect all processors and their connections from service map + flow class queues
|
||||
const allProcessors = [
|
||||
...Object.keys(flowClass.class || {}).map((name) => ({
|
||||
name,
|
||||
type: "class",
|
||||
baseProcessorName: name.replace(/:\{[^}]+\}$/, ""),
|
||||
flowClassConnections: flowClass.class[name],
|
||||
})),
|
||||
...Object.keys(flowClass.flow || {}).map((name) => ({
|
||||
name,
|
||||
type: "flow",
|
||||
baseProcessorName: name.replace(/:\{[^}]+\}$/, ""),
|
||||
flowClassConnections: flowClass.flow[name],
|
||||
})),
|
||||
];
|
||||
|
||||
allProcessors.forEach(
|
||||
({ name, type, baseProcessorName, flowClassConnections }) => {
|
||||
const processorInfo = serviceMap.processors[baseProcessorName];
|
||||
if (!processorInfo?.connections) return;
|
||||
|
||||
const processorId = `${type}-${name}`;
|
||||
|
||||
processorInfo.connections.forEach((connection) => {
|
||||
const connectionType = connection.type;
|
||||
const connectionKind =
|
||||
serviceMap.connection_types[connectionType]?.kind;
|
||||
|
||||
// Extract queues based on connection kind
|
||||
let queues: Record<string, unknown> = {};
|
||||
|
||||
if (connectionKind === "service") {
|
||||
// For service: look for {connection.name}-request and {connection.name}-response for consumers
|
||||
// For providers: look for request and response
|
||||
if (connection.role === "provides") {
|
||||
queues = {
|
||||
request: flowClassConnections.request,
|
||||
response: flowClassConnections.response,
|
||||
};
|
||||
} else if (connection.role === "consumes") {
|
||||
queues = {
|
||||
request: flowClassConnections[`${connection.name}-request`],
|
||||
response: flowClassConnections[`${connection.name}-response`],
|
||||
};
|
||||
}
|
||||
} else if (connectionKind === "flow") {
|
||||
// For flow: single queue value at connection.name
|
||||
queues = { value: flowClassConnections[connection.name] };
|
||||
} else if (connectionKind === "passive") {
|
||||
// For passive: both consumer and provider use single queue value
|
||||
queues = { value: flowClassConnections[connection.name] };
|
||||
}
|
||||
|
||||
// Only add if we found valid queues
|
||||
if (Object.values(queues).some((q) => q !== undefined)) {
|
||||
if (connection.role === "provides") {
|
||||
if (!providersByType.has(connectionType)) {
|
||||
providersByType.set(connectionType, []);
|
||||
}
|
||||
providersByType.get(connectionType)!.push({
|
||||
processorId,
|
||||
processorName: name,
|
||||
connectionName: connection.name,
|
||||
queues,
|
||||
});
|
||||
} else if (connection.role === "consumes") {
|
||||
if (!consumersByType.has(connectionType)) {
|
||||
consumersByType.set(connectionType, []);
|
||||
}
|
||||
consumersByType.get(connectionType)!.push({
|
||||
processorId,
|
||||
processorName: name,
|
||||
connectionName: connection.name,
|
||||
queues,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
// Create edges by matching providers and consumers using the three algorithms
|
||||
consumersByType.forEach((consumers, connectionType) => {
|
||||
const providers = providersByType.get(connectionType) || [];
|
||||
const connectionKind = serviceMap.connection_types[connectionType]?.kind;
|
||||
|
||||
if (connectionKind === "passive") {
|
||||
// Passive connections - no special handling needed
|
||||
}
|
||||
|
||||
consumers.forEach((consumer) => {
|
||||
providers.forEach((provider) => {
|
||||
// Skip self-connections
|
||||
if (consumer.processorId === provider.processorId) return;
|
||||
|
||||
let isMatch = false;
|
||||
|
||||
if (connectionKind === "service") {
|
||||
// Service: consumer's {connection-name}-request/response = provider's request/response
|
||||
isMatch =
|
||||
consumer.queues.request === provider.queues.request &&
|
||||
consumer.queues.response === provider.queues.response;
|
||||
} else if (connectionKind === "flow") {
|
||||
// Flow: same queue value
|
||||
isMatch = consumer.queues.value === provider.queues.value;
|
||||
} else if (connectionKind === "passive") {
|
||||
// Passive: consumer's single queue = provider's single queue
|
||||
isMatch = consumer.queues.value === provider.queues.value;
|
||||
}
|
||||
|
||||
if (isMatch) {
|
||||
// Determine edge styling
|
||||
let edgeColor = "#666666";
|
||||
if (connectionKind === "service") edgeColor = "#2563eb";
|
||||
else if (connectionKind === "flow") edgeColor = "#16a34a";
|
||||
else if (connectionKind === "passive") edgeColor = "#dc2626";
|
||||
|
||||
// For logical flow direction (consumer requests → provider responds):
|
||||
// Use correct logical direction for both animation and layout
|
||||
edges.push({
|
||||
id: `edge-${edgeIndex++}`,
|
||||
source: consumer.processorId, // Logical source (consumer makes request)
|
||||
target: provider.processorId, // Logical target (provider receives request)
|
||||
sourceHandle: `consume-${consumer.connectionName}`, // Consumer's outgoing handle
|
||||
targetHandle: `provide-${provider.connectionName}`, // Provider's incoming handle
|
||||
animated: connectionKind === "service",
|
||||
style: {
|
||||
stroke: edgeColor,
|
||||
strokeWidth: connectionKind === "passive" ? 1 : 2,
|
||||
},
|
||||
label: connectionType,
|
||||
type: connectionKind === "passive" ? "step" : "default",
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Connect interfaces to their implementing processors
|
||||
|
||||
Object.entries(flowClass.interfaces || {}).forEach(
|
||||
([interfaceName, interfaceQueues]) => {
|
||||
const interfaceDefinition = serviceMap.interfaces?.[interfaceName];
|
||||
const interfaceKind = interfaceDefinition?.kind;
|
||||
|
||||
if (!interfaceKind) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Find processors that match this interface's queue pattern
|
||||
allProcessors.forEach(
|
||||
({ name, type, baseProcessorName, flowClassConnections }) => {
|
||||
const processorId = `${type}-${name}`;
|
||||
const processorInfo = serviceMap.processors[baseProcessorName];
|
||||
if (!processorInfo?.connections) return;
|
||||
|
||||
let isMatch = false;
|
||||
let matchingConnection: Connection | null = null;
|
||||
|
||||
if (interfaceKind === "service") {
|
||||
// For service interfaces: check if processor PROVIDES this service
|
||||
const interfaceRequest = (
|
||||
interfaceQueues as Record<string, unknown>
|
||||
).request;
|
||||
const interfaceResponse = (
|
||||
interfaceQueues as Record<string, unknown>
|
||||
).response;
|
||||
|
||||
// Check if this processor provides this service
|
||||
if (
|
||||
flowClassConnections.request === interfaceRequest &&
|
||||
flowClassConnections.response === interfaceResponse
|
||||
) {
|
||||
// Find the service connection that provides
|
||||
matchingConnection = processorInfo.connections.find(
|
||||
(c) => c.role === "provides" && c.name === "service",
|
||||
);
|
||||
if (matchingConnection) {
|
||||
isMatch = true;
|
||||
}
|
||||
}
|
||||
} else if (interfaceKind === "flow") {
|
||||
// For flow interfaces: check if processor PROVIDES this flow
|
||||
const interfaceQueue = interfaceQueues as string;
|
||||
|
||||
// Check only provider connections for matching queue
|
||||
processorInfo.connections.forEach((connection) => {
|
||||
if (connection.role === "provides") {
|
||||
const connectionQueue = flowClassConnections[connection.name];
|
||||
if (connectionQueue === interfaceQueue) {
|
||||
matchingConnection = connection;
|
||||
isMatch = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (isMatch && matchingConnection) {
|
||||
// Create edge from interface to processor
|
||||
const edgeColor =
|
||||
interfaceKind === "service" ? "#8b5cf6" : "#ec4899";
|
||||
|
||||
edges.push({
|
||||
id: `interface-edge-${edgeIndex++}`,
|
||||
source: `interface-${interfaceName}`,
|
||||
target: processorId,
|
||||
sourceHandle: `interface-${interfaceName}`,
|
||||
targetHandle:
|
||||
matchingConnection.role === "provides"
|
||||
? `provide-${matchingConnection.name}`
|
||||
: `consume-${matchingConnection.name}`,
|
||||
animated: true,
|
||||
style: {
|
||||
stroke: edgeColor,
|
||||
strokeWidth: 2,
|
||||
strokeDasharray: "5,5",
|
||||
},
|
||||
label: `implements ${interfaceName}`,
|
||||
type: "default",
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
return edges;
|
||||
};
|
||||
|
||||
export const FlowClassEditorView: React.FC<FlowClassEditorViewProps> = ({
|
||||
flowClassId,
|
||||
onBack,
|
||||
}) => {
|
||||
const { flowClasses } = useFlowClasses();
|
||||
const flowClass = flowClasses.find((fc) => fc.id === flowClassId);
|
||||
|
||||
// Generate nodes and edges from flow class data using useMemo - must be before early return
|
||||
const initialNodes = useMemo(() => {
|
||||
if (!flowClass) return [];
|
||||
const nodes = generateNodesFromFlowClass(flowClass);
|
||||
return nodes;
|
||||
}, [flowClass]);
|
||||
|
||||
const generatedEdges = useMemo(() => {
|
||||
if (!flowClass) return [];
|
||||
const edges = generateEdgesFromFlowClass(flowClass);
|
||||
return edges;
|
||||
}, [flowClass]);
|
||||
|
||||
const layoutedNodes = useMemo(() => {
|
||||
const layouted = applyDagreLayout(initialNodes, generatedEdges);
|
||||
return layouted;
|
||||
}, [initialNodes, generatedEdges]);
|
||||
|
||||
const [nodes, setNodes, onNodesChange] = useNodesState([]);
|
||||
const [edges, setEdges, onEdgesChange] = useEdgesState([]);
|
||||
|
||||
// Update nodes and edges when the data changes
|
||||
useEffect(() => {
|
||||
setNodes(layoutedNodes);
|
||||
}, [layoutedNodes, setNodes]);
|
||||
|
||||
useEffect(() => {
|
||||
setEdges(generatedEdges);
|
||||
}, [generatedEdges, setEdges]);
|
||||
|
||||
const onConnect = useCallback(
|
||||
(params: Connection) => setEdges((eds) => addEdge(params, eds)),
|
||||
[setEdges],
|
||||
);
|
||||
|
||||
if (!flowClass) {
|
||||
return (
|
||||
<Box p={6}>
|
||||
<HStack spacing={4} mb={4}>
|
||||
<Button
|
||||
onClick={onBack}
|
||||
leftIcon={<ArrowLeft size={16} />}
|
||||
variant="ghost"
|
||||
>
|
||||
Back to Flow Classes
|
||||
</Button>
|
||||
</HStack>
|
||||
<Text>Flow class not found.</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box h="100vh" display="flex" flexDirection="column">
|
||||
{/* Header */}
|
||||
<VStack
|
||||
spacing={4}
|
||||
p={6}
|
||||
bg="white"
|
||||
borderBottom="1px"
|
||||
borderColor="gray.200"
|
||||
>
|
||||
<HStack justifyContent="space-between" w="100%">
|
||||
<HStack spacing={4}>
|
||||
<Button
|
||||
onClick={onBack}
|
||||
leftIcon={<ArrowLeft size={16} />}
|
||||
variant="ghost"
|
||||
>
|
||||
Back to Flow Classes
|
||||
</Button>
|
||||
</HStack>
|
||||
<HStack spacing={4}>
|
||||
{/*
|
||||
<Button leftIcon={<FileCode size={16} />} variant="outline" size="sm">
|
||||
Export
|
||||
</Button>
|
||||
<Button leftIcon={<Construction size={16} />} variant="outline" size="sm">
|
||||
Build
|
||||
</Button>
|
||||
*/}
|
||||
</HStack>
|
||||
</HStack>
|
||||
|
||||
<VStack spacing={2} align="start" w="100%">
|
||||
<Heading size="lg">{flowClass.name}</Heading>
|
||||
</VStack>
|
||||
|
||||
<Separator />
|
||||
</VStack>
|
||||
|
||||
{/* ReactFlow Canvas */}
|
||||
<Box flex={1} position="relative">
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
onConnect={onConnect}
|
||||
nodeTypes={nodeTypes}
|
||||
connectionMode={ConnectionMode.Loose}
|
||||
fitView
|
||||
attributionPosition="bottom-left"
|
||||
>
|
||||
<Background />
|
||||
<Controls />
|
||||
<MiniMap
|
||||
nodeColor={(node) => {
|
||||
return node.data?.type === "class" ? "#2563eb" : "#16a34a";
|
||||
}}
|
||||
position="top-right"
|
||||
style={{
|
||||
backgroundColor: "rgba(255, 255, 255, 0.8)",
|
||||
}}
|
||||
/>
|
||||
</ReactFlow>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
125
src/components/flow-classes/FlowClassTable.tsx
Normal file
125
src/components/flow-classes/FlowClassTable.tsx
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
import React from "react";
|
||||
import { getCoreRowModel, useReactTable } from "@tanstack/react-table";
|
||||
import { Box } from "@chakra-ui/react";
|
||||
|
||||
import {
|
||||
useFlowClasses,
|
||||
generateFlowClassId,
|
||||
FlowClassDefinition,
|
||||
} from "@trustgraph/react-state";
|
||||
import { flowClassColumns, FlowClassRow } from "../../model/flow-class-table";
|
||||
|
||||
import SelectableTable from "../common/SelectableTable";
|
||||
import FlowClassActions from "./FlowClassActions";
|
||||
import FlowClassControls from "./FlowClassControls";
|
||||
|
||||
interface FlowClassTableProps {
|
||||
onEdit?: (flowClassId: string) => void;
|
||||
}
|
||||
|
||||
const FlowClassTable: React.FC<FlowClassTableProps> = ({ onEdit }) => {
|
||||
const { flowClasses, createFlowClass, deleteFlowClass, duplicateFlowClass } =
|
||||
useFlowClasses();
|
||||
|
||||
// No need for selected flow class state - actions handled by ActionBar
|
||||
|
||||
// Transform flow classes data if it's in [key, value] format
|
||||
const transformedFlowClasses = React.useMemo(() => {
|
||||
if (!flowClasses || !Array.isArray(flowClasses)) return [];
|
||||
|
||||
// Check if first item is an array [key, value] pair
|
||||
if (
|
||||
flowClasses.length > 0 &&
|
||||
Array.isArray(flowClasses[0]) &&
|
||||
flowClasses[0].length === 2
|
||||
) {
|
||||
return flowClasses.map(([id, flowClass]) => ({
|
||||
id,
|
||||
...(flowClass as Omit<FlowClassDefinition, "id">),
|
||||
}));
|
||||
}
|
||||
|
||||
// Already transformed
|
||||
return flowClasses;
|
||||
}, [flowClasses]);
|
||||
|
||||
// Initialize React Table with flow class data and column configuration
|
||||
const table = useReactTable({
|
||||
data: (transformedFlowClasses as FlowClassRow[]) || [],
|
||||
columns: flowClassColumns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
});
|
||||
|
||||
// Get array of selected flow class IDs from the table selection
|
||||
const selectedRows = table.getSelectedRowModel().rows;
|
||||
const selectedIds = selectedRows.map((row) => row.original.id!);
|
||||
const selectedCount = selectedIds.length;
|
||||
|
||||
const handleEdit = () => {
|
||||
if (selectedRows.length === 1) {
|
||||
const flowClassId = selectedRows[0].original.id;
|
||||
onEdit?.(flowClassId);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDuplicate = async () => {
|
||||
if (selectedRows.length === 1) {
|
||||
const sourceId = selectedRows[0].original.id!;
|
||||
const targetId = generateFlowClassId(`${sourceId}-copy`);
|
||||
|
||||
try {
|
||||
await duplicateFlowClass({ sourceId, targetId });
|
||||
table.setRowSelection({});
|
||||
} catch (error) {
|
||||
console.error("Failed to duplicate flow class:", error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (selectedIds.length > 0) {
|
||||
await Promise.all(selectedIds.map((id) => deleteFlowClass(id)));
|
||||
table.setRowSelection({});
|
||||
}
|
||||
};
|
||||
|
||||
const handleNew = async (id: string) => {
|
||||
const newFlowClass = {
|
||||
class: {},
|
||||
flow: {},
|
||||
interfaces: {},
|
||||
description: "New flow class",
|
||||
tags: [],
|
||||
};
|
||||
|
||||
try {
|
||||
await createFlowClass({ id, flowClass: newFlowClass });
|
||||
} catch (error) {
|
||||
console.error("Failed to create flow class:", error);
|
||||
}
|
||||
};
|
||||
|
||||
// Removed edit panel handlers - no longer needed
|
||||
|
||||
return (
|
||||
<Box position="relative">
|
||||
{/* Action buttons for bulk operations on selected flow classes */}
|
||||
<FlowClassActions
|
||||
selectedCount={selectedCount}
|
||||
onEdit={handleEdit}
|
||||
onDuplicate={handleDuplicate}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
|
||||
{/* Main table displaying flow classes with selection capabilities */}
|
||||
<SelectableTable table={table} />
|
||||
|
||||
{/* Controls for flow class operations - create */}
|
||||
<FlowClassControls onNew={handleNew} />
|
||||
|
||||
{/* No edit panel needed - actions are handled by the ActionBar */}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default FlowClassTable;
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue