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:
elpresidank 2026-04-05 21:08:02 -05:00
commit a8390532f7
310 changed files with 56430 additions and 0 deletions

28
.github/workflows/pull-request.yaml vendored Normal file
View 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
View 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
View 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
View file

@ -0,0 +1,7 @@
*.json
*.yaml
*.md
pulumi
**/node_modules
env
dist

3
.prettierrc Normal file
View file

@ -0,0 +1,3 @@
{
"printWidth": 79
}

836
CODEBOT-INSTRUCTIONS.md Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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.

View 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

View 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

View 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

File diff suppressed because it is too large Load diff

View 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)

View 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

File diff suppressed because it is too large Load diff

150
docs/tech-specs/schema.md Normal file
View 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
View 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

View 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
View 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
View 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

File diff suppressed because it is too large Load diff

67
package.json Normal file
View 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
View 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

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 13 KiB

14
pulumi/Pulumi.dev.yaml Normal file
View 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
View 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
View file

@ -0,0 +1,3 @@
name: config-ui
runtime: nodejs
description: Config UI

348
pulumi/index.ts Normal file
View 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

File diff suppressed because it is too large Load diff

12
pulumi/package.json Normal file
View 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
View 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
View file

@ -0,0 +1,3 @@
aiohttp
pyyaml
wheel

16
src/App.scss Normal file
View 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
View 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;

View 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
View 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
View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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();
});
});

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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>
);
};

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View file

@ -0,0 +1,11 @@
import { Badge } from "@chakra-ui/react";
const RecommendedBadge = () => {
return (
<Badge colorPalette="green" size="sm">
recommended
</Badge>
);
};
export default RecommendedBadge;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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>
);

View 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;

View 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;

View 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;

View 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;

View 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;

View 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();
});
});

View 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();
});
});

View 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");
});
});

View 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("");
});
});

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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>
);
};

View 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