mirror of
https://github.com/soconnor0919/hristudio.git
synced 2026-03-24 03:37:51 -04:00
Compare commits
14 Commits
nao_ros2
...
c16d0d2565
| Author | SHA1 | Date | |
|---|---|---|---|
| c16d0d2565 | |||
| c37acad3d2 | |||
| 0051946bde | |||
| 61af467cc8 | |||
| 60d4fae72c | |||
| 72971a4b49 | |||
| 568d408587 | |||
| 93de577939 | |||
| 85b951f742 | |||
| a8c868ad3f | |||
| 0f535f6887 | |||
| 388897c70e | |||
| 0ec63b3c97 | |||
| 89c44efcf7 |
@@ -1,7 +1,7 @@
|
||||
module.exports = {
|
||||
"extends": [".eslintrc.cjs"],
|
||||
"rules": {
|
||||
extends: [".eslintrc.cjs"],
|
||||
rules: {
|
||||
// Only enable the rule we want to autofix
|
||||
"@typescript-eslint/prefer-nullish-coalescing": "error"
|
||||
}
|
||||
"@typescript-eslint/prefer-nullish-coalescing": "error",
|
||||
},
|
||||
};
|
||||
@@ -19,12 +19,13 @@ HRIStudio addresses critical challenges in HRI research by providing a comprehen
|
||||
- **Hierarchical Structure**: Study → Experiment → Trial → Step → Action
|
||||
- **Visual Experiment Designer**: Drag-and-drop protocol creation with 26+ core blocks
|
||||
- **Plugin System**: Extensible robot platform integration (RESTful, ROS2, custom)
|
||||
- **Consolidated Wizard Interface**: 3-panel design with trial controls, horizontal timeline, and unified robot controls
|
||||
- **Real-time Trial Execution**: Live wizard control with comprehensive data capture
|
||||
- **Role-Based Access**: Administrator, Researcher, Wizard, Observer (4 distinct roles)
|
||||
- **Unified Form Experiences**: 73% code reduction through standardized patterns
|
||||
- **Enterprise DataTables**: Advanced filtering, pagination, export capabilities
|
||||
- **Real-time Trial Execution**: Professional wizard interface with live monitoring
|
||||
- **Mock Robot Integration**: Complete simulation system for development and testing
|
||||
- **Intelligent Control Flow**: Loops with implicit approval, branching logic, parallel execution
|
||||
|
||||
## Quick Start
|
||||
|
||||
@@ -96,6 +97,9 @@ bun dev
|
||||
- Plugin Store with trust levels (Official, Verified, Community)
|
||||
|
||||
#### 3. Adaptive Wizard Interface
|
||||
- **3-Panel Design**: Trial controls (left), horizontal timeline (center), robot control & status (right)
|
||||
- **Horizontal Step Progress**: Non-scrolling step navigation with visual progress indicators
|
||||
- **Consolidated Robot Controls**: Single location for connection, autonomous life, actions, and monitoring
|
||||
- Real-time experiment execution dashboard
|
||||
- Step-by-step guidance for consistent execution
|
||||
- Quick actions for unscripted interventions
|
||||
|
||||
@@ -29,6 +29,18 @@ services:
|
||||
- minio_data:/data
|
||||
command: server --console-address ":9001" /data
|
||||
|
||||
createbuckets:
|
||||
image: minio/mc
|
||||
depends_on:
|
||||
- minio
|
||||
entrypoint: >
|
||||
/bin/sh -c "
|
||||
/usr/bin/mc alias set myminio http://minio:9000 minioadmin minioadmin;
|
||||
/usr/bin/mc mb myminio/hristudio;
|
||||
/usr/bin/mc anonymous set public myminio/hristudio;
|
||||
exit 0;
|
||||
"
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
minio_data:
|
||||
|
||||
@@ -227,6 +227,7 @@ bun dev
|
||||
### **Development Experience**
|
||||
- **Unified Components**: Significant reduction in code duplication
|
||||
- **Panel Architecture**: 90% code sharing between experiment designer and wizard interface
|
||||
- **Consolidated Wizard**: 3-panel design with trial controls, horizontal timeline, and unified robot controls
|
||||
- **Enterprise DataTables**: Advanced filtering, export, pagination
|
||||
- **Comprehensive Testing**: Realistic seed data with complete scenarios
|
||||
- **Developer Friendly**: Clear patterns and extensive documentation
|
||||
@@ -253,11 +254,12 @@ bun dev
|
||||
- ✅ **Database Schema** - 31 tables with comprehensive relationships
|
||||
- ✅ **Authentication** - Role-based access control system
|
||||
- ✅ **Visual Designer** - Repository-based plugin architecture
|
||||
- ✅ **Panel-Based Wizard Interface** - Consistent with experiment designer architecture
|
||||
- ✅ **Consolidated Wizard Interface** - 3-panel design with horizontal timeline and unified robot controls
|
||||
- ✅ **Core Blocks System** - 26 blocks across events, wizard, control, observation
|
||||
- ✅ **Plugin Architecture** - Unified system for core blocks and robot actions
|
||||
- ✅ **Development Environment** - Realistic test data and scenarios
|
||||
- ✅ **NAO6 Robot Integration** - Full ROS2 integration with comprehensive control and monitoring
|
||||
- ✅ **Intelligent Control Flow** - Loops with implicit approval, branching, parallel execution
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -37,6 +37,13 @@ HRIStudio is a web-based platform designed to standardize and improve the reprod
|
||||
- Context-sensitive help and best practice guidance
|
||||
- Automatic generation of robot-specific action components
|
||||
- Parameter configuration with validation
|
||||
- **System Plugins**:
|
||||
- **Core (`hristudio-core`)**: Control flow (loops, branches) and observation blocks
|
||||
- **Wizard (`hristudio-woz`)**: Wizard interactions (speech, text input)
|
||||
- **External Robot Plugins**:
|
||||
- Located in `robot-plugins/` repository (e.g., `nao6-ros2`)
|
||||
- Loaded dynamically per study
|
||||
- Map abstract actions (Say, Walk) to ROS2 topics
|
||||
- **Core Block Categories**:
|
||||
- Events (4): Trial triggers, speech detection, timers, key presses
|
||||
- Wizard Actions (6): Speech, gestures, object handling, rating, notes
|
||||
|
||||
@@ -2,278 +2,366 @@
|
||||
|
||||
## Overview
|
||||
|
||||
The Wizard Interface is a real-time control panel for conducting Human-Robot Interaction (HRI) trials. It provides wizards with comprehensive tools to execute experiment protocols, monitor participant interactions, and control robot behaviors in real-time.
|
||||
The HRIStudio wizard interface provides a comprehensive, real-time trial execution environment with a consolidated 3-panel design optimized for efficient experiment control and monitoring.
|
||||
|
||||
## Key Features
|
||||
## Interface Layout
|
||||
|
||||
- **Real-time Trial Execution**: Live step-by-step protocol execution with WebSocket connectivity
|
||||
- **Robot Status Monitoring**: Battery levels, connection status, sensor readings, and position tracking
|
||||
- **Participant Information**: Demographics, consent status, and session details
|
||||
- **Live Event Logging**: Real-time capture of all trial events and wizard interventions
|
||||
- **Action Controls**: Quick access to common wizard actions and robot commands
|
||||
|
||||
## WebSocket System
|
||||
|
||||
### Connection Setup
|
||||
|
||||
The wizard interface automatically connects to a WebSocket server for real-time communication:
|
||||
|
||||
```typescript
|
||||
// WebSocket URL format
|
||||
wss://your-domain.com/api/websocket?trialId={TRIAL_ID}&token={AUTH_TOKEN}
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||
│ Trial Execution Header │
|
||||
│ [Trial Name] - [Participant] - [Status] │
|
||||
└─────────────────────────────────────────────────────────────────────────────┘
|
||||
┌──────────────┬──────────────────────────────────────┬──────────────────────┐
|
||||
│ │ │ │
|
||||
│ Trial │ Execution Timeline │ Robot Control │
|
||||
│ Control │ │ & Status │
|
||||
│ │ │ │
|
||||
│ ┌──────────┐ │ ┌──┬──┬──┬──┬──┐ Step Progress │ 📷 Camera View │
|
||||
│ │ Start │ │ │✓ │✓ │● │ │ │ │ │
|
||||
│ │ Pause │ │ └──┴──┴──┴──┴──┘ │ Connection: ✓ │
|
||||
│ │ Next Step│ │ │ │
|
||||
│ │ Complete │ │ Current Step: "Greeting" │ Autonomous Life: ON │
|
||||
│ │ Abort │ │ ┌────────────────────────────────┐ │ │
|
||||
│ └──────────┘ │ │ Actions: │ │ Robot Actions: │
|
||||
│ │ │ • Say "Hello" [Run] │ │ ┌──────────────────┐ │
|
||||
│ Progress: │ │ • Wave Hand [Run] │ │ │ Quick Commands │ │
|
||||
│ Step 3/5 │ │ • Wait 2s [Run] │ │ └──────────────────┘ │
|
||||
│ │ └────────────────────────────────┘ │ │
|
||||
│ │ │ Movement Controls │
|
||||
│ │ │ Quick Actions │
|
||||
│ │ │ Status Monitoring │
|
||||
└──────────────┴──────────────────────────────────────┴──────────────────────┘
|
||||
```
|
||||
|
||||
### Message Types
|
||||
## Panel Descriptions
|
||||
|
||||
#### Incoming Messages (from server):
|
||||
- `connection_established` - Connection acknowledgment
|
||||
- `trial_status` - Current trial state and step information
|
||||
- `trial_action_executed` - Confirmation of action execution
|
||||
- `step_changed` - Step transition notifications
|
||||
- `intervention_logged` - Wizard intervention confirmations
|
||||
### Left Panel: Trial Control
|
||||
|
||||
#### Outgoing Messages (to server):
|
||||
- `heartbeat` - Keep connection alive
|
||||
- `trial_action` - Execute trial actions (start, complete, abort)
|
||||
- `wizard_intervention` - Log wizard interventions
|
||||
- `step_transition` - Advance to next step
|
||||
**Purpose**: Manage overall trial flow and progression
|
||||
|
||||
### Example Usage
|
||||
**Features:**
|
||||
- **Start Trial**: Begin experiment execution
|
||||
- **Pause/Resume**: Temporarily halt trial without aborting
|
||||
- **Next Step**: Manually advance to next step (when all actions complete)
|
||||
- **Complete Trial**: Mark trial as successfully completed
|
||||
- **Abort Trial**: Emergency stop with reason logging
|
||||
|
||||
```typescript
|
||||
// Start a trial
|
||||
webSocket.sendMessage({
|
||||
type: "trial_action",
|
||||
data: {
|
||||
actionType: "start_trial",
|
||||
step_index: 0,
|
||||
data: { notes: "Trial started by wizard" }
|
||||
}
|
||||
});
|
||||
**Progress Indicators:**
|
||||
- Current step number (e.g., "Step 3 of 5")
|
||||
- Overall trial status
|
||||
- Time elapsed
|
||||
|
||||
// Log wizard intervention
|
||||
webSocket.sendMessage({
|
||||
type: "wizard_intervention",
|
||||
data: {
|
||||
action_type: "manual_correction",
|
||||
step_index: currentStepIndex,
|
||||
action_data: { message: "Clarified instruction" }
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## Trial Execution Workflow
|
||||
|
||||
### 1. Pre-Trial Setup
|
||||
- Verify participant consent and demographics
|
||||
- Check robot connection and status
|
||||
- Review experiment protocol steps
|
||||
- Confirm WebSocket connectivity
|
||||
|
||||
### 2. Starting a Trial
|
||||
1. Click "Start Trial" button
|
||||
2. System automatically:
|
||||
- Updates trial status to "in_progress"
|
||||
- Records start timestamp
|
||||
- Loads first protocol step
|
||||
- Broadcasts status to all connected clients
|
||||
|
||||
### 3. Step-by-Step Execution
|
||||
- **Current Step Display**: Shows active step details and actions
|
||||
- **Execute Step**: Trigger step-specific actions (robot commands, wizard prompts)
|
||||
- **Next Step**: Advance to subsequent protocol step
|
||||
- **Quick Actions**: Access common wizard interventions
|
||||
|
||||
### 4. Real-time Monitoring
|
||||
- **Robot Status**: Live updates on battery, signal, position, sensors
|
||||
- **Event Log**: Chronological list of all trial events
|
||||
- **Progress Tracking**: Visual progress bar and step completion status
|
||||
|
||||
### 5. Trial Completion
|
||||
- Click "Complete" for successful trials
|
||||
- Click "Abort" for early termination
|
||||
- System records end timestamp and final status
|
||||
- Automatic redirect to analysis page
|
||||
|
||||
## Experiment Data Integration
|
||||
|
||||
### Loading Real Experiment Steps
|
||||
|
||||
The wizard interface automatically loads experiment steps from the database:
|
||||
|
||||
```typescript
|
||||
// Steps are fetched from the experiments API
|
||||
const { data: experimentSteps } = api.experiments.getSteps.useQuery({
|
||||
experimentId: trial.experimentId
|
||||
});
|
||||
```
|
||||
|
||||
### Step Types and Actions
|
||||
|
||||
Supported step types from the experiment designer:
|
||||
- **Wizard Steps**: Manual wizard actions and prompts
|
||||
- **Robot Steps**: Automated robot behaviors and movements
|
||||
- **Parallel Steps**: Concurrent actions executed simultaneously
|
||||
- **Conditional Steps**: Branching logic based on participant responses
|
||||
|
||||
## Seed Data and Testing
|
||||
|
||||
### Available Test Data
|
||||
|
||||
The development database includes realistic test scenarios:
|
||||
|
||||
```bash
|
||||
# Seed the database with test data
|
||||
bun db:seed
|
||||
|
||||
# Default login credentials
|
||||
Email: sean@soconnor.dev
|
||||
Password: password123
|
||||
```
|
||||
|
||||
### Test Experiments
|
||||
|
||||
1. **"Basic Interaction Protocol 1"** (Study: Real-time HRI Coordination)
|
||||
- 3 steps: Introduction, Wait for Response, Robot Feedback
|
||||
- Includes wizard actions and NAO robot integration
|
||||
- Estimated duration: 25 minutes
|
||||
|
||||
2. **"Dialogue Timing Pilot"** (Study: Wizard-of-Oz Dialogue Study)
|
||||
- Multi-step protocol with parallel and conditional actions
|
||||
- Timer-based transitions and conditional follow-ups
|
||||
- Estimated duration: 35 minutes
|
||||
|
||||
### Test Participants
|
||||
|
||||
Pre-loaded participants with complete demographics:
|
||||
- Various age groups (18-65)
|
||||
- Different educational backgrounds
|
||||
- Robot experience levels
|
||||
- Consent already verified
|
||||
|
||||
## Robot Integration
|
||||
|
||||
### Supported Robots
|
||||
|
||||
- **TurtleBot3 Burger**: Navigation and sensing capabilities
|
||||
- **NAO Humanoid Robot**: Speech, gestures, and animations
|
||||
- **Plugin System**: Extensible support for additional platforms
|
||||
|
||||
### Robot Actions
|
||||
|
||||
Common robot actions available during trials:
|
||||
- **Speech**: Text-to-speech with configurable speed/volume
|
||||
- **Movement**: Navigation commands and position control
|
||||
- **Gestures**: Pre-defined animation sequences
|
||||
- **LED Control**: Visual feedback through color changes
|
||||
- **Sensor Readings**: Real-time environmental data
|
||||
|
||||
## Error Handling and Troubleshooting
|
||||
|
||||
### WebSocket Connection Issues
|
||||
|
||||
- **Connection Failed**: Check network connectivity and server status
|
||||
- **Frequent Disconnections**: Verify firewall settings and WebSocket support
|
||||
- **Authentication Errors**: Ensure valid session and proper token generation
|
||||
|
||||
### Trial Execution Problems
|
||||
|
||||
- **Steps Not Loading**: Verify experiment has published steps in database
|
||||
- **Robot Commands Failing**: Check robot connection and plugin configuration
|
||||
- **Progress Not Updating**: Confirm WebSocket messages are being sent/received
|
||||
|
||||
### Recovery Procedures
|
||||
|
||||
1. **Connection Loss**: Interface automatically attempts reconnection with exponential backoff
|
||||
2. **Trial State Mismatch**: Use "Refresh" button to sync with server state
|
||||
3. **Robot Disconnect**: Monitor robot status panel for connection recovery
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Wizard Guidelines
|
||||
|
||||
1. **Pre-Trial Preparation**
|
||||
- Review complete experiment protocol
|
||||
- Test robot functionality before participant arrival
|
||||
- Verify audio/video recording systems
|
||||
|
||||
2. **During Trial Execution**
|
||||
- Follow protocol steps in sequence
|
||||
- Use intervention logging for any deviations
|
||||
- Monitor participant comfort and engagement
|
||||
- Watch robot status for any issues
|
||||
|
||||
3. **Post-Trial Procedures**
|
||||
- Complete trial properly (don't just abort)
|
||||
- Add summary notes about participant behavior
|
||||
- Review event log for any anomalies
|
||||
|
||||
### Technical Considerations
|
||||
|
||||
- **Browser Compatibility**: Use modern browsers with WebSocket support
|
||||
- **Network Requirements**: Stable internet connection for real-time features
|
||||
- **Performance**: Close unnecessary browser tabs during trials
|
||||
- **Backup Plans**: Have manual procedures ready if technology fails
|
||||
|
||||
## Development and Customization
|
||||
|
||||
### Adding Custom Actions
|
||||
|
||||
```typescript
|
||||
// Register new wizard action
|
||||
const handleCustomAction = async (actionData: Record<string, unknown>) => {
|
||||
await logEventMutation.mutateAsync({
|
||||
trialId: trial.id,
|
||||
type: "wizard_action",
|
||||
data: {
|
||||
action_type: "custom_intervention",
|
||||
...actionData
|
||||
}
|
||||
});
|
||||
};
|
||||
```
|
||||
|
||||
### Extending Robot Support
|
||||
|
||||
1. Create new robot plugin following plugin system guidelines
|
||||
2. Define action schemas in plugin configuration
|
||||
3. Implement communication protocol (REST/ROS2/WebSocket)
|
||||
4. Test integration with wizard interface
|
||||
|
||||
### Custom Step Types
|
||||
|
||||
To add new step types:
|
||||
1. Update database schema (`stepTypeEnum`)
|
||||
2. Add type mapping in `WizardInterface.tsx`
|
||||
3. Create step-specific UI components
|
||||
4. Update execution engine logic
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- **Authentication**: All WebSocket connections require valid session tokens
|
||||
- **Authorization**: Role-based access control for trial operations
|
||||
- **Data Protection**: All trial data encrypted in transit and at rest
|
||||
- **Session Management**: Automatic cleanup of expired connections
|
||||
|
||||
## Performance Optimization
|
||||
|
||||
- **Connection Pooling**: Efficient WebSocket connection management
|
||||
- **Event Batching**: Group related events to reduce message overhead
|
||||
- **Selective Updates**: Only broadcast relevant changes to connected clients
|
||||
- **Caching**: Local state management for responsive UI updates
|
||||
**Best Practices:**
|
||||
- Use Pause for participant breaks
|
||||
- Use Abort only for unrecoverable issues
|
||||
- Document abort reasons thoroughly
|
||||
|
||||
---
|
||||
|
||||
## Quick Start Checklist
|
||||
### Center Panel: Execution Timeline
|
||||
|
||||
- [ ] Database seeded with test data (`bun db:seed`)
|
||||
- [ ] Development server running (`bun dev`)
|
||||
- [ ] Logged in as administrator (sean@soconnor.dev)
|
||||
- [ ] Navigate to Trials section
|
||||
- [ ] Select a trial and click "Wizard Control"
|
||||
- [ ] Verify WebSocket connection (green "Real-time" badge)
|
||||
- [ ] Start trial and execute steps
|
||||
- [ ] Monitor robot status and event log
|
||||
- [ ] Complete trial and review analysis page
|
||||
**Purpose**: Visualize experiment flow and execute current step actions
|
||||
|
||||
For additional support, refer to the complete HRIStudio documentation in the `docs/` folder.
|
||||
#### Horizontal Step Progress Bar
|
||||
|
||||
**Features:**
|
||||
- **Visual Overview**: See all steps at a glance
|
||||
- **Step States**:
|
||||
- ✓ **Completed** (green checkmark, primary border)
|
||||
- ● **Current** (highlighted, ring effect)
|
||||
- ○ **Upcoming** (muted appearance)
|
||||
- **Click Navigation**: Jump to any step (unless read-only)
|
||||
- **Horizontal Scroll**: For experiments with many steps
|
||||
|
||||
**Step Card Elements:**
|
||||
- Step number or checkmark icon
|
||||
- Truncated step name (hover for full name)
|
||||
- Visual state indicators
|
||||
|
||||
#### Current Step View
|
||||
|
||||
**Features:**
|
||||
- **Step Header**: Name and description
|
||||
- **Action List**: Vertical timeline of actions
|
||||
- **Action States**:
|
||||
- Completed actions (checkmark)
|
||||
- Active action (highlighted, pulsing)
|
||||
- Pending actions (numbered)
|
||||
- **Action Controls**: Run, Skip, Mark Complete buttons
|
||||
- **Progress Tracking**: Auto-scrolls to active action
|
||||
|
||||
**Action Types:**
|
||||
- **Wizard Actions**: Manual tasks for the wizard
|
||||
- **Robot Actions**: Commands sent to the robot
|
||||
- **Control Flow**: Loops, branches, parallel execution
|
||||
- **Observations**: Data collection and recording
|
||||
|
||||
**Best Practices:**
|
||||
- Review step description before starting
|
||||
- Execute actions in order unless branching
|
||||
- Use Skip sparingly and document reasons
|
||||
- Verify robot action completion before proceeding
|
||||
|
||||
---
|
||||
|
||||
### Right Panel: Robot Control & Status
|
||||
|
||||
**Purpose**: Unified location for all robot-related controls and monitoring
|
||||
|
||||
#### Camera View
|
||||
- Live video feed from robot or environment
|
||||
- Multiple camera support (switchable)
|
||||
- Full-screen mode available
|
||||
|
||||
#### Connection Status
|
||||
- **ROS Bridge**: WebSocket connection state
|
||||
- **Robot Status**: Online/offline indicator
|
||||
- **Reconnect**: Manual reconnection button
|
||||
- **Auto-reconnect**: Automatic retry on disconnect
|
||||
|
||||
#### Autonomous Life Toggle
|
||||
- **Purpose**: Enable/disable robot's autonomous behaviors
|
||||
- **States**:
|
||||
- ON: Robot exhibits idle animations, breathing, awareness
|
||||
- OFF: Robot remains still, fully manual control
|
||||
- **Best Practice**: Turn OFF during precise interactions
|
||||
|
||||
#### Robot Actions Panel
|
||||
- **Quick Commands**: Pre-configured robot actions
|
||||
- **Parameter Controls**: Adjust action parameters
|
||||
- **Execution Status**: Real-time feedback
|
||||
- **Action History**: Recent commands log
|
||||
|
||||
#### Movement Controls
|
||||
- **Directional Pad**: Manual robot navigation
|
||||
- **Speed Control**: Adjust movement speed
|
||||
- **Safety Limits**: Collision detection and boundaries
|
||||
- **Emergency Stop**: Immediate halt
|
||||
|
||||
#### Quick Actions
|
||||
- **Text-to-Speech**: Send custom speech commands
|
||||
- **Preset Gestures**: Common robot gestures
|
||||
- **LED Control**: Change robot LED colors
|
||||
- **Posture Control**: Sit, stand, crouch commands
|
||||
|
||||
#### Status Monitoring
|
||||
- **Battery Level**: Remaining charge percentage
|
||||
- **Joint Status**: Motor temperatures and positions
|
||||
- **Sensor Data**: Ultrasonic, tactile, IMU readings
|
||||
- **Warnings**: Overheating, low battery, errors
|
||||
|
||||
**Best Practices:**
|
||||
- Monitor battery level throughout trial
|
||||
- Check connection status before robot actions
|
||||
- Use Emergency Stop for safety concerns
|
||||
- Document any robot malfunctions
|
||||
|
||||
---
|
||||
|
||||
## Workflow Guide
|
||||
|
||||
### Pre-Trial Setup
|
||||
|
||||
1. **Verify Robot Connection**
|
||||
- Check ROS Bridge status (green indicator)
|
||||
- Test robot responsiveness with quick action
|
||||
- Confirm camera feed is visible
|
||||
|
||||
2. **Review Experiment Protocol**
|
||||
- Scan horizontal step progress bar
|
||||
- Review first step's actions
|
||||
- Prepare any physical materials
|
||||
|
||||
3. **Configure Robot Settings** (Researchers/Admins only)
|
||||
- Click Settings icon in robot panel
|
||||
- Adjust speech, movement, connection parameters
|
||||
- Save configuration for this study
|
||||
|
||||
### During Trial Execution
|
||||
|
||||
1. **Start Trial**
|
||||
- Click "Start" in left panel
|
||||
- First step becomes active
|
||||
- First action highlights in timeline
|
||||
|
||||
2. **Execute Actions**
|
||||
- Follow action sequence in center panel
|
||||
- Use action controls (Run/Skip/Complete)
|
||||
- Monitor robot status in right panel
|
||||
- Document any deviations
|
||||
|
||||
3. **Navigate Steps**
|
||||
- Wait for "Complete Step" button after all actions
|
||||
- Click to advance to next step
|
||||
- Or click step in progress bar to jump
|
||||
|
||||
4. **Handle Issues**
|
||||
- **Participant Question**: Use Pause
|
||||
- **Robot Malfunction**: Check status panel, use Emergency Stop if needed
|
||||
- **Protocol Deviation**: Document in notes, continue or abort as appropriate
|
||||
|
||||
### Post-Trial Completion
|
||||
|
||||
1. **Complete Trial**
|
||||
- Click "Complete Trial" after final step
|
||||
- Confirm completion dialog
|
||||
- Trial marked as completed
|
||||
|
||||
2. **Review Data**
|
||||
- All actions logged with timestamps
|
||||
- Robot commands recorded
|
||||
- Sensor data captured
|
||||
- Video recordings saved
|
||||
|
||||
---
|
||||
|
||||
## Control Flow Features
|
||||
|
||||
### Loops
|
||||
|
||||
**Behavior:**
|
||||
- Loops execute their child actions repeatedly
|
||||
- **Implicit Approval**: Wizard automatically approves each iteration
|
||||
- **Manual Override**: Wizard can skip or abort loop
|
||||
- **Progress Tracking**: Shows current iteration (e.g., "2 of 5")
|
||||
|
||||
**Best Practices:**
|
||||
- Monitor participant engagement during loops
|
||||
- Use abort if participant shows distress
|
||||
- Document any skipped iterations
|
||||
|
||||
### Branches
|
||||
|
||||
**Behavior:**
|
||||
- Conditional execution based on criteria
|
||||
- Wizard selects branch path
|
||||
- Only selected branch actions execute
|
||||
- Other branches are skipped
|
||||
|
||||
**Best Practices:**
|
||||
- Review branch conditions before choosing
|
||||
- Document branch selection rationale
|
||||
- Ensure participant meets branch criteria
|
||||
|
||||
### Parallel Execution
|
||||
|
||||
**Behavior:**
|
||||
- Multiple actions execute simultaneously
|
||||
- All must complete before proceeding
|
||||
- Independent progress tracking
|
||||
|
||||
**Best Practices:**
|
||||
- Monitor all parallel actions
|
||||
- Be prepared for simultaneous robot and wizard tasks
|
||||
- Coordinate timing carefully
|
||||
|
||||
---
|
||||
|
||||
## Keyboard Shortcuts
|
||||
|
||||
| Shortcut | Action |
|
||||
|----------|--------|
|
||||
| `Space` | Start/Pause Trial |
|
||||
| `→` | Next Step |
|
||||
| `Esc` | Abort Trial (with confirmation) |
|
||||
| `R` | Run Current Action |
|
||||
| `S` | Skip Current Action |
|
||||
| `C` | Complete Current Action |
|
||||
| `E` | Emergency Stop Robot |
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Robot Not Responding
|
||||
|
||||
1. Check ROS Bridge connection (right panel)
|
||||
2. Click Reconnect button
|
||||
3. Verify robot is powered on
|
||||
4. Check network connectivity
|
||||
5. Restart ROS Bridge if needed
|
||||
|
||||
### Camera Feed Not Showing
|
||||
|
||||
1. Verify camera is enabled in robot settings
|
||||
2. Check camera topic in ROS
|
||||
3. Refresh browser page
|
||||
4. Check camera hardware connection
|
||||
|
||||
### Actions Not Progressing
|
||||
|
||||
1. Verify action has completed
|
||||
2. Check for error messages
|
||||
3. Manually mark complete if stuck
|
||||
4. Document issue in trial notes
|
||||
|
||||
### Timeline Not Updating
|
||||
|
||||
1. Refresh browser page
|
||||
2. Check WebSocket connection
|
||||
3. Verify trial status is "in_progress"
|
||||
4. Contact administrator if persists
|
||||
|
||||
---
|
||||
|
||||
## Role-Specific Features
|
||||
|
||||
### Wizards
|
||||
- Full trial execution control
|
||||
- Action execution and skipping
|
||||
- Robot control (if permitted)
|
||||
- Real-time decision making
|
||||
|
||||
### Researchers
|
||||
- All wizard features
|
||||
- Robot settings configuration
|
||||
- Trial monitoring and oversight
|
||||
- Protocol deviation approval
|
||||
|
||||
### Observers
|
||||
- **Read-only access**
|
||||
- View trial progress
|
||||
- Monitor robot status
|
||||
- Add annotations (no control)
|
||||
|
||||
### Administrators
|
||||
- All features enabled
|
||||
- System configuration
|
||||
- Plugin management
|
||||
- Emergency overrides
|
||||
|
||||
---
|
||||
|
||||
## Best Practices Summary
|
||||
|
||||
✅ **Before Trial**
|
||||
- Verify all connections
|
||||
- Test robot responsiveness
|
||||
- Review protocol thoroughly
|
||||
|
||||
✅ **During Trial**
|
||||
- Follow action sequence
|
||||
- Monitor robot status continuously
|
||||
- Document deviations immediately
|
||||
- Use Pause for breaks, not Abort
|
||||
|
||||
✅ **After Trial**
|
||||
- Complete trial properly
|
||||
- Review captured data
|
||||
- Document any issues
|
||||
- Debrief with participant
|
||||
|
||||
❌ **Avoid**
|
||||
- Skipping actions without documentation
|
||||
- Ignoring robot warnings
|
||||
- Aborting trials unnecessarily
|
||||
- Deviating from protocol without approval
|
||||
|
||||
---
|
||||
|
||||
## Additional Resources
|
||||
|
||||
- **[Quick Reference](./quick-reference.md)** - Essential commands and shortcuts
|
||||
- **[Implementation Details](./implementation-details.md)** - Technical architecture
|
||||
- **[NAO6 Quick Reference](./nao6-quick-reference.md)** - Robot-specific commands
|
||||
- **[Troubleshooting Guide](./nao6-integration-complete-guide.md)** - Detailed problem resolution
|
||||
45
errors.txt
Normal file
45
errors.txt
Normal file
@@ -0,0 +1,45 @@
|
||||
scripts/seed-dev.ts(762,61): error TS2769: No overload matches this call.
|
||||
Overload 1 of 2, '(value: { experimentId: string | SQL<unknown> | Placeholder<string, any>; duration?: number | SQL<unknown> | Placeholder<string, any> | null | undefined; id?: string | ... 2 more ... | undefined; ... 11 more ...; parameters?: unknown; }): PgInsertBase<...>', gave the following error.
|
||||
Object literal may only specify known properties, and 'currentStepId' does not exist in type '{ experimentId: string | SQL<unknown> | Placeholder<string, any>; duration?: number | SQL<unknown> | Placeholder<string, any> | null | undefined; id?: string | SQL<...> | Placeholder<...> | undefined; ... 11 more ...; parameters?: unknown; }'.
|
||||
Overload 2 of 2, '(values: { experimentId: string | SQL<unknown> | Placeholder<string, any>; duration?: number | SQL<unknown> | Placeholder<string, any> | null | undefined; id?: string | ... 2 more ... | undefined; ... 11 more ...; parameters?: unknown; }[]): PgInsertBase<...>', gave the following error.
|
||||
Object literal may only specify known properties, and 'experimentId' does not exist in type '{ experimentId: string | SQL<unknown> | Placeholder<string, any>; duration?: number | SQL<unknown> | Placeholder<string, any> | null | undefined; id?: string | SQL<...> | Placeholder<...> | undefined; ... 11 more ...; parameters?: unknown; }[]'.
|
||||
src/app/(dashboard)/studies/[id]/trials/[trialId]/analysis/page.tsx(99,13): error TS2322: Type '{ startedAt: Date | null; completedAt: Date | null; eventCount: any; mediaCount: any; media: { url: string; contentType: string; id: string; trialId: string; mediaType: "video" | "audio" | "image" | null; ... 8 more ...; createdAt: Date; }[]; ... 13 more ...; participant: { ...; }; }' is not assignable to type '{ id: string; status: string; startedAt: Date | null; completedAt: Date | null; duration: number | null; experiment: { name: string; studyId: string; }; participant: { participantCode: string; }; eventCount?: number | undefined; mediaCount?: number | undefined; media?: { ...; }[] | undefined; }'.
|
||||
Types of property 'media' are incompatible.
|
||||
Type '{ url: string; contentType: string; id: string; trialId: string; mediaType: "video" | "audio" | "image" | null; storagePath: string; fileSize: number | null; duration: number | null; format: string | null; ... 4 more ...; createdAt: Date; }[]' is not assignable to type '{ url: string; mediaType: string; format?: string | undefined; contentType?: string | undefined; }[]'.
|
||||
Type '{ url: string; contentType: string; id: string; trialId: string; mediaType: "video" | "audio" | "image" | null; storagePath: string; fileSize: number | null; duration: number | null; format: string | null; ... 4 more ...; createdAt: Date; }' is not assignable to type '{ url: string; mediaType: string; format?: string | undefined; contentType?: string | undefined; }'.
|
||||
Types of property 'mediaType' are incompatible.
|
||||
Type 'string | null' is not assignable to type 'string'.
|
||||
Type 'null' is not assignable to type 'string'.
|
||||
src/lib/experiment-designer/__tests__/control-flow.test.ts(2,38): error TS2307: Cannot find module 'vitest' or its corresponding type declarations.
|
||||
src/lib/experiment-designer/__tests__/control-flow.test.ts(64,16): error TS2532: Object is possibly 'undefined'.
|
||||
src/lib/experiment-designer/__tests__/control-flow.test.ts(65,17): error TS2532: Object is possibly 'undefined'.
|
||||
src/lib/experiment-designer/__tests__/control-flow.test.ts(70,16): error TS2532: Object is possibly 'undefined'.
|
||||
src/lib/experiment-designer/__tests__/control-flow.test.ts(71,17): error TS2532: Object is possibly 'undefined'.
|
||||
src/lib/experiment-designer/__tests__/control-flow.test.ts(72,17): error TS2532: Object is possibly 'undefined'.
|
||||
src/lib/experiment-designer/__tests__/control-flow.test.ts(100,16): error TS2532: Object is possibly 'undefined'.
|
||||
src/lib/experiment-designer/__tests__/control-flow.test.ts(101,17): error TS2532: Object is possibly 'undefined'.
|
||||
src/lib/experiment-designer/__tests__/control-flow.test.ts(107,17): error TS2532: Object is possibly 'undefined'.
|
||||
src/lib/experiment-designer/__tests__/control-flow.test.ts(108,17): error TS2532: Object is possibly 'undefined'.
|
||||
src/lib/experiment-designer/__tests__/hashing.test.ts(2,38): error TS2307: Cannot find module 'vitest' or its corresponding type declarations.
|
||||
src/lib/experiment-designer/__tests__/hashing.test.ts(65,19): error TS2741: Property 'category' is missing in type '{ id: string; type: string; name: string; parameters: { message: string; }; source: { kind: "core"; baseActionId: string; }; execution: { transport: "internal"; }; }' but required in type 'ExperimentAction'.
|
||||
src/lib/experiment-designer/__tests__/hashing.test.ts(86,19): error TS2741: Property 'category' is missing in type '{ id: string; type: string; name: string; parameters: { message: string; }; source: { kind: "core"; baseActionId: string; }; execution: { transport: "internal"; }; }' but required in type 'ExperimentAction'.
|
||||
src/lib/experiment-designer/__tests__/store.test.ts(2,50): error TS2307: Cannot find module 'vitest' or its corresponding type declarations.
|
||||
src/lib/experiment-designer/__tests__/store.test.ts(39,16): error TS2532: Object is possibly 'undefined'.
|
||||
src/lib/experiment-designer/__tests__/store.test.ts(58,16): error TS2532: Object is possibly 'undefined'.
|
||||
src/lib/experiment-designer/__tests__/store.test.ts(103,16): error TS2532: Object is possibly 'undefined'.
|
||||
src/lib/experiment-designer/__tests__/store.test.ts(104,16): error TS2532: Object is possibly 'undefined'.
|
||||
src/lib/experiment-designer/__tests__/store.test.ts(107,16): error TS2532: Object is possibly 'undefined'.
|
||||
src/lib/experiment-designer/__tests__/store.test.ts(108,16): error TS2532: Object is possibly 'undefined'.
|
||||
src/lib/experiment-designer/__tests__/store.test.ts(123,15): error TS2741: Property 'category' is missing in type '{ id: string; type: string; name: string; parameters: {}; source: { kind: "core"; baseActionId: string; }; execution: { transport: "internal"; }; }' but required in type 'ExperimentAction'.
|
||||
src/lib/experiment-designer/__tests__/store.test.ts(135,16): error TS18048: 'storedStep' is possibly 'undefined'.
|
||||
src/lib/experiment-designer/__tests__/store.test.ts(136,16): error TS18048: 'storedStep' is possibly 'undefined'.
|
||||
src/lib/experiment-designer/__tests__/store.test.ts(136,16): error TS2532: Object is possibly 'undefined'.
|
||||
src/lib/experiment-designer/__tests__/validators.test.ts(2,38): error TS2307: Cannot find module 'vitest' or its corresponding type declarations.
|
||||
src/lib/experiment-designer/__tests__/validators.test.ts(11,5): error TS2322: Type '"utility"' is not assignable to type 'ActionCategory'.
|
||||
src/lib/experiment-designer/__tests__/validators.test.ts(14,91): error TS2353: Object literal may only specify known properties, and 'default' does not exist in type 'ActionParameter'.
|
||||
src/lib/experiment-designer/__tests__/validators.test.ts(36,20): error TS2532: Object is possibly 'undefined'.
|
||||
src/lib/experiment-designer/__tests__/validators.test.ts(58,17): error TS2353: Object literal may only specify known properties, and 'order' does not exist in type 'ExperimentAction'.
|
||||
src/lib/experiment-designer/__tests__/validators.test.ts(78,17): error TS2353: Object literal may only specify known properties, and 'order' does not exist in type 'ExperimentAction'.
|
||||
src/lib/experiment-designer/__tests__/validators.test.ts(107,17): error TS2353: Object literal may only specify known properties, and 'order' does not exist in type 'ExperimentAction'.
|
||||
src/lib/experiment-designer/__tests__/validators.test.ts(119,20): error TS2532: Object is possibly 'undefined'.
|
||||
src/server/services/__tests__/trial-execution.test.ts(2,56): error TS2307: Cannot find module 'bun:test' or its corresponding type declarations.
|
||||
122
package.json
122
package.json
@@ -23,87 +23,105 @@
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@auth/drizzle-adapter": "^1.10.0",
|
||||
"@aws-sdk/client-s3": "^3.859.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.859.0",
|
||||
"@auth/drizzle-adapter": "^1.11.1",
|
||||
"@aws-sdk/client-s3": "^3.989.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.989.0",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@hookform/resolvers": "^5.1.1",
|
||||
"@radix-ui/react-accordion": "^1.2.11",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.14",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@radix-ui/react-accordion": "^1.2.12",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
"@radix-ui/react-aspect-ratio": "^1.1.8",
|
||||
"@radix-ui/react-avatar": "^1.1.10",
|
||||
"@radix-ui/react-checkbox": "^1.3.2",
|
||||
"@radix-ui/react-collapsible": "^1.1.11",
|
||||
"@radix-ui/react-dialog": "^1.1.14",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.15",
|
||||
"@radix-ui/react-label": "^2.1.7",
|
||||
"@radix-ui/react-progress": "^1.1.7",
|
||||
"@radix-ui/react-scroll-area": "^1.2.9",
|
||||
"@radix-ui/react-select": "^2.2.5",
|
||||
"@radix-ui/react-separator": "^1.1.7",
|
||||
"@radix-ui/react-slider": "^1.3.5",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@radix-ui/react-switch": "^1.2.5",
|
||||
"@radix-ui/react-avatar": "^1.1.11",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-progress": "^1.1.8",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slider": "^1.3.6",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-tooltip": "^1.2.7",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@shadcn/ui": "^0.0.4",
|
||||
"@t3-oss/env-nextjs": "^0.13.8",
|
||||
"@tanstack/react-query": "^5.69.0",
|
||||
"@t3-oss/env-nextjs": "^0.13.10",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@tanstack/react-query": "^5.90.21",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@trpc/client": "^11.0.0",
|
||||
"@trpc/react-query": "^11.0.0",
|
||||
"@trpc/server": "^11.0.0",
|
||||
"@tiptap/extension-table": "^3.20.0",
|
||||
"@tiptap/extension-table-cell": "^3.20.0",
|
||||
"@tiptap/extension-table-header": "^3.20.0",
|
||||
"@tiptap/extension-table-row": "^3.20.0",
|
||||
"@tiptap/pm": "^3.20.0",
|
||||
"@tiptap/react": "^3.20.0",
|
||||
"@tiptap/starter-kit": "^3.20.0",
|
||||
"@trpc/client": "^11.10.0",
|
||||
"@trpc/react-query": "^11.10.0",
|
||||
"@trpc/server": "^11.10.0",
|
||||
"@types/js-cookie": "^3.0.6",
|
||||
"@types/ws": "^8.18.1",
|
||||
"bcryptjs": "^3.0.2",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"driver.js": "^1.4.0",
|
||||
"drizzle-orm": "^0.41.0",
|
||||
"html2pdf.js": "^0.14.0",
|
||||
"js-cookie": "^3.0.5",
|
||||
"lucide-react": "^0.536.0",
|
||||
"minio": "^8.0.6",
|
||||
"next": "^16.1.6",
|
||||
"next-auth": "^5.0.0-beta.29",
|
||||
"next-auth": "^5.0.0-beta.30",
|
||||
"next-themes": "^0.4.6",
|
||||
"postgres": "^3.4.4",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-hook-form": "^7.60.0",
|
||||
"react-resizable-panels": "^3.0.4",
|
||||
"postgres": "^3.4.8",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "^19.2.4",
|
||||
"react-day-picker": "^9.13.2",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-hook-form": "^7.71.1",
|
||||
"react-resizable-panels": "^3.0.6",
|
||||
"react-signature-canvas": "^1.1.0-alpha.2",
|
||||
"react-webcam": "^7.2.0",
|
||||
"server-only": "^0.0.1",
|
||||
"sonner": "^2.0.7",
|
||||
"superjson": "^2.2.1",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"ws": "^8.18.3",
|
||||
"zod": "^4.0.5",
|
||||
"zustand": "^4.5.5"
|
||||
"superjson": "^2.2.6",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tiptap-markdown": "^0.9.0",
|
||||
"uuid": "^13.0.0",
|
||||
"ws": "^8.19.0",
|
||||
"zod": "^4.3.6",
|
||||
"zustand": "^4.5.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3.3.1",
|
||||
"@tailwindcss/postcss": "^4.0.15",
|
||||
"@eslint/eslintrc": "^3.3.3",
|
||||
"@tailwindcss/postcss": "^4.1.18",
|
||||
"@types/bcryptjs": "^3.0.0",
|
||||
"@types/bun": "^1.3.9",
|
||||
"@types/crypto-js": "^4.2.2",
|
||||
"@types/node": "^20.14.10",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"drizzle-kit": "^0.30.5",
|
||||
"eslint": "^9.23.0",
|
||||
"eslint-config-next": "^15.2.3",
|
||||
"@types/node": "^20.19.33",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/uuid": "^11.0.0",
|
||||
"drizzle-kit": "^0.30.6",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-config-next": "^15.5.12",
|
||||
"eslint-plugin-drizzle": "^0.2.3",
|
||||
"postcss": "^8.5.3",
|
||||
"prettier": "^3.5.3",
|
||||
"prettier-plugin-tailwindcss": "^0.6.11",
|
||||
"tailwindcss": "^4.0.15",
|
||||
"postcss": "^8.5.6",
|
||||
"prettier": "^3.8.1",
|
||||
"prettier-plugin-tailwindcss": "^0.6.14",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"ts-unused-exports": "^11.0.1",
|
||||
"tw-animate-css": "^1.3.5",
|
||||
"typescript": "^5.8.2",
|
||||
"typescript-eslint": "^8.27.0"
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.55.0",
|
||||
"vitest": "^4.0.18"
|
||||
},
|
||||
"ct3aMetadata": {
|
||||
"initVersion": "7.39.3"
|
||||
|
||||
Submodule robot-plugins updated: c6310d3144...f83a207b16
46
scripts/archive/check-db-actions.ts
Normal file
46
scripts/archive/check-db-actions.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { drizzle } from "drizzle-orm/postgres-js";
|
||||
import postgres from "postgres";
|
||||
import * as schema from "../../src/server/db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
const connectionString = process.env.DATABASE_URL!;
|
||||
const connection = postgres(connectionString);
|
||||
const db = drizzle(connection, { schema });
|
||||
|
||||
async function main() {
|
||||
console.log("🔍 Checking seeded actions...");
|
||||
|
||||
const actions = await db.query.actions.findMany({
|
||||
where: (actions, { or, eq, like }) =>
|
||||
or(
|
||||
eq(actions.type, "sequence"),
|
||||
eq(actions.type, "parallel"),
|
||||
eq(actions.type, "loop"),
|
||||
eq(actions.type, "branch"),
|
||||
like(actions.type, "hristudio-core%"),
|
||||
),
|
||||
limit: 10,
|
||||
});
|
||||
|
||||
console.log(`Found ${actions.length} control actions.`);
|
||||
|
||||
for (const action of actions) {
|
||||
console.log(`\nAction: ${action.name} (${action.type})`);
|
||||
console.log(`ID: ${action.id}`);
|
||||
// Explicitly log parameters to check structure
|
||||
console.log("Parameters:", JSON.stringify(action.parameters, null, 2));
|
||||
|
||||
const params = action.parameters as any;
|
||||
if (params.children) {
|
||||
console.log(`✅ Has ${params.children.length} children in parameters.`);
|
||||
} else if (params.trueBranch || params.falseBranch) {
|
||||
console.log(`✅ Has branches in parameters.`);
|
||||
} else {
|
||||
console.log(`❌ No children/branches found in parameters.`);
|
||||
}
|
||||
}
|
||||
|
||||
await connection.end();
|
||||
}
|
||||
|
||||
main();
|
||||
66
scripts/archive/check-db.ts
Normal file
66
scripts/archive/check-db.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { drizzle } from "drizzle-orm/postgres-js";
|
||||
import postgres from "postgres";
|
||||
import * as schema from "../../src/server/db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
const connectionString = process.env.DATABASE_URL!;
|
||||
const connection = postgres(connectionString);
|
||||
const db = drizzle(connection, { schema });
|
||||
|
||||
async function main() {
|
||||
console.log("🔍 Checking Database State...");
|
||||
|
||||
// 1. Check Plugin
|
||||
const plugins = await db.query.plugins.findMany();
|
||||
console.log(`\nFound ${plugins.length} plugins.`);
|
||||
|
||||
const expectedKeys = new Set<string>();
|
||||
|
||||
for (const p of plugins) {
|
||||
const meta = p.metadata as any;
|
||||
const defs = p.actionDefinitions as any[];
|
||||
|
||||
console.log(`Plugin [${p.name}] (ID: ${p.id}):`);
|
||||
console.log(` - Robot ID (Column): ${p.robotId}`);
|
||||
console.log(` - Metadata.robotId: ${meta?.robotId}`);
|
||||
console.log(` - Action Definitions: ${defs?.length ?? 0} found.`);
|
||||
|
||||
if (defs && meta?.robotId) {
|
||||
defs.forEach((d) => {
|
||||
const key = `${meta.robotId}.${d.id}`;
|
||||
expectedKeys.add(key);
|
||||
// console.log(` -> Registers: ${key}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Check Actions
|
||||
const actions = await db.query.actions.findMany();
|
||||
console.log(`\nFound ${actions.length} actions.`);
|
||||
let errorCount = 0;
|
||||
for (const a of actions) {
|
||||
// Only check plugin actions
|
||||
if (a.sourceKind === "plugin" || a.type.includes(".")) {
|
||||
const isRegistered = expectedKeys.has(a.type);
|
||||
const pluginIdMatch = a.pluginId === "nao6-ros2";
|
||||
|
||||
console.log(`Action [${a.name}] (Type: ${a.type}):`);
|
||||
console.log(` - PluginId: ${a.pluginId} ${pluginIdMatch ? "✅" : "❌"}`);
|
||||
console.log(` - In Registry: ${isRegistered ? "✅" : "❌"}`);
|
||||
|
||||
if (!isRegistered || !pluginIdMatch) errorCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (errorCount > 0) {
|
||||
console.log(`\n❌ Found ${errorCount} actions with issues.`);
|
||||
} else {
|
||||
console.log(
|
||||
"\n✅ All plugin actions validated successfully against registry definitions.",
|
||||
);
|
||||
}
|
||||
|
||||
await connection.end();
|
||||
}
|
||||
|
||||
main().catch(console.error);
|
||||
60
scripts/archive/debug-experiment-structure.ts
Normal file
60
scripts/archive/debug-experiment-structure.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { db } from "~/server/db";
|
||||
import { steps, experiments, actions } from "~/server/db/schema";
|
||||
import { eq, asc } from "drizzle-orm";
|
||||
|
||||
async function debugExperimentStructure() {
|
||||
console.log("Debugging Experiment Structure for Interactive Storyteller...");
|
||||
|
||||
// Find the experiment
|
||||
const experiment = await db.query.experiments.findFirst({
|
||||
where: eq(experiments.name, "The Interactive Storyteller"),
|
||||
with: {
|
||||
steps: {
|
||||
orderBy: [asc(steps.orderIndex)],
|
||||
with: {
|
||||
actions: {
|
||||
orderBy: [asc(actions.orderIndex)],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!experiment) {
|
||||
console.error("Experiment not found!");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Experiment: ${experiment.name} (${experiment.id})`);
|
||||
console.log(`Plugin Dependencies:`, experiment.pluginDependencies);
|
||||
console.log("---------------------------------------------------");
|
||||
|
||||
experiment.steps.forEach((step, index) => {
|
||||
console.log(`Step ${index + 1}: ${step.name}`);
|
||||
console.log(` ID: ${step.id}`);
|
||||
console.log(` Type: ${step.type}`);
|
||||
console.log(` Order: ${step.orderIndex}`);
|
||||
console.log(` Conditions:`, JSON.stringify(step.conditions, null, 2));
|
||||
|
||||
if (step.actions && step.actions.length > 0) {
|
||||
console.log(` Actions (${step.actions.length}):`);
|
||||
step.actions.forEach((action, actionIndex) => {
|
||||
console.log(` ${actionIndex + 1}. [${action.type}] ${action.name}`);
|
||||
if (action.type === "wizard_wait_for_response") {
|
||||
console.log(
|
||||
` Options:`,
|
||||
JSON.stringify((action.parameters as any)?.options, null, 2),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
console.log("---------------------------------------------------");
|
||||
});
|
||||
}
|
||||
|
||||
debugExperimentStructure()
|
||||
.then(() => process.exit(0))
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
41
scripts/archive/inspect-all-steps.ts
Normal file
41
scripts/archive/inspect-all-steps.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { db } from "../../src/server/db";
|
||||
import { experiments, steps } from "../../src/server/db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
async function inspectAllSteps() {
|
||||
const result = await db.query.experiments.findMany({
|
||||
with: {
|
||||
steps: {
|
||||
orderBy: (steps, { asc }) => [asc(steps.orderIndex)],
|
||||
columns: {
|
||||
id: true,
|
||||
name: true,
|
||||
type: true,
|
||||
orderIndex: true,
|
||||
conditions: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`Found ${result.length} experiments.`);
|
||||
|
||||
for (const exp of result) {
|
||||
console.log(`Experiment: ${exp.name} (${exp.id})`);
|
||||
for (const step of exp.steps) {
|
||||
// Only print conditional steps or the first step
|
||||
if (step.type === "conditional" || step.orderIndex === 0) {
|
||||
console.log(` [${step.orderIndex}] ${step.name} (${step.type})`);
|
||||
console.log(` Conditions: ${JSON.stringify(step.conditions)}`);
|
||||
}
|
||||
}
|
||||
console.log("---");
|
||||
}
|
||||
}
|
||||
|
||||
inspectAllSteps()
|
||||
.then(() => process.exit(0))
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
46
scripts/archive/inspect-branch-action.ts
Normal file
46
scripts/archive/inspect-branch-action.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { db } from "~/server/db";
|
||||
import { actions, steps } from "~/server/db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
async function inspectAction() {
|
||||
console.log("Inspecting Action 10851aef-e720-45fc-ba5e-05e1e3425dab...");
|
||||
|
||||
const actionId = "10851aef-e720-45fc-ba5e-05e1e3425dab";
|
||||
|
||||
const action = await db.query.actions.findFirst({
|
||||
where: eq(actions.id, actionId),
|
||||
with: {
|
||||
step: {
|
||||
columns: {
|
||||
id: true,
|
||||
name: true,
|
||||
type: true,
|
||||
conditions: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!action) {
|
||||
console.error("Action not found!");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("Action Found:");
|
||||
console.log(" Name:", action.name);
|
||||
console.log(" Type:", action.type);
|
||||
console.log(" Parameters:", JSON.stringify(action.parameters, null, 2));
|
||||
|
||||
console.log("Parent Step:");
|
||||
console.log(" ID:", action.step.id);
|
||||
console.log(" Name:", action.step.name);
|
||||
console.log(" Type:", action.step.type);
|
||||
console.log(" Conditions:", JSON.stringify(action.step.conditions, null, 2));
|
||||
}
|
||||
|
||||
inspectAction()
|
||||
.then(() => process.exit(0))
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
29
scripts/archive/inspect-branch-steps.ts
Normal file
29
scripts/archive/inspect-branch-steps.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { db } from "~/server/db";
|
||||
import { steps } from "~/server/db/schema";
|
||||
import { eq, inArray } from "drizzle-orm";
|
||||
|
||||
async function inspectBranchSteps() {
|
||||
console.log("Inspecting Steps 4 (Branch A) and 5 (Branch B)...");
|
||||
|
||||
const step4Id = "3a2dc0b7-a43e-4236-9b9e-f957abafc1e5";
|
||||
const step5Id = "3ae2fe8a-fc5d-4a04-baa5-699a21f19e30";
|
||||
|
||||
const branchSteps = await db.query.steps.findMany({
|
||||
where: inArray(steps.id, [step4Id, step5Id]),
|
||||
});
|
||||
|
||||
branchSteps.forEach((step) => {
|
||||
console.log(`Step: ${step.name} (${step.id})`);
|
||||
console.log(` Type: ${step.type}`);
|
||||
console.log(` Order: ${step.orderIndex}`);
|
||||
console.log(` Conditions:`, JSON.stringify(step.conditions, null, 2));
|
||||
console.log("---------------------------------------------------");
|
||||
});
|
||||
}
|
||||
|
||||
inspectBranchSteps()
|
||||
.then(() => process.exit(0))
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
29
scripts/archive/inspect-db.ts
Normal file
29
scripts/archive/inspect-db.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { db } from "../../src/server/db";
|
||||
import { steps } from "../../src/server/db/schema";
|
||||
import { eq, like } from "drizzle-orm";
|
||||
|
||||
async function checkSteps() {
|
||||
const allSteps = await db
|
||||
.select()
|
||||
.from(steps)
|
||||
.where(like(steps.name, "%Comprehension Check%"));
|
||||
|
||||
console.log("Found steps:", allSteps.length);
|
||||
|
||||
for (const step of allSteps) {
|
||||
console.log("Step Name:", step.name);
|
||||
console.log("Type:", step.type);
|
||||
console.log("Conditions (typeof):", typeof step.conditions);
|
||||
console.log(
|
||||
"Conditions (value):",
|
||||
JSON.stringify(step.conditions, null, 2),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
checkSteps()
|
||||
.then(() => process.exit(0))
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
62
scripts/archive/inspect-step.ts
Normal file
62
scripts/archive/inspect-step.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { db } from "~/server/db";
|
||||
import { steps, experiments } from "~/server/db/schema";
|
||||
import { eq, asc } from "drizzle-orm";
|
||||
|
||||
async function inspectExperimentSteps() {
|
||||
// Find experiment by ID
|
||||
const experiment = await db.query.experiments.findFirst({
|
||||
where: eq(experiments.id, "961d0cb1-256d-4951-8387-6d855a0ae603"),
|
||||
});
|
||||
|
||||
if (!experiment) {
|
||||
console.log("Experiment not found!");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Inspecting Experiment: ${experiment.name} (${experiment.id})`);
|
||||
|
||||
const experimentSteps = await db.query.steps.findMany({
|
||||
where: eq(steps.experimentId, experiment.id),
|
||||
orderBy: [asc(steps.orderIndex)],
|
||||
with: {
|
||||
actions: {
|
||||
orderBy: (actions, { asc }) => [asc(actions.orderIndex)],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`Found ${experimentSteps.length} steps.`);
|
||||
|
||||
for (const step of experimentSteps) {
|
||||
console.log("--------------------------------------------------");
|
||||
console.log(`Step [${step.orderIndex}] ID: ${step.id}`);
|
||||
console.log(`Name: ${step.name}`);
|
||||
console.log(`Type: ${step.type}`);
|
||||
|
||||
if (step.type === "conditional") {
|
||||
console.log("Conditions:", JSON.stringify(step.conditions, null, 2));
|
||||
}
|
||||
|
||||
if (step.actions.length > 0) {
|
||||
console.log("Actions:");
|
||||
for (const action of step.actions) {
|
||||
console.log(
|
||||
` - [${action.orderIndex}] ${action.name} (${action.type})`,
|
||||
);
|
||||
if (action.type === "wizard_wait_for_response") {
|
||||
console.log(
|
||||
" Parameters:",
|
||||
JSON.stringify(action.parameters, null, 2),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
inspectExperimentSteps()
|
||||
.then(() => process.exit(0))
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
32
scripts/archive/inspect-visual-design.ts
Normal file
32
scripts/archive/inspect-visual-design.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { db } from "../../src/server/db";
|
||||
import { experiments } from "../../src/server/db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
async function inspectVisualDesign() {
|
||||
const exps = await db.select().from(experiments);
|
||||
|
||||
for (const exp of exps) {
|
||||
console.log(`Experiment: ${exp.name}`);
|
||||
if (exp.visualDesign) {
|
||||
const vd = exp.visualDesign as any;
|
||||
console.log("Visual Design Steps:");
|
||||
if (vd.steps && Array.isArray(vd.steps)) {
|
||||
vd.steps.forEach((s: any, i: number) => {
|
||||
console.log(` [${i}] ${s.name} (${s.type})`);
|
||||
console.log(` Trigger: ${JSON.stringify(s.trigger)}`);
|
||||
});
|
||||
} else {
|
||||
console.log(" No steps in visualDesign or invalid format.");
|
||||
}
|
||||
} else {
|
||||
console.log(" No visualDesign blob.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
inspectVisualDesign()
|
||||
.then(() => process.exit(0))
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
74
scripts/archive/patch-branch-action-params.ts
Normal file
74
scripts/archive/patch-branch-action-params.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { db } from "~/server/db";
|
||||
import { actions, steps } from "~/server/db/schema";
|
||||
import { eq, sql } from "drizzle-orm";
|
||||
|
||||
async function patchActionParams() {
|
||||
console.log("Patching Action Parameters for Interactive Storyteller...");
|
||||
|
||||
// Target Step IDs
|
||||
const step3CondId = "b9d43f8c-c40c-4f1c-9fdc-9076338d3c85"; // Step 3: Comprehension Check
|
||||
const actionId = "10851aef-e720-45fc-ba5e-05e1e3425dab"; // Action: Wait for Choice
|
||||
|
||||
// 1. Get the authoritative conditions from the Step
|
||||
const step = await db.query.steps.findFirst({
|
||||
where: eq(steps.id, step3CondId),
|
||||
});
|
||||
|
||||
if (!step) {
|
||||
console.error("Step 3 not found!");
|
||||
return;
|
||||
}
|
||||
|
||||
const conditions = step.conditions as any;
|
||||
const richOptions = conditions?.options;
|
||||
|
||||
if (!richOptions || !Array.isArray(richOptions)) {
|
||||
console.error("Step 3 conditions are missing valid options!");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(
|
||||
"Found rich options in Step:",
|
||||
JSON.stringify(richOptions, null, 2),
|
||||
);
|
||||
|
||||
// 2. Get the Action
|
||||
const action = await db.query.actions.findFirst({
|
||||
where: eq(actions.id, actionId),
|
||||
});
|
||||
|
||||
if (!action) {
|
||||
console.error("Action not found!");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(
|
||||
"Current Action Parameters:",
|
||||
JSON.stringify(action.parameters, null, 2),
|
||||
);
|
||||
|
||||
// 3. Patch the Action Parameters
|
||||
// We replace the simple string options with the rich object options
|
||||
const currentParams = action.parameters as any;
|
||||
const newParams = {
|
||||
...currentParams,
|
||||
options: richOptions, // Overwrite with rich options from step
|
||||
};
|
||||
|
||||
console.log("New Action Parameters:", JSON.stringify(newParams, null, 2));
|
||||
|
||||
await db.execute(sql`
|
||||
UPDATE hs_action
|
||||
SET parameters = ${JSON.stringify(newParams)}::jsonb
|
||||
WHERE id = ${actionId}
|
||||
`);
|
||||
|
||||
console.log("Action parameters successfully patched.");
|
||||
}
|
||||
|
||||
patchActionParams()
|
||||
.then(() => process.exit(0))
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
100
scripts/archive/patch-branch-steps.ts
Normal file
100
scripts/archive/patch-branch-steps.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { db } from "~/server/db";
|
||||
import { steps } from "~/server/db/schema";
|
||||
import { eq, sql } from "drizzle-orm";
|
||||
|
||||
async function patchBranchSteps() {
|
||||
console.log("Patching branch steps for Interactive Storyteller...");
|
||||
|
||||
// Target Step IDs (From debug output)
|
||||
const step3CondId = "b9d43f8c-c40c-4f1c-9fdc-9076338d3c85"; // Step 3: Comprehension Check
|
||||
const stepBranchAId = "3a2dc0b7-a43e-4236-9b9e-f957abafc1e5"; // Step 4: Branch A (Correct)
|
||||
const stepBranchBId = "3ae2fe8a-fc5d-4a04-baa5-699a21f19e30"; // Step 5: Branch B (Incorrect)
|
||||
const stepConclusionId = "cc3fbc7f-29e5-45e0-8d46-e80813c54292"; // Step 6: Conclusion
|
||||
|
||||
// Update Step 3 (The Conditional Step)
|
||||
console.log("Updating Step 3 (Conditional Step)...");
|
||||
const step3Conditional = await db.query.steps.findFirst({
|
||||
where: eq(steps.id, step3CondId),
|
||||
});
|
||||
|
||||
if (step3Conditional) {
|
||||
const currentConditions = (step3Conditional.conditions as any) || {};
|
||||
const options = currentConditions.options || [];
|
||||
|
||||
// Patch options to point to real step IDs
|
||||
const newOptions = options.map((opt: any) => {
|
||||
if (opt.value === "Correct") return { ...opt, nextStepId: stepBranchAId };
|
||||
if (opt.value === "Incorrect")
|
||||
return { ...opt, nextStepId: stepBranchBId };
|
||||
return opt;
|
||||
});
|
||||
|
||||
const newConditions = { ...currentConditions, options: newOptions };
|
||||
|
||||
await db.execute(sql`
|
||||
UPDATE hs_step
|
||||
SET conditions = ${JSON.stringify(newConditions)}::jsonb
|
||||
WHERE id = ${step3CondId}
|
||||
`);
|
||||
console.log("Step 3 (Conditional) updated links.");
|
||||
} else {
|
||||
console.log("Step 3 (Conditional) not found.");
|
||||
}
|
||||
|
||||
// Update Step 4 (Branch A)
|
||||
console.log("Updating Step 4 (Branch A)...");
|
||||
/*
|
||||
Note: We already patched Step 4 in previous run but under wrong assumption?
|
||||
Let's re-patch to be safe.
|
||||
Debug output showed ID: 3a2dc0b7-a43e-4236-9b9e-f957abafc1e5
|
||||
It should jump to Conclusion (cc3fbc7f...)
|
||||
*/
|
||||
const stepBranchA = await db.query.steps.findFirst({
|
||||
where: eq(steps.id, stepBranchAId),
|
||||
});
|
||||
|
||||
if (stepBranchA) {
|
||||
const currentConditions =
|
||||
(stepBranchA.conditions as Record<string, unknown>) || {};
|
||||
const newConditions = {
|
||||
...currentConditions,
|
||||
nextStepId: stepConclusionId,
|
||||
};
|
||||
|
||||
await db.execute(sql`
|
||||
UPDATE hs_step
|
||||
SET conditions = ${JSON.stringify(newConditions)}::jsonb
|
||||
WHERE id = ${stepBranchAId}
|
||||
`);
|
||||
console.log("Step 4 (Branch A) updated jump target.");
|
||||
}
|
||||
|
||||
// Update Step 5 (Branch B)
|
||||
console.log("Updating Step 5 (Branch B)...");
|
||||
const stepBranchB = await db.query.steps.findFirst({
|
||||
where: eq(steps.id, stepBranchBId),
|
||||
});
|
||||
|
||||
if (stepBranchB) {
|
||||
const currentConditions =
|
||||
(stepBranchB.conditions as Record<string, unknown>) || {};
|
||||
const newConditions = {
|
||||
...currentConditions,
|
||||
nextStepId: stepConclusionId,
|
||||
};
|
||||
|
||||
await db.execute(sql`
|
||||
UPDATE hs_step
|
||||
SET conditions = ${JSON.stringify(newConditions)}::jsonb
|
||||
WHERE id = ${stepBranchBId}
|
||||
`);
|
||||
console.log("Step 5 (Branch B) updated jump target.");
|
||||
}
|
||||
}
|
||||
|
||||
patchBranchSteps()
|
||||
.then(() => process.exit(0))
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
87
scripts/archive/reproduce-hydration.ts
Normal file
87
scripts/archive/reproduce-hydration.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { convertDatabaseToSteps } from "../../src/lib/experiment-designer/block-converter";
|
||||
import { type ExperimentStep } from "../../src/lib/experiment-designer/types";
|
||||
|
||||
// Mock DB Steps (simulating what experimentsRouter returns before conversion)
|
||||
const mockDbSteps = [
|
||||
{
|
||||
id: "step-1",
|
||||
name: "Step 1",
|
||||
type: "wizard",
|
||||
orderIndex: 0,
|
||||
actions: [
|
||||
{
|
||||
id: "seq-1",
|
||||
name: "Test Sequence",
|
||||
type: "sequence",
|
||||
parameters: {
|
||||
children: [
|
||||
{
|
||||
id: "child-1",
|
||||
name: "Child 1",
|
||||
type: "wait",
|
||||
parameters: { duration: 1 },
|
||||
},
|
||||
{
|
||||
id: "child-2",
|
||||
name: "Child 2",
|
||||
type: "wait",
|
||||
parameters: { duration: 2 },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// Mock Store Logic (simulating store.ts)
|
||||
function cloneActions(actions: any[]): any[] {
|
||||
return actions.map((a) => ({
|
||||
...a,
|
||||
children: a.children ? cloneActions(a.children) : undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
function cloneSteps(steps: any[]): any[] {
|
||||
return steps.map((s) => ({
|
||||
...s,
|
||||
actions: cloneActions(s.actions),
|
||||
}));
|
||||
}
|
||||
|
||||
console.log("🔹 Testing Hydration & Cloning...");
|
||||
|
||||
// 1. Convert DB -> Runtime
|
||||
const runtimeSteps = convertDatabaseToSteps(mockDbSteps);
|
||||
const seq = runtimeSteps[0]?.actions[0];
|
||||
|
||||
if (!seq) {
|
||||
console.error("❌ Conversion Failed: Sequence action not found.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`Runtime Children Count: ${seq.children?.length ?? "undefined"}`);
|
||||
|
||||
if (!seq.children || seq.children.length === 0) {
|
||||
console.error("❌ Conversion Failed: Children not hydrated from parameters.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 2. Store Cloning
|
||||
const clonedSteps = cloneSteps(runtimeSteps);
|
||||
const clonedSeq = clonedSteps[0]?.actions[0];
|
||||
|
||||
if (!clonedSeq) {
|
||||
console.error("❌ Cloning Failed: Sequence action lost.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`Cloned Children Count: ${clonedSeq.children?.length ?? "undefined"}`,
|
||||
);
|
||||
|
||||
if (clonedSeq.children?.length === 2) {
|
||||
console.log("✅ SUCCESS: Data hydrated and cloned correctly.");
|
||||
} else {
|
||||
console.error("❌ CLONING FAILED: Children lost during clone.");
|
||||
}
|
||||
136
scripts/archive/seed-control-demo-draft.ts
Normal file
136
scripts/archive/seed-control-demo-draft.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import { drizzle } from "drizzle-orm/postgres-js";
|
||||
import postgres from "postgres";
|
||||
import * as schema from "../../src/server/db/schema";
|
||||
import { sql } from "drizzle-orm";
|
||||
|
||||
// Database connection
|
||||
const connectionString = process.env.DATABASE_URL!;
|
||||
const connection = postgres(connectionString);
|
||||
const db = drizzle(connection, { schema });
|
||||
|
||||
async function main() {
|
||||
console.log("🌱 Seeding 'Control Flow Demo' experiment...");
|
||||
|
||||
try {
|
||||
// 1. Find Admin User & Study
|
||||
const user = await db.query.users.findFirst({
|
||||
where: (users, { eq }) => eq(users.email, "sean@soconnor.dev"),
|
||||
});
|
||||
if (!user)
|
||||
throw new Error(
|
||||
"Admin user 'sean@soconnor.dev' not found. Run seed-dev.ts first.",
|
||||
);
|
||||
|
||||
const study = await db.query.studies.findFirst({
|
||||
where: (studies, { eq }) => eq(studies.name, "Comparative WoZ Study"),
|
||||
});
|
||||
if (!study)
|
||||
throw new Error(
|
||||
"Study 'Comparative WoZ Study' not found. Run seed-dev.ts first.",
|
||||
);
|
||||
|
||||
// Find Robot
|
||||
const robot = await db.query.robots.findFirst({
|
||||
where: (robots, { eq }) => eq(robots.name, "NAO6"),
|
||||
});
|
||||
if (!robot)
|
||||
throw new Error("Robot 'NAO6' not found. Run seed-dev.ts first.");
|
||||
|
||||
// 2. Create Experiment
|
||||
const [experiment] = await db
|
||||
.insert(schema.experiments)
|
||||
.values({
|
||||
studyId: study.id,
|
||||
name: "Control Flow Demo",
|
||||
description:
|
||||
"Demonstration of enhanced control flow actions: Sequence, Parallel, Wait, Loop, Branch.",
|
||||
version: 1,
|
||||
status: "draft",
|
||||
robotId: robot.id,
|
||||
createdBy: user.id,
|
||||
})
|
||||
.returning();
|
||||
|
||||
if (!experiment) throw new Error("Failed to create experiment");
|
||||
console.log(`✅ Created Experiment: ${experiment.id}`);
|
||||
|
||||
// 3. Create Steps
|
||||
|
||||
// Step 1: Sequence & Parallel
|
||||
const [step1] = await db
|
||||
.insert(schema.steps)
|
||||
.values({
|
||||
experimentId: experiment.id,
|
||||
name: "Complex Action Structures",
|
||||
description: "Demonstrating Sequence and Parallel groups",
|
||||
type: "robot",
|
||||
orderIndex: 0,
|
||||
required: true,
|
||||
durationEstimate: 30,
|
||||
})
|
||||
.returning();
|
||||
|
||||
// Step 2: Loops & Waits
|
||||
const [step2] = await db
|
||||
.insert(schema.steps)
|
||||
.values({
|
||||
experimentId: experiment.id,
|
||||
name: "Repetition & Delays",
|
||||
description: "Demonstrating Loop and Wait actions",
|
||||
type: "robot",
|
||||
orderIndex: 1,
|
||||
required: true,
|
||||
durationEstimate: 45,
|
||||
})
|
||||
.returning();
|
||||
|
||||
// 4. Create Actions
|
||||
|
||||
// --- Step 1 Actions ---
|
||||
|
||||
// Top-level Sequence
|
||||
const seqId = `seq-${Date.now()}`;
|
||||
await db.insert(schema.actions).values({
|
||||
stepId: step1!.id,
|
||||
name: "Introduction Sequence",
|
||||
type: "sequence", // New type
|
||||
orderIndex: 0,
|
||||
parameters: {},
|
||||
pluginId: "hristudio-core",
|
||||
category: "control",
|
||||
// No explicit children column in schema?
|
||||
// Wait, schema.actions has "children" as jsonb or it's a recursive relationship?
|
||||
// Let's check schema/types.
|
||||
// Looking at ActionChip, it expects `action.children`.
|
||||
// In DB, it's likely stored in `children` jsonb column if it exists, OR we need to perform recursive inserts if schema supports parentId.
|
||||
// Checking `types.ts` or schema...
|
||||
// Assuming flat list references for now or JSONB.
|
||||
// Wait, `ExperimentAction` in types has `children?: ExperimentAction[]`.
|
||||
// If the DB schema `actions` table handles nesting via `parameters` or specific column, I need to know.
|
||||
// Defaulting to "children" property in JSON parameter if DB doesn't have parentId.
|
||||
// Checking `schema.ts`: "children" is likely NOT a column if I haven't seen it in seed-dev.
|
||||
// However, `ActionChip` uses `action.children`. Steps map to `actions`.
|
||||
// If `actions` table has `parentId` or `children` JSONB.
|
||||
// I will assume `children` is part of the `parameters` or a simplified representation for now,
|
||||
// BUT `FlowWorkspace` treats `action.children` as real actions.
|
||||
// Let's check `schema.ts` quickly.
|
||||
});
|
||||
|
||||
// I need to check schema.actions definition effectively.
|
||||
// For this pass, I will insert them as flat actions since I can't confirm nesting storage without checking schema.
|
||||
// But the user WANTS to see the nesting (Sequence, Parallel).
|
||||
// The `SortableActionChip` renders `action.children`.
|
||||
// The `TrialExecutionEngine` executes `action.children`.
|
||||
// So the data MUST include children.
|
||||
// Most likely `actions` table has a `children` JSONB column.
|
||||
|
||||
// I will insert a Parallel action with embedded children in the `children` column (if it exists) or `parameters`.
|
||||
// Re-reading `scripts/seed-dev.ts`: It doesn't show any nested actions.
|
||||
// I will read `src/server/db/schema.ts` to be sure.
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// I'll write the file AFTER checking schema to ensure I structure the nested actions correctly.
|
||||
254
scripts/archive/seed-control-demo.ts
Normal file
254
scripts/archive/seed-control-demo.ts
Normal file
@@ -0,0 +1,254 @@
|
||||
import { drizzle } from "drizzle-orm/postgres-js";
|
||||
import postgres from "postgres";
|
||||
import * as schema from "../../src/server/db/schema";
|
||||
import { sql } from "drizzle-orm";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
// Database connection
|
||||
const connectionString = process.env.DATABASE_URL!;
|
||||
const connection = postgres(connectionString);
|
||||
const db = drizzle(connection, { schema });
|
||||
|
||||
async function main() {
|
||||
console.log("🌱 Seeding 'Control Flow Demo' experiment...");
|
||||
|
||||
try {
|
||||
// 1. Find Admin User & Study
|
||||
const user = await db.query.users.findFirst({
|
||||
where: (users, { eq }) => eq(users.email, "sean@soconnor.dev"),
|
||||
});
|
||||
if (!user)
|
||||
throw new Error(
|
||||
"Admin user 'sean@soconnor.dev' not found. Run seed-dev.ts first.",
|
||||
);
|
||||
|
||||
const study = await db.query.studies.findFirst({
|
||||
where: (studies, { eq }) => eq(studies.name, "Comparative WoZ Study"),
|
||||
});
|
||||
if (!study)
|
||||
throw new Error(
|
||||
"Study 'Comparative WoZ Study' not found. Run seed-dev.ts first.",
|
||||
);
|
||||
|
||||
// Find Robot
|
||||
const robot = await db.query.robots.findFirst({
|
||||
where: (robots, { eq }) => eq(robots.name, "NAO6"),
|
||||
});
|
||||
if (!robot)
|
||||
throw new Error("Robot 'NAO6' not found. Run seed-dev.ts first.");
|
||||
|
||||
// 2. Create Experiment
|
||||
const [experiment] = await db
|
||||
.insert(schema.experiments)
|
||||
.values({
|
||||
studyId: study.id,
|
||||
name: "Control Flow Demo",
|
||||
description:
|
||||
"Demonstration of enhanced control flow actions: Sequence, Parallel, Wait, Loop, Branch.",
|
||||
version: 1,
|
||||
status: "draft",
|
||||
robotId: robot.id,
|
||||
createdBy: user.id,
|
||||
})
|
||||
.returning();
|
||||
|
||||
if (!experiment) throw new Error("Failed to create experiment");
|
||||
console.log(`✅ Created Experiment: ${experiment.id}`);
|
||||
|
||||
// 3. Create Steps
|
||||
|
||||
// Step 1: Sequence & Parallel
|
||||
const [step1] = await db
|
||||
.insert(schema.steps)
|
||||
.values({
|
||||
experimentId: experiment.id,
|
||||
name: "Complex Action Structures",
|
||||
description: "Demonstrating Sequence and Parallel groups",
|
||||
type: "robot",
|
||||
orderIndex: 0,
|
||||
required: true,
|
||||
durationEstimate: 30,
|
||||
})
|
||||
.returning();
|
||||
if (!step1) throw new Error("Failed to create step1");
|
||||
|
||||
// Step 2: Loops & Waits
|
||||
const [step2] = await db
|
||||
.insert(schema.steps)
|
||||
.values({
|
||||
experimentId: experiment.id,
|
||||
name: "Repetition & Delays",
|
||||
description: "Demonstrating Loop and Wait actions",
|
||||
type: "robot",
|
||||
orderIndex: 1,
|
||||
required: true,
|
||||
durationEstimate: 45,
|
||||
})
|
||||
.returning();
|
||||
if (!step2) throw new Error("Failed to create step2");
|
||||
|
||||
// 4. Create Actions
|
||||
|
||||
// --- Step 1 Actions ---
|
||||
|
||||
// Action 1: Sequence
|
||||
// Note: Nested children are stored in 'children' property of the action object in frontend,
|
||||
// but in DB 'parameters' is the JSONB field.
|
||||
// However, looking at ActionChip, it expects `action.children`.
|
||||
// The `ExperimentAction` type usually has `children` at top level.
|
||||
// If the DB doesn't have it, the API must be hydrating it.
|
||||
// BUT, for the purpose of this seed which writes to DB directly, I will put it in `parameters.children`
|
||||
// and assume the frontend/API handles it or I'm missing a column.
|
||||
// Actually, looking at schema again, `actions` table DOES NOT have children.
|
||||
// So it MUST be in `parameters` or it's not persisted in this table structure yet (which would be a bug, but I'm seeding what exists).
|
||||
// Wait, if I put it in parameters, does the UI read it?
|
||||
// `ActionChip` reads `action.children`.
|
||||
// I will try to put it in `parameters` and distinct `children` property in the JSON passed to `parameters`?
|
||||
// No, `parameters` is jsonb.
|
||||
// I will assume for now that the system expects it in parameters if it's not a column, OR it's not fully supported in DB yet.
|
||||
// I will stick to what the UI likely consumes. `parameters: { children: [...] }`
|
||||
|
||||
// Sequence
|
||||
await db.insert(schema.actions).values({
|
||||
stepId: step1.id,
|
||||
name: "Introduction Sequence",
|
||||
type: "sequence",
|
||||
orderIndex: 0,
|
||||
// Embedding children here to demonstrate.
|
||||
// Real implementation might vary if keys are strictly checked.
|
||||
parameters: {
|
||||
children: [
|
||||
{
|
||||
id: uuidv4(),
|
||||
name: "Say Hello",
|
||||
type: "nao6-ros2.say_text",
|
||||
parameters: { text: "Hello there!" },
|
||||
category: "interaction",
|
||||
},
|
||||
{
|
||||
id: uuidv4(),
|
||||
name: "Wave Hand",
|
||||
type: "nao6-ros2.move_arm",
|
||||
parameters: { arm: "right", action: "wave" },
|
||||
category: "movement",
|
||||
},
|
||||
],
|
||||
},
|
||||
pluginId: "hristudio-core",
|
||||
category: "control",
|
||||
sourceKind: "core",
|
||||
});
|
||||
|
||||
// Parallel
|
||||
await db.insert(schema.actions).values({
|
||||
stepId: step1.id,
|
||||
name: "Parallel Actions",
|
||||
type: "parallel",
|
||||
orderIndex: 1,
|
||||
parameters: {
|
||||
children: [
|
||||
{
|
||||
id: uuidv4(),
|
||||
name: "Say 'Moving'",
|
||||
type: "nao6-ros2.say_text",
|
||||
parameters: { text: "I am moving and talking." },
|
||||
category: "interaction",
|
||||
},
|
||||
{
|
||||
id: uuidv4(),
|
||||
name: "Walk Forward",
|
||||
type: "nao6-ros2.move_to",
|
||||
parameters: { x: 0.5, y: 0 },
|
||||
category: "movement",
|
||||
},
|
||||
],
|
||||
},
|
||||
pluginId: "hristudio-core",
|
||||
category: "control",
|
||||
sourceKind: "core",
|
||||
});
|
||||
|
||||
// --- Step 2 Actions ---
|
||||
|
||||
// Loop
|
||||
await db.insert(schema.actions).values({
|
||||
stepId: step2.id,
|
||||
name: "Repeat Message",
|
||||
type: "loop",
|
||||
orderIndex: 0,
|
||||
parameters: {
|
||||
iterations: 3,
|
||||
children: [
|
||||
{
|
||||
id: uuidv4(),
|
||||
name: "Say 'Echo'",
|
||||
type: "nao6-ros2.say_text",
|
||||
parameters: { text: "Echo" },
|
||||
category: "interaction",
|
||||
},
|
||||
],
|
||||
},
|
||||
pluginId: "hristudio-core",
|
||||
category: "control",
|
||||
sourceKind: "core",
|
||||
});
|
||||
|
||||
// Wait
|
||||
await db.insert(schema.actions).values({
|
||||
stepId: step2.id,
|
||||
name: "Wait 5 Seconds",
|
||||
type: "wait",
|
||||
orderIndex: 1,
|
||||
parameters: { duration: 5 },
|
||||
pluginId: "hristudio-core",
|
||||
category: "control",
|
||||
sourceKind: "core",
|
||||
});
|
||||
|
||||
// Branch (Controls step routing, not nested actions)
|
||||
// Note: Branch configuration is stored in step.trigger.conditions, not action.parameters
|
||||
// The branch action itself is just a marker that this step has conditional routing
|
||||
await db.insert(schema.actions).values({
|
||||
stepId: step2.id,
|
||||
name: "Conditional Routing",
|
||||
type: "branch",
|
||||
orderIndex: 2,
|
||||
parameters: {
|
||||
// Branch actions don't have nested children
|
||||
// Routing is configured at the step level via trigger.conditions
|
||||
},
|
||||
pluginId: "hristudio-core",
|
||||
category: "control",
|
||||
sourceKind: "core",
|
||||
});
|
||||
|
||||
// Update step2 to have conditional routing
|
||||
await db
|
||||
.update(schema.steps)
|
||||
.set({
|
||||
type: "conditional",
|
||||
conditions: {
|
||||
options: [
|
||||
{
|
||||
label: "High Score Path",
|
||||
nextStepIndex: 2, // Would go to a hypothetical step 3
|
||||
variant: "default",
|
||||
},
|
||||
{
|
||||
label: "Low Score Path",
|
||||
nextStepIndex: 0, // Loop back to step 1
|
||||
variant: "outline",
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
.where(sql`id = ${step2.id}`);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
await connection.end();
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
92
scripts/archive/simulate-branch-logic.ts
Normal file
92
scripts/archive/simulate-branch-logic.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
// Mock of the logic in WizardInterface.tsx handleNextStep
|
||||
const steps = [
|
||||
{
|
||||
id: "b9d43f8c-c40c-4f1c-9fdc-9076338d3c85",
|
||||
name: "Step 3 (Conditional)",
|
||||
order: 2,
|
||||
},
|
||||
{
|
||||
id: "3a2dc0b7-a43e-4236-9b9e-f957abafc1e5",
|
||||
name: "Step 4 (Branch A)",
|
||||
order: 3,
|
||||
conditions: {
|
||||
nextStepId: "cc3fbc7f-29e5-45e0-8d46-e80813c54292",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "3ae2fe8a-fc5d-4a04-baa5-699a21f19e30",
|
||||
name: "Step 5 (Branch B)",
|
||||
order: 4,
|
||||
conditions: {
|
||||
nextStepId: "cc3fbc7f-29e5-45e0-8d46-e80813c54292",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "cc3fbc7f-29e5-45e0-8d46-e80813c54292",
|
||||
name: "Step 6 (Conclusion)",
|
||||
order: 5,
|
||||
},
|
||||
];
|
||||
|
||||
function simulateNextStep(currentStepIndex: number) {
|
||||
const currentStep = steps[currentStepIndex];
|
||||
|
||||
if (!currentStep) {
|
||||
console.log("No step found at index:", currentStepIndex);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`\n--- Simulating Next Step from: ${currentStep.name} ---`);
|
||||
console.log("Current Step Data:", JSON.stringify(currentStep, null, 2));
|
||||
|
||||
// Logic from WizardInterface.tsx
|
||||
console.log(
|
||||
"[WizardInterface] Checking for nextStepId condition:",
|
||||
currentStep?.conditions,
|
||||
);
|
||||
|
||||
if (currentStep?.conditions?.nextStepId) {
|
||||
const nextId = String(currentStep.conditions.nextStepId);
|
||||
const targetIndex = steps.findIndex((s) => s.id === nextId);
|
||||
|
||||
console.log(`Target ID: ${nextId}`);
|
||||
console.log(`Target Index Found: ${targetIndex}`);
|
||||
|
||||
if (targetIndex !== -1) {
|
||||
console.log(
|
||||
`[WizardInterface] Condition-based jump to step ${targetIndex} (${nextId})`,
|
||||
);
|
||||
return targetIndex;
|
||||
} else {
|
||||
console.warn(
|
||||
`[WizardInterface] Targeted nextStepId ${nextId} not found in steps list.`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
console.log(
|
||||
"[WizardInterface] No nextStepId found in conditions, proceeding linearly.",
|
||||
);
|
||||
}
|
||||
|
||||
// Default: Linear progression
|
||||
const nextIndex = currentStepIndex + 1;
|
||||
console.log(`Proceeding linearly to index ${nextIndex}`);
|
||||
return nextIndex;
|
||||
}
|
||||
|
||||
// Simulate Branch A (Index 1 in this array, but 3 in real experiment?)
|
||||
// In real exp, Step 3 is index 2. Step 4 (Branch A) is index 3.
|
||||
console.log("Real experiment indices:");
|
||||
// 0: Hook, 1: Narrative, 2: Conditional, 3: Branch A, 4: Branch B, 5: Conclusion
|
||||
const indexStep4 = 1; // logical index in my mock array
|
||||
const indexStep5 = 2; // logical index
|
||||
|
||||
console.log("Testing Branch A Logic:");
|
||||
const resultA = simulateNextStep(indexStep4);
|
||||
if (resultA === 3) console.log("SUCCESS: Branch A jumped to Conclusion");
|
||||
else console.log("FAILURE: Branch A fell through");
|
||||
|
||||
console.log("\nTesting Branch B Logic:");
|
||||
const resultB = simulateNextStep(indexStep5);
|
||||
if (resultB === 3) console.log("SUCCESS: Branch B jumped to Conclusion");
|
||||
else console.log("FAILURE: Branch B fell through");
|
||||
59
scripts/archive/test-converter.ts
Normal file
59
scripts/archive/test-converter.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { convertDatabaseToAction } from "../../src/lib/experiment-designer/block-converter";
|
||||
|
||||
const mockDbAction = {
|
||||
id: "eaf8f85b-75cf-4973-b436-092516b4e0e4",
|
||||
name: "Introduction Sequence",
|
||||
description: null,
|
||||
type: "sequence",
|
||||
orderIndex: 0,
|
||||
parameters: {
|
||||
children: [
|
||||
{
|
||||
id: "75018b01-a964-41fb-8612-940a29020d4a",
|
||||
name: "Say Hello",
|
||||
type: "nao6-ros2.say_text",
|
||||
category: "interaction",
|
||||
parameters: {
|
||||
text: "Hello there!",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "d7020530-6477-41f3-84a4-5141778c93da",
|
||||
name: "Wave Hand",
|
||||
type: "nao6-ros2.move_arm",
|
||||
category: "movement",
|
||||
parameters: {
|
||||
arm: "right",
|
||||
action: "wave",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
timeout: null,
|
||||
retryCount: 0,
|
||||
sourceKind: "core",
|
||||
pluginId: "hristudio-core",
|
||||
pluginVersion: null,
|
||||
robotId: null,
|
||||
baseActionId: null,
|
||||
category: "control",
|
||||
transport: null,
|
||||
ros2: null,
|
||||
rest: null,
|
||||
retryable: null,
|
||||
parameterSchemaRaw: null,
|
||||
};
|
||||
|
||||
console.log("Testing convertDatabaseToAction...");
|
||||
try {
|
||||
const result = convertDatabaseToAction(mockDbAction);
|
||||
console.log("Result:", JSON.stringify(result, null, 2));
|
||||
|
||||
if (result.children && result.children.length > 0) {
|
||||
console.log("✅ Children hydrated successfully.");
|
||||
} else {
|
||||
console.error("❌ Children NOT hydrated.");
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("❌ Error during conversion:", e);
|
||||
}
|
||||
74
scripts/archive/test-trpc-client.ts
Normal file
74
scripts/archive/test-trpc-client.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { appRouter } from "../../src/server/api/root";
|
||||
import { createCallerFactory } from "../../src/server/api/trpc";
|
||||
import { drizzle } from "drizzle-orm/postgres-js";
|
||||
import postgres from "postgres";
|
||||
import * as schema from "../../src/server/db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
// 1. Setup DB Context
|
||||
const connectionString = process.env.DATABASE_URL!;
|
||||
const connection = postgres(connectionString);
|
||||
const db = drizzle(connection, { schema });
|
||||
|
||||
// 2. Mock Session
|
||||
const mockSession = {
|
||||
user: {
|
||||
id: "0e830889-ab46-4b48-a8ba-1d4bd3e665ed", // Admin user ID from seed
|
||||
name: "Sean O'Connor",
|
||||
email: "sean@soconnor.dev",
|
||||
},
|
||||
expires: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// 3. Create Caller
|
||||
const createCaller = createCallerFactory(appRouter);
|
||||
const caller = createCaller({
|
||||
db,
|
||||
session: mockSession as any,
|
||||
headers: new Headers(),
|
||||
});
|
||||
|
||||
async function main() {
|
||||
console.log("🔍 Fetching experiment via TRPC caller...");
|
||||
|
||||
// Get ID first
|
||||
const exp = await db.query.experiments.findFirst({
|
||||
where: eq(schema.experiments.name, "Control Flow Demo"),
|
||||
columns: { id: true },
|
||||
});
|
||||
|
||||
if (!exp) {
|
||||
console.error("❌ Experiment not found");
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await caller.experiments.get({ id: exp.id });
|
||||
|
||||
console.log(`✅ Fetched experiment: ${result.name} (${result.id})`);
|
||||
|
||||
if (result.steps && result.steps.length > 0) {
|
||||
console.log(`Checking ${result.steps.length} steps...`);
|
||||
const actions = result.steps[0]!.actions; // Step 1 actions
|
||||
console.log(`Step 1 has ${actions.length} actions.`);
|
||||
|
||||
actions.forEach((a) => {
|
||||
if (["sequence", "parallel", "loop", "branch"].includes(a.type)) {
|
||||
console.log(`\nAction: ${a.name} (${a.type})`);
|
||||
console.log(
|
||||
`Children Count: ${a.children ? a.children.length : "UNDEFINED"}`,
|
||||
);
|
||||
if (a.children && a.children.length > 0) {
|
||||
console.log(
|
||||
`First Child: ${a.children[0]!.name} (${a.children[0]!.type})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
} else {
|
||||
console.error("❌ No steps found in result.");
|
||||
}
|
||||
|
||||
await connection.end();
|
||||
}
|
||||
|
||||
main();
|
||||
46
scripts/archive/verify-conversion.ts
Normal file
46
scripts/archive/verify-conversion.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { db } from "../../src/server/db";
|
||||
import { experiments } from "../../src/server/db/schema";
|
||||
import { eq, asc } from "drizzle-orm";
|
||||
import { convertDatabaseToSteps } from "../../src/lib/experiment-designer/block-converter";
|
||||
|
||||
async function verifyConversion() {
|
||||
const experiment = await db.query.experiments.findFirst({
|
||||
with: {
|
||||
steps: {
|
||||
orderBy: (steps, { asc }) => [asc(steps.orderIndex)],
|
||||
with: {
|
||||
actions: {
|
||||
orderBy: (actions, { asc }) => [asc(actions.orderIndex)],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!experiment) {
|
||||
console.log("No experiment found");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("Raw DB Steps Count:", experiment.steps.length);
|
||||
const converted = convertDatabaseToSteps(experiment.steps);
|
||||
|
||||
console.log("Converted Steps:");
|
||||
converted.forEach((s, idx) => {
|
||||
console.log(`[${idx}] ${s.name} (${s.type})`);
|
||||
console.log(` Trigger:`, JSON.stringify(s.trigger));
|
||||
if (s.type === "conditional") {
|
||||
console.log(
|
||||
` Conditions populated?`,
|
||||
Object.keys(s.trigger.conditions).length > 0,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
verifyConversion()
|
||||
.then(() => process.exit(0))
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
107
scripts/archive/verify-study-readiness.ts
Normal file
107
scripts/archive/verify-study-readiness.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { drizzle } from "drizzle-orm/postgres-js";
|
||||
import { eq, sql } from "drizzle-orm";
|
||||
import postgres from "postgres";
|
||||
import * as schema from "../../src/server/db/schema";
|
||||
|
||||
const connectionString = process.env.DATABASE_URL!;
|
||||
const client = postgres(connectionString);
|
||||
const db = drizzle(client, { schema });
|
||||
|
||||
async function verify() {
|
||||
console.log("🔍 Verifying Study Readiness...");
|
||||
|
||||
// 1. Check Study
|
||||
const study = await db.query.studies.findFirst({
|
||||
where: eq(schema.studies.name, "Comparative WoZ Study"),
|
||||
});
|
||||
|
||||
if (!study) {
|
||||
console.error("❌ Study 'Comparative WoZ Study' not found.");
|
||||
process.exit(1);
|
||||
}
|
||||
console.log("✅ Study found:", study.name);
|
||||
|
||||
// 2. Check Experiment
|
||||
const experiment = await db.query.experiments.findFirst({
|
||||
where: eq(schema.experiments.name, "The Interactive Storyteller"),
|
||||
});
|
||||
|
||||
if (!experiment) {
|
||||
console.error("❌ Experiment 'The Interactive Storyteller' not found.");
|
||||
process.exit(1);
|
||||
}
|
||||
console.log("✅ Experiment found:", experiment.name);
|
||||
|
||||
// 3. Check Steps
|
||||
const steps = await db.query.steps.findMany({
|
||||
where: eq(schema.steps.experimentId, experiment.id),
|
||||
orderBy: schema.steps.orderIndex,
|
||||
});
|
||||
|
||||
console.log(`ℹ️ Found ${steps.length} steps.`);
|
||||
if (steps.length < 5) {
|
||||
console.error("❌ Expected at least 5 steps, found " + steps.length);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Verify Step Names
|
||||
const expectedSteps = [
|
||||
"The Hook",
|
||||
"The Narrative - Part 1",
|
||||
"Comprehension Check",
|
||||
"Positive Feedback",
|
||||
"Conclusion",
|
||||
];
|
||||
for (let i = 0; i < expectedSteps.length; i++) {
|
||||
const step = steps[i];
|
||||
if (!step) continue;
|
||||
|
||||
if (step.name !== expectedSteps[i]) {
|
||||
console.error(
|
||||
`❌ Step mismatch at index ${i}. Expected '${expectedSteps[i]}', got '${step.name}'`,
|
||||
);
|
||||
} else {
|
||||
console.log(`✅ Step ${i + 1}: ${step.name}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Check Plugin Actions
|
||||
// Find the NAO6 plugin
|
||||
const plugin = await db.query.plugins.findFirst({
|
||||
where: (plugins, { eq, and }) =>
|
||||
and(
|
||||
eq(plugins.name, "NAO6 Robot (Enhanced ROS2 Integration)"),
|
||||
eq(plugins.status, "active"),
|
||||
),
|
||||
});
|
||||
|
||||
if (!plugin) {
|
||||
console.error("❌ NAO6 Plugin not found.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const actions = plugin.actionDefinitions as any[];
|
||||
const requiredActions = [
|
||||
"nao_nod",
|
||||
"nao_shake_head",
|
||||
"nao_bow",
|
||||
"nao_open_hand",
|
||||
];
|
||||
|
||||
for (const actionId of requiredActions) {
|
||||
const found = actions.find((a) => a.id === actionId);
|
||||
if (!found) {
|
||||
console.error(`❌ Plugin missing action: ${actionId}`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`✅ Plugin has action: ${actionId}`);
|
||||
}
|
||||
|
||||
console.log("🎉 Verification Complete: Platform is ready for the study!");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
verify().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
86
scripts/archive/verify-trpc-logic.ts
Normal file
86
scripts/archive/verify-trpc-logic.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { db } from "~/server/db";
|
||||
import { experiments, steps, actions } from "~/server/db/schema";
|
||||
import { eq, asc, desc } from "drizzle-orm";
|
||||
import { convertDatabaseToSteps } from "~/lib/experiment-designer/block-converter";
|
||||
|
||||
async function verifyTrpcLogic() {
|
||||
console.log("Verifying TRPC Logic for Interactive Storyteller...");
|
||||
|
||||
// 1. Simulate the DB Query from experiments.ts
|
||||
const experiment = await db.query.experiments.findFirst({
|
||||
where: eq(experiments.name, "The Interactive Storyteller"),
|
||||
with: {
|
||||
study: {
|
||||
columns: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
createdBy: {
|
||||
columns: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
robot: true,
|
||||
steps: {
|
||||
with: {
|
||||
actions: {
|
||||
orderBy: [asc(actions.orderIndex)],
|
||||
},
|
||||
},
|
||||
orderBy: [asc(steps.orderIndex)],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!experiment) {
|
||||
console.error("Experiment not found!");
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Simulate the Transformation
|
||||
console.log("Transforming DB steps to Designer steps...");
|
||||
const transformedSteps = convertDatabaseToSteps(experiment.steps);
|
||||
|
||||
// 3. Inspect Step 4 (Branch A)
|
||||
// Step index 3 (0-based) is Branch A
|
||||
const branchAStep = transformedSteps[3];
|
||||
|
||||
if (branchAStep) {
|
||||
console.log("Step 4 (Branch A):", branchAStep.name);
|
||||
console.log(" Type:", branchAStep.type);
|
||||
console.log(" Trigger:", JSON.stringify(branchAStep.trigger, null, 2));
|
||||
} else {
|
||||
console.error("Step 4 (Branch A) not found in transformed steps!");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Check conditions specifically
|
||||
const conditions = branchAStep.trigger?.conditions as any;
|
||||
if (conditions?.nextStepId) {
|
||||
console.log(
|
||||
"SUCCESS: nextStepId found in conditions:",
|
||||
conditions.nextStepId,
|
||||
);
|
||||
} else {
|
||||
console.error("FAILURE: nextStepId MISSING in conditions!");
|
||||
}
|
||||
|
||||
// Inspect Step 5 (Branch B) for completeness
|
||||
const branchBStep = transformedSteps[4];
|
||||
if (branchBStep) {
|
||||
console.log("Step 5 (Branch B):", branchBStep.name);
|
||||
console.log(" Trigger:", JSON.stringify(branchBStep.trigger, null, 2));
|
||||
} else {
|
||||
console.warn("Step 5 (Branch B) not found in transformed steps.");
|
||||
}
|
||||
}
|
||||
|
||||
verifyTrpcLogic()
|
||||
.then(() => process.exit(0))
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
25
scripts/get-demo-id.ts
Normal file
25
scripts/get-demo-id.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { drizzle } from "drizzle-orm/postgres-js";
|
||||
import postgres from "postgres";
|
||||
import * as schema from "../src/server/db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
const connectionString = process.env.DATABASE_URL!;
|
||||
const connection = postgres(connectionString);
|
||||
const db = drizzle(connection, { schema });
|
||||
|
||||
async function main() {
|
||||
const exp = await db.query.experiments.findFirst({
|
||||
where: eq(schema.experiments.name, "Control Flow Demo"),
|
||||
columns: { id: true },
|
||||
});
|
||||
|
||||
if (exp) {
|
||||
console.log(`Experiment ID: ${exp.id}`);
|
||||
} else {
|
||||
console.error("Experiment not found");
|
||||
}
|
||||
|
||||
await connection.end();
|
||||
}
|
||||
|
||||
main();
|
||||
25
scripts/get-user-id.ts
Normal file
25
scripts/get-user-id.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { drizzle } from "drizzle-orm/postgres-js";
|
||||
import postgres from "postgres";
|
||||
import * as schema from "../src/server/db/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
const connectionString = process.env.DATABASE_URL!;
|
||||
const connection = postgres(connectionString);
|
||||
const db = drizzle(connection, { schema });
|
||||
|
||||
async function main() {
|
||||
const user = await db.query.users.findFirst({
|
||||
where: eq(schema.users.email, "sean@soconnor.dev"),
|
||||
columns: { id: true },
|
||||
});
|
||||
|
||||
if (user) {
|
||||
console.log(`User ID: ${user.id}`);
|
||||
} else {
|
||||
console.error("User not found");
|
||||
}
|
||||
|
||||
await connection.end();
|
||||
}
|
||||
|
||||
main();
|
||||
1067
scripts/seed-dev.ts
1067
scripts/seed-dev.ts
File diff suppressed because it is too large
Load Diff
@@ -1,90 +0,0 @@
|
||||
import { drizzle } from "drizzle-orm/postgres-js";
|
||||
import { eq, sql } from "drizzle-orm";
|
||||
import postgres from "postgres";
|
||||
import * as schema from "../src/server/db/schema";
|
||||
|
||||
const connectionString = process.env.DATABASE_URL!;
|
||||
const client = postgres(connectionString);
|
||||
const db = drizzle(client, { schema });
|
||||
|
||||
async function verify() {
|
||||
console.log("🔍 Verifying Study Readiness...");
|
||||
|
||||
// 1. Check Study
|
||||
const study = await db.query.studies.findFirst({
|
||||
where: eq(schema.studies.name, "Comparative WoZ Study")
|
||||
});
|
||||
|
||||
if (!study) {
|
||||
console.error("❌ Study 'Comparative WoZ Study' not found.");
|
||||
process.exit(1);
|
||||
}
|
||||
console.log("✅ Study found:", study.name);
|
||||
|
||||
// 2. Check Experiment
|
||||
const experiment = await db.query.experiments.findFirst({
|
||||
where: eq(schema.experiments.name, "The Interactive Storyteller")
|
||||
});
|
||||
|
||||
if (!experiment) {
|
||||
console.error("❌ Experiment 'The Interactive Storyteller' not found.");
|
||||
process.exit(1);
|
||||
}
|
||||
console.log("✅ Experiment found:", experiment.name);
|
||||
|
||||
// 3. Check Steps
|
||||
const steps = await db.query.steps.findMany({
|
||||
where: eq(schema.steps.experimentId, experiment.id),
|
||||
orderBy: schema.steps.orderIndex
|
||||
});
|
||||
|
||||
console.log(`ℹ️ Found ${steps.length} steps.`);
|
||||
if (steps.length < 5) {
|
||||
console.error("❌ Expected at least 5 steps, found " + steps.length);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Verify Step Names
|
||||
const expectedSteps = ["The Hook", "The Narrative - Part 1", "Comprehension Check", "Positive Feedback", "Conclusion"];
|
||||
for (let i = 0; i < expectedSteps.length; i++) {
|
||||
const step = steps[i];
|
||||
if (!step) continue;
|
||||
|
||||
if (step.name !== expectedSteps[i]) {
|
||||
console.error(`❌ Step mismatch at index ${i}. Expected '${expectedSteps[i]}', got '${step.name}'`);
|
||||
} else {
|
||||
console.log(`✅ Step ${i + 1}: ${step.name}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Check Plugin Actions
|
||||
// Find the NAO6 plugin
|
||||
const plugin = await db.query.plugins.findFirst({
|
||||
where: (plugins, { eq, and }) => and(eq(plugins.name, "NAO6 Robot (Enhanced ROS2 Integration)"), eq(plugins.status, "active"))
|
||||
});
|
||||
|
||||
if (!plugin) {
|
||||
console.error("❌ NAO6 Plugin not found.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const actions = plugin.actionDefinitions as any[];
|
||||
const requiredActions = ["nao_nod", "nao_shake_head", "nao_bow", "nao_open_hand"];
|
||||
|
||||
for (const actionId of requiredActions) {
|
||||
const found = actions.find(a => a.id === actionId);
|
||||
if (!found) {
|
||||
console.error(`❌ Plugin missing action: ${actionId}`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`✅ Plugin has action: ${actionId}`);
|
||||
}
|
||||
|
||||
console.log("🎉 Verification Complete: Platform is ready for the study!");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
verify().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -46,7 +46,10 @@ export default function DebugPage() {
|
||||
|
||||
const ROS_BRIDGE_URL = "ws://134.82.159.25:9090";
|
||||
|
||||
const addLog = (message: string, type: "info" | "error" | "success" = "info") => {
|
||||
const addLog = (
|
||||
message: string,
|
||||
type: "info" | "error" | "success" = "info",
|
||||
) => {
|
||||
const timestamp = new Date().toLocaleTimeString();
|
||||
const logEntry = `[${timestamp}] [${type.toUpperCase()}] ${message}`;
|
||||
setLogs((prev) => [...prev.slice(-99), logEntry]);
|
||||
@@ -79,7 +82,9 @@ export default function DebugPage() {
|
||||
setConnectionStatus("connecting");
|
||||
setConnectionAttempts((prev) => prev + 1);
|
||||
setLastError(null);
|
||||
addLog(`Attempting connection #${connectionAttempts + 1} to ${ROS_BRIDGE_URL}`);
|
||||
addLog(
|
||||
`Attempting connection #${connectionAttempts + 1} to ${ROS_BRIDGE_URL}`,
|
||||
);
|
||||
|
||||
const socket = new WebSocket(ROS_BRIDGE_URL);
|
||||
|
||||
@@ -96,7 +101,10 @@ export default function DebugPage() {
|
||||
setConnectionStatus("connected");
|
||||
setRosSocket(socket);
|
||||
setLastError(null);
|
||||
addLog("✅ WebSocket connection established successfully", "success");
|
||||
addLog(
|
||||
"[SUCCESS] WebSocket connection established successfully",
|
||||
"success",
|
||||
);
|
||||
|
||||
// Test basic functionality by advertising
|
||||
const advertiseMsg = {
|
||||
@@ -138,16 +146,20 @@ export default function DebugPage() {
|
||||
addLog(`Connection closed normally: ${event.reason || reason}`);
|
||||
} else if (event.code === 1006) {
|
||||
reason = "Connection lost/refused";
|
||||
setLastError("ROS Bridge server not responding - check if rosbridge_server is running");
|
||||
addLog(`❌ Connection failed: ${reason} (${event.code})`, "error");
|
||||
setLastError(
|
||||
"ROS Bridge server not responding - check if rosbridge_server is running",
|
||||
);
|
||||
addLog(`[ERROR] Connection failed: ${reason} (${event.code})`, "error");
|
||||
} else if (event.code === 1011) {
|
||||
reason = "Server error";
|
||||
setLastError("ROS Bridge server encountered an error");
|
||||
addLog(`❌ Server error: ${reason} (${event.code})`, "error");
|
||||
addLog(`[ERROR] Server error: ${reason} (${event.code})`, "error");
|
||||
} else {
|
||||
reason = `Code ${event.code}`;
|
||||
setLastError(`Connection closed with code ${event.code}: ${event.reason || "No reason given"}`);
|
||||
addLog(`❌ Connection closed: ${reason}`, "error");
|
||||
setLastError(
|
||||
`Connection closed with code ${event.code}: ${event.reason || "No reason given"}`,
|
||||
);
|
||||
addLog(`[ERROR] Connection closed: ${reason}`, "error");
|
||||
}
|
||||
|
||||
if (wasConnected) {
|
||||
@@ -160,7 +172,7 @@ export default function DebugPage() {
|
||||
setConnectionStatus("error");
|
||||
const errorMsg = "WebSocket error occurred";
|
||||
setLastError(errorMsg);
|
||||
addLog(`❌ ${errorMsg}`, "error");
|
||||
addLog(`[ERROR] ${errorMsg}`, "error");
|
||||
console.error("WebSocket error details:", error);
|
||||
};
|
||||
};
|
||||
@@ -298,7 +310,7 @@ export default function DebugPage() {
|
||||
>
|
||||
{connectionStatus.toUpperCase()}
|
||||
</Badge>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
<span className="text-muted-foreground text-sm">
|
||||
Attempts: {connectionAttempts}
|
||||
</span>
|
||||
</div>
|
||||
@@ -306,7 +318,9 @@ export default function DebugPage() {
|
||||
{lastError && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertDescription className="text-sm">{lastError}</AlertDescription>
|
||||
<AlertDescription className="text-sm">
|
||||
{lastError}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
@@ -318,7 +332,9 @@ export default function DebugPage() {
|
||||
className="flex-1"
|
||||
>
|
||||
<Play className="mr-2 h-4 w-4" />
|
||||
{connectionStatus === "connecting" ? "Connecting..." : "Connect"}
|
||||
{connectionStatus === "connecting"
|
||||
? "Connecting..."
|
||||
: "Connect"}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
@@ -479,27 +495,32 @@ export default function DebugPage() {
|
||||
key={index}
|
||||
className={`rounded p-2 text-xs ${
|
||||
msg.direction === "sent"
|
||||
? "bg-blue-50 border-l-2 border-blue-400"
|
||||
: "bg-green-50 border-l-2 border-green-400"
|
||||
? "border-l-2 border-blue-400 bg-blue-50"
|
||||
: "border-l-2 border-green-400 bg-green-50"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="mb-1 flex items-center justify-between">
|
||||
<Badge
|
||||
variant={msg.direction === "sent" ? "default" : "secondary"}
|
||||
variant={
|
||||
msg.direction === "sent" ? "default" : "secondary"
|
||||
}
|
||||
className="text-xs"
|
||||
>
|
||||
{msg.direction === "sent" ? "SENT" : "RECEIVED"}
|
||||
</Badge>
|
||||
<span className="text-muted-foreground">{msg.timestamp}</span>
|
||||
<span className="text-muted-foreground">
|
||||
{msg.timestamp}
|
||||
</span>
|
||||
</div>
|
||||
<pre className="whitespace-pre-wrap text-xs">
|
||||
<pre className="text-xs whitespace-pre-wrap">
|
||||
{JSON.stringify(msg.data, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
))}
|
||||
{messages.length === 0 && (
|
||||
<div className="text-center text-muted-foreground py-8">
|
||||
No messages yet. Connect and send a test message to see data here.
|
||||
<div className="text-muted-foreground py-8 text-center">
|
||||
No messages yet. Connect and send a test message to see data
|
||||
here.
|
||||
</div>
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
|
||||
146
src/app/(dashboard)/help/page.tsx
Normal file
146
src/app/(dashboard)/help/page.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
import {
|
||||
BookOpen,
|
||||
FlaskConical,
|
||||
PlayCircle,
|
||||
BarChart3,
|
||||
HelpCircle,
|
||||
FileText,
|
||||
Video,
|
||||
ExternalLink,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "~/components/ui/card";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { PageLayout } from "~/components/ui/page-layout";
|
||||
import Link from "next/link";
|
||||
|
||||
export default function HelpCenterPage() {
|
||||
const guides = [
|
||||
{
|
||||
title: "Getting Started",
|
||||
description: "Learn the basics of HRIStudio and set up your first study.",
|
||||
icon: BookOpen,
|
||||
items: [
|
||||
{ label: "Platform Overview", href: "#" },
|
||||
{ label: "Creating a New Study", href: "#" },
|
||||
{ label: "Managing Team Members", href: "#" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Designing Experiments",
|
||||
description: "Master the visual experiment designer and flow control.",
|
||||
icon: FlaskConical,
|
||||
items: [
|
||||
{ label: "Using the Visual Designer", href: "#" },
|
||||
{ label: "Robot Actions & Plugins", href: "#" },
|
||||
{ label: "Variables & Logic", href: "#" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Running Trials",
|
||||
description: "Execute experiments and manage Wizard of Oz sessions.",
|
||||
icon: PlayCircle,
|
||||
items: [
|
||||
{ label: "Wizard Interface Guide", href: "#" },
|
||||
{ label: "Participant Management", href: "#" },
|
||||
{ label: "Handling Robot Errors", href: "#" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Analysis & Data",
|
||||
description: "Analyze trial results and export research data.",
|
||||
icon: BarChart3,
|
||||
items: [
|
||||
{ label: "Understanding Analytics", href: "#" },
|
||||
{ label: "Exporting Data (CSV/JSON)", href: "#" },
|
||||
{ label: "Video Replay & Annotation", href: "#" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<PageLayout
|
||||
title="Help Center"
|
||||
description="Documentation, guides, and support for HRIStudio researchers."
|
||||
>
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
{guides.map((guide, index) => (
|
||||
<Card key={index}>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="bg-primary/10 rounded-lg p-2">
|
||||
<guide.icon className="text-primary h-5 w-5" />
|
||||
</div>
|
||||
<CardTitle className="text-xl">{guide.title}</CardTitle>
|
||||
</div>
|
||||
<CardDescription>{guide.description}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ul className="space-y-2">
|
||||
{guide.items.map((item, i) => (
|
||||
<li key={i}>
|
||||
<Button
|
||||
variant="link"
|
||||
className="text-foreground hover:text-primary h-auto justify-start p-0 font-normal"
|
||||
asChild
|
||||
>
|
||||
<Link href={item.href}>
|
||||
<FileText className="text-muted-foreground mr-2 h-4 w-4" />
|
||||
{item.label}
|
||||
</Link>
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-8">
|
||||
<h2 className="mb-4 text-2xl font-bold tracking-tight">
|
||||
Video Tutorials
|
||||
</h2>
|
||||
<div className="grid gap-6 md:grid-cols-3">
|
||||
{[
|
||||
"Introduction to HRIStudio",
|
||||
"Advanced Flow Control",
|
||||
"ROS2 Integration Deep Dive",
|
||||
].map((title, i) => (
|
||||
<Card key={i} className="overflow-hidden">
|
||||
<div className="bg-muted group hover:bg-muted/80 relative flex aspect-video cursor-pointer items-center justify-center transition-colors">
|
||||
<PlayCircle className="text-muted-foreground group-hover:text-primary h-12 w-12 transition-colors" />
|
||||
</div>
|
||||
<CardHeader className="p-4">
|
||||
<CardTitle className="text-base">{title}</CardTitle>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-muted/50 mt-8 rounded-xl border p-8 text-center">
|
||||
<div className="bg-background mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full shadow-sm">
|
||||
<HelpCircle className="text-primary h-6 w-6" />
|
||||
</div>
|
||||
<h2 className="mb-2 text-xl font-semibold">Still need help?</h2>
|
||||
<p className="text-muted-foreground mx-auto mb-6 max-w-md">
|
||||
Contact your system administrator or check the official documentation
|
||||
for technical support.
|
||||
</p>
|
||||
<div className="flex justify-center gap-4">
|
||||
<Button variant="outline" className="gap-2">
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
Official Docs
|
||||
</Button>
|
||||
<Button className="gap-2">Contact Support</Button>
|
||||
</div>
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
@@ -365,7 +365,9 @@ export default function NaoTestPage() {
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Walk Speed: {(walkSpeed[0] ?? 0).toFixed(2)} m/s</Label>
|
||||
<Label>
|
||||
Walk Speed: {(walkSpeed[0] ?? 0).toFixed(2)} m/s
|
||||
</Label>
|
||||
<Slider
|
||||
value={walkSpeed}
|
||||
onValueChange={setWalkSpeed}
|
||||
@@ -375,7 +377,9 @@ export default function NaoTestPage() {
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Turn Speed: {(turnSpeed[0] ?? 0).toFixed(2)} rad/s</Label>
|
||||
<Label>
|
||||
Turn Speed: {(turnSpeed[0] ?? 0).toFixed(2)} rad/s
|
||||
</Label>
|
||||
<Slider
|
||||
value={turnSpeed}
|
||||
onValueChange={setTurnSpeed}
|
||||
@@ -415,7 +419,9 @@ export default function NaoTestPage() {
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Head Yaw: {(headYaw[0] ?? 0).toFixed(2)} rad</Label>
|
||||
<Label>
|
||||
Head Yaw: {(headYaw[0] ?? 0).toFixed(2)} rad
|
||||
</Label>
|
||||
<Slider
|
||||
value={headYaw}
|
||||
onValueChange={setHeadYaw}
|
||||
@@ -425,7 +431,9 @@ export default function NaoTestPage() {
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Head Pitch: {(headPitch[0] ?? 0).toFixed(2)} rad</Label>
|
||||
<Label>
|
||||
Head Pitch: {(headPitch[0] ?? 0).toFixed(2)} rad
|
||||
</Label>
|
||||
<Slider
|
||||
value={headPitch}
|
||||
onValueChange={setHeadPitch}
|
||||
|
||||
@@ -16,8 +16,19 @@ import { Separator } from "~/components/ui/separator";
|
||||
import { PageHeader } from "~/components/ui/page-header";
|
||||
import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider";
|
||||
import { formatRole, getRoleDescription } from "~/lib/auth-client";
|
||||
import { User, Shield, Download, Trash2, ExternalLink } from "lucide-react";
|
||||
import {
|
||||
User,
|
||||
Shield,
|
||||
Download,
|
||||
Trash2,
|
||||
ExternalLink,
|
||||
Lock,
|
||||
UserCog,
|
||||
Mail,
|
||||
Fingerprint,
|
||||
} from "lucide-react";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
interface ProfileUser {
|
||||
id: string;
|
||||
@@ -32,185 +43,169 @@ interface ProfileUser {
|
||||
|
||||
function ProfileContent({ user }: { user: ProfileUser }) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="animate-in fade-in space-y-8 duration-500">
|
||||
<PageHeader
|
||||
title="Profile"
|
||||
description="Manage your account settings and preferences"
|
||||
title={user.name ?? "User"}
|
||||
description={user.email}
|
||||
icon={User}
|
||||
badges={[
|
||||
{ label: `ID: ${user.id}`, variant: "outline" },
|
||||
...(user.roles?.map((r) => ({
|
||||
label: formatRole(r.role),
|
||||
variant: "secondary" as const,
|
||||
})) ?? []),
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
{/* Profile Information */}
|
||||
<div className="space-y-6 lg:col-span-2">
|
||||
{/* Basic Information */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Basic Information</CardTitle>
|
||||
<CardDescription>
|
||||
Your personal account information
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ProfileEditForm
|
||||
user={{
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
image: user.image,
|
||||
}}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="grid grid-cols-1 gap-8 lg:grid-cols-3">
|
||||
{/* Main Content (Left Column) */}
|
||||
<div className="space-y-8 lg:col-span-2">
|
||||
{/* Personal Information */}
|
||||
<section className="space-y-4">
|
||||
<div className="flex items-center gap-2 border-b pb-2">
|
||||
<User className="text-primary h-5 w-5" />
|
||||
<h3 className="text-lg font-semibold">Personal Information</h3>
|
||||
</div>
|
||||
<Card className="border-border/60 hover:border-border transition-colors">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Contact Details</CardTitle>
|
||||
<CardDescription>
|
||||
Update your public profile information
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ProfileEditForm
|
||||
user={{
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
image: user.image,
|
||||
}}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
{/* Password Change */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Password</CardTitle>
|
||||
<CardDescription>Change your account password</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<PasswordChangeForm />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Account Actions */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Account Actions</CardTitle>
|
||||
<CardDescription>Manage your account settings</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h4 className="text-sm font-medium">Export Data</h4>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Download all your research data and account information
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" disabled>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Export Data
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h4 className="text-destructive text-sm font-medium">
|
||||
Delete Account
|
||||
</h4>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Permanently delete your account and all associated data
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="destructive" disabled>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Delete Account
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/* Security */}
|
||||
<section className="space-y-4">
|
||||
<div className="flex items-center gap-2 border-b pb-2">
|
||||
<Lock className="text-primary h-5 w-5" />
|
||||
<h3 className="text-lg font-semibold">Security</h3>
|
||||
</div>
|
||||
<Card className="border-border/60 hover:border-border transition-colors">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Password</CardTitle>
|
||||
<CardDescription>
|
||||
Ensure your account stays secure
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<PasswordChangeForm />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
<div className="space-y-6">
|
||||
{/* User Summary */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Account Summary</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="bg-primary/10 flex h-12 w-12 items-center justify-center rounded-full">
|
||||
<span className="text-primary text-lg font-semibold">
|
||||
{(user.name ?? user.email ?? "U").charAt(0).toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">{user.name ?? "Unnamed User"}</p>
|
||||
<p className="text-muted-foreground text-sm">{user.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div>
|
||||
<p className="mb-2 text-sm font-medium">User ID</p>
|
||||
<p className="text-muted-foreground bg-muted rounded p-2 font-mono text-xs break-all">
|
||||
{user.id}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* System Roles */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Shield className="h-4 w-4" />
|
||||
System Roles
|
||||
</CardTitle>
|
||||
<CardDescription>Your current system permissions</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{user.roles && user.roles.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{user.roles.map((roleInfo, index: number) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-start justify-between"
|
||||
>
|
||||
<div className="flex-1">
|
||||
<div className="mb-1 flex items-center gap-2">
|
||||
<Badge variant="secondary">
|
||||
{/* Sidebar (Right Column) */}
|
||||
<div className="space-y-8">
|
||||
{/* Permissions */}
|
||||
<section className="space-y-4">
|
||||
<div className="flex items-center gap-2 border-b pb-2">
|
||||
<Shield className="text-primary h-5 w-5" />
|
||||
<h3 className="text-lg font-semibold">Permissions</h3>
|
||||
</div>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
{user.roles && user.roles.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{user.roles.map((roleInfo, index) => (
|
||||
<div key={index} className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium">
|
||||
{formatRole(roleInfo.role)}
|
||||
</Badge>
|
||||
</span>
|
||||
<span className="text-muted-foreground bg-muted rounded px-1.5 py-0.5 text-[10px]">
|
||||
Since{" "}
|
||||
{new Date(roleInfo.grantedAt).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
<p className="text-muted-foreground text-xs leading-relaxed">
|
||||
{getRoleDescription(roleInfo.role)}
|
||||
</p>
|
||||
<p className="text-muted-foreground/80 mt-1 text-xs">
|
||||
Granted{" "}
|
||||
{new Date(roleInfo.grantedAt).toLocaleDateString()}
|
||||
</p>
|
||||
{index < (user.roles?.length || 0) - 1 && (
|
||||
<Separator className="my-2" />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<div className="text-muted-foreground mt-4 rounded-lg border border-blue-100 bg-blue-50/50 p-3 text-xs dark:border-blue-900/30 dark:bg-blue-900/10">
|
||||
<div className="text-primary mb-1 flex items-center gap-2 font-medium">
|
||||
<Shield className="h-3 w-3" />
|
||||
<span>Role Management</span>
|
||||
</div>
|
||||
System roles are managed by administrators. Contact
|
||||
support if you need access adjustments.
|
||||
</div>
|
||||
))}
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="text-center">
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Need additional permissions?{" "}
|
||||
<Button
|
||||
variant="link"
|
||||
size="sm"
|
||||
className="h-auto p-0 text-xs"
|
||||
>
|
||||
Contact an administrator
|
||||
<ExternalLink className="ml-1 h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-4 text-center">
|
||||
<p className="text-sm font-medium">No Roles Assigned</p>
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
Contact an admin to request access.
|
||||
</p>
|
||||
<Button size="sm" variant="outline" className="mt-3 w-full">
|
||||
Request Access
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-6 text-center">
|
||||
<div className="bg-muted mx-auto mb-3 flex h-12 w-12 items-center justify-center rounded-lg">
|
||||
<Shield className="text-muted-foreground h-6 w-6" />
|
||||
</div>
|
||||
<p className="mb-1 text-sm font-medium">No Roles Assigned</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
You don't have any system roles yet. Contact an
|
||||
administrator to get access to HRIStudio features.
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
{/* Data & Privacy */}
|
||||
<section className="space-y-4">
|
||||
<div className="flex items-center gap-2 border-b pb-2">
|
||||
<Download className="text-primary h-5 w-5" />
|
||||
<h3 className="text-lg font-semibold">Data & Privacy</h3>
|
||||
</div>
|
||||
|
||||
<Card className="border-destructive/10 bg-destructive/5 overflow-hidden">
|
||||
<CardContent className="space-y-4 pt-6">
|
||||
<div>
|
||||
<h4 className="mb-1 text-sm font-semibold">Export Data</h4>
|
||||
<p className="text-muted-foreground mb-3 text-xs">
|
||||
Download a copy of your personal data.
|
||||
</p>
|
||||
<Button size="sm" variant="outline">
|
||||
Request Access
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="bg-background w-full"
|
||||
disabled
|
||||
>
|
||||
<Download className="mr-2 h-3 w-3" />
|
||||
Download Archive
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Separator className="bg-destructive/10" />
|
||||
<div>
|
||||
<h4 className="text-destructive mb-1 text-sm font-semibold">
|
||||
Delete Account
|
||||
</h4>
|
||||
<p className="text-muted-foreground mb-3 text-xs">
|
||||
This action is irreversible.
|
||||
</p>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
disabled
|
||||
>
|
||||
<Trash2 className="mr-2 h-3 w-3" />
|
||||
Delete Account
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -218,13 +213,21 @@ function ProfileContent({ user }: { user: ProfileUser }) {
|
||||
}
|
||||
|
||||
export default function ProfilePage() {
|
||||
const { data: session } = useSession();
|
||||
const { data: session, status } = useSession();
|
||||
|
||||
useBreadcrumbsEffect([
|
||||
{ label: "Dashboard", href: "/dashboard" },
|
||||
{ label: "Profile" },
|
||||
]);
|
||||
|
||||
if (status === "loading") {
|
||||
return (
|
||||
<div className="text-muted-foreground animate-pulse p-8">
|
||||
Loading profile...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!session?.user) {
|
||||
redirect("/auth/signin");
|
||||
}
|
||||
|
||||
@@ -1,190 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { useParams } from "next/navigation";
|
||||
import { Suspense, useEffect, useState } from "react";
|
||||
import {
|
||||
BarChart3,
|
||||
Search,
|
||||
Filter,
|
||||
PlayCircle,
|
||||
Calendar,
|
||||
Clock,
|
||||
ChevronRight,
|
||||
User,
|
||||
LayoutGrid
|
||||
} from "lucide-react";
|
||||
import { Suspense, useEffect } from "react";
|
||||
import { BarChart3 } from "lucide-react";
|
||||
|
||||
import { PageHeader } from "~/components/ui/page-header";
|
||||
import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider";
|
||||
import { useStudyContext } from "~/lib/study-context";
|
||||
import { useSelectedStudyDetails } from "~/hooks/useSelectedStudyDetails";
|
||||
import { api } from "~/trpc/react";
|
||||
import { TrialAnalysisView } from "~/components/trials/views/TrialAnalysisView";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "~/components/ui/select";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { ScrollArea } from "~/components/ui/scroll-area";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
|
||||
// -- Sub-Components --
|
||||
|
||||
function AnalyticsContent({
|
||||
selectedTrialId,
|
||||
setSelectedTrialId,
|
||||
trialsList,
|
||||
isLoadingList
|
||||
}: {
|
||||
selectedTrialId: string | null;
|
||||
setSelectedTrialId: (id: string | null) => void;
|
||||
trialsList: any[];
|
||||
isLoadingList: boolean;
|
||||
}) {
|
||||
|
||||
// Fetch full details of selected trial
|
||||
const {
|
||||
data: selectedTrial,
|
||||
isLoading: isLoadingTrial,
|
||||
error: trialError
|
||||
} = api.trials.get.useQuery(
|
||||
{ id: selectedTrialId! },
|
||||
{ enabled: !!selectedTrialId }
|
||||
);
|
||||
|
||||
// Transform trial data
|
||||
const trialData = selectedTrial ? {
|
||||
...selectedTrial,
|
||||
startedAt: selectedTrial.startedAt ? new Date(selectedTrial.startedAt) : null,
|
||||
completedAt: selectedTrial.completedAt ? new Date(selectedTrial.completedAt) : null,
|
||||
eventCount: (selectedTrial as any).eventCount,
|
||||
mediaCount: (selectedTrial as any).mediaCount,
|
||||
} : null;
|
||||
|
||||
return (
|
||||
<div className="h-[calc(100vh-140px)] flex flex-col">
|
||||
{selectedTrialId ? (
|
||||
isLoadingTrial ? (
|
||||
<div className="flex-1 flex items-center justify-center bg-background/50 rounded-lg border border-dashed">
|
||||
<div className="flex flex-col items-center gap-2 animate-pulse">
|
||||
<div className="h-8 w-8 rounded-full border-2 border-primary border-t-transparent animate-spin" />
|
||||
<span className="text-muted-foreground text-sm">Loading trial data...</span>
|
||||
</div>
|
||||
</div>
|
||||
) : trialError ? (
|
||||
<div className="flex-1 flex items-center justify-center p-8 bg-background/50 rounded-lg border border-dashed text-destructive">
|
||||
<div className="max-w-md text-center">
|
||||
<h3 className="font-semibold mb-2">Error Loading Trial</h3>
|
||||
<p className="text-sm opacity-80">{trialError.message}</p>
|
||||
<Button variant="outline" className="mt-4" onClick={() => setSelectedTrialId(null)}>
|
||||
Return to Overview
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : trialData ? (
|
||||
<TrialAnalysisView trial={trialData} />
|
||||
) : null
|
||||
) : (
|
||||
<div className="flex-1 bg-background/50 rounded-lg border shadow-sm overflow-hidden">
|
||||
<StudyOverviewPlaceholder
|
||||
trials={trialsList ?? []}
|
||||
onSelect={(id) => setSelectedTrialId(id)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StudyOverviewPlaceholder({ trials, onSelect }: { trials: any[], onSelect: (id: string) => void }) {
|
||||
const recentTrials = [...trials].sort((a, b) =>
|
||||
new Date(b.startedAt || b.createdAt).getTime() - new Date(a.startedAt || a.createdAt).getTime()
|
||||
).slice(0, 5);
|
||||
|
||||
return (
|
||||
<div className="h-full p-8 grid place-items-center bg-muted/5">
|
||||
<div className="max-w-3xl w-full grid gap-8 md:grid-cols-2">
|
||||
{/* Left: Illustration / Prompt */}
|
||||
<div className="flex flex-col justify-center space-y-4">
|
||||
<div className="bg-primary/10 w-16 h-16 rounded-2xl flex items-center justify-center mb-2">
|
||||
<BarChart3 className="h-8 w-8 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold tracking-tight">Analytics & Playback</h2>
|
||||
<CardDescription className="text-base mt-2">
|
||||
Select a session from the top right to review video recordings, event logs, and metrics.
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex gap-4 pt-4">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<PlayCircle className="h-4 w-4" />
|
||||
Feature-rich playback
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Clock className="h-4 w-4" />
|
||||
Synchronized timeline
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: Recent Sessions */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base">Recent Sessions</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<ScrollArea className="h-[240px]">
|
||||
<div className="px-4 pb-4 space-y-1">
|
||||
{recentTrials.map(trial => (
|
||||
<button
|
||||
key={trial.id}
|
||||
onClick={() => onSelect(trial.id)}
|
||||
className="w-full flex items-center gap-3 p-3 rounded-md hover:bg-accent transition-colors text-left group"
|
||||
>
|
||||
<div className="h-8 w-8 rounded-full bg-primary/10 flex items-center justify-center text-xs font-mono font-medium text-primary">
|
||||
{trial.sessionNumber}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-sm truncate">
|
||||
{trial.participant?.participantCode ?? "Unknown"}
|
||||
</span>
|
||||
<span className={`text-[10px] px-1.5 py-0.5 rounded-full border capitalize ${trial.status === 'completed' ? 'bg-green-500/10 text-green-500 border-green-500/20' :
|
||||
trial.status === 'in_progress' ? 'bg-blue-500/10 text-blue-500 border-blue-500/20' :
|
||||
'bg-slate-500/10 text-slate-500 border-slate-500/20'
|
||||
}`}>
|
||||
{trial.status.replace('_', ' ')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground flex items-center gap-2 mt-0.5">
|
||||
<Calendar className="h-3 w-3" />
|
||||
{new Date(trial.createdAt).toLocaleDateString()}
|
||||
<span className="text-muted-foreground top-[1px] relative text-[10px]">•</span>
|
||||
{formatDistanceToNow(new Date(trial.createdAt), { addSuffix: true })}
|
||||
</div>
|
||||
</div>
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground/30 group-hover:text-primary transition-colors" />
|
||||
</button>
|
||||
))}
|
||||
{recentTrials.length === 0 && (
|
||||
<div className="py-8 text-center text-sm text-muted-foreground">
|
||||
No sessions found.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// -- Main Page --
|
||||
import { StudyAnalyticsDataTable } from "~/components/analytics/study-analytics-data-table";
|
||||
|
||||
export default function StudyAnalyticsPage() {
|
||||
const params = useParams();
|
||||
@@ -192,13 +17,10 @@ export default function StudyAnalyticsPage() {
|
||||
const { setSelectedStudyId, selectedStudyId } = useStudyContext();
|
||||
const { study } = useSelectedStudyDetails();
|
||||
|
||||
// State lifted up
|
||||
const [selectedTrialId, setSelectedTrialId] = useState<string | null>(null);
|
||||
|
||||
// Fetch list of trials for the selector
|
||||
const { data: trialsList, isLoading: isLoadingList } = api.trials.list.useQuery(
|
||||
// Fetch list of trials
|
||||
const { data: trialsList, isLoading } = api.trials.list.useQuery(
|
||||
{ studyId, limit: 100 },
|
||||
{ enabled: !!studyId }
|
||||
{ enabled: !!studyId },
|
||||
);
|
||||
|
||||
// Set breadcrumbs
|
||||
@@ -217,50 +39,34 @@ export default function StudyAnalyticsPage() {
|
||||
}, [studyId, selectedStudyId, setSelectedStudyId]);
|
||||
|
||||
return (
|
||||
<div className="h-[calc(100vh-64px)] flex flex-col p-6 gap-6">
|
||||
<div className="flex-none">
|
||||
<PageHeader
|
||||
title="Analytics"
|
||||
description="Analyze trial data and replay sessions"
|
||||
icon={BarChart3}
|
||||
actions={
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Session Selector in Header */}
|
||||
<div className="w-[300px]">
|
||||
<Select
|
||||
value={selectedTrialId ?? "overview"}
|
||||
onValueChange={(val) => setSelectedTrialId(val === "overview" ? null : val)}
|
||||
>
|
||||
<SelectTrigger className="w-full h-9 text-xs">
|
||||
<LayoutGrid className="h-3.5 w-3.5 mr-2 text-muted-foreground" />
|
||||
<SelectValue placeholder="Select Session" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="max-h-[400px]" align="end">
|
||||
<SelectItem value="overview" className="border-b mb-1 pb-1 font-medium text-xs">
|
||||
Show Study Overview
|
||||
</SelectItem>
|
||||
{trialsList?.map((trial) => (
|
||||
<SelectItem key={trial.id} value={trial.id} className="text-xs">
|
||||
<span className="font-mono mr-2 text-muted-foreground">#{trial.sessionNumber}</span>
|
||||
{trial.participant?.participantCode ?? "Unknown"} <span className="text-muted-foreground ml-1">({new Date(trial.createdAt).toLocaleDateString()})</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Analysis"
|
||||
description="View and analyze session data across all trials"
|
||||
icon={BarChart3}
|
||||
/>
|
||||
|
||||
<div className="bg-transparent">
|
||||
<Suspense fallback={<div>Loading analytics...</div>}>
|
||||
{isLoading ? (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<div className="flex animate-pulse flex-col items-center gap-2">
|
||||
<div className="border-primary h-8 w-8 animate-spin rounded-full border-2 border-t-transparent" />
|
||||
<span className="text-muted-foreground text-sm">
|
||||
Loading session data...
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0 bg-transparent">
|
||||
<Suspense fallback={<div>Loading analytics...</div>}>
|
||||
<AnalyticsContent
|
||||
selectedTrialId={selectedTrialId}
|
||||
setSelectedTrialId={setSelectedTrialId}
|
||||
trialsList={trialsList ?? []}
|
||||
isLoadingList={isLoadingList}
|
||||
/>
|
||||
) : (
|
||||
<StudyAnalyticsDataTable
|
||||
data={(trialsList ?? []).map((t) => ({
|
||||
...t,
|
||||
startedAt: t.startedAt ? new Date(t.startedAt) : null,
|
||||
completedAt: t.completedAt ? new Date(t.completedAt) : null,
|
||||
createdAt: new Date(t.createdAt),
|
||||
}))}
|
||||
/>
|
||||
)}
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { DesignerRoot } from "~/components/experiments/designer/DesignerRoot";
|
||||
import { useActionRegistry } from "~/components/experiments/designer/ActionRegistry";
|
||||
import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider";
|
||||
import type { ExperimentStep } from "~/lib/experiment-designer/types";
|
||||
|
||||
@@ -9,6 +11,10 @@ interface DesignerPageClientProps {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
status: string;
|
||||
studyId: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
study: {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -28,6 +34,22 @@ export function DesignerPageClient({
|
||||
experiment,
|
||||
initialDesign,
|
||||
}: DesignerPageClientProps) {
|
||||
// Initialize action registry early to prevent CLS
|
||||
useActionRegistry();
|
||||
|
||||
// Calculate design statistics
|
||||
const designStats = useMemo(() => {
|
||||
if (!initialDesign) return undefined;
|
||||
|
||||
const stepCount = initialDesign.steps.length;
|
||||
const actionCount = initialDesign.steps.reduce(
|
||||
(sum, step) => sum + step.actions.length,
|
||||
0,
|
||||
);
|
||||
|
||||
return { stepCount, actionCount };
|
||||
}, [initialDesign]);
|
||||
|
||||
// Set breadcrumbs
|
||||
useBreadcrumbsEffect([
|
||||
{
|
||||
@@ -56,6 +78,11 @@ export function DesignerPageClient({
|
||||
]);
|
||||
|
||||
return (
|
||||
<DesignerRoot experimentId={experiment.id} initialDesign={initialDesign} />
|
||||
<DesignerRoot
|
||||
experimentId={experiment.id}
|
||||
initialDesign={initialDesign}
|
||||
experiment={experiment}
|
||||
designStats={designStats}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -20,7 +20,9 @@ export default async function ExperimentDesignerPage({
|
||||
}: ExperimentDesignerPageProps) {
|
||||
try {
|
||||
const resolvedParams = await params;
|
||||
const experiment = await api.experiments.get({ id: resolvedParams.experimentId });
|
||||
const experiment = await api.experiments.get({
|
||||
id: resolvedParams.experimentId,
|
||||
});
|
||||
|
||||
if (!experiment) {
|
||||
notFound();
|
||||
@@ -36,13 +38,13 @@ export default async function ExperimentDesignerPage({
|
||||
// Only pass initialDesign if there's existing visual design data
|
||||
let initialDesign:
|
||||
| {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
steps: ExperimentStep[];
|
||||
version: number;
|
||||
lastSaved: Date;
|
||||
}
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
steps: ExperimentStep[];
|
||||
version: number;
|
||||
lastSaved: Date;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
if (existingDesign?.steps && existingDesign.steps.length > 0) {
|
||||
@@ -121,7 +123,8 @@ export default async function ExperimentDesignerPage({
|
||||
};
|
||||
});
|
||||
const mapped: ExperimentStep[] = exec.steps.map((s, idx) => {
|
||||
const actions: ExperimentAction[] = s.actions.map((a) => {
|
||||
// Recursive function to hydrate actions with children
|
||||
const hydrateAction = (a: any): ExperimentAction => {
|
||||
// Normalize legacy plugin action ids and provenance
|
||||
const rawType = a.type ?? "";
|
||||
|
||||
@@ -188,11 +191,24 @@ export default async function ExperimentDesignerPage({
|
||||
const pluginId = legacy?.pluginId;
|
||||
const pluginVersion = legacy?.pluginVersion;
|
||||
|
||||
// Extract children from parameters for control flow actions
|
||||
const params = (a.parameters ?? {}) as Record<string, unknown>;
|
||||
let children: ExperimentAction[] | undefined = undefined;
|
||||
|
||||
// Handle control flow structures (sequence, parallel, loop only)
|
||||
// Branch actions control step routing, not nested actions
|
||||
const childrenRaw = params.children;
|
||||
|
||||
// Recursively hydrate nested children for container actions
|
||||
if (Array.isArray(childrenRaw) && childrenRaw.length > 0) {
|
||||
children = childrenRaw.map((child: any) => hydrateAction(child));
|
||||
}
|
||||
|
||||
return {
|
||||
id: a.id,
|
||||
type: typeOut,
|
||||
name: a.name,
|
||||
parameters: (a.parameters ?? {}) as Record<string, unknown>,
|
||||
parameters: params,
|
||||
category: categoryOut,
|
||||
source: {
|
||||
kind: sourceKind,
|
||||
@@ -202,8 +218,13 @@ export default async function ExperimentDesignerPage({
|
||||
baseActionId: legacy?.baseId,
|
||||
},
|
||||
execution,
|
||||
children, // Add children at top level
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const actions: ExperimentAction[] = s.actions.map((a) =>
|
||||
hydrateAction(a),
|
||||
);
|
||||
return {
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
@@ -222,7 +243,10 @@ export default async function ExperimentDesignerPage({
|
||||
: "sequential";
|
||||
})(),
|
||||
order: s.orderIndex ?? idx,
|
||||
trigger: { type: "trial_start", conditions: {} },
|
||||
trigger: {
|
||||
type: idx === 0 ? "trial_start" : "previous_step",
|
||||
conditions: (s.conditions as Record<string, unknown>) || {},
|
||||
},
|
||||
actions,
|
||||
expanded: true,
|
||||
};
|
||||
@@ -258,7 +282,9 @@ export async function generateMetadata({
|
||||
}> {
|
||||
try {
|
||||
const resolvedParams = await params;
|
||||
const experiment = await api.experiments.get({ id: resolvedParams.experimentId });
|
||||
const experiment = await api.experiments.get({
|
||||
id: resolvedParams.experimentId,
|
||||
});
|
||||
|
||||
return {
|
||||
title: `${experiment?.name} - Designer | HRIStudio`,
|
||||
|
||||
@@ -1,164 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import * as z from "zod";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "~/components/ui/form";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { Textarea } from "~/components/ui/textarea";
|
||||
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "~/components/ui/select";
|
||||
import { toast } from "sonner";
|
||||
import { api } from "~/trpc/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { type experiments, experimentStatusEnum } from "~/server/db/schema";
|
||||
import { type InferSelectModel } from "drizzle-orm";
|
||||
|
||||
type Experiment = InferSelectModel<typeof experiments>;
|
||||
|
||||
const formSchema = z.object({
|
||||
name: z.string().min(2, {
|
||||
message: "Name must be at least 2 characters.",
|
||||
}),
|
||||
description: z.string().optional(),
|
||||
status: z.enum(experimentStatusEnum.enumValues),
|
||||
});
|
||||
|
||||
interface ExperimentFormProps {
|
||||
experiment: Experiment;
|
||||
}
|
||||
|
||||
export function ExperimentForm({ experiment }: ExperimentFormProps) {
|
||||
const router = useRouter();
|
||||
const updateExperiment = api.experiments.update.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Experiment updated successfully");
|
||||
router.refresh();
|
||||
router.push(`/studies/${experiment.studyId}/experiments/${experiment.id}`);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(`Error updating experiment: ${error.message}`);
|
||||
},
|
||||
});
|
||||
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
name: experiment.name,
|
||||
description: experiment.description ?? "",
|
||||
status: experiment.status,
|
||||
},
|
||||
});
|
||||
|
||||
function onSubmit(values: z.infer<typeof formSchema>) {
|
||||
updateExperiment.mutate({
|
||||
id: experiment.id,
|
||||
name: values.name,
|
||||
description: values.description,
|
||||
status: values.status,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Experiment name" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
The name of your experiment.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Description</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="Describe your experiment..."
|
||||
className="resize-none"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
A short description of the experiment goals.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="status"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Status</FormLabel>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a status" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="draft">Draft</SelectItem>
|
||||
<SelectItem value="testing">Testing</SelectItem>
|
||||
<SelectItem value="ready">Ready</SelectItem>
|
||||
<SelectItem value="deprecated">Deprecated</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>
|
||||
The current status of the experiment.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex gap-4">
|
||||
<Button type="submit" disabled={updateExperiment.isPending}>
|
||||
{updateExperiment.isPending ? "Saving..." : "Save Changes"}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
router.push(
|
||||
`/studies/${experiment.studyId}/experiments/${experiment.id}`,
|
||||
)
|
||||
}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { type experiments } from "~/server/db/schema";
|
||||
import { type InferSelectModel } from "drizzle-orm";
|
||||
|
||||
type Experiment = InferSelectModel<typeof experiments>;
|
||||
import { api } from "~/trpc/server";
|
||||
import { ExperimentForm } from "./experiment-form";
|
||||
import {
|
||||
EntityView,
|
||||
EntityViewHeader,
|
||||
EntityViewSection,
|
||||
} from "~/components/ui/entity-view";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import Link from "next/link";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
|
||||
interface ExperimentEditPageProps {
|
||||
params: Promise<{ id: string; experimentId: string }>;
|
||||
}
|
||||
|
||||
export default async function ExperimentEditPage({
|
||||
params,
|
||||
}: ExperimentEditPageProps) {
|
||||
const { id: studyId, experimentId } = await params;
|
||||
|
||||
const experiment = await api.experiments.get({ id: experimentId });
|
||||
|
||||
if (!experiment) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
// Ensure experiment belongs to study
|
||||
if (experiment.studyId !== studyId) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
// Convert to type expected by form
|
||||
const experimentData: Experiment = {
|
||||
...experiment,
|
||||
status: experiment.status as Experiment["status"],
|
||||
};
|
||||
|
||||
return (
|
||||
<EntityView>
|
||||
<EntityViewHeader
|
||||
title="Edit Experiment"
|
||||
subtitle={`Update settings for ${experiment.name}`}
|
||||
icon="Edit"
|
||||
/>
|
||||
|
||||
<div className="max-w-2xl">
|
||||
<EntityViewSection title="Experiment Details" icon="Settings">
|
||||
<ExperimentForm experiment={experimentData} />
|
||||
</EntityViewSection>
|
||||
</div>
|
||||
</EntityView>
|
||||
);
|
||||
}
|
||||
@@ -1,20 +1,29 @@
|
||||
"use client";
|
||||
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { Calendar, Clock, Edit, Play, Settings, Users } from "lucide-react";
|
||||
import {
|
||||
Calendar,
|
||||
Clock,
|
||||
Edit,
|
||||
Play,
|
||||
Settings,
|
||||
Users,
|
||||
TestTube,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { PageHeader } from "~/components/ui/page-header";
|
||||
import {
|
||||
EntityView,
|
||||
EntityViewHeader,
|
||||
EntityViewSection,
|
||||
EmptyState,
|
||||
InfoGrid,
|
||||
QuickActions,
|
||||
StatsGrid,
|
||||
EntityView,
|
||||
EntityViewHeader,
|
||||
EntityViewSection,
|
||||
EmptyState,
|
||||
InfoGrid,
|
||||
QuickActions,
|
||||
StatsGrid,
|
||||
} from "~/components/ui/entity-view";
|
||||
import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider";
|
||||
import { api } from "~/trpc/react";
|
||||
@@ -22,447 +31,443 @@ import { useSession } from "next-auth/react";
|
||||
import { useStudyManagement } from "~/hooks/useStudyManagement";
|
||||
|
||||
interface ExperimentDetailPageProps {
|
||||
params: Promise<{ id: string; experimentId: string }>;
|
||||
params: Promise<{ id: string; experimentId: string }>;
|
||||
}
|
||||
|
||||
const statusConfig = {
|
||||
draft: {
|
||||
label: "Draft",
|
||||
variant: "secondary" as const,
|
||||
icon: "FileText" as const,
|
||||
},
|
||||
testing: {
|
||||
label: "Testing",
|
||||
variant: "outline" as const,
|
||||
icon: "TestTube" as const,
|
||||
},
|
||||
ready: {
|
||||
label: "Ready",
|
||||
variant: "default" as const,
|
||||
icon: "CheckCircle" as const,
|
||||
},
|
||||
deprecated: {
|
||||
label: "Deprecated",
|
||||
variant: "destructive" as const,
|
||||
icon: "AlertTriangle" as const,
|
||||
},
|
||||
draft: {
|
||||
label: "Draft",
|
||||
variant: "secondary" as const,
|
||||
icon: "FileText" as const,
|
||||
},
|
||||
testing: {
|
||||
label: "Testing",
|
||||
variant: "outline" as const,
|
||||
icon: "TestTube" as const,
|
||||
},
|
||||
ready: {
|
||||
label: "Ready",
|
||||
variant: "default" as const,
|
||||
icon: "CheckCircle" as const,
|
||||
},
|
||||
deprecated: {
|
||||
label: "Deprecated",
|
||||
variant: "destructive" as const,
|
||||
icon: "AlertTriangle" as const,
|
||||
},
|
||||
};
|
||||
|
||||
type Experiment = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
status: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
study: { id: string; name: string };
|
||||
robot: { id: string; name: string; description: string | null } | null;
|
||||
protocol?: { blocks: unknown[] } | null;
|
||||
visualDesign?: unknown;
|
||||
studyId: string;
|
||||
createdBy: string;
|
||||
robotId: string | null;
|
||||
version: number;
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
status: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
study: { id: string; name: string };
|
||||
robot: { id: string; name: string; description: string | null } | null;
|
||||
protocol?: { blocks: unknown[] } | null;
|
||||
visualDesign?: unknown;
|
||||
studyId: string;
|
||||
createdBy: string;
|
||||
robotId: string | null;
|
||||
version: number;
|
||||
};
|
||||
|
||||
type Trial = {
|
||||
id: string;
|
||||
status: string;
|
||||
createdAt: Date;
|
||||
duration: number | null;
|
||||
participant: {
|
||||
id: string;
|
||||
status: string;
|
||||
createdAt: Date;
|
||||
duration: number | null;
|
||||
participant: {
|
||||
id: string;
|
||||
participantCode: string;
|
||||
name?: string | null;
|
||||
} | null;
|
||||
experiment: { name: string } | null;
|
||||
participantId: string | null;
|
||||
experimentId: string;
|
||||
startedAt: Date | null;
|
||||
completedAt: Date | null;
|
||||
notes: string | null;
|
||||
updatedAt: Date;
|
||||
canAccess: boolean;
|
||||
userRole: string;
|
||||
participantCode: string;
|
||||
name?: string | null;
|
||||
} | null;
|
||||
experiment: { name: string } | null;
|
||||
participantId: string | null;
|
||||
experimentId: string;
|
||||
startedAt: Date | null;
|
||||
completedAt: Date | null;
|
||||
notes: string | null;
|
||||
updatedAt: Date;
|
||||
canAccess: boolean;
|
||||
userRole: string;
|
||||
};
|
||||
|
||||
export default function ExperimentDetailPage({
|
||||
params,
|
||||
params,
|
||||
}: ExperimentDetailPageProps) {
|
||||
const { data: session } = useSession();
|
||||
const [experiment, setExperiment] = useState<Experiment | null>(null);
|
||||
const [trials, setTrials] = useState<Trial[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [resolvedParams, setResolvedParams] = useState<{ id: string; experimentId: string } | null>(
|
||||
null,
|
||||
);
|
||||
const { selectStudy } = useStudyManagement();
|
||||
const { data: session } = useSession();
|
||||
const [experiment, setExperiment] = useState<Experiment | null>(null);
|
||||
const [trials, setTrials] = useState<Trial[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [resolvedParams, setResolvedParams] = useState<{
|
||||
id: string;
|
||||
experimentId: string;
|
||||
} | null>(null);
|
||||
const { selectStudy } = useStudyManagement();
|
||||
|
||||
useEffect(() => {
|
||||
const resolveParams = async () => {
|
||||
const resolved = await params;
|
||||
setResolvedParams(resolved);
|
||||
// Ensure study context is synced
|
||||
if (resolved.id) {
|
||||
void selectStudy(resolved.id);
|
||||
}
|
||||
};
|
||||
void resolveParams();
|
||||
}, [params, selectStudy]);
|
||||
useEffect(() => {
|
||||
const resolveParams = async () => {
|
||||
const resolved = await params;
|
||||
setResolvedParams(resolved);
|
||||
// Ensure study context is synced
|
||||
if (resolved.id) {
|
||||
void selectStudy(resolved.id);
|
||||
}
|
||||
};
|
||||
void resolveParams();
|
||||
}, [params, selectStudy]);
|
||||
|
||||
const experimentQuery = api.experiments.get.useQuery(
|
||||
{ id: resolvedParams?.experimentId ?? "" },
|
||||
{ enabled: !!resolvedParams?.experimentId },
|
||||
);
|
||||
const experimentQuery = api.experiments.get.useQuery(
|
||||
{ id: resolvedParams?.experimentId ?? "" },
|
||||
{ enabled: !!resolvedParams?.experimentId },
|
||||
);
|
||||
|
||||
const trialsQuery = api.trials.list.useQuery(
|
||||
{ experimentId: resolvedParams?.experimentId ?? "" },
|
||||
{ enabled: !!resolvedParams?.experimentId },
|
||||
);
|
||||
const trialsQuery = api.trials.list.useQuery(
|
||||
{ experimentId: resolvedParams?.experimentId ?? "" },
|
||||
{ enabled: !!resolvedParams?.experimentId },
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (experimentQuery.data) {
|
||||
setExperiment(experimentQuery.data);
|
||||
}
|
||||
}, [experimentQuery.data]);
|
||||
useEffect(() => {
|
||||
if (experimentQuery.data) {
|
||||
setExperiment(experimentQuery.data);
|
||||
}
|
||||
}, [experimentQuery.data]);
|
||||
|
||||
useEffect(() => {
|
||||
if (trialsQuery.data) {
|
||||
setTrials(trialsQuery.data);
|
||||
}
|
||||
}, [trialsQuery.data]);
|
||||
useEffect(() => {
|
||||
if (trialsQuery.data) {
|
||||
setTrials(trialsQuery.data);
|
||||
}
|
||||
}, [trialsQuery.data]);
|
||||
|
||||
useEffect(() => {
|
||||
if (experimentQuery.isLoading || trialsQuery.isLoading) {
|
||||
setLoading(true);
|
||||
} else {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [experimentQuery.isLoading, trialsQuery.isLoading]);
|
||||
useEffect(() => {
|
||||
if (experimentQuery.isLoading || trialsQuery.isLoading) {
|
||||
setLoading(true);
|
||||
} else {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [experimentQuery.isLoading, trialsQuery.isLoading]);
|
||||
|
||||
// Set breadcrumbs
|
||||
useBreadcrumbsEffect([
|
||||
{
|
||||
label: "Dashboard",
|
||||
href: "/",
|
||||
},
|
||||
{
|
||||
label: "Studies",
|
||||
href: "/studies",
|
||||
},
|
||||
{
|
||||
label: experiment?.study?.name ?? "Study",
|
||||
href: `/studies/${experiment?.study?.id}`,
|
||||
},
|
||||
{
|
||||
label: "Experiments",
|
||||
href: `/studies/${experiment?.study?.id}/experiments`,
|
||||
},
|
||||
{
|
||||
label: experiment?.name ?? "Experiment",
|
||||
},
|
||||
]);
|
||||
// Set breadcrumbs
|
||||
useBreadcrumbsEffect([
|
||||
{
|
||||
label: "Dashboard",
|
||||
href: "/",
|
||||
},
|
||||
{
|
||||
label: "Studies",
|
||||
href: "/studies",
|
||||
},
|
||||
{
|
||||
label: experiment?.study?.name ?? "Study",
|
||||
href: `/studies/${experiment?.study?.id}`,
|
||||
},
|
||||
{
|
||||
label: "Experiments",
|
||||
href: `/studies/${experiment?.study?.id}/experiments`,
|
||||
},
|
||||
{
|
||||
label: experiment?.name ?? "Experiment",
|
||||
},
|
||||
]);
|
||||
|
||||
if (loading) return <div>Loading...</div>;
|
||||
if (experimentQuery.error) return notFound();
|
||||
if (!experiment) return notFound();
|
||||
if (loading) return <div>Loading...</div>;
|
||||
if (experimentQuery.error) return notFound();
|
||||
if (!experiment) return notFound();
|
||||
|
||||
const displayName = experiment.name ?? "Untitled Experiment";
|
||||
const description = experiment.description;
|
||||
const displayName = experiment.name ?? "Untitled Experiment";
|
||||
const description = experiment.description;
|
||||
|
||||
// Check if user can edit this experiment
|
||||
const userRoles = session?.user?.roles?.map((r) => r.role) ?? [];
|
||||
const canEdit =
|
||||
userRoles.includes("administrator") || userRoles.includes("researcher");
|
||||
// Check if user can edit this experiment
|
||||
const userRoles = session?.user?.roles?.map((r) => r.role) ?? [];
|
||||
const canEdit =
|
||||
userRoles.includes("administrator") || userRoles.includes("researcher");
|
||||
|
||||
const statusInfo =
|
||||
statusConfig[experiment.status as keyof typeof statusConfig];
|
||||
const statusInfo =
|
||||
statusConfig[experiment.status as keyof typeof statusConfig];
|
||||
|
||||
const studyId = experiment.study.id;
|
||||
const experimentId = experiment.id;
|
||||
const studyId = experiment.study.id;
|
||||
const experimentId = experiment.id;
|
||||
|
||||
return (
|
||||
<EntityView>
|
||||
<EntityViewHeader
|
||||
title={displayName}
|
||||
subtitle={description ?? undefined}
|
||||
icon="TestTube"
|
||||
status={{
|
||||
label: statusInfo?.label ?? "Unknown",
|
||||
variant: statusInfo?.variant ?? "secondary",
|
||||
icon: statusInfo?.icon ?? "TestTube",
|
||||
}}
|
||||
actions={
|
||||
canEdit ? (
|
||||
<>
|
||||
<Button asChild variant="outline">
|
||||
<Link href={`/studies/${studyId}/experiments/${experimentId}/edit`}>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
Edit
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild variant="outline">
|
||||
<Link href={`/studies/${studyId}/experiments/${experimentId}/designer`}>
|
||||
<Settings className="mr-2 h-4 w-4" />
|
||||
Designer
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild>
|
||||
<Link
|
||||
href={`/studies/${studyId}/trials/new?experimentId=${experimentId}`}
|
||||
>
|
||||
<Play className="mr-2 h-4 w-4" />
|
||||
Start Trial
|
||||
</Link>
|
||||
</Button>
|
||||
</>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
<div className="space-y-6 lg:col-span-2">
|
||||
{/* Basic Information */}
|
||||
<EntityViewSection title="Information" icon="Info">
|
||||
<InfoGrid
|
||||
columns={2}
|
||||
items={[
|
||||
{
|
||||
label: "Study",
|
||||
value: experiment.study ? (
|
||||
<Link
|
||||
href={`/studies/${experiment.study.id}`}
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
{experiment.study.name}
|
||||
</Link>
|
||||
) : (
|
||||
"No study assigned"
|
||||
),
|
||||
},
|
||||
{
|
||||
label: "Status",
|
||||
value: statusInfo?.label ?? "Unknown",
|
||||
},
|
||||
{
|
||||
label: "Created",
|
||||
value: formatDistanceToNow(experiment.createdAt, {
|
||||
addSuffix: true,
|
||||
}),
|
||||
},
|
||||
{
|
||||
label: "Last Updated",
|
||||
value: formatDistanceToNow(experiment.updatedAt, {
|
||||
addSuffix: true,
|
||||
}),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</EntityViewSection>
|
||||
|
||||
{/* Protocol Section */}
|
||||
<EntityViewSection
|
||||
title="Experiment Protocol"
|
||||
icon="FileText"
|
||||
actions={
|
||||
canEdit && (
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link href={`/studies/${studyId}/experiments/${experimentId}/designer`}>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
Edit Protocol
|
||||
</Link>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
>
|
||||
{experiment.protocol &&
|
||||
typeof experiment.protocol === "object" &&
|
||||
experiment.protocol !== null ? (
|
||||
<div className="space-y-3">
|
||||
<div className="text-muted-foreground text-sm">
|
||||
Protocol contains{" "}
|
||||
{Array.isArray(
|
||||
(experiment.protocol as { blocks: unknown[] }).blocks,
|
||||
)
|
||||
? (experiment.protocol as { blocks: unknown[] }).blocks
|
||||
.length
|
||||
: 0}{" "}
|
||||
blocks
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState
|
||||
icon="FileText"
|
||||
title="No protocol defined"
|
||||
description="Create an experiment protocol using the visual designer"
|
||||
action={
|
||||
canEdit && (
|
||||
<Button asChild>
|
||||
<Link href={`/studies/${studyId}/experiments/${experimentId}/designer`}>
|
||||
Open Designer
|
||||
</Link>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</EntityViewSection>
|
||||
|
||||
{/* Recent Trials */}
|
||||
<EntityViewSection
|
||||
title="Recent Trials"
|
||||
icon="Play"
|
||||
actions={
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link href={`/studies/${experiment.study?.id}/trials`}>
|
||||
View All
|
||||
</Link>
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{trials.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{trials.slice(0, 5).map((trial) => (
|
||||
<div
|
||||
key={trial.id}
|
||||
className="hover:bg-muted/50 rounded-lg border p-4 transition-colors"
|
||||
>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<Link
|
||||
href={`/studies/${experiment.study.id}/trials/${trial.id}`}
|
||||
className="font-medium hover:underline"
|
||||
>
|
||||
Trial #{trial.id.slice(-6)}
|
||||
</Link>
|
||||
<Badge
|
||||
variant={
|
||||
trial.status === "completed"
|
||||
? "default"
|
||||
: trial.status === "in_progress"
|
||||
? "secondary"
|
||||
: trial.status === "failed"
|
||||
? "destructive"
|
||||
: "outline"
|
||||
}
|
||||
>
|
||||
{trial.status.charAt(0).toUpperCase() +
|
||||
trial.status.slice(1).replace("_", " ")}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="text-muted-foreground flex items-center gap-4 text-sm">
|
||||
<span className="flex items-center gap-1">
|
||||
<Calendar className="h-4 w-4" />
|
||||
{formatDistanceToNow(trial.createdAt, {
|
||||
addSuffix: true,
|
||||
})}
|
||||
</span>
|
||||
{trial.duration && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="h-4 w-4" />
|
||||
{Math.round(trial.duration / 60)} min
|
||||
</span>
|
||||
)}
|
||||
{trial.participant && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Users className="h-4 w-4" />
|
||||
{trial.participant.name ??
|
||||
trial.participant.participantCode}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState
|
||||
icon="Play"
|
||||
title="No trials yet"
|
||||
description="Start your first trial to collect data"
|
||||
action={
|
||||
canEdit && (
|
||||
<Button asChild>
|
||||
<Link
|
||||
href={`/studies/${studyId}/trials/new?experimentId=${experimentId}`}
|
||||
>
|
||||
Start Trial
|
||||
</Link>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</EntityViewSection>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Statistics */}
|
||||
<EntityViewSection title="Statistics" icon="BarChart">
|
||||
<StatsGrid
|
||||
stats={[
|
||||
{
|
||||
label: "Total Trials",
|
||||
value: trials.length,
|
||||
},
|
||||
{
|
||||
label: "Completed",
|
||||
value: trials.filter((t) => t.status === "completed").length,
|
||||
},
|
||||
{
|
||||
label: "In Progress",
|
||||
value: trials.filter((t) => t.status === "in_progress")
|
||||
.length,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</EntityViewSection>
|
||||
|
||||
{/* Robot Information */}
|
||||
{experiment.robot && (
|
||||
<EntityViewSection title="Robot Platform" icon="Bot">
|
||||
<InfoGrid
|
||||
columns={1}
|
||||
items={[
|
||||
{
|
||||
label: "Platform",
|
||||
value: experiment.robot.name,
|
||||
},
|
||||
{
|
||||
label: "Type",
|
||||
value: experiment.robot.description ?? "Not specified",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</EntityViewSection>
|
||||
)}
|
||||
|
||||
{/* Quick Actions */}
|
||||
<EntityViewSection title="Quick Actions" icon="Zap">
|
||||
<QuickActions
|
||||
actions={[
|
||||
{
|
||||
label: "Export Data",
|
||||
icon: "Download" as const,
|
||||
href: `/studies/${studyId}/experiments/${experimentId}/export`,
|
||||
},
|
||||
...(canEdit
|
||||
? [
|
||||
{
|
||||
label: "Edit Experiment",
|
||||
icon: "Edit" as const,
|
||||
href: `/studies/${studyId}/experiments/${experimentId}/edit`,
|
||||
},
|
||||
{
|
||||
label: "Open Designer",
|
||||
icon: "Palette" as const,
|
||||
href: `/studies/${studyId}/experiments/${experimentId}/designer`,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]}
|
||||
/>
|
||||
</EntityViewSection>
|
||||
</div>
|
||||
return (
|
||||
<EntityView>
|
||||
<PageHeader
|
||||
title={displayName}
|
||||
description={description ?? undefined}
|
||||
icon={TestTube}
|
||||
badges={[
|
||||
{
|
||||
label: statusInfo?.label ?? "Unknown",
|
||||
variant: statusInfo?.variant ?? "secondary",
|
||||
},
|
||||
]}
|
||||
actions={
|
||||
canEdit ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button asChild variant="outline">
|
||||
<Link
|
||||
href={`/studies/${studyId}/experiments/${experimentId}/designer`}
|
||||
>
|
||||
<Settings className="mr-2 h-4 w-4" />
|
||||
Designer
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild>
|
||||
<Link
|
||||
href={`/studies/${studyId}/trials/new?experimentId=${experimentId}`}
|
||||
>
|
||||
<Play className="mr-2 h-4 w-4" />
|
||||
Start Trial
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</EntityView>
|
||||
);
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
<div className="space-y-6 lg:col-span-2">
|
||||
{/* Basic Information */}
|
||||
<EntityViewSection title="Information" icon="Info">
|
||||
<InfoGrid
|
||||
columns={2}
|
||||
items={[
|
||||
{
|
||||
label: "Study",
|
||||
value: experiment.study ? (
|
||||
<Link
|
||||
href={`/studies/${experiment.study.id}`}
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
{experiment.study.name}
|
||||
</Link>
|
||||
) : (
|
||||
"No study assigned"
|
||||
),
|
||||
},
|
||||
{
|
||||
label: "Status",
|
||||
value: statusInfo?.label ?? "Unknown",
|
||||
},
|
||||
{
|
||||
label: "Created",
|
||||
value: formatDistanceToNow(experiment.createdAt, {
|
||||
addSuffix: true,
|
||||
}),
|
||||
},
|
||||
{
|
||||
label: "Last Updated",
|
||||
value: formatDistanceToNow(experiment.updatedAt, {
|
||||
addSuffix: true,
|
||||
}),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</EntityViewSection>
|
||||
|
||||
{/* Protocol Section */}
|
||||
<EntityViewSection
|
||||
title="Experiment Protocol"
|
||||
icon="FileText"
|
||||
actions={
|
||||
canEdit && (
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link
|
||||
href={`/studies/${studyId}/experiments/${experimentId}/designer`}
|
||||
>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
Edit Protocol
|
||||
</Link>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
>
|
||||
{experiment.protocol &&
|
||||
typeof experiment.protocol === "object" &&
|
||||
experiment.protocol !== null ? (
|
||||
<div className="space-y-3">
|
||||
<div className="text-muted-foreground text-sm">
|
||||
Protocol contains{" "}
|
||||
{Array.isArray(
|
||||
(experiment.protocol as { blocks: unknown[] }).blocks,
|
||||
)
|
||||
? (experiment.protocol as { blocks: unknown[] }).blocks
|
||||
.length
|
||||
: 0}{" "}
|
||||
blocks
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState
|
||||
icon="FileText"
|
||||
title="No protocol defined"
|
||||
description="Create an experiment protocol using the visual designer"
|
||||
action={
|
||||
canEdit && (
|
||||
<Button asChild>
|
||||
<Link
|
||||
href={`/studies/${studyId}/experiments/${experimentId}/designer`}
|
||||
>
|
||||
Open Designer
|
||||
</Link>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</EntityViewSection>
|
||||
|
||||
{/* Recent Trials */}
|
||||
<EntityViewSection
|
||||
title="Recent Trials"
|
||||
icon="Play"
|
||||
actions={
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link href={`/studies/${experiment.study?.id}/trials`}>
|
||||
View All
|
||||
</Link>
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{trials.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{trials.slice(0, 5).map((trial) => (
|
||||
<div
|
||||
key={trial.id}
|
||||
className="hover:bg-muted/50 rounded-lg border p-4 transition-colors"
|
||||
>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<Link
|
||||
href={`/studies/${experiment.study.id}/trials/${trial.id}`}
|
||||
className="font-medium hover:underline"
|
||||
>
|
||||
Trial #{trial.id.slice(-6)}
|
||||
</Link>
|
||||
<Badge
|
||||
variant={
|
||||
trial.status === "completed"
|
||||
? "default"
|
||||
: trial.status === "in_progress"
|
||||
? "secondary"
|
||||
: trial.status === "failed"
|
||||
? "destructive"
|
||||
: "outline"
|
||||
}
|
||||
>
|
||||
{trial.status.charAt(0).toUpperCase() +
|
||||
trial.status.slice(1).replace("_", " ")}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="text-muted-foreground flex items-center gap-4 text-sm">
|
||||
<span className="flex items-center gap-1">
|
||||
<Calendar className="h-4 w-4" />
|
||||
{formatDistanceToNow(trial.createdAt, {
|
||||
addSuffix: true,
|
||||
})}
|
||||
</span>
|
||||
{trial.duration && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="h-4 w-4" />
|
||||
{Math.round(trial.duration / 60)} min
|
||||
</span>
|
||||
)}
|
||||
{trial.participant && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Users className="h-4 w-4" />
|
||||
{trial.participant.name ??
|
||||
trial.participant.participantCode}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState
|
||||
icon="Play"
|
||||
title="No trials yet"
|
||||
description="Start your first trial to collect data"
|
||||
action={
|
||||
canEdit && (
|
||||
<Button asChild>
|
||||
<Link
|
||||
href={`/studies/${studyId}/trials/new?experimentId=${experimentId}`}
|
||||
>
|
||||
Start Trial
|
||||
</Link>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</EntityViewSection>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Statistics */}
|
||||
<EntityViewSection title="Statistics" icon="BarChart">
|
||||
<StatsGrid
|
||||
stats={[
|
||||
{
|
||||
label: "Total Trials",
|
||||
value: trials.length,
|
||||
},
|
||||
{
|
||||
label: "Completed",
|
||||
value: trials.filter((t) => t.status === "completed").length,
|
||||
},
|
||||
{
|
||||
label: "In Progress",
|
||||
value: trials.filter((t) => t.status === "in_progress")
|
||||
.length,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</EntityViewSection>
|
||||
|
||||
{/* Robot Information */}
|
||||
{experiment.robot && (
|
||||
<EntityViewSection title="Robot Platform" icon="Bot">
|
||||
<InfoGrid
|
||||
columns={1}
|
||||
items={[
|
||||
{
|
||||
label: "Platform",
|
||||
value: experiment.robot.name,
|
||||
},
|
||||
{
|
||||
label: "Type",
|
||||
value: experiment.robot.description ?? "Not specified",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</EntityViewSection>
|
||||
)}
|
||||
|
||||
{/* Quick Actions */}
|
||||
<EntityViewSection title="Quick Actions" icon="Zap">
|
||||
<QuickActions
|
||||
actions={[
|
||||
{
|
||||
label: "Export Data",
|
||||
icon: "Download" as const,
|
||||
},
|
||||
...(canEdit
|
||||
? [
|
||||
{
|
||||
label: "Open Designer",
|
||||
icon: "Palette" as const,
|
||||
href: `/studies/${studyId}/experiments/${experimentId}/designer`,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]}
|
||||
/>
|
||||
</EntityViewSection>
|
||||
</div>
|
||||
</div>
|
||||
</EntityView>
|
||||
);
|
||||
}
|
||||
|
||||
317
src/app/(dashboard)/studies/[id]/forms/page.tsx
Normal file
317
src/app/(dashboard)/studies/[id]/forms/page.tsx
Normal file
@@ -0,0 +1,317 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { notFound } from "next/navigation";
|
||||
import { FileText, Loader2, Plus, Download, Edit2, Eye, Save } from "lucide-react";
|
||||
import {
|
||||
EntityView,
|
||||
EntityViewHeader,
|
||||
EntityViewSection,
|
||||
EmptyState,
|
||||
} from "~/components/ui/entity-view";
|
||||
import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { api } from "~/trpc/react";
|
||||
import { toast } from "sonner";
|
||||
import { PageHeader } from "~/components/ui/page-header";
|
||||
import { useEditor, EditorContent } from '@tiptap/react';
|
||||
import StarterKit from '@tiptap/starter-kit';
|
||||
import { Markdown } from 'tiptap-markdown';
|
||||
import { Table } from '@tiptap/extension-table';
|
||||
import { TableRow } from '@tiptap/extension-table-row';
|
||||
import { TableCell } from '@tiptap/extension-table-cell';
|
||||
import { TableHeader } from '@tiptap/extension-table-header';
|
||||
import { Bold, Italic, List, ListOrdered, Heading1, Heading2, Quote, Table as TableIcon } from "lucide-react";
|
||||
import { downloadPdfFromHtml } from "~/lib/pdf-generator";
|
||||
|
||||
const Toolbar = ({ editor }: { editor: any }) => {
|
||||
if (!editor) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border border-input bg-transparent rounded-tr-md rounded-tl-md p-1 flex items-center gap-1 flex-wrap">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => editor.chain().focus().toggleBold().run()}
|
||||
disabled={!editor.can().chain().focus().toggleBold().run()}
|
||||
className={editor.isActive('bold') ? 'bg-muted' : ''}
|
||||
>
|
||||
<Bold className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => editor.chain().focus().toggleItalic().run()}
|
||||
disabled={!editor.can().chain().focus().toggleItalic().run()}
|
||||
className={editor.isActive('italic') ? 'bg-muted' : ''}
|
||||
>
|
||||
<Italic className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="w-[1px] h-6 bg-border mx-1" />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
|
||||
className={editor.isActive('heading', { level: 1 }) ? 'bg-muted' : ''}
|
||||
>
|
||||
<Heading1 className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
|
||||
className={editor.isActive('heading', { level: 2 }) ? 'bg-muted' : ''}
|
||||
>
|
||||
<Heading2 className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="w-[1px] h-6 bg-border mx-1" />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => editor.chain().focus().toggleBulletList().run()}
|
||||
className={editor.isActive('bulletList') ? 'bg-muted' : ''}
|
||||
>
|
||||
<List className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => editor.chain().focus().toggleOrderedList().run()}
|
||||
className={editor.isActive('orderedList') ? 'bg-muted' : ''}
|
||||
>
|
||||
<ListOrdered className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => editor.chain().focus().toggleBlockquote().run()}
|
||||
className={editor.isActive('blockquote') ? 'bg-muted' : ''}
|
||||
>
|
||||
<Quote className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="w-[1px] h-6 bg-border mx-1" />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run()}
|
||||
>
|
||||
<TableIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface StudyFormsPageProps {
|
||||
params: Promise<{
|
||||
id: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export default function StudyFormsPage({ params }: StudyFormsPageProps) {
|
||||
const { data: session } = useSession();
|
||||
const utils = api.useUtils();
|
||||
const [resolvedParams, setResolvedParams] = useState<{ id: string } | null>(null);
|
||||
const [editorTarget, setEditorTarget] = useState<string>("");
|
||||
|
||||
useEffect(() => {
|
||||
const resolveParams = async () => {
|
||||
const resolved = await params;
|
||||
setResolvedParams(resolved);
|
||||
};
|
||||
void resolveParams();
|
||||
}, [params]);
|
||||
|
||||
const { data: study } = api.studies.get.useQuery(
|
||||
{ id: resolvedParams?.id ?? "" },
|
||||
{ enabled: !!resolvedParams?.id },
|
||||
);
|
||||
|
||||
const { data: activeConsentForm, refetch: refetchConsentForm } =
|
||||
api.studies.getActiveConsentForm.useQuery(
|
||||
{ studyId: resolvedParams?.id ?? "" },
|
||||
{ enabled: !!resolvedParams?.id },
|
||||
);
|
||||
|
||||
// Only sync once when form loads to avoid resetting user edits
|
||||
useEffect(() => {
|
||||
if (activeConsentForm && !editorTarget) {
|
||||
setEditorTarget(activeConsentForm.content);
|
||||
}
|
||||
}, [activeConsentForm, editorTarget]);
|
||||
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
StarterKit,
|
||||
Table.configure({
|
||||
resizable: true,
|
||||
}),
|
||||
TableRow,
|
||||
TableHeader,
|
||||
TableCell,
|
||||
Markdown.configure({
|
||||
transformPastedText: true,
|
||||
}),
|
||||
],
|
||||
content: editorTarget || '',
|
||||
immediatelyRender: false,
|
||||
onUpdate: ({ editor }) => {
|
||||
// @ts-ignore
|
||||
setEditorTarget(editor.storage.markdown.getMarkdown());
|
||||
},
|
||||
});
|
||||
|
||||
// Sync Tiptap when editorTarget is set (e.g., from DB) but make sure not to overwrite active edits
|
||||
useEffect(() => {
|
||||
if (editor && editorTarget && editor.isEmpty) {
|
||||
editor.commands.setContent(editorTarget);
|
||||
}
|
||||
}, [editorTarget, editor]);
|
||||
|
||||
const generateConsentMutation = api.studies.generateConsentForm.useMutation({
|
||||
onSuccess: (data) => {
|
||||
toast.success("Default Consent Form Generated!");
|
||||
setEditorTarget(data.content);
|
||||
editor?.commands.setContent(data.content);
|
||||
void refetchConsentForm();
|
||||
void utils.studies.getActivity.invalidate({ studyId: resolvedParams?.id ?? "" });
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Error generating consent form", { description: error.message });
|
||||
},
|
||||
});
|
||||
|
||||
const updateConsentMutation = api.studies.updateConsentForm.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Consent Form Saved Successfully!");
|
||||
void refetchConsentForm();
|
||||
void utils.studies.getActivity.invalidate({ studyId: resolvedParams?.id ?? "" });
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Error saving consent form", { description: error.message });
|
||||
},
|
||||
});
|
||||
|
||||
const handleDownloadConsent = async () => {
|
||||
if (!activeConsentForm || !study || !editor) return;
|
||||
|
||||
try {
|
||||
toast.loading("Generating Document...", { id: "pdf-gen" });
|
||||
await downloadPdfFromHtml(editor.getHTML(), {
|
||||
filename: `Consent_Form_${study.name.replace(/\s+/g, "_")}_v${activeConsentForm.version}.pdf`
|
||||
});
|
||||
toast.success("Document Downloaded Successfully!", { id: "pdf-gen" });
|
||||
} catch (error) {
|
||||
toast.error("Error generating PDF", { id: "pdf-gen" });
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
useBreadcrumbsEffect([
|
||||
{ label: "Dashboard", href: "/dashboard" },
|
||||
{ label: "Studies", href: "/studies" },
|
||||
{ label: study?.name ?? "Study", href: `/studies/${resolvedParams?.id}` },
|
||||
{ label: "Forms" },
|
||||
]);
|
||||
|
||||
if (!session?.user) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
if (!study) return <div>Loading...</div>;
|
||||
|
||||
return (
|
||||
<EntityView>
|
||||
<PageHeader
|
||||
title="Study Forms"
|
||||
description="Manage consent forms and future questionnaires for this study"
|
||||
icon={FileText}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-1 gap-8">
|
||||
<EntityViewSection
|
||||
title="Consent Document"
|
||||
icon="FileText"
|
||||
description="Design and manage the consent form that participants must sign before participating in your trials."
|
||||
actions={
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => generateConsentMutation.mutate({ studyId: study.id })}
|
||||
disabled={generateConsentMutation.isPending || updateConsentMutation.isPending}
|
||||
>
|
||||
{generateConsentMutation.isPending ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Generate Default Template
|
||||
</Button>
|
||||
{activeConsentForm && (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => updateConsentMutation.mutate({ studyId: study.id, content: editorTarget })}
|
||||
disabled={updateConsentMutation.isPending || editorTarget === activeConsentForm.content}
|
||||
>
|
||||
{updateConsentMutation.isPending ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Save Changes
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{activeConsentForm ? (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium leading-none">
|
||||
{activeConsentForm.title}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
v{activeConsentForm.version} • Status: Active
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={handleDownloadConsent}
|
||||
>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Download PDF
|
||||
</Button>
|
||||
<Badge variant="outline" className="bg-green-50 text-green-700 hover:bg-green-50">Active</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full flex justify-center bg-muted/30 p-8 rounded-md border border-border overflow-hidden">
|
||||
<div className="max-w-4xl w-full bg-white dark:bg-card shadow-xl ring-1 ring-border rounded-sm flex flex-col">
|
||||
<div className="border-b border-border bg-muted/50 dark:bg-muted/10">
|
||||
<Toolbar editor={editor} />
|
||||
</div>
|
||||
<div className="min-h-[850px] px-16 py-20 text-sm editor-container bg-white dark:bg-card">
|
||||
<EditorContent editor={editor} className="prose prose-sm dark:prose-invert max-w-none h-full outline-none focus:outline-none focus-visible:outline-none" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState
|
||||
icon="FileText"
|
||||
title="No Consent Form"
|
||||
description="Generate a boilerplate consent form for this study to download and collect signatures."
|
||||
/>
|
||||
)}
|
||||
</EntityViewSection>
|
||||
</div>
|
||||
</EntityView>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { Plus, Settings, Shield } from "lucide-react";
|
||||
import { Plus, Settings, Shield, Building } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
QuickActions,
|
||||
StatsGrid,
|
||||
} from "~/components/ui/entity-view";
|
||||
import { PageHeader } from "~/components/ui/page-header";
|
||||
import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { api } from "~/trpc/react";
|
||||
@@ -70,6 +71,7 @@ type Member = {
|
||||
|
||||
export default function StudyDetailPage({ params }: StudyDetailPageProps) {
|
||||
const { data: session } = useSession();
|
||||
const utils = api.useUtils();
|
||||
const [study, setStudy] = useState<Study | null>(null);
|
||||
const [members, setMembers] = useState<Member[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -167,17 +169,18 @@ export default function StudyDetailPage({ params }: StudyDetailPageProps) {
|
||||
return (
|
||||
<EntityView>
|
||||
{/* Header */}
|
||||
<EntityViewHeader
|
||||
<PageHeader
|
||||
title={study.name}
|
||||
subtitle={study.description ?? undefined}
|
||||
icon="Building"
|
||||
status={{
|
||||
label: statusInfo?.label ?? "Unknown",
|
||||
variant: statusInfo?.variant ?? "secondary",
|
||||
icon: statusInfo?.icon ?? "FileText",
|
||||
}}
|
||||
description={study.description ?? undefined}
|
||||
icon={Building}
|
||||
badges={[
|
||||
{
|
||||
label: statusInfo?.label ?? "Unknown",
|
||||
variant: statusInfo?.variant ?? "secondary",
|
||||
},
|
||||
]}
|
||||
actions={
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button asChild variant="outline">
|
||||
<Link href={`/studies/${study.id}/edit`}>
|
||||
<Settings className="mr-2 h-4 w-4" />
|
||||
@@ -190,7 +193,7 @@ export default function StudyDetailPage({ params }: StudyDetailPageProps) {
|
||||
New Experiment
|
||||
</Link>
|
||||
</Button>
|
||||
</>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -271,10 +274,10 @@ export default function StudyDetailPage({ params }: StudyDetailPageProps) {
|
||||
</h4>
|
||||
<span
|
||||
className={`inline-flex items-center rounded-full px-2 py-1 text-xs font-medium ${experiment.status === "draft"
|
||||
? "bg-gray-100 text-gray-800"
|
||||
: experiment.status === "ready"
|
||||
? "bg-green-100 text-green-800"
|
||||
: "bg-blue-100 text-blue-800"
|
||||
? "bg-gray-100 text-gray-800"
|
||||
: experiment.status === "ready"
|
||||
? "bg-green-100 text-green-800"
|
||||
: "bg-blue-100 text-blue-800"
|
||||
}`}
|
||||
>
|
||||
{experiment.status}
|
||||
@@ -299,12 +302,18 @@ export default function StudyDetailPage({ params }: StudyDetailPageProps) {
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link href={`/studies/${study.id}/experiments/${experiment.id}/designer`}>
|
||||
<Link
|
||||
href={`/studies/${study.id}/experiments/${experiment.id}/designer`}
|
||||
>
|
||||
Design
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link href={`/studies/${study.id}/experiments/${experiment.id}`}>View</Link>
|
||||
<Link
|
||||
href={`/studies/${study.id}/experiments/${experiment.id}`}
|
||||
>
|
||||
View
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,29 +3,29 @@ import { api } from "~/trpc/server";
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
interface EditParticipantPageProps {
|
||||
params: Promise<{
|
||||
id: string;
|
||||
participantId: string;
|
||||
}>;
|
||||
params: Promise<{
|
||||
id: string;
|
||||
participantId: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export default async function EditParticipantPage({
|
||||
params,
|
||||
params,
|
||||
}: EditParticipantPageProps) {
|
||||
const { id: studyId, participantId } = await params;
|
||||
const { id: studyId, participantId } = await params;
|
||||
|
||||
const participant = await api.participants.get({ id: participantId });
|
||||
const participant = await api.participants.get({ id: participantId });
|
||||
|
||||
if (!participant || participant.studyId !== studyId) {
|
||||
notFound();
|
||||
}
|
||||
if (!participant || participant.studyId !== studyId) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
// Transform data to match form expectations if needed, or pass directly
|
||||
return (
|
||||
<ParticipantForm
|
||||
mode="edit"
|
||||
studyId={studyId}
|
||||
participantId={participantId}
|
||||
/>
|
||||
);
|
||||
// Transform data to match form expectations if needed, or pass directly
|
||||
return (
|
||||
<ParticipantForm
|
||||
mode="edit"
|
||||
studyId={studyId}
|
||||
participantId={participantId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,108 +1,151 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { api } from "~/trpc/server";
|
||||
import {
|
||||
EntityView,
|
||||
EntityViewHeader,
|
||||
EntityViewSection,
|
||||
EntityView,
|
||||
EntityViewHeader,
|
||||
EntityViewSection,
|
||||
} from "~/components/ui/entity-view";
|
||||
import { ParticipantDocuments } from "./participant-documents";
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "~/components/ui/card";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
} from "~/components/ui/card";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Edit } from "lucide-react";
|
||||
import { Edit, Users } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { PageHeader } from "~/components/ui/page-header";
|
||||
|
||||
import { ParticipantConsentManager } from "~/components/participants/ParticipantConsentManager";
|
||||
|
||||
interface ParticipantDetailPageProps {
|
||||
params: Promise<{ id: string; participantId: string }>;
|
||||
params: Promise<{ id: string; participantId: string }>;
|
||||
}
|
||||
|
||||
export default async function ParticipantDetailPage({
|
||||
params,
|
||||
params,
|
||||
}: ParticipantDetailPageProps) {
|
||||
const { id: studyId, participantId } = await params;
|
||||
const { id: studyId, participantId } = await params;
|
||||
|
||||
const participant = await api.participants.get({ id: participantId });
|
||||
const participant = await api.participants.get({ id: participantId });
|
||||
|
||||
if (!participant) {
|
||||
notFound();
|
||||
}
|
||||
if (!participant) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
// Ensure participant belongs to study
|
||||
if (participant.studyId !== studyId) {
|
||||
notFound();
|
||||
}
|
||||
// Ensure participant belongs to study
|
||||
if (participant.studyId !== studyId) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return (
|
||||
<EntityView>
|
||||
<EntityViewHeader
|
||||
title={participant.participantCode}
|
||||
subtitle={participant.name ?? "Unnamed Participant"}
|
||||
icon="Users"
|
||||
status={{
|
||||
label: participant.consentGiven ? "Consent Given" : "No Consent",
|
||||
variant: participant.consentGiven ? "default" : "secondary"
|
||||
}}
|
||||
actions={
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link href={`/studies/${studyId}/participants/${participantId}/edit`}>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
Edit Participant
|
||||
</Link>
|
||||
</Button>
|
||||
}
|
||||
return (
|
||||
<EntityView>
|
||||
<PageHeader
|
||||
title={participant.participantCode}
|
||||
description={participant.name ?? "Unnamed Participant"}
|
||||
icon={Users}
|
||||
badges={[
|
||||
{
|
||||
label: participant.consentGiven ? "Consent Given" : "No Consent",
|
||||
variant: participant.consentGiven ? "default" : "secondary",
|
||||
},
|
||||
]}
|
||||
actions={
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link
|
||||
href={`/studies/${studyId}/participants/${participantId}/edit`}
|
||||
>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
Edit Participant
|
||||
</Link>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<Tabs defaultValue="overview" className="w-full">
|
||||
<TabsList className="mb-4">
|
||||
<TabsTrigger value="overview">Overview</TabsTrigger>
|
||||
<TabsTrigger value="files">Files & Documents</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="overview">
|
||||
<div className="grid grid-cols-1 gap-6">
|
||||
<ParticipantConsentManager
|
||||
studyId={studyId}
|
||||
participantId={participantId}
|
||||
participantName={participant.name}
|
||||
participantCode={participant.participantCode}
|
||||
consentGiven={participant.consentGiven}
|
||||
consentDate={participant.consentDate}
|
||||
existingConsent={participant.consents[0] ?? null}
|
||||
/>
|
||||
<EntityViewSection title="Participant Information" icon="Info">
|
||||
<div className="grid grid-cols-2 gap-4 text-sm md:grid-cols-4">
|
||||
<div>
|
||||
<span className="text-muted-foreground mb-1 block">Code</span>
|
||||
<span className="text-base font-medium">
|
||||
{participant.participantCode}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="overview" className="w-full">
|
||||
<TabsList className="mb-4">
|
||||
<TabsTrigger value="overview">Overview</TabsTrigger>
|
||||
<TabsTrigger value="files">Files & Documents</TabsTrigger>
|
||||
</TabsList>
|
||||
<div>
|
||||
<span className="text-muted-foreground mb-1 block">Name</span>
|
||||
<span className="text-base font-medium">
|
||||
{participant.name || "-"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<TabsContent value="overview">
|
||||
<div className="grid gap-6 grid-cols-1">
|
||||
<EntityViewSection title="Participant Information" icon="Info">
|
||||
<div className="grid grid-cols-2 gap-4 text-sm md:grid-cols-4">
|
||||
<div>
|
||||
<span className="text-muted-foreground block mb-1">Code</span>
|
||||
<span className="font-medium text-base">{participant.participantCode}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground mb-1 block">
|
||||
Email
|
||||
</span>
|
||||
<span className="text-base font-medium">
|
||||
{participant.email || "-"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className="text-muted-foreground block mb-1">Name</span>
|
||||
<span className="font-medium text-base">{participant.name || "-"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground mb-1 block">
|
||||
Added
|
||||
</span>
|
||||
<span className="text-base font-medium">
|
||||
{new Date(participant.createdAt).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className="text-muted-foreground block mb-1">Email</span>
|
||||
<span className="font-medium text-base">{participant.email || "-"}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground mb-1 block">Age</span>
|
||||
<span className="text-base font-medium">
|
||||
{(participant.demographics as any)?.age || "-"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className="text-muted-foreground block mb-1">Added</span>
|
||||
<span className="font-medium text-base">{new Date(participant.createdAt).toLocaleDateString()}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground mb-1 block">
|
||||
Gender
|
||||
</span>
|
||||
<span className="text-base font-medium capitalize">
|
||||
{(participant.demographics as any)?.gender?.replace(
|
||||
"_",
|
||||
" ",
|
||||
) || "-"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</EntityViewSection>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<div>
|
||||
<span className="text-muted-foreground block mb-1">Age</span>
|
||||
<span className="font-medium text-base">{(participant.demographics as any)?.age || "-"}</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className="text-muted-foreground block mb-1">Gender</span>
|
||||
<span className="font-medium capitalize text-base">{(participant.demographics as any)?.gender?.replace("_", " ") || "-"}</span>
|
||||
</div>
|
||||
</div>
|
||||
</EntityViewSection>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="files">
|
||||
<EntityViewSection title="Documents" icon="FileText">
|
||||
<ParticipantDocuments participantId={participantId} />
|
||||
</EntityViewSection>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</EntityView>
|
||||
);
|
||||
<TabsContent value="files">
|
||||
<EntityViewSection title="Documents" icon="FileText">
|
||||
<ParticipantDocuments participantId={participantId} />
|
||||
</EntityViewSection>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</EntityView>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,184 +4,192 @@ import { useState } from "react";
|
||||
import { Upload, FileText, Trash2, Download, Loader2 } from "lucide-react";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "~/components/ui/card";
|
||||
import { api } from "~/trpc/react";
|
||||
import { formatBytes } from "~/lib/utils";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface ParticipantDocumentsProps {
|
||||
participantId: string;
|
||||
participantId: string;
|
||||
}
|
||||
|
||||
export function ParticipantDocuments({ participantId }: ParticipantDocumentsProps) {
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const utils = api.useUtils();
|
||||
export function ParticipantDocuments({
|
||||
participantId,
|
||||
}: ParticipantDocumentsProps) {
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const utils = api.useUtils();
|
||||
|
||||
const { data: documents, isLoading } = api.files.listParticipantDocuments.useQuery({
|
||||
const { data: documents, isLoading } =
|
||||
api.files.listParticipantDocuments.useQuery({
|
||||
participantId,
|
||||
});
|
||||
|
||||
const getPresignedUrl = api.files.getPresignedUrl.useMutation();
|
||||
const registerUpload = api.files.registerUpload.useMutation();
|
||||
const deleteDocument = api.files.deleteDocument.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Document deleted");
|
||||
utils.files.listParticipantDocuments.invalidate({ participantId });
|
||||
},
|
||||
onError: (err) => toast.error(`Failed to delete: ${err.message}`),
|
||||
});
|
||||
|
||||
// Since presigned URLs are for PUT, we can use a direct fetch
|
||||
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
setIsUploading(true);
|
||||
try {
|
||||
// 1. Get presigned URL
|
||||
const { url, storagePath } = await getPresignedUrl.mutateAsync({
|
||||
filename: file.name,
|
||||
contentType: file.type || "application/octet-stream",
|
||||
participantId,
|
||||
});
|
||||
});
|
||||
|
||||
const getPresignedUrl = api.files.getPresignedUrl.useMutation();
|
||||
const registerUpload = api.files.registerUpload.useMutation();
|
||||
const deleteDocument = api.files.deleteDocument.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Document deleted");
|
||||
utils.files.listParticipantDocuments.invalidate({ participantId });
|
||||
// 2. Upload to MinIO/S3
|
||||
const uploadRes = await fetch(url, {
|
||||
method: "PUT",
|
||||
body: file,
|
||||
headers: {
|
||||
"Content-Type": file.type || "application/octet-stream",
|
||||
},
|
||||
onError: (err) => toast.error(`Failed to delete: ${err.message}`),
|
||||
});
|
||||
});
|
||||
|
||||
// Since presigned URLs are for PUT, we can use a direct fetch
|
||||
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
if (!uploadRes.ok) {
|
||||
throw new Error("Upload to storage failed");
|
||||
}
|
||||
|
||||
setIsUploading(true);
|
||||
try {
|
||||
// 1. Get presigned URL
|
||||
const { url, storagePath } = await getPresignedUrl.mutateAsync({
|
||||
filename: file.name,
|
||||
contentType: file.type || "application/octet-stream",
|
||||
participantId,
|
||||
});
|
||||
// 3. Register in DB
|
||||
await registerUpload.mutateAsync({
|
||||
participantId,
|
||||
name: file.name,
|
||||
type: file.type,
|
||||
storagePath,
|
||||
fileSize: file.size,
|
||||
});
|
||||
|
||||
// 2. Upload to MinIO/S3
|
||||
const uploadRes = await fetch(url, {
|
||||
method: "PUT",
|
||||
body: file,
|
||||
headers: {
|
||||
"Content-Type": file.type || "application/octet-stream",
|
||||
},
|
||||
});
|
||||
toast.success("File uploaded successfully");
|
||||
utils.files.listParticipantDocuments.invalidate({ participantId });
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error("Failed to upload file");
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
// Reset input
|
||||
e.target.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
if (!uploadRes.ok) {
|
||||
throw new Error("Upload to storage failed");
|
||||
}
|
||||
const handleDownload = async (storagePath: string, filename: string) => {
|
||||
// We would typically get a temporary download URL here
|
||||
// For now assuming public bucket or implementing a separate download procedure
|
||||
// Let's implement a quick procedure call right here via client or assume the server router has it.
|
||||
// I added getDownloadUrl to the router in previous steps.
|
||||
try {
|
||||
const { url } = await utils.client.files.getDownloadUrl.query({
|
||||
storagePath,
|
||||
});
|
||||
window.open(url, "_blank");
|
||||
} catch (e) {
|
||||
toast.error("Could not get download URL");
|
||||
}
|
||||
};
|
||||
|
||||
// 3. Register in DB
|
||||
await registerUpload.mutateAsync({
|
||||
participantId,
|
||||
name: file.name,
|
||||
type: file.type,
|
||||
storagePath,
|
||||
fileSize: file.size,
|
||||
});
|
||||
|
||||
toast.success("File uploaded successfully");
|
||||
utils.files.listParticipantDocuments.invalidate({ participantId });
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error("Failed to upload file");
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
// Reset input
|
||||
e.target.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownload = async (storagePath: string, filename: string) => {
|
||||
// We would typically get a temporary download URL here
|
||||
// For now assuming public bucket or implementing a separate download procedure
|
||||
// Let's implement a quick procedure call right here via client or assume the server router has it.
|
||||
// I added getDownloadUrl to the router in previous steps.
|
||||
try {
|
||||
const { url } = await utils.client.files.getDownloadUrl.query({ storagePath });
|
||||
window.open(url, "_blank");
|
||||
} catch (e) {
|
||||
toast.error("Could not get download URL");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<CardTitle>Documents</CardTitle>
|
||||
<CardDescription>
|
||||
Manage consent forms and other files for this participant.
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button disabled={isUploading} asChild>
|
||||
<label className="cursor-pointer">
|
||||
{isUploading ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Upload className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Upload PDF
|
||||
<input
|
||||
type="file"
|
||||
className="hidden"
|
||||
accept=".pdf,.doc,.docx,.txt" // User asked for PDF, but generic is fine
|
||||
onChange={handleFileUpload}
|
||||
disabled={isUploading}
|
||||
/>
|
||||
</label>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center p-4">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : documents?.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-center text-muted-foreground">
|
||||
<FileText className="mb-2 h-8 w-8 opacity-50" />
|
||||
<p>No documents uploaded yet.</p>
|
||||
</div>
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<CardTitle>Documents</CardTitle>
|
||||
<CardDescription>
|
||||
Manage consent forms and other files for this participant.
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button disabled={isUploading} asChild>
|
||||
<label className="cursor-pointer">
|
||||
{isUploading ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{documents?.map((doc) => (
|
||||
<div
|
||||
key={doc.id}
|
||||
className="flex items-center justify-between rounded-lg border p-3 hover:bg-muted/50"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="rounded-md bg-blue-50 p-2">
|
||||
<FileText className="h-4 w-4 text-blue-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">{doc.name}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatBytes(doc.fileSize ?? 0)} • {new Date(doc.createdAt).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleDownload(doc.storagePath, doc.name)}
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
onClick={() => {
|
||||
if (confirm("Are you sure you want to delete this file?")) {
|
||||
deleteDocument.mutate({ id: doc.id });
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Upload className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
Upload PDF
|
||||
<input
|
||||
type="file"
|
||||
className="hidden"
|
||||
accept=".pdf,.doc,.docx,.txt" // User asked for PDF, but generic is fine
|
||||
onChange={handleFileUpload}
|
||||
disabled={isUploading}
|
||||
/>
|
||||
</label>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center p-4">
|
||||
<Loader2 className="text-muted-foreground h-6 w-6 animate-spin" />
|
||||
</div>
|
||||
) : documents?.length === 0 ? (
|
||||
<div className="text-muted-foreground flex flex-col items-center justify-center py-8 text-center">
|
||||
<FileText className="mb-2 h-8 w-8 opacity-50" />
|
||||
<p>No documents uploaded yet.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{documents?.map((doc) => (
|
||||
<div
|
||||
key={doc.id}
|
||||
className="hover:bg-muted/50 flex items-center justify-between rounded-lg border p-3"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="rounded-md bg-blue-50 p-2">
|
||||
<FileText className="h-4 w-4 text-blue-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">{doc.name}</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{formatBytes(doc.fileSize ?? 0)} •{" "}
|
||||
{new Date(doc.createdAt).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleDownload(doc.storagePath, doc.name)}
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
onClick={() => {
|
||||
if (
|
||||
confirm("Are you sure you want to delete this file?")
|
||||
) {
|
||||
deleteDocument.mutate({ id: doc.id });
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,120 +13,112 @@ import { TrialAnalysisView } from "~/components/trials/views/TrialAnalysisView";
|
||||
import { api } from "~/trpc/react";
|
||||
|
||||
function AnalysisPageContent() {
|
||||
const params = useParams();
|
||||
const studyId: string = typeof params.id === "string" ? params.id : "";
|
||||
const trialId: string =
|
||||
typeof params.trialId === "string" ? params.trialId : "";
|
||||
const params = useParams();
|
||||
const studyId: string = typeof params.id === "string" ? params.id : "";
|
||||
const trialId: string =
|
||||
typeof params.trialId === "string" ? params.trialId : "";
|
||||
|
||||
const { setSelectedStudyId, selectedStudyId } = useStudyContext();
|
||||
const { study } = useSelectedStudyDetails();
|
||||
const { setSelectedStudyId, selectedStudyId } = useStudyContext();
|
||||
const { study } = useSelectedStudyDetails();
|
||||
|
||||
// Get trial data
|
||||
const {
|
||||
data: trial,
|
||||
isLoading,
|
||||
error,
|
||||
} = api.trials.get.useQuery({ id: trialId }, { enabled: !!trialId });
|
||||
// Get trial data
|
||||
const {
|
||||
data: trial,
|
||||
isLoading,
|
||||
error,
|
||||
} = api.trials.get.useQuery({ id: trialId }, { enabled: !!trialId });
|
||||
|
||||
// Set breadcrumbs
|
||||
useBreadcrumbsEffect([
|
||||
{ label: "Dashboard", href: "/dashboard" },
|
||||
{ label: "Studies", href: "/studies" },
|
||||
{ label: study?.name ?? "Study", href: `/studies/${studyId}` },
|
||||
{ label: "Trials", href: `/studies/${studyId}/trials` },
|
||||
{
|
||||
label: trial?.experiment.name ?? "Trial",
|
||||
href: `/studies/${studyId}/trials`,
|
||||
},
|
||||
{ label: "Analysis" },
|
||||
]);
|
||||
// Set breadcrumbs
|
||||
useBreadcrumbsEffect([
|
||||
{ label: "Dashboard", href: "/dashboard" },
|
||||
{ label: "Studies", href: "/studies" },
|
||||
{ label: study?.name ?? "Study", href: `/studies/${studyId}` },
|
||||
{ label: "Trials", href: `/studies/${studyId}/trials` },
|
||||
{
|
||||
label: trial?.experiment.name ?? "Trial",
|
||||
href: `/studies/${studyId}/trials`,
|
||||
},
|
||||
{ label: "Analysis" },
|
||||
]);
|
||||
|
||||
// Sync selected study (unified study-context)
|
||||
useEffect(() => {
|
||||
if (studyId && selectedStudyId !== studyId) {
|
||||
setSelectedStudyId(studyId);
|
||||
}
|
||||
}, [studyId, selectedStudyId, setSelectedStudyId]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-96 items-center justify-center">
|
||||
<div className="text-muted-foreground">Loading analysis...</div>
|
||||
</div>
|
||||
);
|
||||
// Sync selected study (unified study-context)
|
||||
useEffect(() => {
|
||||
if (studyId && selectedStudyId !== studyId) {
|
||||
setSelectedStudyId(studyId);
|
||||
}
|
||||
}, [studyId, selectedStudyId, setSelectedStudyId]);
|
||||
|
||||
if (error || !trial) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Trial Analysis"
|
||||
description="Analyze trial results"
|
||||
icon={LineChart}
|
||||
actions={
|
||||
<Button asChild variant="outline">
|
||||
<Link href={`/studies/${studyId}/trials`}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Trials
|
||||
</Link>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<div className="flex h-96 items-center justify-center">
|
||||
<div className="text-center">
|
||||
<h3 className="text-destructive mb-2 text-lg font-semibold">
|
||||
{error ? "Error Loading Trial" : "Trial Not Found"}
|
||||
</h3>
|
||||
<p className="text-muted-foreground">
|
||||
{error?.message || "The requested trial could not be found."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const trialData = {
|
||||
...trial,
|
||||
startedAt: trial.startedAt ? new Date(trial.startedAt) : null,
|
||||
completedAt: trial.completedAt ? new Date(trial.completedAt) : null,
|
||||
eventCount: (trial as any).eventCount,
|
||||
mediaCount: (trial as any).mediaCount,
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<PageHeader
|
||||
title="Trial Analysis"
|
||||
description={`Analysis for Session ${trial.sessionNumber} • Participant ${trial.participant.participantCode}`}
|
||||
icon={LineChart}
|
||||
actions={
|
||||
<Button asChild variant="outline">
|
||||
<Link href={`/studies/${studyId}/trials/${trialId}`}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Trial Details
|
||||
</Link>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="min-h-0 flex-1">
|
||||
<TrialAnalysisView trial={trialData} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex h-96 items-center justify-center">
|
||||
<div className="text-muted-foreground">Loading analysis...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !trial) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Trial Analysis"
|
||||
description="Analyze trial results"
|
||||
icon={LineChart}
|
||||
actions={
|
||||
<Button asChild variant="outline">
|
||||
<Link href={`/studies/${studyId}/trials`}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Trials
|
||||
</Link>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<div className="flex h-96 items-center justify-center">
|
||||
<div className="text-center">
|
||||
<h3 className="text-destructive mb-2 text-lg font-semibold">
|
||||
{error ? "Error Loading Trial" : "Trial Not Found"}
|
||||
</h3>
|
||||
<p className="text-muted-foreground">
|
||||
{error?.message || "The requested trial could not be found."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const customTrialData = {
|
||||
...trial,
|
||||
startedAt: trial.startedAt ? new Date(trial.startedAt) : null,
|
||||
completedAt: trial.completedAt ? new Date(trial.completedAt) : null,
|
||||
eventCount: (trial as any).eventCount,
|
||||
mediaCount: (trial as any).mediaCount,
|
||||
media:
|
||||
trial.media?.map((m) => ({
|
||||
...m,
|
||||
mediaType: m.mediaType ?? "video",
|
||||
format: m.format ?? undefined,
|
||||
contentType: m.contentType ?? undefined,
|
||||
})) ?? [],
|
||||
};
|
||||
|
||||
return (
|
||||
<TrialAnalysisView
|
||||
trial={customTrialData}
|
||||
backHref={`/studies/${studyId}/trials/${trialId}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TrialAnalysisPage() {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex h-96 items-center justify-center">
|
||||
<div className="text-muted-foreground">Loading...</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<AnalysisPageContent />
|
||||
</Suspense>
|
||||
);
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex h-96 items-center justify-center">
|
||||
<div className="text-muted-foreground">Loading...</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<AnalysisPageContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,14 @@
|
||||
import { useParams } from "next/navigation";
|
||||
import { Suspense, useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import { Play, Zap, ArrowLeft, User, FlaskConical, LineChart } from "lucide-react";
|
||||
import {
|
||||
Play,
|
||||
Zap,
|
||||
ArrowLeft,
|
||||
User,
|
||||
FlaskConical,
|
||||
LineChart,
|
||||
} from "lucide-react";
|
||||
import { PageHeader } from "~/components/ui/page-header";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
@@ -140,6 +147,12 @@ function TrialDetailContent() {
|
||||
title={`Trial: ${trial.participant.participantCode}`}
|
||||
description={`${trial.experiment.name} - Session ${trial.sessionNumber}`}
|
||||
icon={Play}
|
||||
badges={[
|
||||
{
|
||||
label: trial.status.replace("_", " ").toUpperCase(),
|
||||
variant: getStatusBadgeVariant(trial.status),
|
||||
},
|
||||
]}
|
||||
actions={
|
||||
<div className="flex gap-2">
|
||||
{trial.status === "scheduled" && (
|
||||
@@ -150,13 +163,13 @@ function TrialDetailContent() {
|
||||
)}
|
||||
{(trial.status === "in_progress" ||
|
||||
trial.status === "scheduled") && (
|
||||
<Button asChild>
|
||||
<Link href={`/studies/${studyId}/trials/${trialId}/wizard`}>
|
||||
<Zap className="mr-2 h-4 w-4" />
|
||||
Wizard Interface
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
<Button asChild>
|
||||
<Link href={`/studies/${studyId}/trials/${trialId}/wizard`}>
|
||||
<Zap className="mr-2 h-4 w-4" />
|
||||
Wizard Interface
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
{trial.status === "completed" && (
|
||||
<Button asChild>
|
||||
<Link href={`/studies/${studyId}/trials/${trialId}/analysis`}>
|
||||
|
||||
@@ -171,10 +171,27 @@ function WizardPageContent() {
|
||||
|
||||
const renderView = () => {
|
||||
const trialData = {
|
||||
...trial,
|
||||
id: trial.id,
|
||||
status: trial.status,
|
||||
scheduledAt: trial.scheduledAt,
|
||||
startedAt: trial.startedAt,
|
||||
completedAt: trial.completedAt,
|
||||
duration: trial.duration,
|
||||
sessionNumber: trial.sessionNumber,
|
||||
notes: trial.notes,
|
||||
metadata: trial.metadata as Record<string, unknown> | null,
|
||||
experimentId: trial.experimentId,
|
||||
participantId: trial.participantId,
|
||||
wizardId: trial.wizardId,
|
||||
experiment: {
|
||||
id: trial.experiment.id,
|
||||
name: trial.experiment.name,
|
||||
description: trial.experiment.description,
|
||||
studyId: trial.experiment.studyId,
|
||||
},
|
||||
participant: {
|
||||
...trial.participant,
|
||||
id: trial.participant.id,
|
||||
participantCode: trial.participant.participantCode,
|
||||
demographics: trial.participant.demographics as Record<
|
||||
string,
|
||||
unknown
|
||||
@@ -184,7 +201,7 @@ function WizardPageContent() {
|
||||
|
||||
switch (currentRole) {
|
||||
case "wizard":
|
||||
return <WizardView trial={trialData} />;
|
||||
return <WizardView trial={trialData} userRole={currentRole} />;
|
||||
case "observer":
|
||||
return <ObserverView trial={trialData} />;
|
||||
case "participant":
|
||||
@@ -194,27 +211,7 @@ function WizardPageContent() {
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<PageHeader
|
||||
title={getViewTitle(currentRole)}
|
||||
description={`Session ${trial.sessionNumber} • Participant ${trial.participant.participantCode}`}
|
||||
icon={getViewIcon(currentRole)}
|
||||
actions={
|
||||
currentRole !== "participant" ? (
|
||||
<Button asChild variant="outline">
|
||||
<Link href={`/studies/${studyId}/trials/${trialId}`}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Trial
|
||||
</Link>
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="min-h-0 flex-1">{renderView()}</div>
|
||||
</div>
|
||||
);
|
||||
return <div>{renderView()}</div>;
|
||||
}
|
||||
|
||||
export default function TrialWizardPage() {
|
||||
|
||||
@@ -25,7 +25,7 @@ const handler = (req: NextRequest) =>
|
||||
env.NODE_ENV === "development"
|
||||
? ({ path, error }) => {
|
||||
console.error(
|
||||
`❌ tRPC failed on ${path ?? "<no-path>"}: ${error.message}`,
|
||||
`[tRPC Error] tRPC failed on ${path ?? "<no-path>"}: ${error.message}`,
|
||||
);
|
||||
}
|
||||
: undefined,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { NextResponse, type NextRequest } from "next/server";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
} from "~/lib/storage/minio";
|
||||
import { auth } from "~/server/auth";
|
||||
import { db } from "~/server/db";
|
||||
import { mediaCaptures, trials } from "~/server/db/schema";
|
||||
import { experiments, mediaCaptures, studyMembers, trials } from "~/server/db/schema";
|
||||
|
||||
const uploadSchema = z.object({
|
||||
trialId: z.string().optional(),
|
||||
@@ -71,16 +71,37 @@ export async function POST(request: NextRequest) {
|
||||
// Check trial access if trialId is provided
|
||||
if (validatedTrialId) {
|
||||
const trial = await db
|
||||
.select()
|
||||
.select({
|
||||
id: trials.id,
|
||||
studyId: experiments.studyId,
|
||||
})
|
||||
.from(trials)
|
||||
.innerJoin(experiments, eq(trials.experimentId, experiments.id))
|
||||
.where(eq(trials.id, validatedTrialId))
|
||||
.limit(1);
|
||||
|
||||
if (!trial.length) {
|
||||
if (!trial.length || !trial[0]) {
|
||||
return NextResponse.json({ error: "Trial not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
// TODO: Check if user has access to this trial through study membership
|
||||
// Check if user has access to this trial through study membership
|
||||
const membership = await db
|
||||
.select()
|
||||
.from(studyMembers)
|
||||
.where(
|
||||
and(
|
||||
eq(studyMembers.studyId, trial[0].studyId),
|
||||
eq(studyMembers.userId, session.user.id)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!membership.length) {
|
||||
return NextResponse.json(
|
||||
{ error: "Insufficient permissions to upload to this trial" },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Generate unique file key
|
||||
|
||||
@@ -10,8 +10,9 @@ import {
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle
|
||||
CardTitle,
|
||||
} from "~/components/ui/card";
|
||||
import { Checkbox } from "~/components/ui/checkbox";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { Label } from "~/components/ui/label";
|
||||
import { Logo } from "~/components/ui/logo";
|
||||
@@ -21,6 +22,7 @@ export default function SignInPage() {
|
||||
const [password, setPassword] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [notRobot, setNotRobot] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
@@ -28,6 +30,12 @@ export default function SignInPage() {
|
||||
setIsLoading(true);
|
||||
setError("");
|
||||
|
||||
if (!notRobot) {
|
||||
setError("Please confirm you're not a robot");
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await signIn("credentials", {
|
||||
email,
|
||||
@@ -53,25 +61,30 @@ export default function SignInPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative flex min-h-screen items-center justify-center overflow-hidden bg-background px-4">
|
||||
<div className="bg-background relative flex min-h-screen items-center justify-center overflow-hidden px-4">
|
||||
{/* Background Gradients */}
|
||||
<div className="absolute top-0 left-1/2 -z-10 h-[500px] w-[1000px] -translate-x-1/2 rounded-full bg-primary/20 blur-3xl opacity-30 dark:opacity-20" />
|
||||
<div className="absolute bottom-0 right-0 -z-10 h-[300px] w-[300px] rounded-full bg-violet-500/10 blur-3xl" />
|
||||
<div className="bg-primary/20 absolute top-0 left-1/2 -z-10 h-[500px] w-[1000px] -translate-x-1/2 rounded-full opacity-30 blur-3xl dark:opacity-20" />
|
||||
<div className="absolute right-0 bottom-0 -z-10 h-[300px] w-[300px] rounded-full bg-violet-500/10 blur-3xl" />
|
||||
|
||||
<div className="w-full max-w-md animate-in fade-in zoom-in-95 duration-500">
|
||||
<div className="animate-in fade-in zoom-in-95 w-full max-w-md duration-500">
|
||||
{/* Header */}
|
||||
<div className="mb-8 text-center">
|
||||
<Link href="/" className="inline-flex items-center justify-center transition-opacity hover:opacity-80">
|
||||
<Logo iconSize="lg" showText={false} />
|
||||
<Link
|
||||
href="/"
|
||||
className="inline-flex items-center justify-center transition-opacity hover:opacity-80"
|
||||
>
|
||||
<Logo iconSize="lg" showText={true} />
|
||||
</Link>
|
||||
<h1 className="mt-6 text-2xl font-bold tracking-tight text-foreground">Welcome back</h1>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
<h1 className="text-foreground mt-6 text-2xl font-bold tracking-tight">
|
||||
Welcome back
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-2 text-sm">
|
||||
Sign in to your research account
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Sign In Card */}
|
||||
<Card className="border-muted/40 bg-card/50 backdrop-blur-sm shadow-xl">
|
||||
<Card className="border-muted/40 bg-card/50 shadow-xl backdrop-blur-sm">
|
||||
<CardHeader>
|
||||
<CardTitle>Sign In</CardTitle>
|
||||
<CardDescription>
|
||||
@@ -81,7 +94,7 @@ export default function SignInPage() {
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{error && (
|
||||
<div className="rounded-md bg-destructive/15 p-3 text-sm text-destructive font-medium border border-destructive/20">
|
||||
<div className="bg-destructive/15 text-destructive border-destructive/20 rounded-md border p-3 text-sm font-medium">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
@@ -103,7 +116,12 @@ export default function SignInPage() {
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Link href="#" className="text-xs text-primary hover:underline">Forgot password?</Link>
|
||||
<Link
|
||||
href="#"
|
||||
className="text-primary text-xs hover:underline"
|
||||
>
|
||||
Forgot password?
|
||||
</Link>
|
||||
</div>
|
||||
<Input
|
||||
id="password"
|
||||
@@ -116,16 +134,39 @@ export default function SignInPage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button type="submit" className="w-full" disabled={isLoading} size="lg">
|
||||
<div className="flex items-center space-x-2 py-2">
|
||||
<Checkbox
|
||||
id="not-robot"
|
||||
checked={notRobot}
|
||||
onCheckedChange={(checked) => setNotRobot(checked === true)}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<label
|
||||
htmlFor="not-robot"
|
||||
className="cursor-pointer text-sm leading-none font-medium peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
I'm not a robot{" "}
|
||||
<span className="text-muted-foreground text-xs italic">
|
||||
(ironic, isn't it?)
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={isLoading}
|
||||
size="lg"
|
||||
>
|
||||
{isLoading ? "Signing in..." : "Sign In"}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<div className="mt-6 text-center text-sm text-muted-foreground">
|
||||
<div className="text-muted-foreground mt-6 text-center text-sm">
|
||||
Don't have an account?{" "}
|
||||
<Link
|
||||
href="/auth/signup"
|
||||
className="font-medium text-primary hover:text-primary/80"
|
||||
className="text-primary hover:text-primary/80 font-medium"
|
||||
>
|
||||
Sign up
|
||||
</Link>
|
||||
@@ -134,7 +175,7 @@ export default function SignInPage() {
|
||||
</Card>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="mt-8 text-center text-xs text-muted-foreground">
|
||||
<div className="text-muted-foreground mt-8 text-center text-xs">
|
||||
<p>
|
||||
© {new Date().getFullYear()} HRIStudio. All rights reserved.
|
||||
</p>
|
||||
|
||||
@@ -6,11 +6,11 @@ import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "~/components/ui/card";
|
||||
|
||||
export default function SignOutPage() {
|
||||
@@ -44,7 +44,7 @@ export default function SignOutPage() {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-gradient-to-br from-slate-50 to-slate-100">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
|
||||
<div className="mx-auto mb-4 h-12 w-12 animate-spin rounded-full border-b-2 border-blue-600"></div>
|
||||
<p className="text-slate-600">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -79,7 +79,8 @@ export default function SignOutPage() {
|
||||
<CardContent className="space-y-4">
|
||||
<div className="rounded-md bg-blue-50 p-3 text-sm text-blue-700">
|
||||
<p className="font-medium">
|
||||
Currently signed in as: {session.user.name ?? session.user.email}
|
||||
Currently signed in as:{" "}
|
||||
{session.user.name ?? session.user.email}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle
|
||||
CardTitle,
|
||||
} from "~/components/ui/card";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { Label } from "~/components/ui/label";
|
||||
@@ -56,25 +56,30 @@ export default function SignUpPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative flex min-h-screen items-center justify-center overflow-hidden bg-background px-4">
|
||||
<div className="bg-background relative flex min-h-screen items-center justify-center overflow-hidden px-4">
|
||||
{/* Background Gradients */}
|
||||
<div className="absolute top-0 left-1/2 -z-10 h-[500px] w-[1000px] -translate-x-1/2 rounded-full bg-primary/20 blur-3xl opacity-30 dark:opacity-20" />
|
||||
<div className="bg-primary/20 absolute top-0 left-1/2 -z-10 h-[500px] w-[1000px] -translate-x-1/2 rounded-full opacity-30 blur-3xl dark:opacity-20" />
|
||||
<div className="absolute bottom-0 left-0 -z-10 h-[300px] w-[300px] rounded-full bg-blue-500/10 blur-3xl" />
|
||||
|
||||
<div className="w-full max-w-md animate-in fade-in zoom-in-95 duration-500">
|
||||
<div className="animate-in fade-in zoom-in-95 w-full max-w-md duration-500">
|
||||
{/* Header */}
|
||||
<div className="mb-8 text-center">
|
||||
<Link href="/" className="inline-flex items-center justify-center transition-opacity hover:opacity-80">
|
||||
<Link
|
||||
href="/"
|
||||
className="inline-flex items-center justify-center transition-opacity hover:opacity-80"
|
||||
>
|
||||
<Logo iconSize="lg" showText={false} />
|
||||
</Link>
|
||||
<h1 className="mt-6 text-2xl font-bold tracking-tight text-foreground">Create an account</h1>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
<h1 className="text-foreground mt-6 text-2xl font-bold tracking-tight">
|
||||
Create an account
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-2 text-sm">
|
||||
Start your journey in HRI research
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Sign Up Card */}
|
||||
<Card className="border-muted/40 bg-card/50 backdrop-blur-sm shadow-xl">
|
||||
<Card className="border-muted/40 bg-card/50 shadow-xl backdrop-blur-sm">
|
||||
<CardHeader>
|
||||
<CardTitle>Sign Up</CardTitle>
|
||||
<CardDescription>
|
||||
@@ -84,7 +89,7 @@ export default function SignUpPage() {
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{error && (
|
||||
<div className="rounded-md bg-destructive/15 p-3 text-sm text-destructive font-medium border border-destructive/20">
|
||||
<div className="bg-destructive/15 text-destructive border-destructive/20 rounded-md border p-3 text-sm font-medium">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
@@ -155,15 +160,17 @@ export default function SignUpPage() {
|
||||
disabled={createUser.isPending}
|
||||
size="lg"
|
||||
>
|
||||
{createUser.isPending ? "Creating account..." : "Create Account"}
|
||||
{createUser.isPending
|
||||
? "Creating account..."
|
||||
: "Create Account"}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<div className="mt-6 text-center text-sm text-muted-foreground">
|
||||
<div className="text-muted-foreground mt-6 text-center text-sm">
|
||||
Already have an account?{" "}
|
||||
<Link
|
||||
href="/auth/signin"
|
||||
className="font-medium text-primary hover:text-primary/80"
|
||||
className="text-primary hover:text-primary/80 font-medium"
|
||||
>
|
||||
Sign in
|
||||
</Link>
|
||||
@@ -172,7 +179,7 @@ export default function SignUpPage() {
|
||||
</Card>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="mt-8 text-center text-xs text-muted-foreground">
|
||||
<div className="text-muted-foreground mt-8 text-center text-xs">
|
||||
<p>
|
||||
© {new Date().getFullYear()} HRIStudio. All rights reserved.
|
||||
</p>
|
||||
|
||||
@@ -7,18 +7,26 @@ import { formatDistanceToNow } from "date-fns";
|
||||
import {
|
||||
Activity,
|
||||
ArrowRight,
|
||||
Bot,
|
||||
Calendar,
|
||||
CheckCircle,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
FlaskConical,
|
||||
HelpCircle,
|
||||
LayoutDashboard,
|
||||
MoreHorizontal,
|
||||
Play,
|
||||
PlayCircle,
|
||||
Plus,
|
||||
Search,
|
||||
Settings,
|
||||
Users,
|
||||
Radio,
|
||||
Gamepad2,
|
||||
AlertTriangle,
|
||||
Bot,
|
||||
User,
|
||||
MessageSquare,
|
||||
} from "lucide-react";
|
||||
|
||||
import { Button } from "~/components/ui/button";
|
||||
@@ -49,9 +57,11 @@ import { Badge } from "~/components/ui/badge";
|
||||
import { ScrollArea } from "~/components/ui/scroll-area";
|
||||
import { api } from "~/trpc/react";
|
||||
import { useTour } from "~/components/onboarding/TourProvider";
|
||||
import { useSession } from "next-auth/react";
|
||||
|
||||
export default function DashboardPage() {
|
||||
const { startTour } = useTour();
|
||||
const { data: session } = useSession();
|
||||
const [studyFilter, setStudyFilter] = React.useState<string | null>(null);
|
||||
|
||||
// --- Data Fetching ---
|
||||
@@ -65,14 +75,13 @@ export default function DashboardPage() {
|
||||
studyId: studyFilter ?? undefined,
|
||||
});
|
||||
|
||||
const { data: scheduledTrials } = api.trials.list.useQuery({
|
||||
studyId: studyFilter ?? undefined,
|
||||
status: "scheduled",
|
||||
limit: 5,
|
||||
});
|
||||
const { data: liveTrials } = api.dashboard.getLiveTrials.useQuery(
|
||||
{ studyId: studyFilter ?? undefined },
|
||||
{ refetchInterval: 5000 },
|
||||
);
|
||||
|
||||
const { data: recentActivity } = api.dashboard.getRecentActivity.useQuery({
|
||||
limit: 10,
|
||||
limit: 15,
|
||||
studyId: studyFilter ?? undefined,
|
||||
});
|
||||
|
||||
@@ -81,19 +90,40 @@ export default function DashboardPage() {
|
||||
studyId: studyFilter ?? undefined,
|
||||
});
|
||||
|
||||
const userName = session?.user?.name ?? "Researcher";
|
||||
|
||||
const getWelcomeMessage = () => {
|
||||
const hour = new Date().getHours();
|
||||
let greeting = "Good evening";
|
||||
if (hour < 12) greeting = "Good morning";
|
||||
else if (hour < 18) greeting = "Good afternoon";
|
||||
|
||||
return `${greeting}, ${userName.split(" ")[0]}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col space-y-8 animate-in fade-in duration-500">
|
||||
<div className="animate-in fade-in space-y-8 duration-500">
|
||||
{/* Header Section */}
|
||||
<div id="dashboard-header" className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<div
|
||||
id="dashboard-header"
|
||||
className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between"
|
||||
>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight text-foreground">Dashboard</h1>
|
||||
<h1 className="text-foreground text-3xl font-bold tracking-tight">
|
||||
{getWelcomeMessage()}
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Overview of your research activities and upcoming tasks.
|
||||
Here's what's happening with your research today.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="ghost" size="icon" onClick={() => startTour("dashboard")} title="Start Tour">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => startTour("dashboard")}
|
||||
title="Start Tour"
|
||||
>
|
||||
<HelpCircle className="h-5 w-5" />
|
||||
</Button>
|
||||
<Select
|
||||
@@ -102,7 +132,7 @@ export default function DashboardPage() {
|
||||
setStudyFilter(value === "all" ? null : value)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-[200px] bg-background">
|
||||
<SelectTrigger className="bg-background w-[200px]">
|
||||
<SelectValue placeholder="All Studies" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -123,166 +153,296 @@ export default function DashboardPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div id="tour-dashboard-stats" className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<StatsCard
|
||||
title="Total Participants"
|
||||
value={stats?.totalParticipants ?? 0}
|
||||
icon={Users}
|
||||
description="Across all studies"
|
||||
trend="+2 this week"
|
||||
/>
|
||||
{/* Main Stats Grid */}
|
||||
<div
|
||||
id="tour-dashboard-stats"
|
||||
className="grid gap-4 md:grid-cols-2 lg:grid-cols-4"
|
||||
>
|
||||
<StatsCard
|
||||
title="Active Trials"
|
||||
value={stats?.activeTrials ?? 0}
|
||||
icon={Activity}
|
||||
description="Currently in progress"
|
||||
|
||||
description="Currently running sessions"
|
||||
iconColor="text-emerald-500"
|
||||
/>
|
||||
<StatsCard
|
||||
title="Completed Trials"
|
||||
title="Completed Today"
|
||||
value={stats?.completedToday ?? 0}
|
||||
icon={CheckCircle2}
|
||||
description="Completed today"
|
||||
icon={CheckCircle}
|
||||
description="Successful completions"
|
||||
iconColor="text-blue-500"
|
||||
/>
|
||||
<StatsCard
|
||||
title="Scheduled"
|
||||
value={stats?.scheduledTrials ?? 0}
|
||||
icon={Calendar}
|
||||
description="Upcoming sessions"
|
||||
iconColor="text-violet-500"
|
||||
/>
|
||||
<StatsCard
|
||||
title="Total Interventions"
|
||||
value={stats?.totalInterventions ?? 0}
|
||||
icon={Gamepad2}
|
||||
description="Wizard manual overrides"
|
||||
iconColor="text-orange-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Action Center & Recent Activity */}
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-7">
|
||||
|
||||
{/* Main Column: Scheduled Trials & Study Progress */}
|
||||
<div className="col-span-4 space-y-4">
|
||||
|
||||
{/* Scheduled Trials */}
|
||||
<Card id="tour-scheduled-trials" className="col-span-4 border-muted/40 shadow-sm">
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>Upcoming Sessions</CardTitle>
|
||||
<CardDescription>
|
||||
You have {scheduledTrials?.length ?? 0} scheduled trials coming up.
|
||||
</CardDescription>
|
||||
{/* Quick Actions Card */}
|
||||
<Card className="from-primary/5 to-background border-primary/20 col-span-3 h-fit bg-gradient-to-br">
|
||||
<CardHeader>
|
||||
<CardTitle>Quick Actions</CardTitle>
|
||||
<CardDescription>Common tasks to get you started</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-primary/20 hover:border-primary/50 hover:bg-primary/5 group h-auto justify-start px-4 py-4"
|
||||
asChild
|
||||
>
|
||||
<Link href="/studies/new">
|
||||
<div className="bg-primary/10 group-hover:bg-primary/20 mr-4 rounded-full p-2 transition-colors">
|
||||
<FlaskConical className="text-primary h-5 w-5" />
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link href="/trials?status=scheduled">View All <ArrowRight className="ml-2 h-4 w-4" /></Link>
|
||||
<div className="text-left">
|
||||
<div className="font-semibold">Create New Study</div>
|
||||
<div className="text-muted-foreground text-xs font-normal">
|
||||
Design a new experiment protocol
|
||||
</div>
|
||||
</div>
|
||||
<ArrowRight className="text-muted-foreground group-hover:text-primary ml-auto h-4 w-4 opacity-0 transition-all group-hover:opacity-100" />
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
className="group h-auto justify-start px-4 py-4"
|
||||
asChild
|
||||
>
|
||||
<Link href="/studies">
|
||||
<div className="bg-secondary mr-4 rounded-full p-2">
|
||||
<Search className="text-foreground h-5 w-5" />
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<div className="font-semibold">Browse Studies</div>
|
||||
<div className="text-muted-foreground text-xs font-normal">
|
||||
Find and manage existing studies
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
className="group h-auto justify-start px-4 py-4"
|
||||
asChild
|
||||
>
|
||||
<Link href="/trials">
|
||||
<div className="mr-4 rounded-full bg-emerald-500/10 p-2">
|
||||
<Activity className="h-5 w-5 text-emerald-600" />
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<div className="font-semibold">Monitor Active Trials</div>
|
||||
<div className="text-muted-foreground text-xs font-normal">
|
||||
Jump into the Wizard Interface
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Recent Activity Card */}
|
||||
<Card
|
||||
id="tour-recent-activity"
|
||||
className="border-muted/40 col-span-4 shadow-sm"
|
||||
>
|
||||
<CardHeader>
|
||||
<CardTitle>Recent Activity</CardTitle>
|
||||
<CardDescription>
|
||||
Your latest interactions across the platform
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ScrollArea className="h-[400px] pr-4">
|
||||
<div className="space-y-4">
|
||||
{recentActivity?.map((activity) => {
|
||||
let eventColor = "bg-primary/30 ring-background";
|
||||
let Icon = Activity;
|
||||
if (activity.type === "trial_started") {
|
||||
eventColor = "bg-blue-500 ring-blue-100 dark:ring-blue-900";
|
||||
Icon = PlayCircle;
|
||||
} else if (activity.type === "trial_completed") {
|
||||
eventColor =
|
||||
"bg-green-500 ring-green-100 dark:ring-green-900";
|
||||
Icon = CheckCircle;
|
||||
} else if (activity.type === "error") {
|
||||
eventColor = "bg-red-500 ring-red-100 dark:ring-red-900";
|
||||
Icon = AlertTriangle;
|
||||
} else if (activity.type === "intervention") {
|
||||
eventColor =
|
||||
"bg-orange-500 ring-orange-100 dark:ring-orange-900";
|
||||
Icon = Gamepad2;
|
||||
} else if (activity.type === "annotation") {
|
||||
eventColor =
|
||||
"bg-yellow-500 ring-yellow-100 dark:ring-yellow-900";
|
||||
Icon = MessageSquare;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={activity.id}
|
||||
className="border-muted-foreground/20 relative border-l pb-4 pl-6 last:border-0"
|
||||
>
|
||||
<span
|
||||
className={`absolute top-0 left-[-9px] flex h-4 w-4 items-center justify-center rounded-full ring-4 ${eventColor}`}
|
||||
>
|
||||
<Icon className="h-2.5 w-2.5 text-white" />
|
||||
</span>
|
||||
<div className="mb-0.5 text-sm leading-none font-medium">
|
||||
{activity.title}
|
||||
</div>
|
||||
<div className="text-muted-foreground mb-1 text-xs">
|
||||
{activity.description}
|
||||
</div>
|
||||
<div className="text-muted-foreground/70 font-mono text-[10px] uppercase">
|
||||
{formatDistanceToNow(new Date(activity.time), {
|
||||
addSuffix: true,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{!recentActivity?.length && (
|
||||
<div className="text-muted-foreground flex flex-col items-center justify-center py-8 text-center">
|
||||
<Clock className="mb-3 h-10 w-10 opacity-20" />
|
||||
<p>No recent activity recorded.</p>
|
||||
<p className="mt-1 text-xs">
|
||||
Start a trial to see experiment events stream here.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-7">
|
||||
{/* Live Trials */}
|
||||
<Card
|
||||
id="tour-live-trials"
|
||||
className={`${liveTrials && liveTrials.length > 0 ? "border-primary bg-primary/5 shadow-sm" : "border-muted/40"} col-span-4 transition-colors duration-500`}
|
||||
>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
Live Sessions
|
||||
{liveTrials && liveTrials.length > 0 && (
|
||||
<span className="relative flex h-3 w-3">
|
||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-red-400 opacity-75"></span>
|
||||
<span className="relative inline-flex h-3 w-3 rounded-full bg-red-500"></span>
|
||||
</span>
|
||||
)}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Currently running trials in the Wizard interface
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link href="/trials">
|
||||
View All <ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{!liveTrials?.length ? (
|
||||
<div className="border-muted-foreground/30 animate-in fade-in-50 bg-background/50 flex h-[150px] flex-col items-center justify-center rounded-md border border-dashed text-center">
|
||||
<Radio className="text-muted-foreground/50 mb-2 h-8 w-8" />
|
||||
<p className="text-muted-foreground text-sm">
|
||||
No trials are currently running.
|
||||
</p>
|
||||
<Button variant="link" size="sm" asChild className="mt-1">
|
||||
<Link href="/trials">Start a Trial</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{!scheduledTrials?.length ? (
|
||||
<div className="flex h-[150px] flex-col items-center justify-center rounded-md border border-dashed text-center animate-in fade-in-50">
|
||||
<Calendar className="h-8 w-8 text-muted-foreground/50" />
|
||||
<p className="mt-2 text-sm text-muted-foreground">No scheduled trials found.</p>
|
||||
<Button variant="link" size="sm" asChild className="mt-1">
|
||||
<Link href="/trials/new">Schedule a Trial</Link>
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{scheduledTrials.map((trial) => (
|
||||
<div key={trial.id} className="flex items-center justify-between rounded-lg border p-3 hover:bg-muted/50 transition-colors">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-blue-100 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400">
|
||||
<Calendar className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-sm">
|
||||
{trial.participant.participantCode}
|
||||
<span className="ml-2 text-muted-foreground font-normal text-xs">• {trial.experiment.name}</span>
|
||||
</p>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Clock className="h-3 w-3" />
|
||||
{trial.scheduledAt ? format(trial.scheduledAt, "MMM d, h:mm a") : "Unscheduled"}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{liveTrials.map((trial) => (
|
||||
<div
|
||||
key={trial.id}
|
||||
className="border-primary/20 bg-background flex items-center justify-between rounded-lg border p-3 shadow-sm transition-all duration-200 hover:shadow"
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-red-100 text-red-600 dark:bg-red-900/40 dark:text-red-400">
|
||||
<Radio className="h-5 w-5 animate-pulse" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium">
|
||||
{trial.participantCode}
|
||||
<span className="text-muted-foreground ml-2 text-xs font-normal">
|
||||
• {trial.experimentName}
|
||||
</span>
|
||||
</p>
|
||||
<div className="text-muted-foreground flex items-center gap-2 text-xs">
|
||||
<Clock className="h-3 w-3" />
|
||||
Started{" "}
|
||||
{trial.startedAt
|
||||
? formatDistanceToNow(new Date(trial.startedAt), {
|
||||
addSuffix: true,
|
||||
})
|
||||
: "just now"}
|
||||
</div>
|
||||
</div>
|
||||
<Button size="sm" className="gap-2" asChild>
|
||||
<Link href={`/wizard/${trial.id}`}>
|
||||
<Play className="h-3.5 w-3.5" /> Start
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Study Progress */}
|
||||
<Card className="border-muted/40 shadow-sm">
|
||||
<CardHeader>
|
||||
<CardTitle>Study Progress</CardTitle>
|
||||
<CardDescription>
|
||||
Completion tracking for active studies
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{studyProgress?.map((study) => (
|
||||
<div key={study.id} className="space-y-2">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<div className="font-medium">{study.name}</div>
|
||||
<div className="text-muted-foreground">{study.participants} / {study.totalParticipants} Participants</div>
|
||||
<Button
|
||||
size="sm"
|
||||
className="bg-primary hover:bg-primary/90 gap-2"
|
||||
asChild
|
||||
>
|
||||
<Link href={`/wizard/${trial.id}`}>
|
||||
<Play className="h-3.5 w-3.5" /> Spectate / Jump In
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Study Progress */}
|
||||
<Card className="border-muted/40 col-span-3 shadow-sm">
|
||||
<CardHeader>
|
||||
<CardTitle>Study Progress</CardTitle>
|
||||
<CardDescription>
|
||||
Completion tracking for active studies
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{studyProgress?.map((study) => (
|
||||
<div key={study.id} className="space-y-2">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<div className="font-medium">{study.name}</div>
|
||||
<div className="text-muted-foreground">
|
||||
{study.participants} / {study.totalParticipants}{" "}
|
||||
Participants
|
||||
</div>
|
||||
<Progress value={study.progress} className="h-2" />
|
||||
</div>
|
||||
))}
|
||||
{!studyProgress?.length && (
|
||||
<p className="text-sm text-muted-foreground text-center py-4">No active studies to track.</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Side Column: Recent Activity & Quick Actions */}
|
||||
<div className="col-span-3 space-y-4">
|
||||
{/* Quick Actions */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Button variant="outline" className="h-24 flex-col gap-2 hover:border-primary/50 hover:bg-primary/5" asChild>
|
||||
<Link href="/experiments/new">
|
||||
<Bot className="h-6 w-6 mb-1" />
|
||||
<span>New Experim.</span>
|
||||
</Link>
|
||||
</Button>
|
||||
<Button variant="outline" className="h-24 flex-col gap-2 hover:border-primary/50 hover:bg-primary/5" asChild>
|
||||
<Link href="/trials/new">
|
||||
<PlayCircle className="h-6 w-6 mb-1" />
|
||||
<span>Run Trial</span>
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Recent Activity */}
|
||||
<Card id="tour-recent-activity" className="border-muted/40 shadow-sm h-full">
|
||||
<CardHeader>
|
||||
<CardTitle>Recent Activity</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ScrollArea className="h-[400px] pr-4">
|
||||
<div className="space-y-4">
|
||||
{recentActivity?.map((activity) => (
|
||||
<div key={activity.id} className="relative pl-4 pb-1 border-l last:border-0 border-muted-foreground/20">
|
||||
<span className="absolute left-[-5px] top-1 h-2.5 w-2.5 rounded-full bg-primary/30 ring-4 ring-background" />
|
||||
<div className="mb-1 text-sm font-medium leading-none">{activity.title}</div>
|
||||
<div className="text-xs text-muted-foreground mb-1">{activity.description}</div>
|
||||
<div className="text-[10px] text-muted-foreground/70 uppercase">
|
||||
{formatDistanceToNow(activity.time, { addSuffix: true })}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{!recentActivity?.length && (
|
||||
<p className="text-sm text-muted-foreground text-center py-8">No recent activity.</p>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<Progress value={study.progress} className="h-2" />
|
||||
</div>
|
||||
))}
|
||||
{!studyProgress?.length && (
|
||||
<p className="text-muted-foreground py-4 text-center text-sm">
|
||||
No active studies to track.
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -294,24 +454,30 @@ function StatsCard({
|
||||
icon: Icon,
|
||||
description,
|
||||
trend,
|
||||
iconColor,
|
||||
}: {
|
||||
title: string;
|
||||
value: string | number;
|
||||
icon: React.ElementType;
|
||||
description: string;
|
||||
trend?: string;
|
||||
iconColor?: string;
|
||||
}) {
|
||||
return (
|
||||
<Card className="border-muted/40 shadow-sm">
|
||||
<Card className="border-muted/40 hover:border-primary/20 shadow-sm transition-all duration-200 hover:shadow-md">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">{title}</CardTitle>
|
||||
<Icon className="h-4 w-4 text-muted-foreground" />
|
||||
<Icon className={`h-4 w-4 ${iconColor || "text-muted-foreground"}`} />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{value}</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{description}
|
||||
{trend && <span className="ml-1 text-green-600 dark:text-green-400 font-medium">{trend}</span>}
|
||||
{trend && (
|
||||
<span className="ml-1 font-medium text-green-600 dark:text-green-400">
|
||||
{trend}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
209
src/app/page.tsx
209
src/app/page.tsx
@@ -16,6 +16,7 @@ import {
|
||||
PlayCircle,
|
||||
Settings2,
|
||||
Share2,
|
||||
Sparkles,
|
||||
} from "lucide-react";
|
||||
|
||||
export default async function Home() {
|
||||
@@ -26,9 +27,9 @@ export default async function Home() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col bg-background text-foreground">
|
||||
<div className="bg-background text-foreground flex min-h-screen flex-col">
|
||||
{/* Navbar */}
|
||||
<header className="sticky top-0 z-50 w-full border-b bg-background/80 backdrop-blur-sm">
|
||||
<header className="bg-background/80 sticky top-0 z-50 w-full border-b backdrop-blur-sm">
|
||||
<div className="container mx-auto flex h-16 items-center justify-between px-4">
|
||||
<Logo iconSize="md" showText={true} />
|
||||
<nav className="flex items-center gap-4">
|
||||
@@ -38,7 +39,7 @@ export default async function Home() {
|
||||
<Button variant="ghost" asChild className="hidden sm:inline-flex">
|
||||
<Link href="#architecture">Architecture</Link>
|
||||
</Button>
|
||||
<div className="h-6 w-px bg-border hidden sm:block" />
|
||||
<div className="bg-border hidden h-6 w-px sm:block" />
|
||||
<Button variant="ghost" asChild>
|
||||
<Link href="/auth/signin">Sign In</Link>
|
||||
</Button>
|
||||
@@ -53,11 +54,15 @@ export default async function Home() {
|
||||
{/* Hero Section */}
|
||||
<section className="relative overflow-hidden pt-20 pb-32 md:pt-32">
|
||||
{/* Background Gradients */}
|
||||
<div className="absolute top-0 left-1/2 -z-10 h-[500px] w-[1000px] -translate-x-1/2 rounded-full bg-primary/20 blur-3xl opacity-30 dark:opacity-20" />
|
||||
<div className="bg-primary/20 absolute top-0 left-1/2 -z-10 h-[500px] w-[1000px] -translate-x-1/2 rounded-full opacity-30 blur-3xl dark:opacity-20" />
|
||||
|
||||
<div className="container mx-auto flex flex-col items-center px-4 text-center">
|
||||
<Badge variant="secondary" className="mb-6 rounded-full px-4 py-1.5 text-sm font-medium">
|
||||
✨ The Modern Standard for HRI Research
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="mb-6 rounded-full px-4 py-1.5 text-sm font-medium"
|
||||
>
|
||||
<Sparkles className="mr-2 h-4 w-4 text-yellow-500" />
|
||||
The Modern Standard for HRI Research
|
||||
</Badge>
|
||||
|
||||
<h1 className="max-w-4xl text-5xl font-extrabold tracking-tight sm:text-6xl md:text-7xl">
|
||||
@@ -67,7 +72,7 @@ export default async function Home() {
|
||||
</span>
|
||||
</h1>
|
||||
|
||||
<p className="mt-6 max-w-2xl text-lg text-muted-foreground md:text-xl">
|
||||
<p className="text-muted-foreground mt-6 max-w-2xl text-lg md:text-xl">
|
||||
HRIStudio is the open-source platform that bridges the gap between
|
||||
ease of use and scientific rigor. Design, execute, and analyze
|
||||
human-robot interaction experiments with zero friction.
|
||||
@@ -80,22 +85,32 @@ export default async function Home() {
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
<Button size="lg" variant="outline" className="h-12 px-8 text-base" asChild>
|
||||
<Link href="https://github.com/robolab/hristudio" target="_blank">
|
||||
<Button
|
||||
size="lg"
|
||||
variant="outline"
|
||||
className="h-12 px-8 text-base"
|
||||
asChild
|
||||
>
|
||||
<Link
|
||||
href="https://github.com/robolab/hristudio"
|
||||
target="_blank"
|
||||
>
|
||||
View on GitHub
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Mockup / Visual Interest */}
|
||||
<div className="relative mt-20 w-full max-w-5xl rounded-xl border bg-background/50 p-2 shadow-2xl backdrop-blur-sm lg:rounded-2xl lg:p-4">
|
||||
<div className="absolute inset-x-0 -top-px mx-auto h-px w-3/4 bg-gradient-to-r from-transparent via-foreground/20 to-transparent" />
|
||||
<div className="aspect-[16/9] w-full overflow-hidden rounded-lg border bg-muted/50 flex items-center justify-center relative">
|
||||
<div className="bg-background/50 relative mt-20 w-full max-w-5xl rounded-xl border p-2 shadow-2xl backdrop-blur-sm lg:rounded-2xl lg:p-4">
|
||||
<div className="via-foreground/20 absolute inset-x-0 -top-px mx-auto h-px w-3/4 bg-gradient-to-r from-transparent to-transparent" />
|
||||
<div className="bg-muted/50 relative flex aspect-[16/9] w-full items-center justify-center overflow-hidden rounded-lg border">
|
||||
{/* Placeholder for actual app screenshot */}
|
||||
<div className="absolute inset-0 bg-gradient-to-tr from-blue-500/10 to-violet-500/10" />
|
||||
<div className="text-center p-8">
|
||||
<LayoutTemplate className="w-16 h-16 mx-auto text-muted-foreground/50 mb-4" />
|
||||
<p className="text-muted-foreground font-medium">Interactive Experiment Designer</p>
|
||||
<div className="p-8 text-center">
|
||||
<LayoutTemplate className="text-muted-foreground/50 mx-auto mb-4 h-16 w-16" />
|
||||
<p className="text-muted-foreground font-medium">
|
||||
Interactive Experiment Designer
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -105,13 +120,17 @@ export default async function Home() {
|
||||
{/* Features Bento Grid */}
|
||||
<section id="features" className="container mx-auto px-4 py-24">
|
||||
<div className="mb-12 text-center">
|
||||
<h2 className="text-3xl font-bold tracking-tight md:text-4xl">Everything You Need</h2>
|
||||
<p className="mt-4 text-lg text-muted-foreground">Built for the specific needs of HRI researchers and wizards.</p>
|
||||
<h2 className="text-3xl font-bold tracking-tight md:text-4xl">
|
||||
Everything You Need
|
||||
</h2>
|
||||
<p className="text-muted-foreground mt-4 text-lg">
|
||||
Built for the specific needs of HRI researchers and wizards.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-3 lg:grid-cols-4 lg:grid-rows-2">
|
||||
{/* Visual Designer - Large Item */}
|
||||
<Card className="col-span-1 md:col-span-2 lg:col-span-2 row-span-2 flex flex-col overflow-hidden bg-gradient-to-br from-blue-500/5 to-violet-500/5 dark:from-blue-900/10 dark:to-violet-900/10">
|
||||
<Card className="col-span-1 row-span-2 flex flex-col overflow-hidden bg-gradient-to-br from-blue-500/5 to-violet-500/5 md:col-span-2 lg:col-span-2 dark:from-blue-900/10 dark:to-violet-900/10">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<LayoutTemplate className="h-5 w-5 text-blue-500" />
|
||||
@@ -120,16 +139,19 @@ export default async function Home() {
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1">
|
||||
<p className="text-muted-foreground mb-6">
|
||||
Construct complex branching narratives without writing a single line of code.
|
||||
Our node-based editor handles logic, timing, and robot actions automatically.
|
||||
Construct complex branching narratives without writing a
|
||||
single line of code. Our node-based editor handles logic,
|
||||
timing, and robot actions automatically.
|
||||
</p>
|
||||
<div className="rounded-lg border bg-background/50 p-4 h-full min-h-[200px] flex items-center justify-center shadow-inner">
|
||||
<div className="flex gap-2 items-center text-sm text-muted-foreground">
|
||||
<span className="rounded bg-accent p-2">Start</span>
|
||||
<div className="bg-background/50 flex h-full min-h-[200px] items-center justify-center rounded-lg border p-4 shadow-inner">
|
||||
<div className="text-muted-foreground flex items-center gap-2 text-sm">
|
||||
<span className="bg-accent rounded p-2">Start</span>
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
<span className="rounded bg-primary/10 p-2 border border-primary/20 text-primary font-medium">Robot: Greet</span>
|
||||
<span className="bg-primary/10 border-primary/20 text-primary rounded border p-2 font-medium">
|
||||
Robot: Greet
|
||||
</span>
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
<span className="rounded bg-accent p-2">Wait: 5s</span>
|
||||
<span className="bg-accent rounded p-2">Wait: 5s</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -145,14 +167,15 @@ export default async function Home() {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-muted-foreground">
|
||||
Switch between robots instantly. Whether it's a NAO, Pepper, or a custom ROS2 bot,
|
||||
your experiment logic remains strictly separated from hardware implementation.
|
||||
Switch between robots instantly. Whether it's a NAO, Pepper,
|
||||
or a custom ROS2 bot, your experiment logic remains strictly
|
||||
separated from hardware implementation.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Role Based */}
|
||||
<Card className="col-span-1 md:col-span-1 lg:col-span-1 bg-muted/30">
|
||||
<Card className="bg-muted/30 col-span-1 md:col-span-1 lg:col-span-1">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Lock className="h-4 w-4 text-orange-500" />
|
||||
@@ -160,14 +183,15 @@ export default async function Home() {
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Granular permissions for Principal Investigators, Wizards, and Observers.
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Granular permissions for Principal Investigators, Wizards, and
|
||||
Observers.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Data Logging */}
|
||||
<Card className="col-span-1 md:col-span-1 lg:col-span-1 bg-muted/30">
|
||||
<Card className="bg-muted/30 col-span-1 md:col-span-1 lg:col-span-1">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Database className="h-4 w-4 text-rose-500" />
|
||||
@@ -175,8 +199,9 @@ export default async function Home() {
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Every wizard action, automated response, and sensor reading is time-stamped and logged.
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Every wizard action, automated response, and sensor reading is
|
||||
time-stamped and logged.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -184,41 +209,56 @@ export default async function Home() {
|
||||
</section>
|
||||
|
||||
{/* Architecture Section */}
|
||||
<section id="architecture" className="border-t bg-muted/30 py-24">
|
||||
<section id="architecture" className="bg-muted/30 border-t py-24">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="grid gap-12 lg:grid-cols-2 lg:gap-8 items-center">
|
||||
<div className="grid items-center gap-12 lg:grid-cols-2 lg:gap-8">
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold tracking-tight">Enterprise-Grade Architecture</h2>
|
||||
<p className="mt-4 text-lg text-muted-foreground">
|
||||
Designed for reliability and scale. HRIStudio uses a modern stack to ensure your data is safe and your experiments run smoothly.
|
||||
<h2 className="text-3xl font-bold tracking-tight">
|
||||
Enterprise-Grade Architecture
|
||||
</h2>
|
||||
<p className="text-muted-foreground mt-4 text-lg">
|
||||
Designed for reliability and scale. HRIStudio uses a modern
|
||||
stack to ensure your data is safe and your experiments run
|
||||
smoothly.
|
||||
</p>
|
||||
|
||||
<div className="mt-8 space-y-4">
|
||||
<div className="flex gap-4">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-background border shadow-sm">
|
||||
<Network className="h-5 w-5 text-primary" />
|
||||
<div className="bg-background flex h-10 w-10 shrink-0 items-center justify-center rounded-lg border shadow-sm">
|
||||
<Network className="text-primary h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold">3-Layer Design</h3>
|
||||
<p className="text-muted-foreground">Clear separation between UI, Data, and Hardware layers for maximum stability.</p>
|
||||
<p className="text-muted-foreground">
|
||||
Clear separation between UI, Data, and Hardware layers
|
||||
for maximum stability.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-4">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-background border shadow-sm">
|
||||
<Share2 className="h-5 w-5 text-primary" />
|
||||
<div className="bg-background flex h-10 w-10 shrink-0 items-center justify-center rounded-lg border shadow-sm">
|
||||
<Share2 className="text-primary h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold">Collaborative by Default</h3>
|
||||
<p className="text-muted-foreground">Real-time state synchronization allows multiple researchers to monitor a single trial.</p>
|
||||
<h3 className="font-semibold">
|
||||
Collaborative by Default
|
||||
</h3>
|
||||
<p className="text-muted-foreground">
|
||||
Real-time state synchronization allows multiple
|
||||
researchers to monitor a single trial.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-4">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-background border shadow-sm">
|
||||
<Settings2 className="h-5 w-5 text-primary" />
|
||||
<div className="bg-background flex h-10 w-10 shrink-0 items-center justify-center rounded-lg border shadow-sm">
|
||||
<Settings2 className="text-primary h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold">ROS2 Integration</h3>
|
||||
<p className="text-muted-foreground">Native support for ROS2 nodes, topics, and actions right out of the box.</p>
|
||||
<p className="text-muted-foreground">
|
||||
Native support for ROS2 nodes, topics, and actions right
|
||||
out of the box.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -226,34 +266,46 @@ export default async function Home() {
|
||||
|
||||
<div className="relative mx-auto w-full max-w-[500px]">
|
||||
{/* Abstract representation of architecture */}
|
||||
<div className="space-y-4 relative z-10">
|
||||
<Card className="border-blue-500/20 bg-blue-500/5 relative left-0 hover:left-2 transition-all cursor-default">
|
||||
<div className="relative z-10 space-y-4">
|
||||
<Card className="relative left-0 cursor-default border-blue-500/20 bg-blue-500/5 transition-all hover:left-2">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-blue-600 dark:text-blue-400 text-sm font-mono">APP LAYER</CardTitle>
|
||||
<CardTitle className="font-mono text-sm text-blue-600 dark:text-blue-400">
|
||||
APP LAYER
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="font-semibold">Next.js Dashboard + Experiment Designer</p>
|
||||
<p className="font-semibold">
|
||||
Next.js Dashboard + Experiment Designer
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="border-violet-500/20 bg-violet-500/5 relative left-4 hover:left-6 transition-all cursor-default">
|
||||
<Card className="relative left-4 cursor-default border-violet-500/20 bg-violet-500/5 transition-all hover:left-6">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-violet-600 dark:text-violet-400 text-sm font-mono">DATA LAYER</CardTitle>
|
||||
<CardTitle className="font-mono text-sm text-violet-600 dark:text-violet-400">
|
||||
DATA LAYER
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="font-semibold">PostgreSQL + MinIO + TRPC API</p>
|
||||
<p className="font-semibold">
|
||||
PostgreSQL + MinIO + TRPC API
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="border-green-500/20 bg-green-500/5 relative left-8 hover:left-10 transition-all cursor-default">
|
||||
<Card className="relative left-8 cursor-default border-green-500/20 bg-green-500/5 transition-all hover:left-10">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-green-600 dark:text-green-400 text-sm font-mono">HARDWARE LAYER</CardTitle>
|
||||
<CardTitle className="font-mono text-sm text-green-600 dark:text-green-400">
|
||||
HARDWARE LAYER
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="font-semibold">ROS2 Bridge + Robot Plugins</p>
|
||||
<p className="font-semibold">
|
||||
ROS2 Bridge + Robot Plugins
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
{/* Decorative blobs */}
|
||||
<div className="absolute top-1/2 left-1/2 -z-10 h-[300px] w-[300px] -translate-x-1/2 -translate-y-1/2 rounded-full bg-primary/10 blur-3xl" />
|
||||
<div className="bg-primary/10 absolute top-1/2 left-1/2 -z-10 h-[300px] w-[300px] -translate-x-1/2 -translate-y-1/2 rounded-full blur-3xl" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -261,31 +313,46 @@ export default async function Home() {
|
||||
|
||||
{/* CTA Section */}
|
||||
<section className="container mx-auto px-4 py-24 text-center">
|
||||
<h2 className="text-3xl font-bold tracking-tight md:text-4xl">Ready to upgrade your lab?</h2>
|
||||
<p className="mx-auto mt-4 max-w-2xl text-lg text-muted-foreground">
|
||||
Join the community of researchers building the future of HRI with reproducible, open-source tools.
|
||||
<h2 className="text-3xl font-bold tracking-tight md:text-4xl">
|
||||
Ready to upgrade your lab?
|
||||
</h2>
|
||||
<p className="text-muted-foreground mx-auto mt-4 max-w-2xl text-lg">
|
||||
Join the community of researchers building the future of HRI with
|
||||
reproducible, open-source tools.
|
||||
</p>
|
||||
<div className="mt-8">
|
||||
<Button size="lg" className="h-12 px-8 text-base shadow-lg shadow-primary/20" asChild>
|
||||
<Button
|
||||
size="lg"
|
||||
className="shadow-primary/20 h-12 px-8 text-base shadow-lg"
|
||||
asChild
|
||||
>
|
||||
<Link href="/auth/signup">Get Started for Free</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer className="border-t bg-muted/40 py-12">
|
||||
<div className="container mx-auto px-4 flex flex-col items-center justify-between gap-6 md:flex-row text-center md:text-left">
|
||||
<footer className="bg-muted/40 border-t py-12">
|
||||
<div className="container mx-auto flex flex-col items-center justify-between gap-6 px-4 text-center md:flex-row md:text-left">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Logo iconSize="sm" showText={true} />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
© {new Date().getFullYear()} HRIStudio. All rights reserved.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-6 text-sm text-muted-foreground">
|
||||
<Link href="#" className="hover:text-foreground">Privacy</Link>
|
||||
<Link href="#" className="hover:text-foreground">Terms</Link>
|
||||
<Link href="#" className="hover:text-foreground">GitHub</Link>
|
||||
<Link href="#" className="hover:text-foreground">Documentation</Link>
|
||||
<div className="text-muted-foreground flex gap-6 text-sm">
|
||||
<Link href="#" className="hover:text-foreground">
|
||||
Privacy
|
||||
</Link>
|
||||
<Link href="#" className="hover:text-foreground">
|
||||
Terms
|
||||
</Link>
|
||||
<Link href="#" className="hover:text-foreground">
|
||||
GitHub
|
||||
</Link>
|
||||
<Link href="#" className="hover:text-foreground">
|
||||
Documentation
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import Link from "next/link";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "~/components/ui/card";
|
||||
import { auth } from "~/server/auth";
|
||||
|
||||
|
||||
@@ -4,7 +4,9 @@ import { Badge } from "~/components/ui/badge";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import { Separator } from "~/components/ui/separator";
|
||||
import {
|
||||
getAvailableRoles, getRoleColor, getRolePermissions
|
||||
getAvailableRoles,
|
||||
getRoleColor,
|
||||
getRolePermissions,
|
||||
} from "~/lib/auth-client";
|
||||
|
||||
export function RoleManagement() {
|
||||
|
||||
@@ -2,13 +2,12 @@
|
||||
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import { api } from "~/trpc/react";
|
||||
|
||||
export function SystemStats() {
|
||||
// TODO: Implement admin.getSystemStats API endpoint
|
||||
// const { data: stats, isLoading } = api.admin.getSystemStats.useQuery({});
|
||||
const isLoading = false;
|
||||
const { data: stats, isLoading } = api.admin.getSystemStats.useQuery({});
|
||||
|
||||
if (isLoading) {
|
||||
if (isLoading || !stats) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
@@ -26,19 +25,30 @@ export function SystemStats() {
|
||||
);
|
||||
}
|
||||
|
||||
// Mock data for now since we don't have the actual admin router implemented
|
||||
const mockStats = {
|
||||
totalUsers: 42,
|
||||
totalStudies: 15,
|
||||
totalExperiments: 38,
|
||||
totalTrials: 127,
|
||||
activeTrials: 3,
|
||||
systemHealth: "healthy",
|
||||
uptime: "7 days, 14 hours",
|
||||
storageUsed: "2.3 GB",
|
||||
const formatBytes = (bytes: number) => {
|
||||
if (bytes === 0) return "0 B";
|
||||
const k = 1024;
|
||||
const sizes = ["B", "KB", "MB", "GB", "TB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
|
||||
};
|
||||
|
||||
const displayStats = mockStats;
|
||||
const formatUptime = (seconds: number) => {
|
||||
const d = Math.floor(seconds / (3600 * 24));
|
||||
const h = Math.floor((seconds % (3600 * 24)) / 3600);
|
||||
return `${d} days, ${h} hours`;
|
||||
};
|
||||
|
||||
const displayStats = {
|
||||
totalUsers: stats.users.total,
|
||||
totalStudies: stats.studies.total,
|
||||
totalExperiments: stats.experiments.total,
|
||||
totalTrials: stats.trials.total,
|
||||
activeTrials: stats.trials.running,
|
||||
systemHealth: "healthy",
|
||||
uptime: formatUptime(stats.system.uptime),
|
||||
storageUsed: formatBytes(stats.storage.totalSize),
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
|
||||
341
src/components/analytics/study-analytics-data-table.tsx
Normal file
341
src/components/analytics/study-analytics-data-table.tsx
Normal file
@@ -0,0 +1,341 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
type ColumnDef,
|
||||
type ColumnFiltersState,
|
||||
type SortingState,
|
||||
type VisibilityState,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getFilteredRowModel,
|
||||
getPaginationRowModel,
|
||||
getSortedRowModel,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "~/components/ui/table";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
ArrowUpDown,
|
||||
MoreHorizontal,
|
||||
Calendar,
|
||||
Clock,
|
||||
Activity,
|
||||
Eye,
|
||||
Video,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "~/components/ui/dropdown-menu";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import Link from "next/link";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
|
||||
export type AnalyticsTrial = {
|
||||
id: string;
|
||||
sessionNumber: number;
|
||||
status: string;
|
||||
createdAt: Date;
|
||||
startedAt: Date | null;
|
||||
completedAt: Date | null;
|
||||
duration: number | null;
|
||||
eventCount: number;
|
||||
mediaCount: number;
|
||||
experimentId: string;
|
||||
participant: {
|
||||
participantCode: string;
|
||||
};
|
||||
experiment: {
|
||||
name: string;
|
||||
studyId: string;
|
||||
};
|
||||
};
|
||||
|
||||
export const columns: ColumnDef<AnalyticsTrial>[] = [
|
||||
{
|
||||
accessorKey: "sessionNumber",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
||||
>
|
||||
Session
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => (
|
||||
<div className="text-center font-mono">
|
||||
#{row.getValue("sessionNumber")}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "participant.participantCode",
|
||||
id: "participantCode",
|
||||
header: "Participant",
|
||||
cell: ({ row }) => (
|
||||
<div className="font-medium">
|
||||
{row.original.participant?.participantCode ?? "Unknown"}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "status",
|
||||
header: "Status",
|
||||
cell: ({ row }) => {
|
||||
const status = row.getValue("status") as string;
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`capitalize ${
|
||||
status === "completed"
|
||||
? "border-green-500/20 bg-green-500/10 text-green-500"
|
||||
: status === "in_progress"
|
||||
? "border-blue-500/20 bg-blue-500/10 text-blue-500"
|
||||
: "border-slate-500/20 bg-slate-500/10 text-slate-500"
|
||||
}`}
|
||||
>
|
||||
{status.replace("_", " ")}
|
||||
</Badge>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "createdAt",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
||||
>
|
||||
Date
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const date = new Date(row.getValue("createdAt"));
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm">{date.toLocaleDateString()}</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{formatDistanceToNow(date, { addSuffix: true })}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "duration",
|
||||
header: "Duration",
|
||||
cell: ({ row }) => {
|
||||
const duration = row.getValue("duration") as number | null;
|
||||
if (!duration) return <span className="text-muted-foreground">-</span>;
|
||||
const m = Math.floor(duration / 60);
|
||||
const s = Math.floor(duration % 60);
|
||||
return <div className="font-mono">{`${m}m ${s}s`}</div>;
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "eventCount",
|
||||
header: "Events",
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<Activity className="text-muted-foreground h-3 w-3" />
|
||||
<span>{row.getValue("eventCount")}</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "mediaCount",
|
||||
header: "Media",
|
||||
cell: ({ row }) => {
|
||||
const count = row.getValue("mediaCount") as number;
|
||||
if (count === 0) return <span className="text-muted-foreground">-</span>;
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<Video className="text-muted-foreground h-3 w-3" />
|
||||
<span>{count}</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
cell: ({ row }) => {
|
||||
const trial = row.original;
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||
<span className="sr-only">Open menu</span>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link
|
||||
href={`/studies/${trial.experiment?.studyId}/trials/${trial.id}/analysis`}
|
||||
>
|
||||
<Eye className="mr-2 h-4 w-4" />
|
||||
View Analysis
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link
|
||||
href={`/experiments/${trial.experimentId}/trials/${trial.id}`}
|
||||
>
|
||||
View Trial Details
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
interface StudyAnalyticsDataTableProps {
|
||||
data: AnalyticsTrial[];
|
||||
}
|
||||
|
||||
export function StudyAnalyticsDataTable({
|
||||
data,
|
||||
}: StudyAnalyticsDataTableProps) {
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
|
||||
const [rowSelection, setRowSelection] = useState({});
|
||||
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns,
|
||||
onSortingChange: setSorting,
|
||||
onColumnFiltersChange: setColumnFilters,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
state: {
|
||||
sorting,
|
||||
columnFilters,
|
||||
columnVisibility,
|
||||
rowSelection,
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="w-full" id="tour-analytics-table">
|
||||
<div className="flex items-center py-4">
|
||||
<Input
|
||||
placeholder="Filter participants..."
|
||||
value={
|
||||
(table.getColumn("participantCode")?.getFilterValue() as string) ??
|
||||
""
|
||||
}
|
||||
onChange={(event) =>
|
||||
table
|
||||
.getColumn("participantCode")
|
||||
?.setFilterValue(event.target.value)
|
||||
}
|
||||
className="max-w-sm"
|
||||
id="tour-analytics-filter"
|
||||
/>
|
||||
</div>
|
||||
<div className="bg-card rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => {
|
||||
return (
|
||||
<TableHead key={header.id}>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext(),
|
||||
)}
|
||||
</TableHead>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows?.length ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={row.getIsSelected() && "selected"}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext(),
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={columns.length}
|
||||
className="h-24 text-center"
|
||||
>
|
||||
No results.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<div className="flex items-center justify-end space-x-2 py-4">
|
||||
<div className="text-muted-foreground flex-1 text-sm">
|
||||
{table.getFilteredSelectedRowModel().rows.length} of{" "}
|
||||
{table.getFilteredRowModel().rows.length} row(s) selected.
|
||||
</div>
|
||||
<div className="space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => table.previousPage()}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => table.nextPage()}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,125 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Activity, Calendar, CheckCircle, FlaskConical } from "lucide-react";
|
||||
import { DashboardOverviewLayout } from "~/components/ui/page-layout";
|
||||
|
||||
interface DashboardContentProps {
|
||||
userName: string;
|
||||
userRole: string;
|
||||
totalStudies: number;
|
||||
activeTrials: number;
|
||||
scheduledTrials: number;
|
||||
completedToday: number;
|
||||
canControl: boolean;
|
||||
canManage: boolean;
|
||||
_recentTrials: unknown[];
|
||||
}
|
||||
|
||||
export function DashboardContent({
|
||||
userName,
|
||||
userRole,
|
||||
totalStudies,
|
||||
activeTrials,
|
||||
scheduledTrials,
|
||||
completedToday,
|
||||
canControl,
|
||||
canManage,
|
||||
_recentTrials,
|
||||
}: DashboardContentProps) {
|
||||
const getWelcomeMessage = () => {
|
||||
switch (userRole) {
|
||||
case "wizard":
|
||||
return "Ready to control trials";
|
||||
case "researcher":
|
||||
return "Your research platform awaits";
|
||||
case "administrator":
|
||||
return "System management dashboard";
|
||||
default:
|
||||
return "Welcome to HRIStudio";
|
||||
}
|
||||
};
|
||||
|
||||
const quickActions = [
|
||||
...(canManage
|
||||
? [
|
||||
{
|
||||
title: "Create Study",
|
||||
description: "Start a new research study",
|
||||
icon: FlaskConical,
|
||||
href: "/studies/new",
|
||||
variant: "primary" as const,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(canControl
|
||||
? [
|
||||
{
|
||||
title: "Browse Studies",
|
||||
description: "View and manage studies",
|
||||
icon: Calendar,
|
||||
href: "/studies",
|
||||
variant: "default" as const,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
|
||||
const stats = [
|
||||
{
|
||||
title: "Studies",
|
||||
value: totalStudies,
|
||||
description: "Research studies",
|
||||
icon: FlaskConical,
|
||||
variant: "primary" as const,
|
||||
action: {
|
||||
label: "View All",
|
||||
href: "/studies",
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Active Trials",
|
||||
value: activeTrials,
|
||||
description: "Currently running",
|
||||
icon: Activity,
|
||||
variant: "success" as const,
|
||||
...(canControl && {
|
||||
action: {
|
||||
label: "View",
|
||||
href: "/studies",
|
||||
},
|
||||
}),
|
||||
},
|
||||
{
|
||||
title: "Scheduled",
|
||||
value: scheduledTrials,
|
||||
description: "Upcoming trials",
|
||||
icon: Calendar,
|
||||
variant: "default" as const,
|
||||
},
|
||||
{
|
||||
title: "Completed Today",
|
||||
value: completedToday,
|
||||
description: "Finished trials",
|
||||
icon: CheckCircle,
|
||||
variant: "success" as const,
|
||||
},
|
||||
];
|
||||
|
||||
const alerts: never[] = [];
|
||||
|
||||
const recentActivity = null;
|
||||
|
||||
return (
|
||||
<DashboardOverviewLayout
|
||||
title={`${getWelcomeMessage()}, ${userName}`}
|
||||
description="Monitor your HRI research activities and manage ongoing studies"
|
||||
userName={userName}
|
||||
userRole={userRole}
|
||||
breadcrumb={[{ label: "Dashboard" }]}
|
||||
quickActions={quickActions}
|
||||
stats={stats}
|
||||
alerts={alerts}
|
||||
recentActivity={recentActivity}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,27 +1,31 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect } from "react";
|
||||
import React, { useEffect, useRef } from "react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { signOut, useSession } from "next-auth/react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
BarChart3,
|
||||
BookOpen,
|
||||
Building,
|
||||
ChevronDown,
|
||||
FlaskConical,
|
||||
Home,
|
||||
LogOut,
|
||||
MoreHorizontal,
|
||||
PlayCircle,
|
||||
Puzzle,
|
||||
Settings,
|
||||
TestTube,
|
||||
User,
|
||||
UserCheck,
|
||||
Users,
|
||||
FileText,
|
||||
} from "lucide-react";
|
||||
|
||||
import { useSidebar } from "~/components/ui/sidebar";
|
||||
import { useTour } from "~/components/onboarding/TourProvider";
|
||||
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -93,6 +97,11 @@ const studyWorkItems = [
|
||||
url: "/experiments",
|
||||
icon: FlaskConical,
|
||||
},
|
||||
{
|
||||
title: "Forms",
|
||||
url: "/forms",
|
||||
icon: FileText,
|
||||
},
|
||||
{
|
||||
title: "Analytics",
|
||||
url: "/analytics",
|
||||
@@ -113,6 +122,20 @@ const adminItems = [
|
||||
},
|
||||
];
|
||||
|
||||
const helpItems = [
|
||||
{
|
||||
title: "Help Center",
|
||||
url: "/help",
|
||||
icon: BookOpen,
|
||||
},
|
||||
{
|
||||
title: "Interactive Tour",
|
||||
url: "#tour",
|
||||
icon: PlayCircle,
|
||||
action: "tour",
|
||||
},
|
||||
];
|
||||
|
||||
interface AppSidebarProps extends React.ComponentProps<typeof Sidebar> {
|
||||
userRole?: string;
|
||||
}
|
||||
@@ -126,8 +149,39 @@ export function AppSidebar({
|
||||
const isAdmin = userRole === "administrator";
|
||||
const { state: sidebarState } = useSidebar();
|
||||
const isCollapsed = sidebarState === "collapsed";
|
||||
const { selectedStudyId, userStudies, selectStudy, refreshStudyData } =
|
||||
useStudyManagement();
|
||||
const {
|
||||
selectedStudyId,
|
||||
userStudies,
|
||||
selectStudy,
|
||||
refreshStudyData,
|
||||
isLoadingUserStudies,
|
||||
} = useStudyManagement();
|
||||
|
||||
const { startTour, isTourActive } = useTour();
|
||||
|
||||
// Reference to track if we've already attempted auto-selection to avoid fighting with manual clearing
|
||||
const hasAutoSelected = useRef(false);
|
||||
|
||||
// Auto-select most recently touched study if none selected
|
||||
useEffect(() => {
|
||||
// Only run if not loading, no study selected, and we have studies available
|
||||
// And only run once per session (using ref) to allow user to clear selection if desired
|
||||
if (
|
||||
!isLoadingUserStudies &&
|
||||
!selectedStudyId &&
|
||||
userStudies.length > 0 &&
|
||||
!hasAutoSelected.current
|
||||
) {
|
||||
// userStudies is sorted by updatedAt desc from the API, so the first one is the most recent
|
||||
// userStudies is sorted by updatedAt desc from the API, so the first one is the most recent
|
||||
const mostRecent = userStudies[0];
|
||||
if (mostRecent) {
|
||||
console.log("Auto-selecting most recent study:", mostRecent.name);
|
||||
void selectStudy(mostRecent.id);
|
||||
hasAutoSelected.current = true;
|
||||
}
|
||||
}
|
||||
}, [isLoadingUserStudies, selectedStudyId, userStudies, selectStudy]);
|
||||
|
||||
// Debug API call
|
||||
const { data: debugData } = api.dashboard.debug.useQuery(undefined, {
|
||||
@@ -261,6 +315,17 @@ export function AppSidebar({
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
{isTourActive && !isCollapsed && (
|
||||
<div className="mt-1 px-3 pb-2">
|
||||
<div className="bg-primary/10 text-primary border-primary/20 animate-in fade-in slide-in-from-top-2 flex items-center gap-2 rounded-md border px-2.5 py-1.5 text-xs font-medium shadow-sm">
|
||||
<span className="relative flex h-2 w-2">
|
||||
<span className="bg-primary absolute inline-flex h-full w-full animate-ping rounded-full opacity-75"></span>
|
||||
<span className="bg-primary relative inline-flex h-2 w-2 rounded-full"></span>
|
||||
</span>
|
||||
Tutorial Active
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</SidebarHeader>
|
||||
|
||||
<SidebarContent>
|
||||
@@ -276,7 +341,10 @@ export function AppSidebar({
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<SidebarMenuButton className="w-full" id="tour-sidebar-study-selector">
|
||||
<SidebarMenuButton
|
||||
className="w-full"
|
||||
id="tour-sidebar-study-selector"
|
||||
>
|
||||
<Building className="h-4 w-4 flex-shrink-0" />
|
||||
<span className="truncate">
|
||||
{selectedStudy?.name ?? "Select Study"}
|
||||
@@ -325,7 +393,10 @@ export function AppSidebar({
|
||||
) : (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<SidebarMenuButton className="w-full" id="tour-sidebar-study-selector">
|
||||
<SidebarMenuButton
|
||||
className="w-full"
|
||||
id="tour-sidebar-study-selector"
|
||||
>
|
||||
<Building className="h-4 w-4 flex-shrink-0" />
|
||||
<span className="truncate">
|
||||
{selectedStudy?.name ?? "Select Study"}
|
||||
@@ -520,6 +591,53 @@ export function AppSidebar({
|
||||
)}
|
||||
</SidebarContent>
|
||||
|
||||
{/* Help Section */}
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>Support</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{helpItems.map((item) => {
|
||||
const isActive = pathname.startsWith(item.url);
|
||||
|
||||
const menuButton =
|
||||
item.action === "tour" ? (
|
||||
<SidebarMenuButton
|
||||
onClick={() => startTour("full_platform")}
|
||||
isActive={false}
|
||||
>
|
||||
<item.icon className="h-4 w-4" />
|
||||
<span>{item.title}</span>
|
||||
</SidebarMenuButton>
|
||||
) : (
|
||||
<SidebarMenuButton asChild isActive={isActive}>
|
||||
<Link href={item.url}>
|
||||
<item.icon className="h-4 w-4" />
|
||||
<span>{item.title}</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
);
|
||||
|
||||
return (
|
||||
<SidebarMenuItem key={item.title}>
|
||||
{isCollapsed ? (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{menuButton}</TooltipTrigger>
|
||||
<TooltipContent side="right" className="text-sm">
|
||||
{item.title}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
) : (
|
||||
menuButton
|
||||
)}
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
})}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
|
||||
{/* Debug info moved to footer tooltip button */}
|
||||
|
||||
<SidebarFooter>
|
||||
|
||||
@@ -87,33 +87,33 @@ export function ExperimentForm({ mode, experimentId }: ExperimentFormProps) {
|
||||
{ label: "Studies", href: "/studies" },
|
||||
...(selectedStudyId
|
||||
? [
|
||||
{
|
||||
label: experiment?.study?.name ?? "Study",
|
||||
href: `/studies/${selectedStudyId}`,
|
||||
},
|
||||
{ label: "Experiments", href: "/experiments" },
|
||||
...(mode === "edit" && experiment
|
||||
? [
|
||||
{
|
||||
label: experiment.name,
|
||||
href: `/studies/${selectedStudyId}/experiments/${experiment.id}`,
|
||||
},
|
||||
{ label: "Edit" },
|
||||
]
|
||||
: [{ label: "New Experiment" }]),
|
||||
]
|
||||
{
|
||||
label: experiment?.study?.name ?? "Study",
|
||||
href: `/studies/${selectedStudyId}`,
|
||||
},
|
||||
{ label: "Experiments", href: "/experiments" },
|
||||
...(mode === "edit" && experiment
|
||||
? [
|
||||
{
|
||||
label: experiment.name,
|
||||
href: `/studies/${selectedStudyId}/experiments/${experiment.id}`,
|
||||
},
|
||||
{ label: "Edit" },
|
||||
]
|
||||
: [{ label: "New Experiment" }]),
|
||||
]
|
||||
: [
|
||||
{ label: "Experiments", href: "/experiments" },
|
||||
...(mode === "edit" && experiment
|
||||
? [
|
||||
{
|
||||
label: experiment.name,
|
||||
href: `/studies/${experiment.studyId}/experiments/${experiment.id}`,
|
||||
},
|
||||
{ label: "Edit" },
|
||||
]
|
||||
: [{ label: "New Experiment" }]),
|
||||
]),
|
||||
{ label: "Experiments", href: "/experiments" },
|
||||
...(mode === "edit" && experiment
|
||||
? [
|
||||
{
|
||||
label: experiment.name,
|
||||
href: `/studies/${experiment.studyId}/experiments/${experiment.id}`,
|
||||
},
|
||||
{ label: "Edit" },
|
||||
]
|
||||
: [{ label: "New Experiment" }]),
|
||||
]),
|
||||
];
|
||||
|
||||
useBreadcrumbsEffect(breadcrumbs);
|
||||
@@ -153,14 +153,18 @@ export function ExperimentForm({ mode, experimentId }: ExperimentFormProps) {
|
||||
...data,
|
||||
estimatedDuration: data.estimatedDuration ?? undefined,
|
||||
});
|
||||
router.push(`/studies/${data.studyId}/experiments/${newExperiment.id}/designer`);
|
||||
router.push(
|
||||
`/studies/${data.studyId}/experiments/${newExperiment.id}/designer`,
|
||||
);
|
||||
} else {
|
||||
const updatedExperiment = await updateExperimentMutation.mutateAsync({
|
||||
id: experimentId!,
|
||||
...data,
|
||||
estimatedDuration: data.estimatedDuration ?? undefined,
|
||||
});
|
||||
router.push(`/studies/${experiment?.studyId ?? data.studyId}/experiments/${updatedExperiment.id}`);
|
||||
router.push(
|
||||
`/studies/${experiment?.studyId ?? data.studyId}/experiments/${updatedExperiment.id}`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
setError(
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { Calendar, FlaskConical, Plus, Settings, Users } from "lucide-react";
|
||||
import {
|
||||
Calendar,
|
||||
FlaskConical,
|
||||
Plus,
|
||||
Settings,
|
||||
Users,
|
||||
FileEdit,
|
||||
TestTube,
|
||||
CheckCircle2,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
@@ -45,22 +55,22 @@ const statusConfig = {
|
||||
draft: {
|
||||
label: "Draft",
|
||||
className: "bg-gray-100 text-gray-800 hover:bg-gray-200",
|
||||
icon: "📝",
|
||||
icon: FileEdit,
|
||||
},
|
||||
testing: {
|
||||
label: "Testing",
|
||||
className: "bg-yellow-100 text-yellow-800 hover:bg-yellow-200",
|
||||
icon: "🧪",
|
||||
icon: TestTube,
|
||||
},
|
||||
ready: {
|
||||
label: "Ready",
|
||||
className: "bg-green-100 text-green-800 hover:bg-green-200",
|
||||
icon: "✅",
|
||||
icon: CheckCircle2,
|
||||
},
|
||||
deprecated: {
|
||||
label: "Deprecated",
|
||||
className: "bg-red-100 text-red-800 hover:bg-red-200",
|
||||
icon: "🗑️",
|
||||
icon: Trash2,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -98,7 +108,7 @@ function ExperimentCard({ experiment }: ExperimentCardProps) {
|
||||
</div>
|
||||
</div>
|
||||
<Badge className={statusInfo.className} variant="secondary">
|
||||
<span className="mr-1">{statusInfo.icon}</span>
|
||||
<statusInfo.icon className="mr-1 h-3.5 w-3.5" />
|
||||
{statusInfo.label}
|
||||
</Badge>
|
||||
</div>
|
||||
@@ -158,10 +168,16 @@ function ExperimentCard({ experiment }: ExperimentCardProps) {
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2 pt-2">
|
||||
<Button asChild size="sm" className="flex-1">
|
||||
<Link href={`/studies/${experiment.studyId}/experiments/${experiment.id}`}>View Details</Link>
|
||||
<Link
|
||||
href={`/studies/${experiment.studyId}/experiments/${experiment.id}`}
|
||||
>
|
||||
View Details
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild size="sm" variant="outline" className="flex-1">
|
||||
<Link href={`/studies/${experiment.studyId}/experiments/${experiment.id}/designer`}>
|
||||
<Link
|
||||
href={`/studies/${experiment.studyId}/experiments/${experiment.id}/designer`}
|
||||
>
|
||||
<Settings className="mr-1 h-3 w-3" />
|
||||
Design
|
||||
</Link>
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { type ColumnDef } from "@tanstack/react-table";
|
||||
import { ArrowUpDown, MoreHorizontal, Copy, Eye, Edit, LayoutTemplate, PlayCircle, Archive } from "lucide-react";
|
||||
import {
|
||||
ArrowUpDown,
|
||||
MoreHorizontal,
|
||||
Edit,
|
||||
LayoutTemplate,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
@@ -243,65 +249,53 @@ export const columns: ColumnDef<Experiment>[] = [
|
||||
{
|
||||
id: "actions",
|
||||
enableHiding: false,
|
||||
cell: ({ row }) => {
|
||||
const experiment = row.original;
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||
<span className="sr-only">Open menu</span>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigator.clipboard.writeText(experiment.id)}
|
||||
>
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
Copy ID
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/studies/${experiment.studyId}/experiments/${experiment.id}`}>
|
||||
<Eye className="mr-2 h-4 w-4" />
|
||||
Details
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/studies/${experiment.studyId}/experiments/${experiment.id}/edit`}>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
Edit
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/studies/${experiment.studyId}/experiments/${experiment.id}/designer`}>
|
||||
<LayoutTemplate className="mr-2 h-4 w-4" />
|
||||
Designer
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem asChild>
|
||||
<Link
|
||||
href={`/studies/${experiment.studyId}/trials/new?experimentId=${experiment.id}`}
|
||||
>
|
||||
<PlayCircle className="mr-2 h-4 w-4" />
|
||||
Start Trial
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem className="text-red-600">
|
||||
<Archive className="mr-2 h-4 w-4" />
|
||||
Archive
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => <ExperimentActions experiment={row.original} />,
|
||||
},
|
||||
];
|
||||
|
||||
function ExperimentActions({ experiment }: { experiment: Experiment }) {
|
||||
const utils = api.useUtils();
|
||||
const deleteMutation = api.experiments.delete.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.experiments.list.invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
asChild
|
||||
className="text-muted-foreground hover:text-primary h-8 w-8"
|
||||
title="Open Designer"
|
||||
>
|
||||
<Link
|
||||
href={`/studies/${experiment.studyId}/experiments/${experiment.id}/designer`}
|
||||
>
|
||||
<LayoutTemplate className="h-4 w-4" />
|
||||
<span className="sr-only">Design</span>
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => {
|
||||
if (confirm("Are you sure you want to delete this experiment?")) {
|
||||
deleteMutation.mutate({ id: experiment.id });
|
||||
}
|
||||
}}
|
||||
className="text-muted-foreground hover:text-destructive h-8 w-8"
|
||||
title="Delete Experiment"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
<span className="sr-only">Delete</span>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ExperimentsTable() {
|
||||
const { selectedStudyId } = useStudyContext();
|
||||
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import type { ActionDefinition } from "~/lib/experiment-designer/types";
|
||||
import type {
|
||||
ActionDefinition,
|
||||
ExperimentAction,
|
||||
} from "~/lib/experiment-designer/types";
|
||||
import corePluginDef from "~/plugins/definitions/hristudio-core.json";
|
||||
import wozPluginDef from "~/plugins/definitions/hristudio-woz.json";
|
||||
|
||||
/**
|
||||
* ActionRegistry
|
||||
*
|
||||
* Central singleton for loading and serving action definitions from:
|
||||
* - Core system action JSON manifests (served from /hristudio-core/plugins/*.json)
|
||||
* - Core system action JSON manifests (hristudio-core, hristudio-woz)
|
||||
* - Study-installed plugin action definitions (ROS2 / REST / internal transports)
|
||||
*
|
||||
* Responsibilities:
|
||||
@@ -15,12 +20,6 @@ import type { ActionDefinition } from "~/lib/experiment-designer/types";
|
||||
* - Provenance retention (core vs plugin, plugin id/version, robot id)
|
||||
* - Parameter schema → UI parameter mapping (primitive only for now)
|
||||
* - Fallback action population if core load fails (ensures minimal functionality)
|
||||
*
|
||||
* Notes:
|
||||
* - The registry is client-side only (designer runtime); server performs its own
|
||||
* validation & compilation using persisted action instances (never trusts client).
|
||||
* - Action IDs for plugins are namespaced: `${plugin.id}.${action.id}`.
|
||||
* - Core actions retain their base IDs (e.g., wait, wizard_speak) for clarity.
|
||||
*/
|
||||
export class ActionRegistry {
|
||||
private static instance: ActionRegistry;
|
||||
@@ -31,6 +30,8 @@ export class ActionRegistry {
|
||||
private loadedStudyId: string | null = null;
|
||||
private listeners = new Set<() => void>();
|
||||
|
||||
private readonly SYSTEM_PLUGIN_IDS = ["hristudio-core", "hristudio-woz"];
|
||||
|
||||
static getInstance(): ActionRegistry {
|
||||
if (!ActionRegistry.instance) {
|
||||
ActionRegistry.instance = new ActionRegistry();
|
||||
@@ -49,281 +50,26 @@ export class ActionRegistry {
|
||||
this.listeners.forEach((listener) => listener());
|
||||
}
|
||||
|
||||
/* ---------------- Core Actions ---------------- */
|
||||
/* ---------------- Core / System Actions ---------------- */
|
||||
|
||||
async loadCoreActions(): Promise<void> {
|
||||
if (this.coreActionsLoaded) return;
|
||||
|
||||
interface CoreBlockParam {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
placeholder?: string;
|
||||
options?: string[];
|
||||
min?: number;
|
||||
max?: number;
|
||||
value?: string | number | boolean;
|
||||
required?: boolean;
|
||||
description?: string;
|
||||
step?: number;
|
||||
}
|
||||
// Load System Plugins (Core & WoZ)
|
||||
this.registerPluginDefinition(corePluginDef);
|
||||
this.registerPluginDefinition(wozPluginDef);
|
||||
|
||||
interface CoreBlock {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
category: string;
|
||||
icon?: string;
|
||||
color?: string;
|
||||
parameters?: CoreBlockParam[];
|
||||
timeoutMs?: number;
|
||||
retryable?: boolean;
|
||||
nestable?: boolean;
|
||||
}
|
||||
console.log(
|
||||
`[ActionRegistry] Loaded system plugins: ${this.SYSTEM_PLUGIN_IDS.join(", ")}`,
|
||||
);
|
||||
|
||||
try {
|
||||
const coreActionSets = [
|
||||
"wizard-actions",
|
||||
"control-flow",
|
||||
"observation",
|
||||
"events",
|
||||
];
|
||||
|
||||
for (const actionSetId of coreActionSets) {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/hristudio-core/plugins/${actionSetId}.json`,
|
||||
);
|
||||
// Non-blocking skip if not found
|
||||
if (!response.ok) continue;
|
||||
|
||||
const rawActionSet = (await response.json()) as unknown;
|
||||
const actionSet = rawActionSet as { blocks?: CoreBlock[] };
|
||||
if (!actionSet.blocks || !Array.isArray(actionSet.blocks)) continue;
|
||||
|
||||
// Register each block as an ActionDefinition
|
||||
actionSet.blocks.forEach((block) => {
|
||||
if (!block.id || !block.name) return;
|
||||
|
||||
const actionDef: ActionDefinition = {
|
||||
id: block.id,
|
||||
type: block.id,
|
||||
name: block.name,
|
||||
description: block.description ?? "",
|
||||
category: this.mapBlockCategoryToActionCategory(block.category),
|
||||
icon: block.icon ?? "Zap",
|
||||
color: block.color ?? "#6b7280",
|
||||
parameters: (block.parameters ?? []).map((param) => ({
|
||||
id: param.id,
|
||||
name: param.name,
|
||||
type:
|
||||
(param.type as "text" | "number" | "select" | "boolean") ||
|
||||
"text",
|
||||
placeholder: param.placeholder,
|
||||
options: param.options,
|
||||
min: param.min,
|
||||
max: param.max,
|
||||
value: param.value,
|
||||
required: param.required !== false,
|
||||
description: param.description,
|
||||
step: param.step,
|
||||
})),
|
||||
source: {
|
||||
kind: "core",
|
||||
baseActionId: block.id,
|
||||
},
|
||||
execution: {
|
||||
transport: "internal",
|
||||
timeoutMs: block.timeoutMs,
|
||||
retryable: block.retryable,
|
||||
},
|
||||
parameterSchemaRaw: {
|
||||
parameters: block.parameters ?? [],
|
||||
},
|
||||
nestable: block.nestable,
|
||||
};
|
||||
|
||||
this.actions.set(actionDef.id, actionDef);
|
||||
});
|
||||
} catch (error) {
|
||||
// Non-fatal: we will fallback later
|
||||
console.warn(`Failed to load core action set ${actionSetId}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
this.coreActionsLoaded = true;
|
||||
this.notifyListeners();
|
||||
} catch (error) {
|
||||
console.error("Failed to load core actions:", error);
|
||||
this.loadFallbackActions();
|
||||
}
|
||||
}
|
||||
|
||||
private mapBlockCategoryToActionCategory(
|
||||
category: string,
|
||||
): ActionDefinition["category"] {
|
||||
switch (category) {
|
||||
case "wizard":
|
||||
return "wizard";
|
||||
case "event":
|
||||
return "wizard"; // Events are wizard-initiated triggers
|
||||
case "robot":
|
||||
return "robot";
|
||||
case "control":
|
||||
return "control";
|
||||
case "sensor":
|
||||
case "observation":
|
||||
return "observation";
|
||||
default:
|
||||
return "wizard";
|
||||
}
|
||||
}
|
||||
|
||||
private loadFallbackActions(): void {
|
||||
const fallbackActions: ActionDefinition[] = [
|
||||
{
|
||||
id: "wizard_say",
|
||||
type: "wizard_say",
|
||||
name: "Wizard Says",
|
||||
description: "Wizard speaks to participant",
|
||||
category: "wizard",
|
||||
icon: "MessageSquare",
|
||||
color: "#a855f7",
|
||||
parameters: [
|
||||
{
|
||||
id: "message",
|
||||
name: "Message",
|
||||
type: "text",
|
||||
placeholder: "Hello, participant!",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: "tone",
|
||||
name: "Tone",
|
||||
type: "select",
|
||||
options: ["neutral", "friendly", "encouraging"],
|
||||
value: "neutral",
|
||||
},
|
||||
],
|
||||
source: { kind: "core", baseActionId: "wizard_say" },
|
||||
execution: { transport: "internal", timeoutMs: 30000 },
|
||||
parameterSchemaRaw: {},
|
||||
nestable: false,
|
||||
},
|
||||
{
|
||||
id: "wait",
|
||||
type: "wait",
|
||||
name: "Wait",
|
||||
description: "Wait for specified time",
|
||||
category: "control",
|
||||
icon: "Clock",
|
||||
color: "#f59e0b",
|
||||
parameters: [
|
||||
{
|
||||
id: "duration",
|
||||
name: "Duration (seconds)",
|
||||
type: "number",
|
||||
min: 0.1,
|
||||
max: 300,
|
||||
value: 2,
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
source: { kind: "core", baseActionId: "wait" },
|
||||
execution: { transport: "internal", timeoutMs: 60000 },
|
||||
parameterSchemaRaw: {
|
||||
type: "object",
|
||||
properties: {
|
||||
duration: {
|
||||
type: "number",
|
||||
minimum: 0.1,
|
||||
maximum: 300,
|
||||
default: 2,
|
||||
},
|
||||
},
|
||||
required: ["duration"],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "observe",
|
||||
type: "observe",
|
||||
name: "Observe",
|
||||
description: "Record participant behavior",
|
||||
category: "observation",
|
||||
icon: "Eye",
|
||||
color: "#8b5cf6",
|
||||
parameters: [
|
||||
{
|
||||
id: "behavior",
|
||||
name: "Behavior to observe",
|
||||
type: "select",
|
||||
options: ["facial_expression", "body_language", "verbal_response"],
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
source: { kind: "core", baseActionId: "observe" },
|
||||
execution: { transport: "internal", timeoutMs: 120000 },
|
||||
parameterSchemaRaw: {
|
||||
type: "object",
|
||||
properties: {
|
||||
behavior: {
|
||||
type: "string",
|
||||
enum: ["facial_expression", "body_language", "verbal_response"],
|
||||
},
|
||||
},
|
||||
required: ["behavior"],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
fallbackActions.forEach((action) => this.actions.set(action.id, action));
|
||||
this.coreActionsLoaded = true;
|
||||
this.notifyListeners();
|
||||
}
|
||||
|
||||
/* ---------------- Plugin Actions ---------------- */
|
||||
|
||||
loadPluginActions(
|
||||
studyId: string,
|
||||
studyPlugins: Array<{
|
||||
plugin: {
|
||||
id: string;
|
||||
robotId: string | null;
|
||||
version: string | null;
|
||||
actionDefinitions?: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
category?: string;
|
||||
icon?: string;
|
||||
timeout?: number;
|
||||
retryable?: boolean;
|
||||
aliases?: string[];
|
||||
parameterSchema?: unknown;
|
||||
ros2?: {
|
||||
topic?: string;
|
||||
messageType?: string;
|
||||
service?: string;
|
||||
action?: string;
|
||||
payloadMapping?: unknown;
|
||||
qos?: {
|
||||
reliability?: string;
|
||||
durability?: string;
|
||||
history?: string;
|
||||
depth?: number;
|
||||
};
|
||||
};
|
||||
rest?: {
|
||||
method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
|
||||
path: string;
|
||||
headers?: Record<string, string>;
|
||||
};
|
||||
}>;
|
||||
metadata?: Record<string, any>;
|
||||
};
|
||||
}>,
|
||||
): void {
|
||||
// console.log("ActionRegistry.loadPluginActions called with:", { studyId, pluginCount: studyPlugins?.length ?? 0 });
|
||||
|
||||
loadPluginActions(studyId: string, studyPlugins: any[]): void {
|
||||
if (this.pluginActionsLoaded && this.loadedStudyId === studyId) return;
|
||||
|
||||
if (this.loadedStudyId !== studyId) {
|
||||
@@ -332,31 +78,51 @@ export class ActionRegistry {
|
||||
|
||||
let totalActionsLoaded = 0;
|
||||
|
||||
(studyPlugins ?? []).forEach((studyPlugin) => {
|
||||
const { plugin } = studyPlugin;
|
||||
const actionDefs = Array.isArray(plugin.actionDefinitions)
|
||||
? plugin.actionDefinitions
|
||||
: undefined;
|
||||
(studyPlugins ?? []).forEach((plugin) => {
|
||||
this.registerPluginDefinition(plugin);
|
||||
totalActionsLoaded += plugin.actionDefinitions?.length || 0;
|
||||
});
|
||||
|
||||
// console.log(`Plugin ${plugin.id}:`, { actionCount: actionDefs?.length ?? 0 });
|
||||
console.log(
|
||||
`ActionRegistry: Loaded ${totalActionsLoaded} plugin actions for study ${studyId}`,
|
||||
);
|
||||
|
||||
if (!actionDefs) return;
|
||||
this.pluginActionsLoaded = true;
|
||||
this.loadedStudyId = studyId;
|
||||
this.notifyListeners();
|
||||
}
|
||||
|
||||
actionDefs.forEach((action) => {
|
||||
const rawCategory =
|
||||
typeof action.category === "string"
|
||||
? action.category.toLowerCase().trim()
|
||||
: "";
|
||||
const categoryMap: Record<string, ActionDefinition["category"]> = {
|
||||
wizard: "wizard",
|
||||
robot: "robot",
|
||||
control: "control",
|
||||
observation: "observation",
|
||||
};
|
||||
const category = categoryMap[rawCategory] ?? "robot";
|
||||
/* ---------------- Shared Registration Logic ---------------- */
|
||||
|
||||
const execution = action.ros2
|
||||
? {
|
||||
private registerPluginDefinition(plugin: any) {
|
||||
const actionDefs = Array.isArray(plugin.actionDefinitions)
|
||||
? plugin.actionDefinitions
|
||||
: undefined;
|
||||
|
||||
if (!actionDefs) return;
|
||||
|
||||
actionDefs.forEach((action: any) => {
|
||||
const rawCategory =
|
||||
typeof action.category === "string"
|
||||
? action.category.toLowerCase().trim()
|
||||
: "";
|
||||
const categoryMap: Record<string, ActionDefinition["category"]> = {
|
||||
wizard: "wizard",
|
||||
robot: "robot",
|
||||
control: "control",
|
||||
observation: "observation",
|
||||
};
|
||||
|
||||
// Default category based on plugin type or explicit category
|
||||
let category = categoryMap[rawCategory];
|
||||
if (!category) {
|
||||
if (plugin.id === "hristudio-woz") category = "wizard";
|
||||
else if (plugin.id === "hristudio-core") category = "control";
|
||||
else category = "robot";
|
||||
}
|
||||
|
||||
const execution = action.ros2
|
||||
? {
|
||||
transport: "ros2" as const,
|
||||
timeoutMs: action.timeout,
|
||||
retryable: action.retryable,
|
||||
@@ -369,8 +135,8 @@ export class ActionRegistry {
|
||||
payloadMapping: action.ros2.payloadMapping,
|
||||
},
|
||||
}
|
||||
: action.rest
|
||||
? {
|
||||
: action.rest
|
||||
? {
|
||||
transport: "rest" as const,
|
||||
timeoutMs: action.timeout,
|
||||
retryable: action.retryable,
|
||||
@@ -380,62 +146,66 @@ export class ActionRegistry {
|
||||
headers: action.rest.headers,
|
||||
},
|
||||
}
|
||||
: {
|
||||
: {
|
||||
transport: "internal" as const,
|
||||
timeoutMs: action.timeout,
|
||||
retryable: action.retryable,
|
||||
};
|
||||
|
||||
// Extract semantic ID from metadata if available, otherwise fall back to database IDs (which typically causes mismatch if seed uses semantic)
|
||||
// Ideally, plugin.metadata.robotId should populate this.
|
||||
const semanticRobotId = plugin.metadata?.robotId || plugin.robotId || plugin.id;
|
||||
// Extract semantic ID from metadata if available, otherwise fall back to database IDs
|
||||
// Priority: metadata.robotId > metadata.id (for system plugins) > robotId > id
|
||||
const semanticRobotId =
|
||||
plugin.metadata?.robotId ||
|
||||
plugin.metadata?.id ||
|
||||
plugin.robotId ||
|
||||
plugin.id;
|
||||
|
||||
const actionDef: ActionDefinition = {
|
||||
id: `${semanticRobotId}.${action.id}`,
|
||||
type: `${semanticRobotId}.${action.id}`,
|
||||
name: action.name,
|
||||
description: action.description ?? "",
|
||||
category,
|
||||
icon: action.icon ?? "Bot",
|
||||
color: "#10b981",
|
||||
parameters: this.convertParameterSchemaToParameters(
|
||||
action.parameterSchema,
|
||||
),
|
||||
source: {
|
||||
kind: "plugin",
|
||||
pluginId: semanticRobotId, // Use semantic ID here too
|
||||
robotId: plugin.robotId,
|
||||
pluginVersion: plugin.version ?? undefined,
|
||||
baseActionId: action.id,
|
||||
},
|
||||
execution,
|
||||
parameterSchemaRaw: action.parameterSchema ?? undefined,
|
||||
};
|
||||
this.actions.set(actionDef.id, actionDef);
|
||||
// Register aliases if provided by plugin metadata
|
||||
const aliases = Array.isArray(action.aliases)
|
||||
? action.aliases
|
||||
: undefined;
|
||||
if (aliases) {
|
||||
for (const alias of aliases) {
|
||||
if (typeof alias === "string" && alias.trim()) {
|
||||
this.aliasIndex.set(alias, actionDef.id);
|
||||
}
|
||||
// For system plugins, we want to keep the short IDs (wait, branch) to avoid breaking existing save data
|
||||
// For robot plugins, we namespace them (nao6-ros2.say_text)
|
||||
const isSystem = this.SYSTEM_PLUGIN_IDS.includes(semanticRobotId);
|
||||
const actionId = isSystem ? action.id : `${semanticRobotId}.${action.id}`;
|
||||
const actionType = actionId; // Type is usually same as ID
|
||||
|
||||
const actionDef: ActionDefinition = {
|
||||
id: actionId,
|
||||
type: actionType,
|
||||
name: action.name,
|
||||
description: action.description ?? "",
|
||||
category,
|
||||
icon: action.icon ?? "Bot",
|
||||
color: action.color || "#10b981",
|
||||
parameters: this.convertParameterSchemaToParameters(
|
||||
action.parameterSchema,
|
||||
),
|
||||
source: {
|
||||
kind: isSystem ? "core" : "plugin", // Maintain 'core' distinction for UI grouping if needed
|
||||
pluginId: semanticRobotId,
|
||||
robotId: plugin.robotId,
|
||||
pluginVersion: plugin.version ?? undefined,
|
||||
baseActionId: action.id,
|
||||
},
|
||||
execution,
|
||||
parameterSchemaRaw: action.parameterSchema ?? undefined,
|
||||
nestable: action.nestable,
|
||||
};
|
||||
|
||||
// Prevent overwriting if it already exists (first-come-first-served, usually core first)
|
||||
if (!this.actions.has(actionId)) {
|
||||
this.actions.set(actionId, actionDef);
|
||||
}
|
||||
|
||||
// Register aliases
|
||||
const aliases = Array.isArray(action.aliases)
|
||||
? action.aliases
|
||||
: undefined;
|
||||
if (aliases) {
|
||||
for (const alias of aliases) {
|
||||
if (typeof alias === "string" && alias.trim()) {
|
||||
this.aliasIndex.set(alias, actionDef.id);
|
||||
}
|
||||
}
|
||||
totalActionsLoaded++;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
console.log(
|
||||
`ActionRegistry: Loaded ${totalActionsLoaded} plugin actions for study ${studyId}`,
|
||||
);
|
||||
// console.log("Current action registry state:", { totalActions: this.actions.size });
|
||||
|
||||
|
||||
this.pluginActionsLoaded = true;
|
||||
this.loadedStudyId = studyId;
|
||||
this.notifyListeners();
|
||||
}
|
||||
|
||||
private convertParameterSchemaToParameters(
|
||||
@@ -458,7 +228,8 @@ export class ActionRegistry {
|
||||
if (!schema?.properties) return [];
|
||||
|
||||
return Object.entries(schema.properties).map(([key, paramDef]) => {
|
||||
let type: "text" | "number" | "select" | "boolean" = "text";
|
||||
let type: "text" | "number" | "select" | "boolean" | "json" | "array" =
|
||||
"text";
|
||||
|
||||
if (paramDef.type === "number") {
|
||||
type = "number";
|
||||
@@ -466,6 +237,10 @@ export class ActionRegistry {
|
||||
type = "boolean";
|
||||
} else if (paramDef.enum && Array.isArray(paramDef.enum)) {
|
||||
type = "select";
|
||||
} else if (paramDef.type === "array") {
|
||||
type = "array";
|
||||
} else if (paramDef.type === "object") {
|
||||
type = "json";
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -485,29 +260,20 @@ export class ActionRegistry {
|
||||
private resetPluginActions(): void {
|
||||
this.pluginActionsLoaded = false;
|
||||
this.loadedStudyId = null;
|
||||
// Remove existing plugin actions (retain known core ids + fallback ids)
|
||||
const pluginActionIds = Array.from(this.actions.keys()).filter(
|
||||
(id) =>
|
||||
!id.startsWith("wizard_") &&
|
||||
!id.startsWith("when_") &&
|
||||
!id.startsWith("wait") &&
|
||||
!id.startsWith("observe") &&
|
||||
!id.startsWith("repeat") &&
|
||||
!id.startsWith("if_") &&
|
||||
!id.startsWith("parallel") &&
|
||||
!id.startsWith("sequence") &&
|
||||
!id.startsWith("random_") &&
|
||||
!id.startsWith("try_") &&
|
||||
!id.startsWith("break") &&
|
||||
!id.startsWith("measure_") &&
|
||||
!id.startsWith("count_") &&
|
||||
!id.startsWith("record_") &&
|
||||
!id.startsWith("capture_") &&
|
||||
!id.startsWith("log_") &&
|
||||
!id.startsWith("survey_") &&
|
||||
!id.startsWith("physiological_"),
|
||||
);
|
||||
pluginActionIds.forEach((id) => this.actions.delete(id));
|
||||
|
||||
// Robust Reset: Remove valid plugin actions, BUT protect system plugins.
|
||||
const idsToDelete: string[] = [];
|
||||
this.actions.forEach((action, id) => {
|
||||
if (
|
||||
action.source.kind === "plugin" &&
|
||||
!this.SYSTEM_PLUGIN_IDS.includes(action.source.pluginId || "")
|
||||
) {
|
||||
idsToDelete.push(id);
|
||||
}
|
||||
});
|
||||
|
||||
idsToDelete.forEach((id) => this.actions.delete(id));
|
||||
this.notifyListeners();
|
||||
}
|
||||
|
||||
/* ---------------- Query Helpers ---------------- */
|
||||
|
||||
@@ -8,11 +8,23 @@ import React, {
|
||||
useState,
|
||||
} from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Play, RefreshCw, HelpCircle } from "lucide-react";
|
||||
import {
|
||||
Play,
|
||||
RefreshCw,
|
||||
HelpCircle,
|
||||
PanelLeftClose,
|
||||
PanelLeftOpen,
|
||||
PanelRightClose,
|
||||
PanelRightOpen,
|
||||
Maximize2,
|
||||
Minimize2,
|
||||
Settings,
|
||||
} from "lucide-react";
|
||||
|
||||
import { cn } from "~/lib/utils";
|
||||
import { PageHeader } from "~/components/ui/page-header";
|
||||
import { useTour } from "~/components/onboarding/TourProvider";
|
||||
import { SettingsModal } from "./SettingsModal";
|
||||
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { api } from "~/trpc/react";
|
||||
@@ -27,7 +39,7 @@ import {
|
||||
MouseSensor,
|
||||
TouchSensor,
|
||||
KeyboardSensor,
|
||||
closestCorners,
|
||||
closestCenter,
|
||||
type DragEndEvent,
|
||||
type DragStartEvent,
|
||||
type DragOverEvent,
|
||||
@@ -35,7 +47,9 @@ import {
|
||||
import { BottomStatusBar } from "./layout/BottomStatusBar";
|
||||
import { ActionLibraryPanel } from "./panels/ActionLibraryPanel";
|
||||
import { InspectorPanel } from "./panels/InspectorPanel";
|
||||
import { FlowWorkspace } from "./flow/FlowWorkspace";
|
||||
import { FlowWorkspace, StepCardPreview } from "./flow/FlowWorkspace";
|
||||
import { SortableActionChip } from "./flow/ActionChip";
|
||||
import { GripVertical } from "lucide-react";
|
||||
|
||||
import {
|
||||
type ExperimentDesign,
|
||||
@@ -44,12 +58,13 @@ import {
|
||||
} from "~/lib/experiment-designer/types";
|
||||
|
||||
import { useDesignerStore } from "./state/store";
|
||||
import { actionRegistry } from "./ActionRegistry";
|
||||
import { actionRegistry, useActionRegistry } from "./ActionRegistry";
|
||||
import { computeDesignHash } from "./state/hashing";
|
||||
import {
|
||||
validateExperimentDesign,
|
||||
groupIssuesByEntity,
|
||||
} from "./state/validators";
|
||||
import { convertDatabaseToSteps } from "~/lib/experiment-designer/block-converter";
|
||||
|
||||
/**
|
||||
* DesignerRoot
|
||||
@@ -84,6 +99,23 @@ export interface DesignerRootProps {
|
||||
initialDesign?: ExperimentDesign;
|
||||
autoCompile?: boolean;
|
||||
onPersist?: (design: ExperimentDesign) => void;
|
||||
experiment?: {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
status: string;
|
||||
studyId: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
study: {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
};
|
||||
designStats?: {
|
||||
stepCount: number;
|
||||
actionCount: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface RawExperiment {
|
||||
@@ -94,6 +126,7 @@ interface RawExperiment {
|
||||
integrityHash?: string | null;
|
||||
pluginDependencies?: string[] | null;
|
||||
visualDesign?: unknown;
|
||||
steps?: unknown[]; // DB steps from relation
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
@@ -101,6 +134,65 @@ interface RawExperiment {
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
function adaptExistingDesign(exp: RawExperiment): ExperimentDesign | undefined {
|
||||
console.log("[adaptExistingDesign] Entry - exp.steps:", exp.steps);
|
||||
|
||||
// 1. Prefer database steps (Source of Truth) if valid, to ensure we have the latest
|
||||
// plugin provenance data (which might be missing from stale visualDesign snapshots).
|
||||
// 1. Prefer database steps (Source of Truth) if valid.
|
||||
if (Array.isArray(exp.steps) && exp.steps.length > 0) {
|
||||
console.log(
|
||||
"[adaptExistingDesign] Has steps array, length:",
|
||||
exp.steps.length,
|
||||
);
|
||||
try {
|
||||
// Check if steps are already converted (have trigger property) to avoid double-conversion data loss
|
||||
const firstStep = exp.steps[0] as any;
|
||||
let dbSteps: ExperimentStep[];
|
||||
|
||||
if (
|
||||
firstStep &&
|
||||
typeof firstStep === "object" &&
|
||||
"trigger" in firstStep
|
||||
) {
|
||||
// Already converted by server
|
||||
dbSteps = exp.steps as ExperimentStep[];
|
||||
} else {
|
||||
// Raw DB steps, need conversion
|
||||
console.log("[adaptExistingDesign] Taking raw DB conversion path");
|
||||
dbSteps = convertDatabaseToSteps(exp.steps);
|
||||
|
||||
// DEBUG: Check children after conversion
|
||||
dbSteps.forEach((step) => {
|
||||
step.actions.forEach((action) => {
|
||||
if (
|
||||
["sequence", "parallel", "loop", "branch"].includes(action.type)
|
||||
) {
|
||||
console.log(
|
||||
`[adaptExistingDesign] Post-conversion ${action.type} (${action.name}) children:`,
|
||||
action.children,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
id: exp.id,
|
||||
name: exp.name,
|
||||
description: exp.description ?? "",
|
||||
steps: dbSteps,
|
||||
version: 1, // Reset version on re-hydration
|
||||
lastSaved: new Date(),
|
||||
};
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
"[DesignerRoot] Failed to convert/hydrate steps, falling back to visualDesign:",
|
||||
err,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Fallback to visualDesign blob if DB steps unavailable or conversion failed
|
||||
if (
|
||||
!exp.visualDesign ||
|
||||
typeof exp.visualDesign !== "object" ||
|
||||
@@ -114,6 +206,7 @@ function adaptExistingDesign(exp: RawExperiment): ExperimentDesign | undefined {
|
||||
lastSaved?: string;
|
||||
};
|
||||
if (!Array.isArray(vd.steps)) return undefined;
|
||||
|
||||
return {
|
||||
id: exp.id,
|
||||
name: exp.name,
|
||||
@@ -151,7 +244,12 @@ export function DesignerRoot({
|
||||
initialDesign,
|
||||
autoCompile = true,
|
||||
onPersist,
|
||||
experiment: experimentMetadata,
|
||||
designStats,
|
||||
}: DesignerRootProps) {
|
||||
// Subscribe to registry updates to ensure re-renders when actions load
|
||||
useActionRegistry();
|
||||
|
||||
const { startTour } = useTour();
|
||||
|
||||
/* ----------------------------- Remote Experiment ------------------------- */
|
||||
@@ -159,7 +257,16 @@ export function DesignerRoot({
|
||||
data: experiment,
|
||||
isLoading: loadingExperiment,
|
||||
refetch: refetchExperiment,
|
||||
} = api.experiments.get.useQuery({ id: experimentId });
|
||||
} = api.experiments.get.useQuery(
|
||||
{ id: experimentId },
|
||||
{
|
||||
// Debug Mode: Disable all caching to ensure fresh data from DB
|
||||
refetchOnMount: true,
|
||||
refetchOnWindowFocus: true,
|
||||
staleTime: 0,
|
||||
gcTime: 0, // Garbage collect immediately
|
||||
},
|
||||
);
|
||||
|
||||
const updateExperiment = api.experiments.update.useMutation({
|
||||
onError: (err) => {
|
||||
@@ -199,6 +306,7 @@ export function DesignerRoot({
|
||||
const upsertAction = useDesignerStore((s) => s.upsertAction);
|
||||
const selectStep = useDesignerStore((s) => s.selectStep);
|
||||
const selectAction = useDesignerStore((s) => s.selectAction);
|
||||
const reorderStep = useDesignerStore((s) => s.reorderStep);
|
||||
const setValidationIssues = useDesignerStore((s) => s.setValidationIssues);
|
||||
const clearAllValidationIssues = useDesignerStore(
|
||||
(s) => s.clearAllValidationIssues,
|
||||
@@ -258,6 +366,24 @@ export function DesignerRoot({
|
||||
const [inspectorTab, setInspectorTab] = useState<
|
||||
"properties" | "issues" | "dependencies"
|
||||
>("properties");
|
||||
|
||||
const [leftCollapsed, setLeftCollapsed] = useState(false);
|
||||
const [rightCollapsed, setRightCollapsed] = useState(false);
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
|
||||
// Responsive initialization: Collapse left sidebar on smaller screens (<1280px)
|
||||
useEffect(() => {
|
||||
const checkWidth = () => {
|
||||
if (window.innerWidth < 1280) {
|
||||
setLeftCollapsed(true);
|
||||
}
|
||||
};
|
||||
// Check once on mount
|
||||
checkWidth();
|
||||
// Optional: Add resize listener if we want live responsiveness
|
||||
// window.addEventListener('resize', checkWidth);
|
||||
// return () => window.removeEventListener('resize', checkWidth);
|
||||
}, []);
|
||||
/**
|
||||
* Active action being dragged from the Action Library (for DragOverlay rendering).
|
||||
* Captures a lightweight subset for visual feedback.
|
||||
@@ -269,16 +395,24 @@ export function DesignerRoot({
|
||||
description?: string;
|
||||
} | null>(null);
|
||||
|
||||
const [activeSortableItem, setActiveSortableItem] = useState<{
|
||||
type: "step" | "action";
|
||||
data: any;
|
||||
} | null>(null);
|
||||
|
||||
/* ----------------------------- Initialization ---------------------------- */
|
||||
useEffect(() => {
|
||||
console.log("[DesignerRoot] useEffect triggered", {
|
||||
initialized,
|
||||
loadingExperiment,
|
||||
hasExperiment: !!experiment,
|
||||
hasInitialDesign: !!initialDesign,
|
||||
});
|
||||
|
||||
if (initialized) return;
|
||||
if (loadingExperiment && !initialDesign) return;
|
||||
|
||||
// console.log('[DesignerRoot] 🚀 INITIALIZING', {
|
||||
// hasExperiment: !!experiment,
|
||||
// hasInitialDesign: !!initialDesign,
|
||||
// loadingExperiment,
|
||||
// });
|
||||
console.log("[DesignerRoot] Proceeding with initialization");
|
||||
|
||||
const adapted =
|
||||
initialDesign ??
|
||||
@@ -327,13 +461,14 @@ export function DesignerRoot({
|
||||
.catch((err) => console.error("Core action load failed:", err));
|
||||
}, []);
|
||||
|
||||
// Load plugin actions when study plugins available
|
||||
// Load plugin actions only after we have the flattened, processed plugin list
|
||||
useEffect(() => {
|
||||
if (!experiment?.studyId) return;
|
||||
if (!studyPluginsRaw) return;
|
||||
// @ts-expect-error - studyPluginsRaw type from tRPC is compatible but TypeScript can't infer it
|
||||
actionRegistry.loadPluginActions(experiment.studyId, studyPluginsRaw);
|
||||
}, [experiment?.studyId, studyPluginsRaw]);
|
||||
if (!studyPlugins) return;
|
||||
|
||||
// Pass the flattened plugins which match the structure ActionRegistry expects
|
||||
actionRegistry.loadPluginActions(experiment.studyId, studyPlugins);
|
||||
}, [experiment?.studyId, studyPlugins]);
|
||||
|
||||
/* ------------------------- Ready State Management ------------------------ */
|
||||
// Mark as ready once initialized and plugins are loaded
|
||||
@@ -348,11 +483,10 @@ export function DesignerRoot({
|
||||
// Small delay to ensure all components have rendered
|
||||
const timer = setTimeout(() => {
|
||||
setIsReady(true);
|
||||
// console.log('[DesignerRoot] ✅ Designer ready (plugins loaded), fading in');
|
||||
}, 150);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [initialized, isReady, studyPluginsRaw]);
|
||||
}, [initialized, isReady, studyPlugins]);
|
||||
|
||||
/* ----------------------- Automatic Hash Recomputation -------------------- */
|
||||
// Automatically recompute hash when steps change (debounced to avoid excessive computation)
|
||||
@@ -372,7 +506,6 @@ export function DesignerRoot({
|
||||
return () => clearTimeout(timeoutId);
|
||||
}, [steps, initialized, recomputeHash]);
|
||||
|
||||
|
||||
/* ----------------------------- Derived State ----------------------------- */
|
||||
const hasUnsavedChanges =
|
||||
!!currentDesignHash && lastPersistedHash !== currentDesignHash;
|
||||
@@ -415,6 +548,7 @@ export function DesignerRoot({
|
||||
const currentSteps = [...steps];
|
||||
// Ensure core actions are loaded before validating
|
||||
await actionRegistry.loadCoreActions();
|
||||
|
||||
const result = validateExperimentDesign(currentSteps, {
|
||||
steps: currentSteps,
|
||||
actionDefinitions: actionRegistry.getAllActions(),
|
||||
@@ -424,20 +558,30 @@ export function DesignerRoot({
|
||||
// Debug: Improved structured logging for validation results
|
||||
console.group("🧪 Experiment Validation Results");
|
||||
if (result.valid) {
|
||||
console.log(`%c✓ VALID (0 errors, ${result.warningCount} warnings, ${result.infoCount} hints)`, "color: green; font-weight: bold; font-size: 12px;");
|
||||
console.log(
|
||||
`%c✓ VALID (0 errors, ${result.warningCount} warnings, ${result.infoCount} hints)`,
|
||||
"color: green; font-weight: bold; font-size: 12px;",
|
||||
);
|
||||
} else {
|
||||
console.log(`%c✗ INVALID (${result.errorCount} errors, ${result.warningCount} warnings)`, "color: red; font-weight: bold; font-size: 12px;");
|
||||
console.log(
|
||||
`%c✗ INVALID (${result.errorCount} errors, ${result.warningCount} warnings)`,
|
||||
"color: red; font-weight: bold; font-size: 12px;",
|
||||
);
|
||||
}
|
||||
|
||||
if (result.issues.length > 0) {
|
||||
console.table(
|
||||
result.issues.map(i => ({
|
||||
result.issues.map((i) => ({
|
||||
Severity: i.severity.toUpperCase(),
|
||||
Category: i.category,
|
||||
Message: i.message,
|
||||
Suggest: i.suggestion,
|
||||
Location: i.actionId ? `Action ${i.actionId}` : (i.stepId ? `Step ${i.stepId}` : 'Global')
|
||||
}))
|
||||
Location: i.actionId
|
||||
? `Action ${i.actionId}`
|
||||
: i.stepId
|
||||
? `Step ${i.stepId}`
|
||||
: "Global",
|
||||
})),
|
||||
);
|
||||
} else {
|
||||
console.log("No issues found. Design is perfectly compliant.");
|
||||
@@ -468,7 +612,8 @@ export function DesignerRoot({
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
`Validation error: ${err instanceof Error ? err.message : "Unknown error"
|
||||
`Validation error: ${
|
||||
err instanceof Error ? err.message : "Unknown error"
|
||||
}`,
|
||||
);
|
||||
} finally {
|
||||
@@ -482,11 +627,20 @@ export function DesignerRoot({
|
||||
clearAllValidationIssues,
|
||||
]);
|
||||
|
||||
// Trigger initial validation when ready (plugins loaded) to ensure no stale errors
|
||||
// Trigger initial validation when ready (plugins loaded) to ensure no stale errors
|
||||
// DISABLED: User prefers manual validation to avoid noise on improved sequential architecture
|
||||
// useEffect(() => {
|
||||
// if (isReady) {
|
||||
// void validateDesign();
|
||||
// }
|
||||
// }, [isReady, validateDesign]);
|
||||
|
||||
/* --------------------------------- Save ---------------------------------- */
|
||||
const persist = useCallback(async () => {
|
||||
if (!initialized) return;
|
||||
|
||||
console.log('[DesignerRoot] 💾 SAVE initiated', {
|
||||
console.log("[DesignerRoot] 💾 SAVE initiated", {
|
||||
stepsCount: steps.length,
|
||||
actionsCount: steps.reduce((sum, s) => sum + s.actions.length, 0),
|
||||
currentHash: currentDesignHash?.slice(0, 16),
|
||||
@@ -501,7 +655,7 @@ export function DesignerRoot({
|
||||
lastSaved: new Date().toISOString(),
|
||||
};
|
||||
|
||||
console.log('[DesignerRoot] 💾 Sending to server...', {
|
||||
console.log("[DesignerRoot] 💾 Sending to server...", {
|
||||
experimentId,
|
||||
stepsCount: steps.length,
|
||||
version: designMeta.version,
|
||||
@@ -515,7 +669,7 @@ export function DesignerRoot({
|
||||
compileExecution: autoCompile,
|
||||
});
|
||||
|
||||
console.log('[DesignerRoot] 💾 Server save successful');
|
||||
console.log("[DesignerRoot] 💾 Server save successful");
|
||||
|
||||
// NOTE: We do NOT refetch here because it would reset the local steps state
|
||||
// to the server state, which would cause the hash to match the persisted hash,
|
||||
@@ -525,7 +679,7 @@ export function DesignerRoot({
|
||||
// Recompute hash and update persisted hash
|
||||
const hashResult = await recomputeHash();
|
||||
if (hashResult?.designHash) {
|
||||
console.log('[DesignerRoot] 💾 Updated persisted hash:', {
|
||||
console.log("[DesignerRoot] 💾 Updated persisted hash:", {
|
||||
newPersistedHash: hashResult.designHash.slice(0, 16),
|
||||
fullHash: hashResult.designHash,
|
||||
});
|
||||
@@ -535,7 +689,10 @@ export function DesignerRoot({
|
||||
setLastSavedAt(new Date());
|
||||
toast.success("Experiment saved");
|
||||
|
||||
console.log('[DesignerRoot] 💾 SAVE complete');
|
||||
// Auto-validate after save to clear "Modified" (drift) status
|
||||
void validateDesign();
|
||||
|
||||
console.log("[DesignerRoot] 💾 SAVE complete");
|
||||
|
||||
onPersist?.({
|
||||
id: experimentId,
|
||||
@@ -546,7 +703,7 @@ export function DesignerRoot({
|
||||
lastSaved: new Date(),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[DesignerRoot] 💾 SAVE failed:', error);
|
||||
console.error("[DesignerRoot] 💾 SAVE failed:", error);
|
||||
// Error already handled by mutation onError
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
@@ -602,7 +759,8 @@ export function DesignerRoot({
|
||||
toast.success("Exported design bundle");
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
`Export failed: ${err instanceof Error ? err.message : "Unknown error"
|
||||
`Export failed: ${
|
||||
err instanceof Error ? err.message : "Unknown error"
|
||||
}`,
|
||||
);
|
||||
} finally {
|
||||
@@ -664,15 +822,18 @@ export function DesignerRoot({
|
||||
useSensor(KeyboardSensor),
|
||||
);
|
||||
|
||||
/* ----------------------------- Drag Handlers ----------------------------- */
|
||||
/* ----------------------------- Drag Handlers ----------------------------- */
|
||||
const handleDragStart = useCallback(
|
||||
(event: DragStartEvent) => {
|
||||
const { active } = event;
|
||||
if (
|
||||
active.id.toString().startsWith("action-") &&
|
||||
active.data.current?.action
|
||||
) {
|
||||
const a = active.data.current.action as {
|
||||
const activeId = active.id.toString();
|
||||
const activeData = active.data.current;
|
||||
|
||||
console.log("[DesignerRoot] DragStart", { activeId, activeData });
|
||||
|
||||
if (activeId.startsWith("action-") && activeData?.action) {
|
||||
const a = activeData.action as {
|
||||
id: string;
|
||||
name: string;
|
||||
category: string;
|
||||
@@ -686,6 +847,21 @@ export function DesignerRoot({
|
||||
category: a.category,
|
||||
description: a.description,
|
||||
});
|
||||
} else if (activeId.startsWith("s-step-")) {
|
||||
console.log("[DesignerRoot] Setting active sortable STEP", activeData);
|
||||
setActiveSortableItem({
|
||||
type: "step",
|
||||
data: activeData,
|
||||
});
|
||||
} else if (activeId.startsWith("s-act-")) {
|
||||
console.log(
|
||||
"[DesignerRoot] Setting active sortable ACTION",
|
||||
activeData,
|
||||
);
|
||||
setActiveSortableItem({
|
||||
type: "action",
|
||||
data: activeData,
|
||||
});
|
||||
}
|
||||
},
|
||||
[toggleLibraryScrollLock],
|
||||
@@ -694,16 +870,17 @@ export function DesignerRoot({
|
||||
const handleDragOver = useCallback((event: DragOverEvent) => {
|
||||
const { active, over } = event;
|
||||
const store = useDesignerStore.getState();
|
||||
const activeId = active.id.toString();
|
||||
|
||||
// Only handle Library -> Flow projection
|
||||
if (!active.id.toString().startsWith("action-")) {
|
||||
if (!over) {
|
||||
if (store.insertionProjection) {
|
||||
store.setInsertionProjection(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!over) {
|
||||
// 3. Library -> Flow Projection (Action)
|
||||
if (!activeId.startsWith("action-")) {
|
||||
if (store.insertionProjection) {
|
||||
store.setInsertionProjection(null);
|
||||
}
|
||||
@@ -744,7 +921,7 @@ export function DesignerRoot({
|
||||
// Let's assume index 0 for now (prepend) or implement lookup.
|
||||
// Better: lookup action -> children length.
|
||||
const actionId = parentId;
|
||||
const step = store.steps.find(s => s.id === stepId);
|
||||
const step = store.steps.find((s) => s.id === stepId);
|
||||
// Find action recursive? Store has `findActionById` helper but it is not exported/accessible easily here?
|
||||
// Actually, `store.steps` is available.
|
||||
// We can implement a quick BFS/DFS or just assume 0.
|
||||
@@ -759,7 +936,6 @@ export function DesignerRoot({
|
||||
: overId.slice("step-".length);
|
||||
const step = store.steps.find((s) => s.id === stepId);
|
||||
index = step ? step.actions.length : 0;
|
||||
|
||||
} else if (overId === "projection-placeholder") {
|
||||
// Hovering over our own projection placeholder -> keep current state
|
||||
return;
|
||||
@@ -804,6 +980,7 @@ export function DesignerRoot({
|
||||
// Clear overlay immediately
|
||||
toggleLibraryScrollLock(false);
|
||||
setDragOverlayAction(null);
|
||||
setActiveSortableItem(null);
|
||||
|
||||
// Capture and clear projection
|
||||
const store = useDesignerStore.getState();
|
||||
@@ -814,6 +991,38 @@ export function DesignerRoot({
|
||||
return;
|
||||
}
|
||||
|
||||
const activeId = active.id.toString();
|
||||
|
||||
// Handle Step Reordering (Active is a sortable step)
|
||||
if (activeId.startsWith("s-step-")) {
|
||||
const overId = over.id.toString();
|
||||
// Allow reordering over both sortable steps (s-step-) and drop zones (step-)
|
||||
if (!overId.startsWith("s-step-") && !overId.startsWith("step-"))
|
||||
return;
|
||||
|
||||
// Strip prefixes to get raw IDs
|
||||
const rawActiveId = activeId.replace(/^s-step-/, "");
|
||||
const rawOverId = overId.replace(/^s-step-/, "").replace(/^step-/, "");
|
||||
|
||||
console.log("[DesignerRoot] DragEnd - Step Sort", {
|
||||
activeId,
|
||||
overId,
|
||||
rawActiveId,
|
||||
rawOverId,
|
||||
});
|
||||
|
||||
const oldIndex = steps.findIndex((s) => s.id === rawActiveId);
|
||||
const newIndex = steps.findIndex((s) => s.id === rawOverId);
|
||||
|
||||
console.log("[DesignerRoot] Indices", { oldIndex, newIndex });
|
||||
|
||||
if (oldIndex !== -1 && newIndex !== -1 && oldIndex !== newIndex) {
|
||||
console.log("[DesignerRoot] Reordering...");
|
||||
reorderStep(oldIndex, newIndex);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 1. Determine Target (Step, Parent, Index)
|
||||
let stepId: string | null = null;
|
||||
let parentId: string | null = null;
|
||||
@@ -845,7 +1054,10 @@ export function DesignerRoot({
|
||||
if (!targetStep) return;
|
||||
|
||||
// 2. Instantiate Action
|
||||
if (active.id.toString().startsWith("action-") && active.data.current?.action) {
|
||||
if (
|
||||
active.id.toString().startsWith("action-") &&
|
||||
active.data.current?.action
|
||||
) {
|
||||
const actionDef = active.data.current.action as {
|
||||
id: string; // type
|
||||
type: string;
|
||||
@@ -861,38 +1073,39 @@ export function DesignerRoot({
|
||||
const defaultParams: Record<string, unknown> = {};
|
||||
if (fullDef?.parameters) {
|
||||
for (const param of fullDef.parameters) {
|
||||
// @ts-expect-error - 'default' property access
|
||||
if (param.default !== undefined) {
|
||||
// @ts-expect-error - 'default' property access
|
||||
defaultParams[param.id] = param.default;
|
||||
if (param.value !== undefined) {
|
||||
defaultParams[param.id] = param.value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const execution: ExperimentAction["execution"] =
|
||||
actionDef.execution &&
|
||||
(actionDef.execution.transport === "internal" ||
|
||||
actionDef.execution.transport === "rest" ||
|
||||
actionDef.execution.transport === "ros2")
|
||||
(actionDef.execution.transport === "internal" ||
|
||||
actionDef.execution.transport === "rest" ||
|
||||
actionDef.execution.transport === "ros2")
|
||||
? {
|
||||
transport: actionDef.execution.transport,
|
||||
retryable: actionDef.execution.retryable ?? false,
|
||||
}
|
||||
transport: actionDef.execution.transport,
|
||||
retryable: actionDef.execution.retryable ?? false,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const newId = `action-${Date.now().toString(36)}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
const newAction: ExperimentAction = {
|
||||
id: crypto.randomUUID(),
|
||||
id: newId,
|
||||
type: actionDef.type, // this is the 'type' key
|
||||
name: actionDef.name,
|
||||
category: actionDef.category as any,
|
||||
description: "",
|
||||
parameters: defaultParams,
|
||||
source: actionDef.source ? {
|
||||
kind: actionDef.source.kind as any,
|
||||
pluginId: actionDef.source.pluginId,
|
||||
pluginVersion: actionDef.source.pluginVersion,
|
||||
baseActionId: actionDef.id
|
||||
} : { kind: "core" },
|
||||
source: actionDef.source
|
||||
? {
|
||||
kind: actionDef.source.kind as any,
|
||||
pluginId: actionDef.source.pluginId,
|
||||
pluginVersion: actionDef.source.pluginVersion,
|
||||
baseActionId: actionDef.id,
|
||||
}
|
||||
: { kind: "core" },
|
||||
execution,
|
||||
children: [],
|
||||
};
|
||||
@@ -906,13 +1119,25 @@ export function DesignerRoot({
|
||||
void recomputeHash();
|
||||
}
|
||||
},
|
||||
[steps, upsertAction, selectAction, recomputeHash, toggleLibraryScrollLock],
|
||||
[
|
||||
steps,
|
||||
upsertAction,
|
||||
selectAction,
|
||||
recomputeHash,
|
||||
toggleLibraryScrollLock,
|
||||
reorderStep,
|
||||
],
|
||||
);
|
||||
// validation status badges removed (unused)
|
||||
/* ------------------------------- Panels ---------------------------------- */
|
||||
const leftPanel = useMemo(
|
||||
() => (
|
||||
<div id="tour-designer-blocks" ref={libraryRootRef} data-library-root className="h-full">
|
||||
<div
|
||||
id="tour-designer-blocks"
|
||||
ref={libraryRootRef}
|
||||
data-library-root
|
||||
className="h-full"
|
||||
>
|
||||
<ActionLibraryPanel />
|
||||
</div>
|
||||
),
|
||||
@@ -935,10 +1160,11 @@ export function DesignerRoot({
|
||||
activeTab={inspectorTab}
|
||||
onTabChange={setInspectorTab}
|
||||
studyPlugins={studyPlugins}
|
||||
onClearAll={clearAllValidationIssues}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
[inspectorTab, studyPlugins],
|
||||
[inspectorTab, studyPlugins, clearAllValidationIssues],
|
||||
);
|
||||
|
||||
/* ------------------------------- Render ---------------------------------- */
|
||||
@@ -952,6 +1178,16 @@ export function DesignerRoot({
|
||||
|
||||
const actions = (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{experimentMetadata && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setSettingsOpen(true)}
|
||||
title="Experiment Settings"
|
||||
>
|
||||
<Settings className="h-5 w-5" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@@ -982,83 +1218,198 @@ export function DesignerRoot({
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col overflow-hidden">
|
||||
<div className="bg-background relative flex h-[calc(100vh-5rem)] w-full flex-col overflow-hidden">
|
||||
{/* Subtle Background Gradients */}
|
||||
<div className="bg-primary/10 absolute top-0 left-1/2 -z-10 h-[400px] w-[800px] -translate-x-1/2 rounded-full opacity-20 blur-3xl dark:opacity-10" />
|
||||
<div className="absolute right-0 bottom-0 -z-10 h-[250px] w-[250px] rounded-full bg-violet-500/5 blur-3xl" />
|
||||
<PageHeader
|
||||
title={designMeta.name}
|
||||
description={designMeta.description || "No description"}
|
||||
icon={Play}
|
||||
actions={actions}
|
||||
className="pb-6"
|
||||
className="flex-none pb-4"
|
||||
/>
|
||||
|
||||
<div className="relative flex flex-1 flex-col overflow-hidden">
|
||||
{/* Loading Overlay */}
|
||||
{!isReady && (
|
||||
<div className="absolute inset-0 z-50 flex items-center justify-center bg-background">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<RefreshCw className="h-8 w-8 animate-spin text-primary" />
|
||||
<p className="text-muted-foreground text-sm">Loading designer...</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main Content - Fade in when ready */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-1 flex-col overflow-hidden transition-opacity duration-500",
|
||||
isReady ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
{/* Main Grid Container - 2-4-2 Split */}
|
||||
{/* Main Grid Container - 2-4-2 Split */}
|
||||
<div className="min-h-0 w-full flex-1 overflow-hidden px-2">
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragStart={handleDragStart}
|
||||
onDragOver={handleDragOver}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragCancel={() => toggleLibraryScrollLock(false)}
|
||||
>
|
||||
<div className="flex h-[calc(100vh-12rem)] w-full max-w-full flex-col overflow-hidden rounded-md border">
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCorners}
|
||||
onDragStart={handleDragStart}
|
||||
onDragOver={handleDragOver}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragCancel={() => toggleLibraryScrollLock(false)}
|
||||
<div className="grid h-full w-full grid-cols-8 gap-4 transition-all duration-300 ease-in-out">
|
||||
{/* Left Panel (Library) */}
|
||||
{!leftCollapsed && (
|
||||
<div
|
||||
className={cn(
|
||||
"bg-background flex flex-col overflow-hidden rounded-lg border shadow-sm",
|
||||
rightCollapsed ? "col-span-3" : "col-span-2",
|
||||
)}
|
||||
>
|
||||
<div className="bg-muted/30 flex items-center justify-between border-b px-3 py-2">
|
||||
<span className="text-sm font-medium">Action Library</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={() => setLeftCollapsed(true)}
|
||||
>
|
||||
<PanelLeftClose className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="bg-muted/10 min-h-0 flex-1 overflow-hidden">
|
||||
{leftPanel}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Center Panel (Workspace) */}
|
||||
<div
|
||||
className={cn(
|
||||
"bg-background flex flex-col overflow-hidden rounded-lg border shadow-sm",
|
||||
leftCollapsed && rightCollapsed
|
||||
? "col-span-8"
|
||||
: leftCollapsed
|
||||
? "col-span-6"
|
||||
: rightCollapsed
|
||||
? "col-span-5"
|
||||
: "col-span-4",
|
||||
)}
|
||||
>
|
||||
<PanelsContainer
|
||||
showDividers
|
||||
className="min-h-0 flex-1"
|
||||
left={leftPanel}
|
||||
center={centerPanel}
|
||||
right={rightPanel}
|
||||
/>
|
||||
<DragOverlay>
|
||||
{dragOverlayAction ? (
|
||||
<div className="bg-background flex items-center gap-2 rounded border px-3 py-2 text-xs font-medium shadow-lg select-none">
|
||||
<span
|
||||
className={cn(
|
||||
"h-2.5 w-2.5 rounded-full",
|
||||
{
|
||||
wizard: "bg-blue-500",
|
||||
robot: "bg-emerald-600",
|
||||
control: "bg-amber-500",
|
||||
observation: "bg-purple-600",
|
||||
}[dragOverlayAction.category] || "bg-slate-400",
|
||||
)}
|
||||
/>
|
||||
{dragOverlayAction.name}
|
||||
<div className="bg-muted/30 flex items-center justify-between border-b px-3 py-2">
|
||||
{leftCollapsed && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="mr-2 h-6 w-6"
|
||||
onClick={() => setLeftCollapsed(false)}
|
||||
title="Open Library"
|
||||
>
|
||||
<PanelLeftOpen className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
<span className="text-sm font-medium">Flow Workspace</span>
|
||||
{rightCollapsed && (
|
||||
<div className="flex items-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={() => startTour("designer")}
|
||||
>
|
||||
<HelpCircle className="h-4 w-4" />
|
||||
</Button>
|
||||
{rightCollapsed && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="ml-2 h-6 w-6"
|
||||
onClick={() => setRightCollapsed(false)}
|
||||
title="Open Inspector"
|
||||
>
|
||||
<PanelRightOpen className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
<div className="flex-shrink-0 border-t">
|
||||
<BottomStatusBar
|
||||
onSave={() => persist()}
|
||||
onValidate={() => validateDesign()}
|
||||
onExport={() => handleExport()}
|
||||
onRecalculateHash={() => recomputeHash()}
|
||||
lastSavedAt={lastSavedAt}
|
||||
saving={isSaving}
|
||||
validating={isValidating}
|
||||
exporting={isExporting}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="relative min-h-0 flex-1 overflow-hidden">
|
||||
{centerPanel}
|
||||
</div>
|
||||
<div className="border-t">
|
||||
<BottomStatusBar
|
||||
onSave={() => persist()}
|
||||
onValidate={() => validateDesign()}
|
||||
onExport={() => handleExport()}
|
||||
onRecalculateHash={() => recomputeHash()}
|
||||
lastSavedAt={lastSavedAt}
|
||||
saving={isSaving}
|
||||
validating={isValidating}
|
||||
exporting={isExporting}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Panel (Inspector) */}
|
||||
{!rightCollapsed && (
|
||||
<div
|
||||
className={cn(
|
||||
"bg-background flex flex-col overflow-hidden rounded-lg border shadow-sm",
|
||||
leftCollapsed ? "col-span-2" : "col-span-2",
|
||||
)}
|
||||
>
|
||||
<div className="bg-muted/30 flex items-center justify-between border-b px-3 py-2">
|
||||
<span className="text-sm font-medium">Inspector</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={() => setRightCollapsed(true)}
|
||||
>
|
||||
<PanelRightClose className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="bg-muted/10 min-h-0 flex-1 overflow-hidden">
|
||||
{rightPanel}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DragOverlay dropAnimation={null}>
|
||||
{dragOverlayAction ? (
|
||||
// Library Item Drag
|
||||
<div className="bg-background pointer-events-none flex items-center gap-2 rounded border px-3 py-2 text-xs font-medium shadow-lg ring-2 ring-blue-500/20 select-none">
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-4 w-4 items-center justify-center rounded text-white",
|
||||
dragOverlayAction.category === "robot" && "bg-emerald-600",
|
||||
dragOverlayAction.category === "control" && "bg-amber-500",
|
||||
dragOverlayAction.category === "observation" &&
|
||||
"bg-purple-600",
|
||||
)}
|
||||
/>
|
||||
{dragOverlayAction.name}
|
||||
</div>
|
||||
) : activeSortableItem?.type === "action" ? (
|
||||
// Existing Action Sort
|
||||
<div className="pointer-events-none w-[300px] opacity-90">
|
||||
<SortableActionChip
|
||||
stepId={activeSortableItem.data.stepId}
|
||||
action={activeSortableItem.data.action}
|
||||
parentId={activeSortableItem.data.parentId}
|
||||
selectedActionId={selectedActionId}
|
||||
onSelectAction={() => {}}
|
||||
onDeleteAction={() => {}}
|
||||
dragHandle={true}
|
||||
/>
|
||||
</div>
|
||||
) : activeSortableItem?.type === "step" ? (
|
||||
// Existing Step Sort
|
||||
<div className="pointer-events-none w-[400px] opacity-90">
|
||||
<StepCardPreview
|
||||
step={activeSortableItem.data.step}
|
||||
dragHandle
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
</div>
|
||||
|
||||
{/* Settings Modal */}
|
||||
{experimentMetadata && (
|
||||
<SettingsModal
|
||||
open={settingsOpen}
|
||||
onOpenChange={setSettingsOpen}
|
||||
experiment={experimentMetadata}
|
||||
designStats={designStats}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
type ExperimentDesign,
|
||||
} from "~/lib/experiment-designer/types";
|
||||
import { actionRegistry } from "./ActionRegistry";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import {
|
||||
Settings,
|
||||
Zap,
|
||||
@@ -39,6 +40,9 @@ import {
|
||||
Mic,
|
||||
Activity,
|
||||
Play,
|
||||
Plus,
|
||||
GitBranch,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
|
||||
/**
|
||||
@@ -166,7 +170,30 @@ export function PropertiesPanelBase({
|
||||
|
||||
/* -------------------------- Action Properties View -------------------------- */
|
||||
if (selectedAction && containingStep) {
|
||||
const def = registry.getAction(selectedAction.type);
|
||||
let def = registry.getAction(selectedAction.type);
|
||||
|
||||
// Fallback: If action not found in registry, try without plugin prefix
|
||||
if (!def && selectedAction.type.includes(".")) {
|
||||
const baseType = selectedAction.type.split(".").pop();
|
||||
if (baseType) {
|
||||
def = registry.getAction(baseType);
|
||||
}
|
||||
}
|
||||
|
||||
// Final fallback: Create minimal definition from action data
|
||||
if (!def) {
|
||||
def = {
|
||||
id: selectedAction.type,
|
||||
type: selectedAction.type,
|
||||
name: selectedAction.name,
|
||||
description: `Action type: ${selectedAction.type}`,
|
||||
category: selectedAction.category || "control",
|
||||
icon: "Zap",
|
||||
color: "#6366f1",
|
||||
parameters: [],
|
||||
source: selectedAction.source,
|
||||
};
|
||||
}
|
||||
const categoryColors = {
|
||||
wizard: "bg-blue-500",
|
||||
robot: "bg-emerald-500",
|
||||
@@ -198,12 +225,15 @@ export function PropertiesPanelBase({
|
||||
const ResolvedIcon: React.ComponentType<{ className?: string }> =
|
||||
def?.icon && iconComponents[def.icon]
|
||||
? (iconComponents[def.icon] as React.ComponentType<{
|
||||
className?: string;
|
||||
}>)
|
||||
className?: string;
|
||||
}>)
|
||||
: Zap;
|
||||
|
||||
return (
|
||||
<div className={cn("w-full min-w-0 space-y-3 px-3", className)} id="tour-designer-properties">
|
||||
<div
|
||||
className={cn("w-full min-w-0 space-y-3 px-3", className)}
|
||||
id="tour-designer-properties"
|
||||
>
|
||||
{/* Header / Metadata */}
|
||||
<div className="border-b pb-3">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
@@ -275,8 +305,269 @@ export function PropertiesPanelBase({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Parameters */}
|
||||
{def?.parameters.length ? (
|
||||
{/* Branching Configuration (Special Case) */}
|
||||
{selectedAction.type === "branch" ? (
|
||||
<div className="space-y-3">
|
||||
<div className="text-muted-foreground flex items-center justify-between text-[10px] tracking-wide uppercase">
|
||||
<span>Branch Options</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-5 w-5 p-0"
|
||||
onClick={() => {
|
||||
const currentOptions =
|
||||
((containingStep.trigger.conditions as any)
|
||||
.options as any[]) || [];
|
||||
const newOptions = [
|
||||
...currentOptions,
|
||||
{
|
||||
label: "New Option",
|
||||
nextStepId: design.steps[containingStep.order + 1]?.id,
|
||||
variant: "default",
|
||||
},
|
||||
];
|
||||
|
||||
// Sync to Step Trigger (Source of Truth)
|
||||
onStepUpdate(containingStep.id, {
|
||||
trigger: {
|
||||
...containingStep.trigger,
|
||||
conditions: {
|
||||
...containingStep.trigger.conditions,
|
||||
options: newOptions,
|
||||
},
|
||||
},
|
||||
});
|
||||
// Sync to Action Params (for consistency)
|
||||
onActionUpdate(containingStep.id, selectedAction.id, {
|
||||
parameters: {
|
||||
...selectedAction.parameters,
|
||||
options: newOptions,
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{(
|
||||
((containingStep.trigger.conditions as any).options as any[]) ||
|
||||
[]
|
||||
).map((opt: any, idx: number) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="bg-muted/50 space-y-2 rounded border p-2"
|
||||
>
|
||||
<div className="grid grid-cols-5 gap-2">
|
||||
<div className="col-span-3">
|
||||
<Label className="text-[10px]">Label</Label>
|
||||
<Input
|
||||
value={opt.label}
|
||||
onChange={(e) => {
|
||||
const currentOptions =
|
||||
((containingStep.trigger.conditions as any)
|
||||
.options as any[]) || [];
|
||||
const newOpts = [...currentOptions];
|
||||
newOpts[idx] = {
|
||||
...newOpts[idx],
|
||||
label: e.target.value,
|
||||
};
|
||||
|
||||
onStepUpdate(containingStep.id, {
|
||||
trigger: {
|
||||
...containingStep.trigger,
|
||||
conditions: {
|
||||
...containingStep.trigger.conditions,
|
||||
options: newOpts,
|
||||
},
|
||||
},
|
||||
});
|
||||
onActionUpdate(containingStep.id, selectedAction.id, {
|
||||
parameters: {
|
||||
...selectedAction.parameters,
|
||||
options: newOpts,
|
||||
},
|
||||
});
|
||||
}}
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<Label className="text-[10px]">Target Step</Label>
|
||||
{design.steps.length <= 1 ? (
|
||||
<div
|
||||
className="text-muted-foreground bg-muted/50 flex h-7 items-center truncate rounded border px-2 text-[10px]"
|
||||
title="Add more steps to link"
|
||||
>
|
||||
No linkable steps
|
||||
</div>
|
||||
) : (
|
||||
<Select
|
||||
value={opt.nextStepId ?? ""}
|
||||
onValueChange={(val) => {
|
||||
const currentOptions =
|
||||
((containingStep.trigger.conditions as any)
|
||||
.options as any[]) || [];
|
||||
const newOpts = [...currentOptions];
|
||||
newOpts[idx] = { ...newOpts[idx], nextStepId: val };
|
||||
|
||||
onStepUpdate(containingStep.id, {
|
||||
trigger: {
|
||||
...containingStep.trigger,
|
||||
conditions: {
|
||||
...containingStep.trigger.conditions,
|
||||
options: newOpts,
|
||||
},
|
||||
},
|
||||
});
|
||||
onActionUpdate(
|
||||
containingStep.id,
|
||||
selectedAction.id,
|
||||
{
|
||||
parameters: {
|
||||
...selectedAction.parameters,
|
||||
options: newOpts,
|
||||
},
|
||||
},
|
||||
);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-7 w-full text-xs">
|
||||
<SelectValue placeholder="Select..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="min-w-[180px]">
|
||||
{design.steps.map((s) => (
|
||||
<SelectItem
|
||||
key={s.id}
|
||||
value={s.id}
|
||||
disabled={s.id === containingStep.id}
|
||||
>
|
||||
{s.order + 1}. {s.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<Select
|
||||
value={opt.variant || "default"}
|
||||
onValueChange={(val) => {
|
||||
const currentOptions =
|
||||
((containingStep.trigger.conditions as any)
|
||||
.options as any[]) || [];
|
||||
const newOpts = [...currentOptions];
|
||||
newOpts[idx] = { ...newOpts[idx], variant: val };
|
||||
|
||||
onStepUpdate(containingStep.id, {
|
||||
trigger: {
|
||||
...containingStep.trigger,
|
||||
conditions: {
|
||||
...containingStep.trigger.conditions,
|
||||
options: newOpts,
|
||||
},
|
||||
},
|
||||
});
|
||||
onActionUpdate(containingStep.id, selectedAction.id, {
|
||||
parameters: {
|
||||
...selectedAction.parameters,
|
||||
options: newOpts,
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-6 w-[120px] text-[10px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="default">Default (Next)</SelectItem>
|
||||
<SelectItem value="destructive">
|
||||
Destructive (Red)
|
||||
</SelectItem>
|
||||
<SelectItem value="outline">Outline</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-muted-foreground h-6 w-6 p-0 hover:text-red-500"
|
||||
onClick={() => {
|
||||
const currentOptions =
|
||||
((containingStep.trigger.conditions as any)
|
||||
.options as any[]) || [];
|
||||
const newOpts = [...currentOptions];
|
||||
newOpts.splice(idx, 1);
|
||||
|
||||
onStepUpdate(containingStep.id, {
|
||||
trigger: {
|
||||
...containingStep.trigger,
|
||||
conditions: {
|
||||
...containingStep.trigger.conditions,
|
||||
options: newOpts,
|
||||
},
|
||||
},
|
||||
});
|
||||
onActionUpdate(containingStep.id, selectedAction.id, {
|
||||
parameters: {
|
||||
...selectedAction.parameters,
|
||||
options: newOpts,
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{!((containingStep.trigger.conditions as any).options as any[])
|
||||
?.length && (
|
||||
<div className="text-muted-foreground rounded border border-dashed py-4 text-center text-xs">
|
||||
No options defined.
|
||||
<br />
|
||||
Click + to add a branch.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : selectedAction.type === "loop" ? (
|
||||
/* Loop Configuration */
|
||||
<div className="space-y-3">
|
||||
<div className="text-muted-foreground text-[10px] tracking-wide uppercase">
|
||||
Loop Configuration
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Iterations */}
|
||||
<div>
|
||||
<Label className="text-xs">Iterations</Label>
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
<Slider
|
||||
min={1}
|
||||
max={20}
|
||||
step={1}
|
||||
value={[Number(selectedAction.parameters.iterations || 1)]}
|
||||
onValueChange={(vals) => {
|
||||
onActionUpdate(containingStep.id, selectedAction.id, {
|
||||
parameters: {
|
||||
...selectedAction.parameters,
|
||||
iterations: vals[0],
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<span className="w-8 text-right font-mono text-xs">
|
||||
{Number(selectedAction.parameters.iterations || 1)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : /* Standard Parameters */
|
||||
def?.parameters.length ? (
|
||||
<div className="space-y-3">
|
||||
<div className="text-muted-foreground text-[10px] tracking-wide uppercase">
|
||||
Parameters
|
||||
@@ -295,7 +586,7 @@ export function PropertiesPanelBase({
|
||||
},
|
||||
});
|
||||
}}
|
||||
onCommit={() => { }}
|
||||
onCommit={() => {}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -312,7 +603,10 @@ export function PropertiesPanelBase({
|
||||
/* --------------------------- Step Properties View --------------------------- */
|
||||
if (selectedStep) {
|
||||
return (
|
||||
<div className={cn("w-full min-w-0 space-y-3 px-3", className)} id="tour-designer-properties">
|
||||
<div
|
||||
className={cn("w-full min-w-0 space-y-3 px-3", className)}
|
||||
id="tour-designer-properties"
|
||||
>
|
||||
<div className="border-b pb-2">
|
||||
<h3 className="flex items-center gap-2 text-sm font-medium">
|
||||
<div
|
||||
@@ -388,17 +682,19 @@ export function PropertiesPanelBase({
|
||||
onValueChange={(val) => {
|
||||
onStepUpdate(selectedStep.id, { type: val as StepType });
|
||||
}}
|
||||
disabled={true}
|
||||
>
|
||||
<SelectTrigger className="mt-1 h-7 w-full text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="sequential">Sequential</SelectItem>
|
||||
<SelectItem value="parallel">Parallel</SelectItem>
|
||||
<SelectItem value="conditional">Conditional</SelectItem>
|
||||
<SelectItem value="loop">Loop</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-muted-foreground mt-1 text-[10px]">
|
||||
Steps always execute sequentially. Use control flow actions
|
||||
for parallel/conditional logic.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">Trigger</Label>
|
||||
@@ -469,7 +765,7 @@ const ParameterEditor = React.memo(function ParameterEditor({
|
||||
param,
|
||||
value: rawValue,
|
||||
onUpdate,
|
||||
onCommit
|
||||
onCommit,
|
||||
}: ParameterEditorProps) {
|
||||
// Local state for immediate feedback
|
||||
const [localValue, setLocalValue] = useState<unknown>(rawValue);
|
||||
@@ -480,19 +776,22 @@ const ParameterEditor = React.memo(function ParameterEditor({
|
||||
setLocalValue(rawValue);
|
||||
}, [rawValue]);
|
||||
|
||||
const handleUpdate = useCallback((newVal: unknown, immediate = false) => {
|
||||
setLocalValue(newVal);
|
||||
const handleUpdate = useCallback(
|
||||
(newVal: unknown, immediate = false) => {
|
||||
setLocalValue(newVal);
|
||||
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
|
||||
if (immediate) {
|
||||
onUpdate(newVal);
|
||||
} else {
|
||||
debounceRef.current = setTimeout(() => {
|
||||
if (immediate) {
|
||||
onUpdate(newVal);
|
||||
}, 300);
|
||||
}
|
||||
}, [onUpdate]);
|
||||
} else {
|
||||
debounceRef.current = setTimeout(() => {
|
||||
onUpdate(newVal);
|
||||
}, 300);
|
||||
}
|
||||
},
|
||||
[onUpdate],
|
||||
);
|
||||
|
||||
const handleCommit = useCallback(() => {
|
||||
if (localValue !== rawValue) {
|
||||
@@ -544,13 +843,22 @@ const ParameterEditor = React.memo(function ParameterEditor({
|
||||
</div>
|
||||
);
|
||||
} else if (param.type === "number") {
|
||||
const numericVal = typeof localValue === "number" ? localValue : (param.min ?? 0);
|
||||
const numericVal =
|
||||
typeof localValue === "number" ? localValue : (param.min ?? 0);
|
||||
|
||||
if (param.min !== undefined || param.max !== undefined) {
|
||||
const min = param.min ?? 0;
|
||||
const max = param.max ?? Math.max(min + 1, Number.isFinite(numericVal) ? numericVal : min + 1);
|
||||
const max =
|
||||
param.max ??
|
||||
Math.max(min + 1, Number.isFinite(numericVal) ? numericVal : min + 1);
|
||||
const range = max - min;
|
||||
const step = param.step ?? (range <= 5 ? 0.1 : range <= 50 ? 0.5 : Math.max(1, Math.round(range / 100)));
|
||||
const step =
|
||||
param.step ??
|
||||
(range <= 5
|
||||
? 0.1
|
||||
: range <= 50
|
||||
? 0.5
|
||||
: Math.max(1, Math.round(range / 100)));
|
||||
|
||||
control = (
|
||||
<div className="mt-1">
|
||||
@@ -564,7 +872,9 @@ const ParameterEditor = React.memo(function ParameterEditor({
|
||||
onPointerUp={() => handleUpdate(localValue)} // Commit on release
|
||||
/>
|
||||
<span className="text-muted-foreground min-w-[2.5rem] text-right text-[10px] tabular-nums">
|
||||
{step < 1 ? Number(numericVal).toFixed(2) : Number(numericVal).toString()}
|
||||
{step < 1
|
||||
? Number(numericVal).toFixed(2)
|
||||
: Number(numericVal).toString()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-muted-foreground mt-1 flex justify-between text-[10px]">
|
||||
|
||||
53
src/components/experiments/designer/SettingsModal.tsx
Normal file
53
src/components/experiments/designer/SettingsModal.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
"use client";
|
||||
|
||||
import { SettingsTab } from "./tabs/SettingsTab";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "~/components/ui/dialog";
|
||||
|
||||
interface SettingsModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
experiment: {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
status: string;
|
||||
studyId: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
study: {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
};
|
||||
designStats?: {
|
||||
stepCount: number;
|
||||
actionCount: number;
|
||||
};
|
||||
}
|
||||
|
||||
export function SettingsModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
experiment,
|
||||
designStats,
|
||||
}: SettingsModalProps) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-h-[90vh] max-w-4xl p-0">
|
||||
<DialogHeader className="sr-only">
|
||||
<DialogTitle>Experiment Settings</DialogTitle>
|
||||
<DialogDescription>
|
||||
Configure experiment metadata and status
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<SettingsTab experiment={experiment} designStats={designStats} />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -46,6 +46,10 @@ export interface ValidationPanelProps {
|
||||
* Called to clear all issues for an entity.
|
||||
*/
|
||||
onEntityClear?: (entityId: string) => void;
|
||||
/**
|
||||
* Called to clear all issues globally.
|
||||
*/
|
||||
onClearAll?: () => void;
|
||||
/**
|
||||
* Optional function to map entity IDs to human-friendly names (e.g., step/action names).
|
||||
*/
|
||||
@@ -60,25 +64,25 @@ export interface ValidationPanelProps {
|
||||
const severityConfig = {
|
||||
error: {
|
||||
icon: AlertCircle,
|
||||
color: "text-red-600 dark:text-red-400",
|
||||
bgColor: "bg-red-100 dark:bg-red-950/60",
|
||||
borderColor: "border-red-300 dark:border-red-700",
|
||||
color: "text-validation-error-text",
|
||||
bgColor: "bg-validation-error-bg",
|
||||
borderColor: "border-validation-error-border",
|
||||
badgeVariant: "destructive" as const,
|
||||
label: "Error",
|
||||
},
|
||||
warning: {
|
||||
icon: AlertTriangle,
|
||||
color: "text-amber-600 dark:text-amber-400",
|
||||
bgColor: "bg-amber-100 dark:bg-amber-950/60",
|
||||
borderColor: "border-amber-300 dark:border-amber-700",
|
||||
badgeVariant: "secondary" as const,
|
||||
color: "text-validation-warning-text",
|
||||
bgColor: "bg-validation-warning-bg",
|
||||
borderColor: "border-validation-warning-border",
|
||||
badgeVariant: "outline" as const,
|
||||
label: "Warning",
|
||||
},
|
||||
info: {
|
||||
icon: Info,
|
||||
color: "text-blue-600 dark:text-blue-400",
|
||||
bgColor: "bg-blue-100 dark:bg-blue-950/60",
|
||||
borderColor: "border-blue-300 dark:border-blue-700",
|
||||
color: "text-validation-info-text",
|
||||
bgColor: "bg-validation-info-bg",
|
||||
borderColor: "border-validation-info-border",
|
||||
badgeVariant: "outline" as const,
|
||||
label: "Info",
|
||||
},
|
||||
@@ -102,8 +106,6 @@ function flattenIssues(issuesMap: Record<string, ValidationIssue[]>) {
|
||||
return flattened;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Issue Item Component */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
@@ -141,7 +143,7 @@ function IssueItem({
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-[12px] leading-snug break-words whitespace-normal">
|
||||
<p className="text-foreground text-[12px] leading-snug break-words whitespace-normal">
|
||||
{issue.message}
|
||||
</p>
|
||||
|
||||
@@ -199,6 +201,7 @@ export function ValidationPanel({
|
||||
onIssueClick,
|
||||
onIssueClear,
|
||||
onEntityClear: _onEntityClear,
|
||||
onClearAll,
|
||||
entityLabelForId,
|
||||
className,
|
||||
}: ValidationPanelProps) {
|
||||
@@ -243,8 +246,6 @@ export function ValidationPanel({
|
||||
console.log("[ValidationPanel] issues", issues, { flatIssues, counts });
|
||||
}, [issues, flatIssues, counts]);
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
|
||||
553
src/components/experiments/designer/flow/ActionChip.tsx
Normal file
553
src/components/experiments/designer/flow/ActionChip.tsx
Normal file
@@ -0,0 +1,553 @@
|
||||
"use client";
|
||||
|
||||
import React, { useMemo } from "react";
|
||||
import { useSortable } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { useDroppable } from "@dnd-kit/core";
|
||||
import {
|
||||
ChevronRight,
|
||||
Trash2,
|
||||
Clock,
|
||||
GitBranch,
|
||||
Repeat,
|
||||
Layers,
|
||||
List,
|
||||
AlertCircle,
|
||||
Play,
|
||||
HelpCircle,
|
||||
} from "lucide-react";
|
||||
import { cn } from "~/lib/utils";
|
||||
import { type ExperimentAction } from "~/lib/experiment-designer/types";
|
||||
import { actionRegistry } from "../ActionRegistry";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { useDesignerStore } from "../state/store";
|
||||
|
||||
export interface ActionChipProps {
|
||||
stepId: string;
|
||||
action: ExperimentAction;
|
||||
parentId: string | null;
|
||||
selectedActionId: string | null | undefined;
|
||||
onSelectAction: (stepId: string, actionId: string | undefined) => void;
|
||||
onDeleteAction: (stepId: string, actionId: string) => void;
|
||||
onReorderAction?: (
|
||||
stepId: string,
|
||||
actionId: string,
|
||||
direction: "up" | "down",
|
||||
) => void;
|
||||
dragHandle?: boolean;
|
||||
isFirst?: boolean;
|
||||
isLast?: boolean;
|
||||
}
|
||||
|
||||
export interface ActionChipVisualsProps {
|
||||
action: ExperimentAction;
|
||||
isSelected?: boolean;
|
||||
isDragging?: boolean;
|
||||
isOverNested?: boolean;
|
||||
onSelect?: (e: React.MouseEvent) => void;
|
||||
onDelete?: (e: React.MouseEvent) => void;
|
||||
onReorder?: (direction: "up" | "down") => void;
|
||||
dragHandleProps?: React.HTMLAttributes<HTMLElement>;
|
||||
children?: React.ReactNode;
|
||||
isFirst?: boolean;
|
||||
isLast?: boolean;
|
||||
validationStatus?: "error" | "warning" | "info";
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to determine visual style based on action type/category
|
||||
*/
|
||||
function getActionVisualStyle(action: ExperimentAction) {
|
||||
const def = actionRegistry.getAction(action.type);
|
||||
const category = def?.category || "other";
|
||||
|
||||
// Specific Control Types
|
||||
if (action.type === "hristudio-core.wait" || action.type === "wait") {
|
||||
return {
|
||||
variant: "wait",
|
||||
icon: Clock,
|
||||
bg: "bg-amber-500/10 hover:bg-amber-500/20",
|
||||
border: "border-amber-200 dark:border-amber-800",
|
||||
text: "text-amber-700 dark:text-amber-400",
|
||||
accent: "bg-amber-500",
|
||||
};
|
||||
}
|
||||
|
||||
if (action.type === "hristudio-core.branch" || action.type === "branch") {
|
||||
return {
|
||||
variant: "branch",
|
||||
icon: GitBranch,
|
||||
bg: "bg-orange-500/10 hover:bg-orange-500/20",
|
||||
border: "border-orange-200 dark:border-orange-800",
|
||||
text: "text-orange-700 dark:text-orange-400",
|
||||
accent: "bg-orange-500",
|
||||
};
|
||||
}
|
||||
|
||||
if (action.type === "hristudio-core.loop" || action.type === "loop") {
|
||||
return {
|
||||
variant: "loop",
|
||||
icon: Repeat,
|
||||
bg: "bg-purple-500/10 hover:bg-purple-500/20",
|
||||
border: "border-purple-200 dark:border-purple-800",
|
||||
text: "text-purple-700 dark:text-purple-400",
|
||||
accent: "bg-purple-500",
|
||||
};
|
||||
}
|
||||
|
||||
if (action.type === "hristudio-core.parallel" || action.type === "parallel") {
|
||||
return {
|
||||
variant: "parallel",
|
||||
icon: Layers,
|
||||
bg: "bg-emerald-500/10 hover:bg-emerald-500/20",
|
||||
border: "border-emerald-200 dark:border-emerald-800",
|
||||
text: "text-emerald-700 dark:text-emerald-400",
|
||||
accent: "bg-emerald-500",
|
||||
};
|
||||
}
|
||||
|
||||
// General Categories
|
||||
if (category === "wizard") {
|
||||
return {
|
||||
variant: "wizard",
|
||||
icon: HelpCircle,
|
||||
bg: "bg-indigo-500/5 hover:bg-indigo-500/10",
|
||||
border: "border-indigo-200 dark:border-indigo-800",
|
||||
text: "text-indigo-700 dark:text-indigo-300",
|
||||
accent: "bg-indigo-500",
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
(category as string) === "robot" ||
|
||||
(category as string) === "movement" ||
|
||||
(category as string) === "speech"
|
||||
) {
|
||||
return {
|
||||
variant: "robot",
|
||||
icon: Play, // Or specific robot icon if available
|
||||
bg: "bg-slate-100 hover:bg-slate-200 dark:bg-slate-800 dark:hover:bg-slate-700",
|
||||
border: "border-slate-200 dark:border-slate-700",
|
||||
text: "text-slate-700 dark:text-slate-300",
|
||||
accent: "bg-slate-500",
|
||||
};
|
||||
}
|
||||
|
||||
// Default
|
||||
return {
|
||||
variant: "default",
|
||||
icon: undefined,
|
||||
bg: "bg-muted/40 hover:bg-accent/40",
|
||||
border: "border-border",
|
||||
text: "text-foreground",
|
||||
accent: "bg-muted-foreground",
|
||||
};
|
||||
}
|
||||
|
||||
export function ActionChipVisuals({
|
||||
action,
|
||||
isSelected,
|
||||
isDragging,
|
||||
isOverNested,
|
||||
onSelect,
|
||||
onDelete,
|
||||
onReorder,
|
||||
dragHandleProps,
|
||||
children,
|
||||
isFirst,
|
||||
isLast,
|
||||
validationStatus,
|
||||
}: ActionChipVisualsProps) {
|
||||
const def = actionRegistry.getAction(action.type);
|
||||
const style = getActionVisualStyle(action);
|
||||
const Icon = style.icon;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"group relative flex w-full flex-col items-start gap-1 rounded border px-3 py-2 text-[11px] transition-all duration-200",
|
||||
style.bg,
|
||||
style.border,
|
||||
isSelected && "ring-primary border-primary bg-accent/50 ring-2",
|
||||
isDragging && "scale-95 opacity-70 shadow-lg",
|
||||
isOverNested &&
|
||||
!isDragging &&
|
||||
"bg-blue-50/50 ring-2 ring-blue-400 ring-offset-1 dark:bg-blue-900/20",
|
||||
)}
|
||||
onClick={onSelect}
|
||||
role="button"
|
||||
aria-pressed={isSelected}
|
||||
tabIndex={0}
|
||||
>
|
||||
{/* Accent Bar logic for control flow */}
|
||||
{style.variant !== "default" && style.variant !== "robot" && (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute top-0 bottom-0 left-0 w-1 rounded-l",
|
||||
style.accent,
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2",
|
||||
style.variant !== "default" && style.variant !== "robot" && "pl-2",
|
||||
)}
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||
{Icon && (
|
||||
<Icon className={cn("h-3.5 w-3.5 flex-shrink-0", style.text)} />
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
"truncate leading-snug font-medium break-words",
|
||||
style.text,
|
||||
)}
|
||||
>
|
||||
{action.name}
|
||||
</span>
|
||||
|
||||
{/* Inline Info for Control Actions */}
|
||||
{style.variant === "wait" && !!action.parameters.duration && (
|
||||
<span className="bg-background/50 text-muted-foreground ml-1 rounded px-1.5 py-0.5 font-mono text-[10px]">
|
||||
{String(action.parameters.duration ?? "")}s
|
||||
</span>
|
||||
)}
|
||||
{style.variant === "loop" && (
|
||||
<span className="bg-background/50 text-muted-foreground ml-1 rounded px-1.5 py-0.5 font-mono text-[10px]">
|
||||
{String(action.parameters.iterations || 1)}x
|
||||
</span>
|
||||
)}
|
||||
{style.variant === "loop" &&
|
||||
action.parameters.requireApproval !== false && (
|
||||
<span
|
||||
className="ml-1 flex items-center gap-0.5 rounded bg-purple-500/20 px-1.5 py-0.5 font-mono text-[10px] text-purple-700 dark:text-purple-300"
|
||||
title="Requires Wizard Approval"
|
||||
>
|
||||
<HelpCircle className="h-2 w-2" />
|
||||
Ask
|
||||
</span>
|
||||
)}
|
||||
|
||||
{validationStatus === "error" && (
|
||||
<div
|
||||
className="h-2 w-2 flex-shrink-0 rounded-full bg-red-500 ring-1 ring-red-600"
|
||||
aria-label="Error"
|
||||
/>
|
||||
)}
|
||||
{validationStatus === "warning" && (
|
||||
<div
|
||||
className="h-2 w-2 flex-shrink-0 rounded-full bg-amber-500 ring-1 ring-amber-600"
|
||||
aria-label="Warning"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bg-background/50 border-border/50 mr-1 flex items-center gap-0.5 rounded-md border px-0.5 opacity-0 shadow-sm transition-opacity group-hover:opacity-100">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-muted-foreground hover:text-foreground pointer-events-auto z-20 h-5 w-5 p-0 text-[10px]"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onReorder?.("up");
|
||||
}}
|
||||
disabled={isFirst}
|
||||
aria-label="Move action up"
|
||||
>
|
||||
<ChevronRight className="h-3 w-3 -rotate-90" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-muted-foreground hover:text-foreground pointer-events-auto z-20 h-5 w-5 p-0 text-[10px]"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onReorder?.("down");
|
||||
}}
|
||||
disabled={isLast}
|
||||
aria-label="Move action down"
|
||||
>
|
||||
<ChevronRight className="h-3 w-3 rotate-90" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDelete}
|
||||
className="text-muted-foreground hover:text-destructive rounded p-0.5 opacity-0 transition-opacity group-hover:opacity-100"
|
||||
aria-label="Delete action"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Description / Subtext */}
|
||||
{def?.description && (
|
||||
<div
|
||||
className={cn(
|
||||
"text-muted-foreground mt-0.5 line-clamp-2 w-full pl-2 text-[10px] leading-snug",
|
||||
style.variant !== "default" && style.variant !== "robot" && "pl-4",
|
||||
)}
|
||||
>
|
||||
{def.description}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tags for parameters (hide for specialized control blocks that show inline) */}
|
||||
{def?.parameters?.length &&
|
||||
(style.variant === "default" || style.variant === "robot") ? (
|
||||
<div className="flex flex-wrap gap-1 pt-1">
|
||||
{def.parameters.slice(0, 3).map((p) => (
|
||||
<span
|
||||
key={p.id}
|
||||
className="bg-background/80 text-muted-foreground ring-border max-w-[80px] truncate rounded px-1 py-0.5 text-[9px] font-medium ring-1"
|
||||
>
|
||||
{p.name}
|
||||
</span>
|
||||
))}
|
||||
{def.parameters.length > 3 && (
|
||||
<span className="text-muted-foreground text-[9px]">
|
||||
+{def.parameters.length - 3}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SortableActionChip({
|
||||
stepId,
|
||||
action,
|
||||
parentId,
|
||||
selectedActionId,
|
||||
onSelectAction,
|
||||
onDeleteAction,
|
||||
onReorderAction,
|
||||
dragHandle,
|
||||
isFirst,
|
||||
isLast,
|
||||
}: ActionChipProps) {
|
||||
const isSelected = selectedActionId === action.id;
|
||||
|
||||
const insertionProjection = useDesignerStore((s) => s.insertionProjection);
|
||||
const steps = useDesignerStore((s) => s.steps);
|
||||
const currentStep = steps.find((s) => s.id === stepId);
|
||||
|
||||
// Branch Options Visualization
|
||||
const branchOptions = useMemo(() => {
|
||||
if (!action.type.includes("branch") || !currentStep) return null;
|
||||
|
||||
const options = (currentStep.trigger as any)?.conditions?.options;
|
||||
if (
|
||||
!options?.length &&
|
||||
!(currentStep.trigger as any)?.conditions?.nextStepId
|
||||
) {
|
||||
return (
|
||||
<div className="text-muted-foreground/60 bg-background/50 mt-2 rounded border border-dashed py-2 text-center text-[10px] italic">
|
||||
No branches configured. Add options in properties.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Combine explicit options and unconditional nextStepId
|
||||
// The original FlowWorkspace logic iterated options. logic there:
|
||||
// (step.trigger.conditions as any).options.map...
|
||||
|
||||
return (
|
||||
<div className="mt-2 w-full space-y-1">
|
||||
{options?.map((opt: any, idx: number) => {
|
||||
// Resolve ID to name for display
|
||||
let targetName = "Unlinked";
|
||||
let targetIndex = -1;
|
||||
|
||||
if (opt.nextStepId) {
|
||||
const target = steps.find((s) => s.id === opt.nextStepId);
|
||||
if (target) {
|
||||
targetName = target.name;
|
||||
targetIndex = target.order;
|
||||
}
|
||||
} else if (typeof opt.nextStepIndex === "number") {
|
||||
targetIndex = opt.nextStepIndex;
|
||||
targetName = `Step #${targetIndex + 1}`;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
className="bg-background/50 flex items-center justify-between rounded border p-1.5 text-[10px] shadow-sm"
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"bg-background min-w-[60px] justify-center px-1 py-0 text-[9px] font-bold tracking-wider uppercase",
|
||||
opt.variant === "destructive"
|
||||
? "border-red-500/30 text-red-600 dark:text-red-400"
|
||||
: "text-foreground border-slate-500/30",
|
||||
)}
|
||||
>
|
||||
{opt.label}
|
||||
</Badge>
|
||||
<ChevronRight className="text-muted-foreground/50 h-3 w-3 flex-shrink-0" />
|
||||
</div>
|
||||
|
||||
<div className="flex max-w-[60%] min-w-0 items-center justify-end gap-1.5 text-right">
|
||||
<span
|
||||
className="text-foreground/80 truncate font-medium"
|
||||
title={targetName}
|
||||
>
|
||||
{targetName}
|
||||
</span>
|
||||
{targetIndex !== -1 && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="h-3.5 min-w-[18px] justify-center bg-slate-100 px-1 py-0 text-[9px] tabular-nums dark:bg-slate-800"
|
||||
>
|
||||
#{targetIndex + 1}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Visual indicator for unconditional jump if present and no options matched (though usually logic handles this) */}
|
||||
{/* For now keeping parity with FlowWorkspace which only showed options */}
|
||||
</div>
|
||||
);
|
||||
}, [action.type, currentStep, steps]);
|
||||
|
||||
const displayChildren = useMemo(() => {
|
||||
if (
|
||||
insertionProjection?.stepId === stepId &&
|
||||
insertionProjection.parentId === action.id
|
||||
) {
|
||||
const copy = [...(action.children || [])];
|
||||
copy.splice(insertionProjection.index, 0, insertionProjection.action);
|
||||
return copy;
|
||||
}
|
||||
return action.children || [];
|
||||
}, [action.children, action.id, stepId, insertionProjection]);
|
||||
|
||||
/* ------------------------------------------------------------------------ */
|
||||
/* Main Sortable Logic */
|
||||
/* ------------------------------------------------------------------------ */
|
||||
const isPlaceholder = action.id === "projection-placeholder";
|
||||
|
||||
// Compute validation status
|
||||
const issues = useDesignerStore((s) => s.validationIssues[action.id]);
|
||||
const validationStatus = useMemo(() => {
|
||||
if (!issues?.length) return undefined;
|
||||
if (issues.some((i) => i.severity === "error")) return "error";
|
||||
if (issues.some((i) => i.severity === "warning")) return "warning";
|
||||
return "info";
|
||||
}, [issues]);
|
||||
|
||||
/* ------------------------------------------------------------------------ */
|
||||
/* Sortable (Local) DnD Monitoring */
|
||||
/* ------------------------------------------------------------------------ */
|
||||
// useSortable disabled per user request to remove action drag-and-drop
|
||||
// const { ... } = useSortable(...)
|
||||
|
||||
// Use local dragging state or passed prop
|
||||
const isDragging = dragHandle || false;
|
||||
|
||||
/* ------------------------------------------------------------------------ */
|
||||
/* Nested Droppable (for control flow containers) */
|
||||
/* ------------------------------------------------------------------------ */
|
||||
const def = actionRegistry.getAction(action.type);
|
||||
const nestedDroppableId = `container-${action.id}`;
|
||||
const { isOver: isOverNested, setNodeRef: setNestedNodeRef } = useDroppable({
|
||||
id: nestedDroppableId,
|
||||
disabled: !def?.nestable || isPlaceholder, // Disable droppable for placeholder
|
||||
data: {
|
||||
type: "container",
|
||||
stepId,
|
||||
parentId: action.id,
|
||||
action, // Pass full action for projection logic
|
||||
},
|
||||
});
|
||||
|
||||
const shouldRenderChildren = !!def?.nestable;
|
||||
|
||||
if (isPlaceholder) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex w-full flex-col items-start gap-1 rounded border border-dashed px-3 py-2 text-[11px]",
|
||||
"border-blue-400 bg-blue-50/50 opacity-70 dark:bg-blue-900/20",
|
||||
)}
|
||||
>
|
||||
<div className="flex w-full items-center gap-2">
|
||||
<span className="font-medium text-blue-700 italic">
|
||||
{action.name}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ActionChipVisuals
|
||||
action={action}
|
||||
isSelected={isSelected}
|
||||
isDragging={isDragging}
|
||||
isOverNested={isOverNested && !isDragging}
|
||||
onSelect={(e) => {
|
||||
e.stopPropagation();
|
||||
onSelectAction(stepId, action.id);
|
||||
}}
|
||||
onDelete={(e) => {
|
||||
e.stopPropagation();
|
||||
onDeleteAction(stepId, action.id);
|
||||
}}
|
||||
onReorder={(direction) => onReorderAction?.(stepId, action.id, direction)}
|
||||
isFirst={isFirst}
|
||||
isLast={isLast}
|
||||
validationStatus={validationStatus}
|
||||
>
|
||||
{/* Branch Options Visualization */}
|
||||
{branchOptions}
|
||||
|
||||
{/* Nested Children Rendering (e.g. for Loops/Parallel) */}
|
||||
{shouldRenderChildren && (
|
||||
<div
|
||||
ref={setNestedNodeRef}
|
||||
className={cn(
|
||||
"mt-2 w-full space-y-2 rounded border border-dashed p-1.5 transition-colors",
|
||||
isOverNested
|
||||
? "border-blue-400 bg-blue-100/50 dark:bg-blue-900/20"
|
||||
: "bg-muted/20 dark:bg-muted/10 border-border/50",
|
||||
)}
|
||||
>
|
||||
{displayChildren?.length === 0 ? (
|
||||
<div className="text-muted-foreground/60 py-2 text-center text-[10px] italic">
|
||||
Empty container
|
||||
</div>
|
||||
) : (
|
||||
displayChildren?.map((child, idx) => (
|
||||
<SortableActionChip
|
||||
key={child.id}
|
||||
stepId={stepId}
|
||||
action={child}
|
||||
parentId={action.id}
|
||||
selectedActionId={selectedActionId}
|
||||
onSelectAction={onSelectAction}
|
||||
onDeleteAction={onDeleteAction}
|
||||
onReorderAction={onReorderAction}
|
||||
isFirst={idx === 0}
|
||||
isLast={idx === (displayChildren?.length || 0) - 1}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</ActionChipVisuals>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import React, {
|
||||
useState,
|
||||
} from "react";
|
||||
import {
|
||||
useDndContext,
|
||||
useDroppable,
|
||||
useDndMonitor,
|
||||
type DragEndEvent,
|
||||
@@ -28,6 +29,8 @@ import {
|
||||
Trash2,
|
||||
GitBranch,
|
||||
Edit3,
|
||||
CornerDownRight,
|
||||
Repeat,
|
||||
} from "lucide-react";
|
||||
import { cn } from "~/lib/utils";
|
||||
import {
|
||||
@@ -39,6 +42,7 @@ import { actionRegistry } from "../ActionRegistry";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { SortableActionChip } from "./ActionChip";
|
||||
|
||||
/**
|
||||
* FlowWorkspace
|
||||
@@ -80,21 +84,32 @@ export interface VirtualItem {
|
||||
|
||||
interface StepRowProps {
|
||||
item: VirtualItem;
|
||||
step: ExperimentStep; // Explicit pass for freshness
|
||||
totalSteps: number;
|
||||
selectedStepId: string | null | undefined;
|
||||
selectedActionId: string | null | undefined;
|
||||
renamingStepId: string | null;
|
||||
onSelectStep: (id: string | undefined) => void;
|
||||
onSelectAction: (stepId: string, actionId: string | undefined) => void;
|
||||
onToggleExpanded: (step: ExperimentStep) => void;
|
||||
onRenameStep: (step: ExperimentStep, name: string) => void;
|
||||
onRenameStep: (step: ExperimentStep, newName: string) => void;
|
||||
onDeleteStep: (step: ExperimentStep) => void;
|
||||
onDeleteAction: (stepId: string, actionId: string) => void;
|
||||
setRenamingStepId: (id: string | null) => void;
|
||||
registerMeasureRef: (stepId: string, el: HTMLDivElement | null) => void;
|
||||
onReorderStep: (stepId: string, direction: "up" | "down") => void;
|
||||
onReorderAction?: (
|
||||
stepId: string,
|
||||
actionId: string,
|
||||
direction: "up" | "down",
|
||||
) => void;
|
||||
isChild?: boolean;
|
||||
}
|
||||
|
||||
const StepRow = React.memo(function StepRow({
|
||||
function StepRow({
|
||||
item,
|
||||
step,
|
||||
totalSteps,
|
||||
selectedStepId,
|
||||
selectedActionId,
|
||||
renamingStepId,
|
||||
@@ -106,8 +121,12 @@ const StepRow = React.memo(function StepRow({
|
||||
onDeleteAction,
|
||||
setRenamingStepId,
|
||||
registerMeasureRef,
|
||||
onReorderStep,
|
||||
onReorderAction,
|
||||
isChild,
|
||||
}: StepRowProps) {
|
||||
const step = item.step;
|
||||
// const step = item.step; // Removed local derivation
|
||||
const allSteps = useDesignerStore((s) => s.steps);
|
||||
const insertionProjection = useDesignerStore((s) => s.insertionProjection);
|
||||
|
||||
const displayActions = useMemo(() => {
|
||||
@@ -125,47 +144,39 @@ const StepRow = React.memo(function StepRow({
|
||||
return step.actions;
|
||||
}, [step.actions, step.id, insertionProjection]);
|
||||
|
||||
const {
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
attributes,
|
||||
listeners,
|
||||
isDragging,
|
||||
} = useSortable({
|
||||
id: sortableStepId(step.id),
|
||||
data: {
|
||||
type: "step",
|
||||
step: step,
|
||||
},
|
||||
});
|
||||
|
||||
const style: React.CSSProperties = {
|
||||
position: "absolute",
|
||||
top: item.top,
|
||||
left: 0,
|
||||
right: 0,
|
||||
width: "100%",
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
zIndex: isDragging ? 25 : undefined,
|
||||
transition: "top 300ms cubic-bezier(0.4, 0, 0.2, 1)",
|
||||
// transform: CSS.Transform.toString(transform), // Removed
|
||||
// zIndex: isDragging ? 25 : undefined,
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={setNodeRef} style={style} data-step-id={step.id}>
|
||||
<div style={style} data-step-id={step.id}>
|
||||
<div
|
||||
ref={(el) => registerMeasureRef(step.id, el)}
|
||||
className="relative px-3 py-4"
|
||||
className={cn(
|
||||
"relative px-3 py-4 transition-all duration-300",
|
||||
isChild && "ml-8 pl-0",
|
||||
)}
|
||||
data-step-id={step.id}
|
||||
>
|
||||
{isChild && (
|
||||
<div className="text-muted-foreground/40 absolute top-8 left-[-24px]">
|
||||
<CornerDownRight className="h-5 w-5" />
|
||||
</div>
|
||||
)}
|
||||
<StepDroppableArea stepId={step.id} />
|
||||
<div
|
||||
className={cn(
|
||||
"mb-2 rounded border shadow-sm transition-colors",
|
||||
"mb-2 rounded-lg border shadow-sm transition-colors",
|
||||
selectedStepId === step.id
|
||||
? "border-border bg-accent/30"
|
||||
: "hover:bg-accent/30",
|
||||
isDragging && "opacity-80 ring-1 ring-blue-300",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
@@ -213,7 +224,7 @@ const StepRow = React.memo(function StepRow({
|
||||
onRenameStep(
|
||||
step,
|
||||
(e.target as HTMLInputElement).value.trim() ||
|
||||
step.name,
|
||||
step.name,
|
||||
);
|
||||
setRenamingStepId(null);
|
||||
} else if (e.key === "Escape") {
|
||||
@@ -258,17 +269,85 @@ const StepRow = React.memo(function StepRow({
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<div
|
||||
className="text-muted-foreground cursor-grab p-1"
|
||||
aria-label="Drag step"
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-muted-foreground hover:text-foreground h-7 w-7 p-0 text-[11px]"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onReorderStep(step.id, "up");
|
||||
}}
|
||||
disabled={item.index === 0}
|
||||
aria-label="Move step up"
|
||||
>
|
||||
<GripVertical className="h-4 w-4" />
|
||||
</div>
|
||||
<ChevronRight className="h-4 w-4 -rotate-90" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-muted-foreground hover:text-foreground h-7 w-7 p-0 text-[11px]"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onReorderStep(step.id, "down");
|
||||
}}
|
||||
disabled={item.index === totalSteps - 1}
|
||||
aria-label="Move step down"
|
||||
>
|
||||
<ChevronRight className="h-4 w-4 rotate-90" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Conditional Branching Visualization */}
|
||||
|
||||
{/* Loop Visualization */}
|
||||
{step.type === "loop" && (
|
||||
<div
|
||||
className="mx-3 my-3 rounded-md border text-xs"
|
||||
style={{
|
||||
backgroundColor: "var(--validation-info-bg, #f0f9ff)",
|
||||
borderColor: "var(--validation-info-border, #bae6fd)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="flex items-center gap-2 border-b px-3 py-2 font-medium"
|
||||
style={{
|
||||
borderColor: "var(--validation-info-border, #bae6fd)",
|
||||
color: "var(--validation-info-text, #0369a1)",
|
||||
}}
|
||||
>
|
||||
<Repeat className="h-3.5 w-3.5" />
|
||||
<span>Loop Logic</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 p-2">
|
||||
<div className="flex items-center gap-2 text-[11px]">
|
||||
<span className="text-muted-foreground">Repeat:</span>
|
||||
<Badge variant="outline" className="font-mono">
|
||||
{(step.trigger.conditions as any).loop?.iterations || 1}{" "}
|
||||
times
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-[11px]">
|
||||
<span className="text-muted-foreground">Approval:</span>
|
||||
<Badge
|
||||
variant={
|
||||
(step.trigger.conditions as any).loop?.requireApproval !==
|
||||
false
|
||||
? "default"
|
||||
: "secondary"
|
||||
}
|
||||
>
|
||||
{(step.trigger.conditions as any).loop?.requireApproval !==
|
||||
false
|
||||
? "Required"
|
||||
: "Auto-proceed"}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action List (Collapsible/Virtual content) */}
|
||||
{step.expanded && (
|
||||
<div className="bg-background/40 min-h-[3rem] space-y-2 p-2 pb-8">
|
||||
@@ -278,11 +357,11 @@ const StepRow = React.memo(function StepRow({
|
||||
>
|
||||
<div className="flex w-full flex-col gap-2">
|
||||
{displayActions.length === 0 ? (
|
||||
<div className="flex h-12 items-center justify-center rounded border border-dashed text-xs text-muted-foreground">
|
||||
<div className="text-muted-foreground flex h-12 items-center justify-center rounded border border-dashed text-xs">
|
||||
Drop actions here
|
||||
</div>
|
||||
) : (
|
||||
displayActions.map((action) => (
|
||||
displayActions.map((action, index) => (
|
||||
<SortableActionChip
|
||||
key={action.id}
|
||||
stepId={step.id}
|
||||
@@ -291,6 +370,9 @@ const StepRow = React.memo(function StepRow({
|
||||
selectedActionId={selectedActionId}
|
||||
onSelectAction={onSelectAction}
|
||||
onDeleteAction={onDeleteAction}
|
||||
onReorderAction={onReorderAction}
|
||||
isFirst={index === 0}
|
||||
isLast={index === displayActions.length - 1}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
@@ -302,7 +384,57 @@ const StepRow = React.memo(function StepRow({
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Step Card Preview (for DragOverlay) */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
export function StepCardPreview({
|
||||
step,
|
||||
dragHandle,
|
||||
}: {
|
||||
step: ExperimentStep;
|
||||
dragHandle?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"bg-background rounded-lg border shadow-xl ring-2 ring-blue-500/20",
|
||||
dragHandle && "cursor-grabbing",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2 border-b p-3 px-2 py-1.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="text-muted-foreground rounded p-1">
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</div>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="h-5 px-1.5 text-[10px] font-normal"
|
||||
>
|
||||
{step.order + 1}
|
||||
</Badge>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-sm font-medium">{step.name}</span>
|
||||
</div>
|
||||
<span className="text-muted-foreground hidden text-[11px] md:inline">
|
||||
{step.actions.length} actions
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-muted-foreground flex items-center gap-1">
|
||||
<GripVertical className="h-4 w-4" />
|
||||
</div>
|
||||
</div>
|
||||
{/* Preview optional: show empty body hint or just the header? Header is usually enough for sorting. */}
|
||||
<div className="bg-muted/10 flex h-12 items-center justify-center border-t border-dashed p-2">
|
||||
<span className="text-muted-foreground text-[10px]">
|
||||
{step.actions.length} actions hidden while dragging
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Utility */
|
||||
@@ -312,8 +444,6 @@ function generateStepId(): string {
|
||||
return `step-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
|
||||
}
|
||||
|
||||
|
||||
|
||||
function sortableStepId(stepId: string) {
|
||||
return `s-step-${stepId}`;
|
||||
}
|
||||
@@ -331,256 +461,26 @@ function parseSortableAction(id: string): string | null {
|
||||
/* Droppable Overlay (for palette action drops) */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
function StepDroppableArea({ stepId }: { stepId: string }) {
|
||||
const { isOver } = useDroppable({ id: `step-${stepId}` });
|
||||
return (
|
||||
<div
|
||||
data-step-drop
|
||||
className={cn(
|
||||
"pointer-events-none absolute inset-0 rounded-md transition-colors",
|
||||
isOver &&
|
||||
"bg-blue-50/40 ring-2 ring-blue-400/60 ring-offset-0 dark:bg-blue-950/20",
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const { active } = useDndContext();
|
||||
const isStepDragging = active?.id.toString().startsWith("s-step-");
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Sortable Action Chip */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
interface ActionChipProps {
|
||||
stepId: string;
|
||||
action: ExperimentAction;
|
||||
parentId: string | null;
|
||||
selectedActionId: string | null | undefined;
|
||||
onSelectAction: (stepId: string, actionId: string | undefined) => void;
|
||||
onDeleteAction: (stepId: string, actionId: string) => void;
|
||||
dragHandle?: boolean;
|
||||
}
|
||||
|
||||
function SortableActionChip({
|
||||
stepId,
|
||||
action,
|
||||
parentId,
|
||||
selectedActionId,
|
||||
onSelectAction,
|
||||
onDeleteAction,
|
||||
dragHandle,
|
||||
}: ActionChipProps) {
|
||||
const def = actionRegistry.getAction(action.type);
|
||||
const isSelected = selectedActionId === action.id;
|
||||
|
||||
const insertionProjection = useDesignerStore((s) => s.insertionProjection);
|
||||
const displayChildren = useMemo(() => {
|
||||
if (
|
||||
insertionProjection?.stepId === stepId &&
|
||||
insertionProjection.parentId === action.id
|
||||
) {
|
||||
const copy = [...(action.children || [])];
|
||||
copy.splice(insertionProjection.index, 0, insertionProjection.action);
|
||||
return copy;
|
||||
}
|
||||
return action.children;
|
||||
}, [action.children, action.id, stepId, insertionProjection]);
|
||||
|
||||
/* ------------------------------------------------------------------------ */
|
||||
/* Main Sortable Logic */
|
||||
/* ------------------------------------------------------------------------ */
|
||||
const isPlaceholder = action.id === "projection-placeholder";
|
||||
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging: isSortableDragging,
|
||||
} = useSortable({
|
||||
id: sortableActionId(action.id),
|
||||
disabled: isPlaceholder, // Disable sortable for placeholder
|
||||
data: {
|
||||
type: "action",
|
||||
stepId,
|
||||
parentId,
|
||||
id: action.id,
|
||||
},
|
||||
const { isOver, setNodeRef } = useDroppable({
|
||||
id: `step-${stepId}`,
|
||||
disabled: isStepDragging,
|
||||
});
|
||||
|
||||
// Use local dragging state or passed prop
|
||||
const isDragging = isSortableDragging || dragHandle;
|
||||
|
||||
const style = {
|
||||
transform: CSS.Translate.toString(transform),
|
||||
transition,
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------------ */
|
||||
/* Nested Droppable (for control flow containers) */
|
||||
/* ------------------------------------------------------------------------ */
|
||||
const nestedDroppableId = `container-${action.id}`;
|
||||
const {
|
||||
isOver: isOverNested,
|
||||
setNodeRef: setNestedNodeRef
|
||||
} = useDroppable({
|
||||
id: nestedDroppableId,
|
||||
disabled: !def?.nestable || isPlaceholder, // Disable droppable for placeholder
|
||||
data: {
|
||||
type: "container",
|
||||
stepId,
|
||||
parentId: action.id,
|
||||
action // Pass full action for projection logic
|
||||
}
|
||||
});
|
||||
|
||||
const shouldRenderChildren = def?.nestable;
|
||||
|
||||
if (isPlaceholder) {
|
||||
const { setNodeRef: setPlaceholderRef } = useDroppable({
|
||||
id: "projection-placeholder",
|
||||
data: { type: "placeholder" }
|
||||
});
|
||||
|
||||
// Render simplified placeholder without hooks refs
|
||||
// We still render the content matching the action type for visual fidelity
|
||||
return (
|
||||
<div
|
||||
ref={setPlaceholderRef}
|
||||
className="group relative flex w-full flex-col items-start gap-1 rounded border-2 border-dashed border-blue-300 bg-blue-50/50 px-3 py-2 text-[11px] opacity-70"
|
||||
>
|
||||
<div className="flex w-full items-center gap-2">
|
||||
<span className={cn(
|
||||
"h-2.5 w-2.5 rounded-full",
|
||||
def ? {
|
||||
wizard: "bg-blue-500",
|
||||
robot: "bg-emerald-500",
|
||||
control: "bg-amber-500",
|
||||
observation: "bg-purple-500",
|
||||
}[def.category] : "bg-gray-400"
|
||||
)} />
|
||||
<span className="font-medium text-foreground">{def?.name ?? action.name}</span>
|
||||
</div>
|
||||
{def?.description && (
|
||||
<div className="text-muted-foreground line-clamp-3 w-full text-[10px] leading-snug">
|
||||
{def.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (isStepDragging) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
data-step-drop
|
||||
className={cn(
|
||||
"group relative flex w-full flex-col items-start gap-1 rounded border px-3 py-2 text-[11px]",
|
||||
"bg-muted/40 hover:bg-accent/40 cursor-pointer",
|
||||
isSelected && "border-border bg-accent/30",
|
||||
isDragging && "opacity-70 shadow-lg",
|
||||
// Visual feedback for nested drop
|
||||
isOverNested && !isDragging && "ring-2 ring-blue-400 ring-offset-1 bg-blue-50/50"
|
||||
"pointer-events-none absolute inset-0 rounded-md transition-colors",
|
||||
isOver &&
|
||||
"bg-blue-50/40 ring-2 ring-blue-400/60 ring-offset-0 dark:bg-blue-950/20",
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSelectAction(stepId, action.id);
|
||||
}}
|
||||
{...attributes}
|
||||
role="button"
|
||||
aria-pressed={isSelected}
|
||||
tabIndex={0}
|
||||
>
|
||||
<div className="flex w-full items-center gap-2">
|
||||
<div
|
||||
{...listeners}
|
||||
className="text-muted-foreground/70 hover:text-foreground cursor-grab rounded p-0.5"
|
||||
aria-label="Drag action"
|
||||
>
|
||||
<GripVertical className="h-3.5 w-3.5" />
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
"h-2.5 w-2.5 rounded-full",
|
||||
def
|
||||
? {
|
||||
wizard: "bg-blue-500",
|
||||
robot: "bg-emerald-500",
|
||||
control: "bg-amber-500",
|
||||
observation: "bg-purple-500",
|
||||
}[def.category]
|
||||
: "bg-slate-400",
|
||||
)}
|
||||
/>
|
||||
<span className="flex-1 leading-snug font-medium break-words">
|
||||
{action.name}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDeleteAction(stepId, action.id);
|
||||
}}
|
||||
className="text-muted-foreground hover:text-foreground rounded p-0.5 opacity-0 transition-opacity group-hover:opacity-100"
|
||||
aria-label="Delete action"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
{def?.description && (
|
||||
<div className="text-muted-foreground line-clamp-3 w-full text-[10px] leading-snug">
|
||||
{def.description}
|
||||
</div>
|
||||
)}
|
||||
{def?.parameters.length ? (
|
||||
<div className="flex flex-wrap gap-1 pt-0.5">
|
||||
{def.parameters.slice(0, 4).map((p) => (
|
||||
<span
|
||||
key={p.id}
|
||||
className="bg-background/70 text-muted-foreground ring-border rounded px-1 py-0.5 text-[9px] font-medium ring-1"
|
||||
>
|
||||
{p.name}
|
||||
</span>
|
||||
))}
|
||||
{def.parameters.length > 4 && (
|
||||
<span className="text-[9px] text-muted-foreground">+{def.parameters.length - 4}</span>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Nested Actions Container */}
|
||||
{shouldRenderChildren && (
|
||||
<div
|
||||
ref={setNestedNodeRef}
|
||||
className={cn(
|
||||
"mt-2 w-full flex flex-col gap-2 pl-4 border-l-2 border-border/40 transition-all min-h-[0.5rem] pb-4",
|
||||
)}
|
||||
>
|
||||
<SortableContext
|
||||
items={(displayChildren ?? action.children ?? [])
|
||||
.filter(c => c.id !== "projection-placeholder")
|
||||
.map(c => sortableActionId(c.id))}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
{(displayChildren || action.children || []).map((child) => (
|
||||
<SortableActionChip
|
||||
key={child.id}
|
||||
stepId={stepId}
|
||||
action={child}
|
||||
parentId={action.id}
|
||||
selectedActionId={selectedActionId}
|
||||
onSelectAction={onSelectAction}
|
||||
onDeleteAction={onDeleteAction}
|
||||
/>
|
||||
))}
|
||||
{(!displayChildren?.length && !action.children?.length) && (
|
||||
<div className="text-[10px] text-muted-foreground/60 italic py-1">
|
||||
Drag actions here
|
||||
</div>
|
||||
)}
|
||||
</SortableContext>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -633,6 +533,24 @@ export function FlowWorkspace({
|
||||
return map;
|
||||
}, [steps]);
|
||||
|
||||
/* Hierarchy detection for visual indentation */
|
||||
const childStepIds = useMemo(() => {
|
||||
const children = new Set<string>();
|
||||
for (const step of steps) {
|
||||
if (
|
||||
step.type === "conditional" &&
|
||||
(step.trigger.conditions as any)?.options
|
||||
) {
|
||||
for (const opt of (step.trigger.conditions as any).options) {
|
||||
if (opt.nextStepId) {
|
||||
children.add(opt.nextStepId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return children;
|
||||
}, [steps]);
|
||||
|
||||
/* Resize observer for viewport and width changes */
|
||||
useLayoutEffect(() => {
|
||||
const el = containerRef.current;
|
||||
@@ -796,6 +714,58 @@ export function FlowWorkspace({
|
||||
[removeAction, selectedActionId, selectAction, recomputeHash],
|
||||
);
|
||||
|
||||
const handleReorderStep = useCallback(
|
||||
(stepId: string, direction: "up" | "down") => {
|
||||
console.log("handleReorderStep", stepId, direction);
|
||||
const currentIndex = steps.findIndex((s) => s.id === stepId);
|
||||
console.log("currentIndex", currentIndex, "total", steps.length);
|
||||
if (currentIndex === -1) return;
|
||||
const newIndex = direction === "up" ? currentIndex - 1 : currentIndex + 1;
|
||||
console.log("newIndex", newIndex);
|
||||
if (newIndex < 0 || newIndex >= steps.length) return;
|
||||
reorderStep(currentIndex, newIndex);
|
||||
},
|
||||
[steps, reorderStep],
|
||||
);
|
||||
|
||||
const handleReorderAction = useCallback(
|
||||
(stepId: string, actionId: string, direction: "up" | "down") => {
|
||||
const step = steps.find((s) => s.id === stepId);
|
||||
if (!step) return;
|
||||
|
||||
const findInTree = (
|
||||
list: ExperimentAction[],
|
||||
pId: string | null,
|
||||
): {
|
||||
list: ExperimentAction[];
|
||||
parentId: string | null;
|
||||
index: number;
|
||||
} | null => {
|
||||
const idx = list.findIndex((a) => a.id === actionId);
|
||||
if (idx !== -1) return { list, parentId: pId, index: idx };
|
||||
|
||||
for (const a of list) {
|
||||
if (a.children) {
|
||||
const res = findInTree(a.children, a.id);
|
||||
if (res) return res;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const context = findInTree(step.actions, null);
|
||||
if (!context) return;
|
||||
|
||||
const { parentId, index, list } = context;
|
||||
const newIndex = direction === "up" ? index - 1 : index + 1;
|
||||
|
||||
if (newIndex < 0 || newIndex >= list.length) return;
|
||||
|
||||
moveAction(stepId, actionId, parentId, newIndex);
|
||||
},
|
||||
[steps, moveAction],
|
||||
);
|
||||
|
||||
/* ------------------------------------------------------------------------ */
|
||||
/* Sortable (Local) DnD Monitoring */
|
||||
/* ------------------------------------------------------------------------ */
|
||||
@@ -815,32 +785,25 @@ export function FlowWorkspace({
|
||||
}
|
||||
const activeId = active.id.toString();
|
||||
const overId = over.id.toString();
|
||||
// Step reorder
|
||||
if (activeId.startsWith("s-step-") && overId.startsWith("s-step-")) {
|
||||
const fromStepId = parseSortableStep(activeId);
|
||||
const toStepId = parseSortableStep(overId);
|
||||
if (fromStepId && toStepId && fromStepId !== toStepId) {
|
||||
const fromIndex = steps.findIndex((s) => s.id === fromStepId);
|
||||
const toIndex = steps.findIndex((s) => s.id === toStepId);
|
||||
if (fromIndex >= 0 && toIndex >= 0) {
|
||||
reorderStep(fromIndex, toIndex);
|
||||
void recomputeHash();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Step reorder is now handled globally in DesignerRoot
|
||||
|
||||
// Action reorder (supports nesting)
|
||||
if (activeId.startsWith("s-act-") && overId.startsWith("s-act-")) {
|
||||
const activeData = active.data.current;
|
||||
const overData = over.data.current;
|
||||
|
||||
if (
|
||||
activeData && overData &&
|
||||
activeData &&
|
||||
overData &&
|
||||
activeData.stepId === overData.stepId &&
|
||||
activeData.type === 'action' && overData.type === 'action'
|
||||
activeData.type === "action" &&
|
||||
overData.type === "action"
|
||||
) {
|
||||
const stepId = activeData.stepId as string;
|
||||
const activeActionId = activeData.action.id;
|
||||
const overActionId = overData.action.id;
|
||||
// Fix: SortableActionChip puts 'id' directly on data, not inside 'action' property
|
||||
const activeActionId = activeData.id;
|
||||
const overActionId = overData.id;
|
||||
|
||||
if (activeActionId !== overActionId) {
|
||||
const newParentId = overData.parentId as string | null;
|
||||
@@ -874,11 +837,13 @@ export function FlowWorkspace({
|
||||
if (
|
||||
activeData &&
|
||||
overData &&
|
||||
activeData.type === 'action' &&
|
||||
overData.type === 'action'
|
||||
activeData.type === "action" &&
|
||||
overData.type === "action"
|
||||
) {
|
||||
const activeActionId = activeData.action.id;
|
||||
const overActionId = overData.action.id;
|
||||
// Fix: Access 'id' directly from data payload
|
||||
const activeActionId = activeData.id;
|
||||
const overActionId = overData.id;
|
||||
|
||||
const activeStepId = activeData.stepId;
|
||||
const overStepId = overData.stepId;
|
||||
const activeParentId = activeData.parentId;
|
||||
@@ -888,12 +853,17 @@ export function FlowWorkspace({
|
||||
if (activeParentId !== overParentId || activeStepId !== overStepId) {
|
||||
// Determine new index
|
||||
// verification of safe move handled by store
|
||||
moveAction(overStepId, activeActionId, overParentId, overData.sortable.index);
|
||||
moveAction(
|
||||
overStepId,
|
||||
activeActionId,
|
||||
overParentId,
|
||||
overData.sortable.index,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
[moveAction]
|
||||
[moveAction],
|
||||
);
|
||||
|
||||
useDndMonitor({
|
||||
@@ -956,7 +926,8 @@ export function FlowWorkspace({
|
||||
<div
|
||||
ref={containerRef}
|
||||
id="tour-designer-canvas"
|
||||
className="relative h-0 min-h-0 flex-1 overflow-x-hidden overflow-y-auto"
|
||||
// Removed 'border' class to fix double border issue
|
||||
className="relative h-0 min-h-0 flex-1 overflow-x-hidden overflow-y-auto rounded-md"
|
||||
onScroll={onScroll}
|
||||
>
|
||||
{steps.length === 0 ? (
|
||||
@@ -990,6 +961,8 @@ export function FlowWorkspace({
|
||||
<StepRow
|
||||
key={vi.key}
|
||||
item={vi}
|
||||
step={vi.step}
|
||||
totalSteps={steps.length}
|
||||
selectedStepId={selectedStepId}
|
||||
selectedActionId={selectedActionId}
|
||||
renamingStepId={renamingStepId}
|
||||
@@ -1004,6 +977,9 @@ export function FlowWorkspace({
|
||||
onDeleteAction={deleteAction}
|
||||
setRenamingStepId={setRenamingStepId}
|
||||
registerMeasureRef={registerMeasureRef}
|
||||
onReorderStep={handleReorderStep}
|
||||
onReorderAction={handleReorderAction}
|
||||
isChild={childStepIds.has(vi.step.id)}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
@@ -1017,4 +993,3 @@ export function FlowWorkspace({
|
||||
|
||||
// Wrap in React.memo to prevent unnecessary re-renders causing flashing
|
||||
export default React.memo(FlowWorkspace);
|
||||
|
||||
|
||||
@@ -5,14 +5,11 @@ import {
|
||||
Save,
|
||||
RefreshCw,
|
||||
Download,
|
||||
Hash,
|
||||
AlertTriangle,
|
||||
CheckCircle2,
|
||||
UploadCloud,
|
||||
Wand2,
|
||||
Sparkles,
|
||||
Hash,
|
||||
GitBranch,
|
||||
Keyboard,
|
||||
Sparkles,
|
||||
} from "lucide-react";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
@@ -20,21 +17,6 @@ import { Separator } from "~/components/ui/separator";
|
||||
import { cn } from "~/lib/utils";
|
||||
import { useDesignerStore } from "../state/store";
|
||||
|
||||
/**
|
||||
* BottomStatusBar
|
||||
*
|
||||
* Compact, persistent status + quick-action bar for the Experiment Designer.
|
||||
* Shows:
|
||||
* - Validation / drift / unsaved state
|
||||
* - Short design hash & version
|
||||
* - Aggregate counts (steps / actions)
|
||||
* - Last persisted hash (if available)
|
||||
* - Quick actions (Save, Validate, Export, Command Palette)
|
||||
*
|
||||
* The bar is intentionally UI-only: callback props are used so that higher-level
|
||||
* orchestration (e.g. DesignerRoot / Shell) controls actual side effects.
|
||||
*/
|
||||
|
||||
export interface BottomStatusBarProps {
|
||||
onSave?: () => void;
|
||||
onValidate?: () => void;
|
||||
@@ -45,9 +27,6 @@ export interface BottomStatusBarProps {
|
||||
saving?: boolean;
|
||||
validating?: boolean;
|
||||
exporting?: boolean;
|
||||
/**
|
||||
* Optional externally supplied last saved Date for relative display.
|
||||
*/
|
||||
lastSavedAt?: Date;
|
||||
}
|
||||
|
||||
@@ -55,24 +34,16 @@ export function BottomStatusBar({
|
||||
onSave,
|
||||
onValidate,
|
||||
onExport,
|
||||
onOpenCommandPalette,
|
||||
onRecalculateHash,
|
||||
className,
|
||||
saving,
|
||||
validating,
|
||||
exporting,
|
||||
lastSavedAt,
|
||||
}: BottomStatusBarProps) {
|
||||
/* ------------------------------------------------------------------------ */
|
||||
/* Store Selectors */
|
||||
/* ------------------------------------------------------------------------ */
|
||||
const steps = useDesignerStore((s) => s.steps);
|
||||
const lastPersistedHash = useDesignerStore((s) => s.lastPersistedHash);
|
||||
const currentDesignHash = useDesignerStore((s) => s.currentDesignHash);
|
||||
const lastValidatedHash = useDesignerStore((s) => s.lastValidatedHash);
|
||||
const pendingSave = useDesignerStore((s) => s.pendingSave);
|
||||
const versionStrategy = useDesignerStore((s) => s.versionStrategy);
|
||||
const autoSaveEnabled = useDesignerStore((s) => s.autoSaveEnabled);
|
||||
|
||||
const actionCount = useMemo(
|
||||
() => steps.reduce((sum, st) => sum + st.actions.length, 0),
|
||||
@@ -93,64 +64,28 @@ export function BottomStatusBar({
|
||||
return "valid";
|
||||
}, [currentDesignHash, lastValidatedHash]);
|
||||
|
||||
const shortHash = useMemo(
|
||||
() => (currentDesignHash ? currentDesignHash.slice(0, 8) : "—"),
|
||||
[currentDesignHash],
|
||||
);
|
||||
|
||||
const lastPersistedShort = useMemo(
|
||||
() => (lastPersistedHash ? lastPersistedHash.slice(0, 8) : null),
|
||||
[lastPersistedHash],
|
||||
);
|
||||
|
||||
/* ------------------------------------------------------------------------ */
|
||||
/* Derived Display Helpers */
|
||||
/* ------------------------------------------------------------------------ */
|
||||
function formatRelative(date?: Date): string {
|
||||
if (!date) return "—";
|
||||
const now = Date.now();
|
||||
const diffMs = now - date.getTime();
|
||||
if (diffMs < 30_000) return "just now";
|
||||
const mins = Math.floor(diffMs / 60_000);
|
||||
if (mins < 60) return `${mins}m ago`;
|
||||
const hrs = Math.floor(mins / 60);
|
||||
if (hrs < 24) return `${hrs}h ago`;
|
||||
const days = Math.floor(hrs / 24);
|
||||
return `${days}d ago`;
|
||||
}
|
||||
|
||||
const relSaved = formatRelative(lastSavedAt);
|
||||
|
||||
const validationBadge = (() => {
|
||||
switch (validationStatus) {
|
||||
case "valid":
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-green-400 text-green-600 dark:text-green-400"
|
||||
title="Validated (hash stable)"
|
||||
>
|
||||
<CheckCircle2 className="mr-1 h-3 w-3" />
|
||||
<span className="hidden sm:inline">Validated</span>
|
||||
</Badge>
|
||||
<div className="flex items-center gap-1.5 text-green-600 dark:text-green-400">
|
||||
<CheckCircle2 className="h-3.5 w-3.5" />
|
||||
<span className="hidden sm:inline">Valid</span>
|
||||
</div>
|
||||
);
|
||||
case "drift":
|
||||
return (
|
||||
<Badge
|
||||
variant="destructive"
|
||||
className="border-amber-400 bg-amber-50 text-amber-700 dark:bg-amber-950/30 dark:text-amber-400"
|
||||
title="Drift since last validation"
|
||||
>
|
||||
<AlertTriangle className="mr-1 h-3 w-3" />
|
||||
<span className="hidden sm:inline">Drift</span>
|
||||
</Badge>
|
||||
<div className="flex items-center gap-1.5 text-amber-600 dark:text-amber-400">
|
||||
<AlertTriangle className="h-3.5 w-3.5" />
|
||||
<span className="hidden sm:inline">Modified</span>
|
||||
</div>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<Badge variant="outline" title="Not validated yet">
|
||||
<Hash className="mr-1 h-3 w-3" />
|
||||
<div className="text-muted-foreground flex items-center gap-1.5">
|
||||
<Hash className="h-3.5 w-3.5" />
|
||||
<span className="hidden sm:inline">Unvalidated</span>
|
||||
</Badge>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
})();
|
||||
@@ -159,190 +94,63 @@ export function BottomStatusBar({
|
||||
hasUnsaved && !pendingSave ? (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-orange-300 text-orange-600 dark:text-orange-400"
|
||||
title="Unsaved changes"
|
||||
className="h-5 gap-1 border-orange-300 px-1.5 text-[10px] font-normal text-orange-600 dark:text-orange-400"
|
||||
>
|
||||
<AlertTriangle className="mr-1 h-3 w-3" />
|
||||
<span className="hidden sm:inline">Unsaved</span>
|
||||
Unsaved
|
||||
</Badge>
|
||||
) : null;
|
||||
|
||||
const savingIndicator =
|
||||
pendingSave || saving ? (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="animate-pulse"
|
||||
title="Saving changes"
|
||||
>
|
||||
<RefreshCw className="mr-1 h-3 w-3 animate-spin" />
|
||||
Saving…
|
||||
</Badge>
|
||||
<div className="text-muted-foreground flex animate-pulse items-center gap-1.5">
|
||||
<RefreshCw className="h-3 w-3 animate-spin" />
|
||||
<span>Saving...</span>
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
/* ------------------------------------------------------------------------ */
|
||||
/* Handlers */
|
||||
/* ------------------------------------------------------------------------ */
|
||||
const handleSave = useCallback(() => {
|
||||
if (onSave) onSave();
|
||||
}, [onSave]);
|
||||
|
||||
const handleValidate = useCallback(() => {
|
||||
if (onValidate) onValidate();
|
||||
}, [onValidate]);
|
||||
|
||||
const handleExport = useCallback(() => {
|
||||
if (onExport) onExport();
|
||||
}, [onExport]);
|
||||
|
||||
const handlePalette = useCallback(() => {
|
||||
if (onOpenCommandPalette) onOpenCommandPalette();
|
||||
}, [onOpenCommandPalette]);
|
||||
|
||||
const handleRecalculateHash = useCallback(() => {
|
||||
if (onRecalculateHash) onRecalculateHash();
|
||||
}, [onRecalculateHash]);
|
||||
|
||||
/* ------------------------------------------------------------------------ */
|
||||
/* Render */
|
||||
/* ------------------------------------------------------------------------ */
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"border-border/60 bg-muted/40 supports-[backdrop-filter]:bg-muted/30 backdrop-blur",
|
||||
"flex h-10 w-full flex-shrink-0 items-center gap-3 border-t px-3 text-xs",
|
||||
"font-medium",
|
||||
"flex h-9 w-full flex-shrink-0 items-center gap-4 border-t px-3 text-xs font-medium",
|
||||
className,
|
||||
)}
|
||||
aria-label="Designer status bar"
|
||||
>
|
||||
{/* Left Cluster: Validation & Hash */}
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
{/* Status Indicators */}
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
{validationBadge}
|
||||
{unsavedBadge}
|
||||
{savingIndicator}
|
||||
<Separator orientation="vertical" className="h-4" />
|
||||
<div
|
||||
className="flex items-center gap-1 font-mono text-[11px]"
|
||||
title="Current design hash"
|
||||
>
|
||||
<Hash className="text-muted-foreground h-3 w-3" />
|
||||
{shortHash}
|
||||
{lastPersistedShort && lastPersistedShort !== shortHash && (
|
||||
<span
|
||||
className="text-muted-foreground/70"
|
||||
title="Last persisted hash"
|
||||
>
|
||||
/ {lastPersistedShort}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Middle Cluster: Aggregate Counts */}
|
||||
<div className="text-muted-foreground flex min-w-0 items-center gap-3 truncate">
|
||||
<div
|
||||
className="flex items-center gap-1"
|
||||
title="Steps in current design"
|
||||
>
|
||||
<GitBranch className="h-3 w-3" />
|
||||
<Separator orientation="vertical" className="h-4 opacity-50" />
|
||||
|
||||
{/* Stats */}
|
||||
<div className="text-muted-foreground flex items-center gap-3 truncate">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<GitBranch className="h-3.5 w-3.5 opacity-70" />
|
||||
{steps.length}
|
||||
<span className="hidden sm:inline"> steps</span>
|
||||
</div>
|
||||
<div
|
||||
className="flex items-center gap-1"
|
||||
title="Total actions across all steps"
|
||||
>
|
||||
<Sparkles className="h-3 w-3" />
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Sparkles className="h-3.5 w-3.5 opacity-70" />
|
||||
{actionCount}
|
||||
<span className="hidden sm:inline"> actions</span>
|
||||
</div>
|
||||
<div
|
||||
className="hidden items-center gap-1 sm:flex"
|
||||
title="Auto-save setting"
|
||||
>
|
||||
<UploadCloud className="h-3 w-3" />
|
||||
{autoSaveEnabled ? "auto-save on" : "auto-save off"}
|
||||
</div>
|
||||
<div
|
||||
className="hidden items-center gap-1 font-mono text-[10px] sm:flex"
|
||||
title={`Current design hash: ${currentDesignHash ?? 'Not computed'}`}
|
||||
>
|
||||
<Hash className="h-3 w-3" />
|
||||
{currentDesignHash?.slice(0, 16) ?? '—'}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-5 px-1 ml-1"
|
||||
onClick={handleRecalculateHash}
|
||||
aria-label="Recalculate hash"
|
||||
title="Recalculate hash"
|
||||
>
|
||||
<RefreshCw className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
className="text-muted-foreground/80 hidden items-center gap-1 text-[10px] font-normal tracking-wide md:flex"
|
||||
title="Relative time since last save"
|
||||
>
|
||||
Saved {relSaved}
|
||||
</div>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Flexible Spacer */}
|
||||
<div className="flex-1" />
|
||||
|
||||
{/* Right Cluster: Quick Actions */}
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2"
|
||||
disabled={!hasUnsaved && !pendingSave}
|
||||
onClick={handleSave}
|
||||
aria-label="Save (s)"
|
||||
title="Save (s)"
|
||||
>
|
||||
<Save className="mr-1 h-3 w-3" />
|
||||
<span className="hidden sm:inline">Save</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2"
|
||||
onClick={handleValidate}
|
||||
disabled={validating}
|
||||
aria-label="Validate (v)"
|
||||
title="Validate (v)"
|
||||
>
|
||||
<RefreshCw
|
||||
className={cn("mr-1 h-3 w-3", validating && "animate-spin")}
|
||||
/>
|
||||
<span className="hidden sm:inline">Validate</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2"
|
||||
onClick={handleExport}
|
||||
className="h-7 px-2 text-xs"
|
||||
onClick={onExport}
|
||||
disabled={exporting}
|
||||
aria-label="Export (e)"
|
||||
title="Export (e)"
|
||||
title="Export JSON"
|
||||
>
|
||||
<Download className="mr-1 h-3 w-3" />
|
||||
<span className="hidden sm:inline">Export</span>
|
||||
</Button>
|
||||
<Separator orientation="vertical" className="mx-1 h-4" />
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 px-2"
|
||||
onClick={handlePalette}
|
||||
aria-label="Command Palette (⌘K)"
|
||||
title="Command Palette (⌘K)"
|
||||
>
|
||||
<Keyboard className="mr-1 h-3 w-3" />
|
||||
<span className="hidden sm:inline">Commands</span>
|
||||
<Download className="mr-1.5 h-3.5 w-3.5" />
|
||||
Export
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
import * as React from "react";
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
import { Sheet, SheetContent, SheetTrigger } from "~/components/ui/sheet";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { PanelLeft, Settings2 } from "lucide-react";
|
||||
type Edge = "left" | "right";
|
||||
|
||||
export interface PanelsContainerProps {
|
||||
@@ -36,6 +38,14 @@ export interface PanelsContainerProps {
|
||||
|
||||
/** Keyboard resize step (fractional) per arrow press; Shift increases by 2x */
|
||||
keyboardStepPct?: number;
|
||||
|
||||
/**
|
||||
* Controlled collapse state
|
||||
*/
|
||||
leftCollapsed?: boolean;
|
||||
rightCollapsed?: boolean;
|
||||
onLeftCollapseChange?: (collapsed: boolean) => void;
|
||||
onRightCollapseChange?: (collapsed: boolean) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -43,6 +53,7 @@ export interface PanelsContainerProps {
|
||||
*
|
||||
* Tailwind-first, grid-based panel layout with:
|
||||
* - Drag-resizable left/right panels (no persistence)
|
||||
* - Collapsible side panels
|
||||
* - Strict overflow containment (no page-level x-scroll)
|
||||
* - Internal y-scroll for each panel
|
||||
* - Optional visual dividers on the center panel only (prevents double borders)
|
||||
@@ -53,29 +64,30 @@ export interface PanelsContainerProps {
|
||||
* - Resize handles are absolutely positioned over the grid at the left and right boundaries.
|
||||
* - Fractions are clamped with configurable min/max so panels remain usable at all sizes.
|
||||
*/
|
||||
const Panel: React.FC<React.PropsWithChildren<{
|
||||
className?: string;
|
||||
panelClassName?: string;
|
||||
contentClassName?: string;
|
||||
}>> = ({
|
||||
className: panelCls,
|
||||
panelClassName,
|
||||
contentClassName,
|
||||
children,
|
||||
}) => (
|
||||
<section
|
||||
className={cn("min-w-0 overflow-hidden", panelCls, panelClassName)}
|
||||
const Panel: React.FC<
|
||||
React.PropsWithChildren<{
|
||||
className?: string;
|
||||
panelClassName?: string;
|
||||
contentClassName?: string;
|
||||
}>
|
||||
> = ({ className: panelCls, panelClassName, contentClassName, children }) => (
|
||||
<section
|
||||
className={cn(
|
||||
"min-w-0 overflow-hidden transition-[width,opacity] duration-300 ease-in-out",
|
||||
panelCls,
|
||||
panelClassName,
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"h-full min-h-0 w-full overflow-x-hidden overflow-y-auto",
|
||||
contentClassName,
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"h-full min-h-0 w-full overflow-x-hidden overflow-y-auto",
|
||||
contentClassName,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
{children}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
export function PanelsContainer({
|
||||
left,
|
||||
@@ -91,6 +103,10 @@ export function PanelsContainer({
|
||||
minRightPct = 0.12,
|
||||
maxRightPct = 0.33,
|
||||
keyboardStepPct = 0.02,
|
||||
leftCollapsed = false,
|
||||
rightCollapsed = false,
|
||||
onLeftCollapseChange,
|
||||
onRightCollapseChange,
|
||||
}: PanelsContainerProps) {
|
||||
const hasLeft = Boolean(left);
|
||||
const hasRight = Boolean(right);
|
||||
@@ -116,20 +132,39 @@ export function PanelsContainer({
|
||||
(lp: number, rp: number) => {
|
||||
if (!hasCenter) return { l: 0, c: 0, r: 0 };
|
||||
|
||||
// Effective widths (0 if collapsed)
|
||||
const effectiveL = leftCollapsed ? 0 : lp;
|
||||
const effectiveR = rightCollapsed ? 0 : rp;
|
||||
|
||||
// When logic runs, we must clamp the *underlying* percentages (lp, rp)
|
||||
// but return 0 for the CSS vars if collapsed.
|
||||
|
||||
// Actually, if collapsed, we just want the CSS var to be 0.
|
||||
// But we maintain the state `leftPct` so it restores correctly.
|
||||
|
||||
if (hasLeft && hasRight) {
|
||||
const l = clamp(lp, minLeftPct, maxLeftPct);
|
||||
const r = clamp(rp, minRightPct, maxRightPct);
|
||||
const c = Math.max(0.1, 1 - (l + r)); // always preserve some center space
|
||||
// Standard clamp (on the state values)
|
||||
const lState = clamp(lp, minLeftPct, maxLeftPct);
|
||||
const rState = clamp(rp, minRightPct, maxRightPct);
|
||||
|
||||
// Effective output
|
||||
const l = leftCollapsed ? 0 : lState;
|
||||
const r = rightCollapsed ? 0 : rState;
|
||||
|
||||
// Center takes remainder
|
||||
const c = 1 - (l + r);
|
||||
return { l, c, r };
|
||||
}
|
||||
if (hasLeft && !hasRight) {
|
||||
const l = clamp(lp, minLeftPct, maxLeftPct);
|
||||
const c = Math.max(0.2, 1 - l);
|
||||
const lState = clamp(lp, minLeftPct, maxLeftPct);
|
||||
const l = leftCollapsed ? 0 : lState;
|
||||
const c = 1 - l;
|
||||
return { l, c, r: 0 };
|
||||
}
|
||||
if (!hasLeft && hasRight) {
|
||||
const r = clamp(rp, minRightPct, maxRightPct);
|
||||
const c = Math.max(0.2, 1 - r);
|
||||
const rState = clamp(rp, minRightPct, maxRightPct);
|
||||
const r = rightCollapsed ? 0 : rState;
|
||||
const c = 1 - r;
|
||||
return { l: 0, c, r };
|
||||
}
|
||||
// Center only
|
||||
@@ -143,6 +178,8 @@ export function PanelsContainer({
|
||||
maxLeftPct,
|
||||
minRightPct,
|
||||
maxRightPct,
|
||||
leftCollapsed,
|
||||
rightCollapsed,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -157,10 +194,10 @@ export function PanelsContainer({
|
||||
const deltaPx = e.clientX - d.startX;
|
||||
const deltaPct = deltaPx / d.containerWidth;
|
||||
|
||||
if (d.edge === "left" && hasLeft) {
|
||||
if (d.edge === "left" && hasLeft && !leftCollapsed) {
|
||||
const nextLeft = clamp(d.startLeft + deltaPct, minLeftPct, maxLeftPct);
|
||||
setLeftPct(nextLeft);
|
||||
} else if (d.edge === "right" && hasRight) {
|
||||
} else if (d.edge === "right" && hasRight && !rightCollapsed) {
|
||||
// Dragging the right edge moves leftwards as delta increases
|
||||
const nextRight = clamp(
|
||||
d.startRight - deltaPct,
|
||||
@@ -170,7 +207,16 @@ export function PanelsContainer({
|
||||
setRightPct(nextRight);
|
||||
}
|
||||
},
|
||||
[hasLeft, hasRight, minLeftPct, maxLeftPct, minRightPct, maxRightPct],
|
||||
[
|
||||
hasLeft,
|
||||
hasRight,
|
||||
minLeftPct,
|
||||
maxLeftPct,
|
||||
minRightPct,
|
||||
maxRightPct,
|
||||
leftCollapsed,
|
||||
rightCollapsed,
|
||||
],
|
||||
);
|
||||
|
||||
const endDrag = React.useCallback(() => {
|
||||
@@ -213,14 +259,14 @@ export function PanelsContainer({
|
||||
|
||||
const step = (e.shiftKey ? 2 : 1) * keyboardStepPct;
|
||||
|
||||
if (edge === "left" && hasLeft) {
|
||||
if (edge === "left" && hasLeft && !leftCollapsed) {
|
||||
const next = clamp(
|
||||
leftPct + (e.key === "ArrowRight" ? step : -step),
|
||||
minLeftPct,
|
||||
maxLeftPct,
|
||||
);
|
||||
setLeftPct(next);
|
||||
} else if (edge === "right" && hasRight) {
|
||||
} else if (edge === "right" && hasRight && !rightCollapsed) {
|
||||
const next = clamp(
|
||||
rightPct + (e.key === "ArrowLeft" ? step : -step),
|
||||
minRightPct,
|
||||
@@ -231,111 +277,177 @@ export function PanelsContainer({
|
||||
};
|
||||
|
||||
// CSS variables for the grid fractions
|
||||
// We use FR units instead of % to let the browser handle exact pixel fitting without rounding errors causing overflow
|
||||
const styleVars: React.CSSProperties & Record<string, string> = hasCenter
|
||||
? {
|
||||
"--col-left": `${(hasLeft ? l : 0) * 100}%`,
|
||||
"--col-center": `${c * 100}%`,
|
||||
"--col-right": `${(hasRight ? r : 0) * 100}%`,
|
||||
}
|
||||
"--col-left": `${hasLeft ? l : 0}fr`,
|
||||
"--col-center": `${c}fr`,
|
||||
"--col-right": `${hasRight ? r : 0}fr`,
|
||||
}
|
||||
: {};
|
||||
|
||||
// Explicit grid template depending on which side panels exist
|
||||
const gridAreas =
|
||||
hasLeft && hasRight
|
||||
? '"left center right"'
|
||||
: hasLeft && !hasRight
|
||||
? '"left center"'
|
||||
: !hasLeft && hasRight
|
||||
? '"center right"'
|
||||
: '"center"';
|
||||
|
||||
const gridCols =
|
||||
hasLeft && hasRight
|
||||
? "[grid-template-columns:minmax(0,var(--col-left))_minmax(0,var(--col-center))_minmax(0,var(--col-right))]"
|
||||
? "[grid-template-columns:var(--col-left)_var(--col-center)_var(--col-right)]"
|
||||
: hasLeft && !hasRight
|
||||
? "[grid-template-columns:minmax(0,var(--col-left))_minmax(0,var(--col-center))]"
|
||||
? "[grid-template-columns:var(--col-left)_var(--col-center)]"
|
||||
: !hasLeft && hasRight
|
||||
? "[grid-template-columns:minmax(0,var(--col-center))_minmax(0,var(--col-right))]"
|
||||
: "[grid-template-columns:minmax(0,1fr)]";
|
||||
? "[grid-template-columns:var(--col-center)_var(--col-right)]"
|
||||
: "[grid-template-columns:1fr]";
|
||||
|
||||
// Dividers on the center panel only (prevents double borders if children have their own borders)
|
||||
const centerDividers =
|
||||
showDividers && hasCenter
|
||||
? cn({
|
||||
"border-l": hasLeft,
|
||||
"border-r": hasRight,
|
||||
})
|
||||
"border-l": hasLeft,
|
||||
"border-r": hasRight,
|
||||
})
|
||||
: undefined;
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={rootRef}
|
||||
aria-label={ariaLabel}
|
||||
style={styleVars}
|
||||
className={cn(
|
||||
"relative grid h-full min-h-0 w-full overflow-hidden select-none",
|
||||
gridCols,
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{hasLeft && (
|
||||
<Panel
|
||||
panelClassName={panelClassName}
|
||||
contentClassName={contentClassName}
|
||||
>
|
||||
{left}
|
||||
</Panel>
|
||||
)}
|
||||
<>
|
||||
{/* Mobile Layout (Flex + Sheets) */}
|
||||
<div className={cn("flex h-full w-full flex-col md:hidden", className)}>
|
||||
{/* Mobile Header/Toolbar for access to panels */}
|
||||
<div className="bg-background flex items-center justify-between border-b px-4 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{hasLeft && (
|
||||
<Sheet>
|
||||
<SheetTrigger asChild>
|
||||
<Button variant="outline" size="icon" className="h-8 w-8">
|
||||
<PanelLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent side="left" className="w-[85vw] p-0 sm:max-w-md">
|
||||
<div className="h-full overflow-hidden">{left}</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)}
|
||||
<span className="text-sm font-medium">Designer</span>
|
||||
</div>
|
||||
|
||||
{hasCenter && (
|
||||
<Panel
|
||||
className={centerDividers}
|
||||
panelClassName={panelClassName}
|
||||
contentClassName={contentClassName}
|
||||
>
|
||||
{hasRight && (
|
||||
<Sheet>
|
||||
<SheetTrigger asChild>
|
||||
<Button variant="outline" size="icon" className="h-8 w-8">
|
||||
<Settings2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent side="right" className="w-[85vw] p-0 sm:max-w-md">
|
||||
<div className="h-full overflow-hidden">{right}</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Main Content (Center) */}
|
||||
<div className="relative min-h-0 min-w-0 flex-1 overflow-hidden">
|
||||
{center}
|
||||
</Panel>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{hasRight && (
|
||||
<Panel
|
||||
panelClassName={panelClassName}
|
||||
contentClassName={contentClassName}
|
||||
>
|
||||
{right}
|
||||
</Panel>
|
||||
)}
|
||||
{/* Desktop Layout (Grid) */}
|
||||
<div
|
||||
ref={rootRef}
|
||||
aria-label={ariaLabel}
|
||||
className={cn(
|
||||
"relative hidden h-full min-h-0 w-full max-w-full overflow-hidden select-none md:grid",
|
||||
// 2-3-2 ratio for left-center-right panels when all visible
|
||||
hasLeft &&
|
||||
hasRight &&
|
||||
!leftCollapsed &&
|
||||
!rightCollapsed &&
|
||||
"grid-cols-[2fr_3fr_2fr]",
|
||||
// Left collapsed: center + right (3:2 ratio)
|
||||
hasLeft &&
|
||||
hasRight &&
|
||||
leftCollapsed &&
|
||||
!rightCollapsed &&
|
||||
"grid-cols-[3fr_2fr]",
|
||||
// Right collapsed: left + center (2:3 ratio)
|
||||
hasLeft &&
|
||||
hasRight &&
|
||||
!leftCollapsed &&
|
||||
rightCollapsed &&
|
||||
"grid-cols-[2fr_3fr]",
|
||||
// Both collapsed: center only
|
||||
hasLeft &&
|
||||
hasRight &&
|
||||
leftCollapsed &&
|
||||
rightCollapsed &&
|
||||
"grid-cols-1",
|
||||
// Only left and center
|
||||
hasLeft && !hasRight && !leftCollapsed && "grid-cols-[2fr_3fr]",
|
||||
hasLeft && !hasRight && leftCollapsed && "grid-cols-1",
|
||||
// Only center and right
|
||||
!hasLeft && hasRight && !rightCollapsed && "grid-cols-[3fr_2fr]",
|
||||
!hasLeft && hasRight && rightCollapsed && "grid-cols-1",
|
||||
// Only center
|
||||
!hasLeft && !hasRight && "grid-cols-1",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{hasLeft && !leftCollapsed && (
|
||||
<Panel
|
||||
panelClassName={panelClassName}
|
||||
contentClassName={contentClassName}
|
||||
>
|
||||
{left}
|
||||
</Panel>
|
||||
)}
|
||||
|
||||
{/* Resize handles (only render where applicable) */}
|
||||
{hasCenter && hasLeft && (
|
||||
<button
|
||||
type="button"
|
||||
role="separator"
|
||||
aria-label="Resize left panel"
|
||||
aria-orientation="vertical"
|
||||
onPointerDown={startDrag("left")}
|
||||
onKeyDown={onKeyResize("left")}
|
||||
className={cn(
|
||||
"absolute inset-y-0 z-10 w-1 cursor-col-resize outline-none",
|
||||
"focus-visible:ring-ring focus-visible:ring-2",
|
||||
)}
|
||||
// Position at the boundary between left and center
|
||||
style={{ left: "var(--col-left)", transform: "translateX(-0.5px)" }}
|
||||
tabIndex={0}
|
||||
/>
|
||||
)}
|
||||
{hasCenter && (
|
||||
<Panel
|
||||
className={centerDividers}
|
||||
panelClassName={panelClassName}
|
||||
contentClassName={contentClassName}
|
||||
>
|
||||
{center}
|
||||
</Panel>
|
||||
)}
|
||||
|
||||
{hasCenter && hasRight && (
|
||||
<button
|
||||
type="button"
|
||||
role="separator"
|
||||
aria-label="Resize right panel"
|
||||
aria-orientation="vertical"
|
||||
onPointerDown={startDrag("right")}
|
||||
onKeyDown={onKeyResize("right")}
|
||||
className={cn(
|
||||
"absolute inset-y-0 z-10 w-1 cursor-col-resize outline-none",
|
||||
"focus-visible:ring-ring focus-visible:ring-2",
|
||||
)}
|
||||
// Position at the boundary between center and right (offset from the right)
|
||||
style={{ right: "var(--col-right)", transform: "translateX(0.5px)" }}
|
||||
tabIndex={0}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{hasRight && !rightCollapsed && (
|
||||
<Panel
|
||||
panelClassName={panelClassName}
|
||||
contentClassName={contentClassName}
|
||||
>
|
||||
{right}
|
||||
</Panel>
|
||||
)}
|
||||
|
||||
{/* Resize Handles */}
|
||||
{hasLeft && !leftCollapsed && (
|
||||
<button
|
||||
type="button"
|
||||
className="absolute top-0 bottom-0 z-50 -ml-0.75 w-1.5 cursor-col-resize transition-colors hover:bg-blue-400/50 focus:outline-none"
|
||||
style={{ left: "var(--col-left)" }}
|
||||
onPointerDown={startDrag("left")}
|
||||
onKeyDown={onKeyResize("left")}
|
||||
aria-label="Resize left panel"
|
||||
/>
|
||||
)}
|
||||
{hasRight && !rightCollapsed && (
|
||||
<button
|
||||
type="button"
|
||||
className="absolute top-0 bottom-0 z-50 -mr-0.75 w-1.5 cursor-col-resize transition-colors hover:bg-blue-400/50 focus:outline-none"
|
||||
style={{ right: "var(--col-right)" }}
|
||||
onPointerDown={startDrag("right")}
|
||||
onKeyDown={onKeyResize("right")}
|
||||
aria-label="Resize right panel"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
Eye,
|
||||
X,
|
||||
Layers,
|
||||
PanelLeftClose,
|
||||
} from "lucide-react";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { Button } from "~/components/ui/button";
|
||||
@@ -88,8 +89,8 @@ function DraggableAction({
|
||||
|
||||
const style: React.CSSProperties = transform
|
||||
? {
|
||||
transform: `translate3d(${transform.x}px, ${transform.y}px, 0)`,
|
||||
}
|
||||
transform: `translate3d(${transform.x}px, ${transform.y}px, 0)`,
|
||||
}
|
||||
: {};
|
||||
|
||||
const IconComponent = iconMap[action.icon] ?? Sparkles;
|
||||
@@ -108,7 +109,7 @@ function DraggableAction({
|
||||
{...listeners}
|
||||
style={style}
|
||||
className={cn(
|
||||
"group bg-background/60 hover:bg-accent/50 relative flex w-full cursor-grab touch-none flex-col gap-1 rounded border px-2 text-left transition-colors select-none",
|
||||
"group bg-background/60 hover:bg-accent/50 relative flex w-full cursor-grab touch-none flex-col gap-1 rounded-lg border px-2 text-left transition-colors select-none",
|
||||
compact ? "py-1.5 text-[11px]" : "py-2 text-[12px]",
|
||||
isDragging && "opacity-50",
|
||||
)}
|
||||
@@ -168,7 +169,15 @@ function DraggableAction({
|
||||
);
|
||||
}
|
||||
|
||||
export function ActionLibraryPanel() {
|
||||
export interface ActionLibraryPanelProps {
|
||||
collapsed?: boolean;
|
||||
onCollapse?: (collapsed: boolean) => void;
|
||||
}
|
||||
|
||||
export function ActionLibraryPanel({
|
||||
collapsed,
|
||||
onCollapse,
|
||||
}: ActionLibraryPanelProps = {}) {
|
||||
const registry = useActionRegistry();
|
||||
|
||||
const [search, setSearch] = useState("");
|
||||
@@ -293,8 +302,6 @@ export function ActionLibraryPanel() {
|
||||
setShowOnlyFavorites(false);
|
||||
}, [categories]);
|
||||
|
||||
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const activeCats = selectedCategories;
|
||||
const q = search.trim().toLowerCase();
|
||||
@@ -333,7 +340,10 @@ export function ActionLibraryPanel() {
|
||||
).length;
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden" id="tour-designer-blocks">
|
||||
<div
|
||||
className="flex h-full flex-col overflow-hidden"
|
||||
id="tour-designer-blocks"
|
||||
>
|
||||
<div className="bg-background/60 flex-shrink-0 border-b p-2">
|
||||
<div className="relative mb-2">
|
||||
<Search className="text-muted-foreground absolute top-1/2 left-2 h-3.5 w-3.5 -translate-y-1/2" />
|
||||
@@ -487,4 +497,3 @@ export function ActionLibraryPanel() {
|
||||
|
||||
// Wrap in React.memo to prevent unnecessary re-renders causing flashing in categories
|
||||
export default React.memo(ActionLibraryPanel);
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import React, { useMemo, useState, useCallback } from "react";
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "~/components/ui/tabs";
|
||||
import { Button } from "~/components/ui/button";
|
||||
|
||||
import { cn } from "~/lib/utils";
|
||||
import { useDesignerStore } from "../state/store";
|
||||
@@ -18,7 +19,9 @@ import {
|
||||
AlertTriangle,
|
||||
GitBranch,
|
||||
PackageSearch,
|
||||
PanelRightClose,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
/**
|
||||
* InspectorPanel
|
||||
@@ -47,6 +50,11 @@ export interface InspectorPanelProps {
|
||||
* Called when user changes tab (only if activeTab not externally controlled).
|
||||
*/
|
||||
onTabChange?: (tab: "properties" | "issues" | "dependencies") => void;
|
||||
/**
|
||||
* Collapse state and handler
|
||||
*/
|
||||
collapsed?: boolean;
|
||||
onCollapse?: (collapsed: boolean) => void;
|
||||
/**
|
||||
* If true, auto-switch to "properties" when a selection occurs.
|
||||
*/
|
||||
@@ -60,6 +68,10 @@ export interface InspectorPanelProps {
|
||||
name: string;
|
||||
version: string;
|
||||
}>;
|
||||
/**
|
||||
* Called to clear all validation issues.
|
||||
*/
|
||||
onClearAll?: () => void;
|
||||
}
|
||||
|
||||
export function InspectorPanel({
|
||||
@@ -68,6 +80,9 @@ export function InspectorPanel({
|
||||
onTabChange,
|
||||
autoFocusOnSelection = true,
|
||||
studyPlugins,
|
||||
collapsed,
|
||||
onCollapse,
|
||||
onClearAll,
|
||||
}: InspectorPanelProps) {
|
||||
/* ------------------------------------------------------------------------ */
|
||||
/* Store Selectors */
|
||||
@@ -314,6 +329,7 @@ export function InspectorPanel({
|
||||
>
|
||||
<ValidationPanel
|
||||
issues={validationIssues}
|
||||
onClearAll={onClearAll}
|
||||
entityLabelForId={(entityId) => {
|
||||
if (entityId.startsWith("action-")) {
|
||||
for (const s of steps) {
|
||||
@@ -356,15 +372,13 @@ export function InspectorPanel({
|
||||
actionDefinitions={actionRegistry.getAllActions()}
|
||||
studyPlugins={studyPlugins}
|
||||
onReconcileAction={(actionId) => {
|
||||
// Placeholder: future diff modal / signature update
|
||||
|
||||
console.log("Reconcile TODO for action:", actionId);
|
||||
toast.info("Action Reconcile coming soon!");
|
||||
}}
|
||||
onRefreshDependencies={() => {
|
||||
console.log("Refresh dependencies TODO");
|
||||
toast.info("Refresh dependencies coming soon!");
|
||||
}}
|
||||
onInstallPlugin={(pluginId) => {
|
||||
console.log("Install plugin TODO:", pluginId);
|
||||
toast.info("Install plugin coming soon!");
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -155,9 +155,12 @@ function projectActionForDesign(
|
||||
pluginVersion: action.source.pluginVersion,
|
||||
baseActionId: action.source.baseActionId,
|
||||
},
|
||||
execution: action.execution ? projectExecutionDescriptor(action.execution) : null,
|
||||
execution: action.execution
|
||||
? projectExecutionDescriptor(action.execution)
|
||||
: null,
|
||||
parameterKeysOrValues: parameterProjection,
|
||||
children: action.children?.map(c => projectActionForDesign(c, options)) ?? [],
|
||||
children:
|
||||
action.children?.map((c) => projectActionForDesign(c, options)) ?? [],
|
||||
};
|
||||
|
||||
if (options.includeActionNames) {
|
||||
@@ -176,16 +179,16 @@ function projectExecutionDescriptor(
|
||||
timeoutMs: exec.timeoutMs ?? null,
|
||||
ros2: exec.ros2
|
||||
? {
|
||||
topic: exec.ros2.topic ?? null,
|
||||
service: exec.ros2.service ?? null,
|
||||
action: exec.ros2.action ?? null,
|
||||
}
|
||||
topic: exec.ros2.topic ?? null,
|
||||
service: exec.ros2.service ?? null,
|
||||
action: exec.ros2.action ?? null,
|
||||
}
|
||||
: null,
|
||||
rest: exec.rest
|
||||
? {
|
||||
method: exec.rest.method,
|
||||
path: exec.rest.path,
|
||||
}
|
||||
method: exec.rest.method,
|
||||
path: exec.rest.path,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
@@ -203,8 +206,7 @@ function projectStepForDesign(
|
||||
order: step.order,
|
||||
trigger: {
|
||||
type: step.trigger.type,
|
||||
// Only the sorted keys of conditions (structural presence)
|
||||
conditionKeys: Object.keys(step.trigger.conditions).sort(),
|
||||
conditions: canonicalize(step.trigger.conditions),
|
||||
},
|
||||
actions: step.actions.map((a) => projectActionForDesign(a, options)),
|
||||
};
|
||||
@@ -245,12 +247,14 @@ export async function computeActionSignature(
|
||||
baseActionId: def.baseActionId ?? null,
|
||||
execution: def.execution
|
||||
? {
|
||||
transport: def.execution.transport,
|
||||
retryable: def.execution.retryable ?? false,
|
||||
timeoutMs: def.execution.timeoutMs ?? null,
|
||||
}
|
||||
transport: def.execution.transport,
|
||||
retryable: def.execution.retryable ?? false,
|
||||
timeoutMs: def.execution.timeoutMs ?? null,
|
||||
}
|
||||
: null,
|
||||
schema: def.parameterSchemaRaw
|
||||
? canonicalize(def.parameterSchemaRaw)
|
||||
: null,
|
||||
schema: def.parameterSchemaRaw ? canonicalize(def.parameterSchemaRaw) : null,
|
||||
};
|
||||
return hashObject(projection);
|
||||
}
|
||||
@@ -267,11 +271,39 @@ export async function computeDesignHash(
|
||||
opts: DesignHashOptions = {},
|
||||
): Promise<string> {
|
||||
const options = { ...DEFAULT_OPTIONS, ...opts };
|
||||
const projected = steps
|
||||
.slice()
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.map((s) => projectStepForDesign(s, options));
|
||||
return hashObject({ steps: projected });
|
||||
|
||||
// 1. Sort steps first to ensure order independence of input array
|
||||
const sortedSteps = steps.slice().sort((a, b) => a.order - b.order);
|
||||
|
||||
// 2. Map hierarchically (Merkle style)
|
||||
const stepHashes = await Promise.all(
|
||||
sortedSteps.map(async (s) => {
|
||||
// Action hashes
|
||||
const actionHashes = await Promise.all(
|
||||
s.actions.map((a) => hashObject(projectActionForDesign(a, options))),
|
||||
);
|
||||
|
||||
// Step hash
|
||||
const pStep = {
|
||||
id: s.id,
|
||||
type: s.type,
|
||||
order: s.order,
|
||||
trigger: {
|
||||
type: s.trigger.type,
|
||||
conditions: canonicalize(s.trigger.conditions),
|
||||
},
|
||||
actions: actionHashes,
|
||||
...(options.includeStepNames ? { name: s.name } : {}),
|
||||
};
|
||||
return hashObject(pStep);
|
||||
}),
|
||||
);
|
||||
|
||||
// 3. Aggregate design hash
|
||||
return hashObject({
|
||||
steps: stepHashes,
|
||||
count: steps.length,
|
||||
});
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
@@ -338,7 +370,7 @@ export async function computeIncrementalDesignHash(
|
||||
order: step.order,
|
||||
trigger: {
|
||||
type: step.trigger.type,
|
||||
conditionKeys: Object.keys(step.trigger.conditions).sort(),
|
||||
conditions: canonicalize(step.trigger.conditions),
|
||||
},
|
||||
actions: step.actions.map((a) => actionHashes.get(a.id) ?? ""),
|
||||
...(options.includeStepNames ? { name: step.name } : {}),
|
||||
|
||||
@@ -93,7 +93,7 @@ export interface DesignerState {
|
||||
parentId: string | null;
|
||||
index: number;
|
||||
action: ExperimentAction;
|
||||
} | null
|
||||
} | null,
|
||||
) => void;
|
||||
|
||||
/* ------------------------------ Mutators --------------------------------- */
|
||||
@@ -109,10 +109,20 @@ export interface DesignerState {
|
||||
reorderStep: (from: number, to: number) => void;
|
||||
|
||||
// Actions
|
||||
upsertAction: (stepId: string, action: ExperimentAction, parentId?: string | null, index?: number) => void;
|
||||
upsertAction: (
|
||||
stepId: string,
|
||||
action: ExperimentAction,
|
||||
parentId?: string | null,
|
||||
index?: number,
|
||||
) => void;
|
||||
removeAction: (stepId: string, actionId: string) => void;
|
||||
reorderAction: (stepId: string, from: number, to: number) => void;
|
||||
moveAction: (stepId: string, actionId: string, newParentId: string | null, newIndex: number) => void;
|
||||
moveAction: (
|
||||
stepId: string,
|
||||
actionId: string,
|
||||
newParentId: string | null,
|
||||
newIndex: number,
|
||||
) => void;
|
||||
|
||||
// Dirty
|
||||
markDirty: (id: string) => void;
|
||||
@@ -158,18 +168,22 @@ export interface DesignerState {
|
||||
/* Helpers */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
function cloneActions(actions: ExperimentAction[]): ExperimentAction[] {
|
||||
return actions.map((a) => ({
|
||||
...a,
|
||||
children: a.children ? cloneActions(a.children) : undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
function cloneSteps(steps: ExperimentStep[]): ExperimentStep[] {
|
||||
return steps.map((s) => ({
|
||||
...s,
|
||||
actions: s.actions.map((a) => ({ ...a })),
|
||||
actions: cloneActions(s.actions),
|
||||
}));
|
||||
}
|
||||
|
||||
function reindexSteps(steps: ExperimentStep[]): ExperimentStep[] {
|
||||
return steps
|
||||
.slice()
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.map((s, idx) => ({ ...s, order: idx }));
|
||||
return steps.map((s, idx) => ({ ...s, order: idx }));
|
||||
}
|
||||
|
||||
function reindexActions(actions: ExperimentAction[]): ExperimentAction[] {
|
||||
@@ -250,298 +264,335 @@ function insertActionIntoTree(
|
||||
/* Store Implementation */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
export const useDesignerStore = create<DesignerState>((set, get) => ({
|
||||
steps: [],
|
||||
dirtyEntities: new Set<string>(),
|
||||
validationIssues: {},
|
||||
actionSignatureIndex: new Map(),
|
||||
actionSignatureDrift: new Set(),
|
||||
pendingSave: false,
|
||||
versionStrategy: "auto_minor" as VersionStrategy,
|
||||
autoSaveEnabled: true,
|
||||
busyHashing: false,
|
||||
busyValidating: false,
|
||||
insertionProjection: null,
|
||||
export const createDesignerStore = (props: {
|
||||
initialSteps?: ExperimentStep[];
|
||||
}) =>
|
||||
create<DesignerState>((set, get) => ({
|
||||
steps: props.initialSteps
|
||||
? reindexSteps(cloneSteps(props.initialSteps))
|
||||
: [],
|
||||
dirtyEntities: new Set<string>(),
|
||||
validationIssues: {},
|
||||
actionSignatureIndex: new Map(),
|
||||
actionSignatureDrift: new Set(),
|
||||
pendingSave: false,
|
||||
versionStrategy: "auto_minor" as VersionStrategy,
|
||||
autoSaveEnabled: true,
|
||||
busyHashing: false,
|
||||
busyValidating: false,
|
||||
insertionProjection: null,
|
||||
|
||||
/* ------------------------------ Selection -------------------------------- */
|
||||
selectStep: (id) =>
|
||||
set({
|
||||
selectedStepId: id,
|
||||
selectedActionId: id ? get().selectedActionId : undefined,
|
||||
}),
|
||||
selectAction: (stepId, actionId) =>
|
||||
set({
|
||||
selectedStepId: stepId,
|
||||
selectedActionId: actionId,
|
||||
}),
|
||||
/* ------------------------------ Selection -------------------------------- */
|
||||
selectStep: (id) =>
|
||||
set({
|
||||
selectedStepId: id,
|
||||
selectedActionId: id ? get().selectedActionId : undefined,
|
||||
}),
|
||||
selectAction: (stepId, actionId) =>
|
||||
set({
|
||||
selectedStepId: stepId,
|
||||
selectedActionId: actionId,
|
||||
}),
|
||||
|
||||
/* -------------------------------- Steps ---------------------------------- */
|
||||
setSteps: (steps) =>
|
||||
set(() => ({
|
||||
steps: reindexSteps(cloneSteps(steps)),
|
||||
dirtyEntities: new Set<string>(), // assume authoritative load
|
||||
})),
|
||||
/* -------------------------------- Steps ---------------------------------- */
|
||||
setSteps: (steps) =>
|
||||
set(() => ({
|
||||
steps: reindexSteps(cloneSteps(steps)),
|
||||
dirtyEntities: new Set<string>(), // assume authoritative load
|
||||
})),
|
||||
|
||||
upsertStep: (step) =>
|
||||
set((state) => {
|
||||
const idx = state.steps.findIndex((s) => s.id === step.id);
|
||||
let steps: ExperimentStep[];
|
||||
if (idx >= 0) {
|
||||
steps = [...state.steps];
|
||||
steps[idx] = { ...step };
|
||||
} else {
|
||||
steps = [...state.steps, { ...step, order: state.steps.length }];
|
||||
}
|
||||
return {
|
||||
steps: reindexSteps(steps),
|
||||
dirtyEntities: new Set([...state.dirtyEntities, step.id]),
|
||||
};
|
||||
}),
|
||||
upsertStep: (step) =>
|
||||
set((state) => {
|
||||
const idx = state.steps.findIndex((s) => s.id === step.id);
|
||||
let steps: ExperimentStep[];
|
||||
if (idx >= 0) {
|
||||
steps = [...state.steps];
|
||||
steps[idx] = { ...step };
|
||||
} else {
|
||||
steps = [...state.steps, { ...step, order: state.steps.length }];
|
||||
}
|
||||
return {
|
||||
steps: reindexSteps(steps),
|
||||
dirtyEntities: new Set([...state.dirtyEntities, step.id]),
|
||||
};
|
||||
}),
|
||||
|
||||
removeStep: (stepId) =>
|
||||
set((state) => {
|
||||
const steps = state.steps.filter((s) => s.id !== stepId);
|
||||
const dirty = new Set(state.dirtyEntities);
|
||||
dirty.add(stepId);
|
||||
return {
|
||||
steps: reindexSteps(steps),
|
||||
dirtyEntities: dirty,
|
||||
selectedStepId:
|
||||
state.selectedStepId === stepId ? undefined : state.selectedStepId,
|
||||
selectedActionId: undefined,
|
||||
};
|
||||
}),
|
||||
removeStep: (stepId) =>
|
||||
set((state) => {
|
||||
const steps = state.steps.filter((s) => s.id !== stepId);
|
||||
const dirty = new Set(state.dirtyEntities);
|
||||
dirty.add(stepId);
|
||||
return {
|
||||
steps: reindexSteps(steps),
|
||||
dirtyEntities: dirty,
|
||||
selectedStepId:
|
||||
state.selectedStepId === stepId ? undefined : state.selectedStepId,
|
||||
selectedActionId: undefined,
|
||||
};
|
||||
}),
|
||||
|
||||
reorderStep: (from: number, to: number) =>
|
||||
set((state: DesignerState) => {
|
||||
if (
|
||||
from < 0 ||
|
||||
to < 0 ||
|
||||
from >= state.steps.length ||
|
||||
to >= state.steps.length ||
|
||||
from === to
|
||||
) {
|
||||
return state;
|
||||
}
|
||||
const stepsDraft = [...state.steps];
|
||||
const [moved] = stepsDraft.splice(from, 1);
|
||||
if (!moved) return state;
|
||||
stepsDraft.splice(to, 0, moved);
|
||||
const reindexed = reindexSteps(stepsDraft);
|
||||
return {
|
||||
steps: reindexed,
|
||||
dirtyEntities: new Set<string>([
|
||||
...state.dirtyEntities,
|
||||
...reindexed.map((s) => s.id),
|
||||
]),
|
||||
};
|
||||
}),
|
||||
reorderStep: (from: number, to: number) =>
|
||||
set((state: DesignerState) => {
|
||||
if (
|
||||
from < 0 ||
|
||||
to < 0 ||
|
||||
from >= state.steps.length ||
|
||||
to >= state.steps.length ||
|
||||
from === to
|
||||
) {
|
||||
return state;
|
||||
}
|
||||
const stepsDraft = [...state.steps];
|
||||
const [moved] = stepsDraft.splice(from, 1);
|
||||
if (!moved) return state;
|
||||
stepsDraft.splice(to, 0, moved);
|
||||
const reindexed = reindexSteps(stepsDraft);
|
||||
return {
|
||||
steps: reindexed,
|
||||
dirtyEntities: new Set<string>([
|
||||
...state.dirtyEntities,
|
||||
...reindexed.map((s) => s.id),
|
||||
]),
|
||||
};
|
||||
}),
|
||||
|
||||
/* ------------------------------- Actions --------------------------------- */
|
||||
upsertAction: (stepId: string, action: ExperimentAction, parentId: string | null = null, index?: number) =>
|
||||
set((state: DesignerState) => {
|
||||
const stepsDraft: ExperimentStep[] = state.steps.map((s) => {
|
||||
if (s.id !== stepId) return s;
|
||||
/* ------------------------------- Actions --------------------------------- */
|
||||
upsertAction: (
|
||||
stepId: string,
|
||||
action: ExperimentAction,
|
||||
parentId: string | null = null,
|
||||
index?: number,
|
||||
) =>
|
||||
set((state: DesignerState) => {
|
||||
const stepsDraft: ExperimentStep[] = state.steps.map((s) => {
|
||||
if (s.id !== stepId) return s;
|
||||
|
||||
// Check if exists (update)
|
||||
const exists = findActionById(s.actions, action.id);
|
||||
if (exists) {
|
||||
// If updating, we don't (currently) support moving via upsert.
|
||||
// Use moveAction for moving.
|
||||
return {
|
||||
...s,
|
||||
actions: updateActionInTree(s.actions, action),
|
||||
};
|
||||
}
|
||||
|
||||
// Add new
|
||||
// If index is provided, use it. Otherwise append.
|
||||
const insertIndex = index ?? s.actions.length;
|
||||
|
||||
// Check if exists (update)
|
||||
const exists = findActionById(s.actions, action.id);
|
||||
if (exists) {
|
||||
// If updating, we don't (currently) support moving via upsert.
|
||||
// Use moveAction for moving.
|
||||
return {
|
||||
...s,
|
||||
actions: updateActionInTree(s.actions, action)
|
||||
actions: insertActionIntoTree(
|
||||
s.actions,
|
||||
action,
|
||||
parentId,
|
||||
insertIndex,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
// Add new
|
||||
// If index is provided, use it. Otherwise append.
|
||||
const insertIndex = index ?? s.actions.length;
|
||||
|
||||
});
|
||||
return {
|
||||
...s,
|
||||
actions: insertActionIntoTree(s.actions, action, parentId, insertIndex)
|
||||
steps: stepsDraft,
|
||||
dirtyEntities: new Set<string>([
|
||||
...state.dirtyEntities,
|
||||
action.id,
|
||||
stepId,
|
||||
]),
|
||||
};
|
||||
});
|
||||
return {
|
||||
steps: stepsDraft,
|
||||
dirtyEntities: new Set<string>([
|
||||
...state.dirtyEntities,
|
||||
action.id,
|
||||
stepId,
|
||||
]),
|
||||
};
|
||||
}),
|
||||
}),
|
||||
|
||||
removeAction: (stepId: string, actionId: string) =>
|
||||
set((state: DesignerState) => {
|
||||
const stepsDraft: ExperimentStep[] = state.steps.map((s) =>
|
||||
s.id === stepId
|
||||
? {
|
||||
...s,
|
||||
actions: removeActionFromTree(s.actions, actionId),
|
||||
}
|
||||
: s,
|
||||
);
|
||||
const dirty = new Set<string>(state.dirtyEntities);
|
||||
dirty.add(actionId);
|
||||
dirty.add(stepId);
|
||||
return {
|
||||
steps: stepsDraft,
|
||||
dirtyEntities: dirty,
|
||||
selectedActionId:
|
||||
state.selectedActionId === actionId
|
||||
? undefined
|
||||
: state.selectedActionId,
|
||||
};
|
||||
}),
|
||||
removeAction: (stepId: string, actionId: string) =>
|
||||
set((state: DesignerState) => {
|
||||
const stepsDraft: ExperimentStep[] = state.steps.map((s) =>
|
||||
s.id === stepId
|
||||
? {
|
||||
...s,
|
||||
actions: removeActionFromTree(s.actions, actionId),
|
||||
}
|
||||
: s,
|
||||
);
|
||||
const dirty = new Set<string>(state.dirtyEntities);
|
||||
dirty.add(actionId);
|
||||
dirty.add(stepId);
|
||||
return {
|
||||
steps: stepsDraft,
|
||||
dirtyEntities: dirty,
|
||||
selectedActionId:
|
||||
state.selectedActionId === actionId
|
||||
? undefined
|
||||
: state.selectedActionId,
|
||||
};
|
||||
}),
|
||||
|
||||
moveAction: (stepId: string, actionId: string, newParentId: string | null, newIndex: number) =>
|
||||
set((state: DesignerState) => {
|
||||
const stepsDraft = state.steps.map((s) => {
|
||||
if (s.id !== stepId) return s;
|
||||
moveAction: (
|
||||
stepId: string,
|
||||
actionId: string,
|
||||
newParentId: string | null,
|
||||
newIndex: number,
|
||||
) =>
|
||||
set((state: DesignerState) => {
|
||||
const stepsDraft = state.steps.map((s) => {
|
||||
if (s.id !== stepId) return s;
|
||||
|
||||
const actionToMove = findActionById(s.actions, actionId);
|
||||
if (!actionToMove) return s;
|
||||
const actionToMove = findActionById(s.actions, actionId);
|
||||
if (!actionToMove) return s;
|
||||
|
||||
const pruned = removeActionFromTree(s.actions, actionId);
|
||||
const inserted = insertActionIntoTree(pruned, actionToMove, newParentId, newIndex);
|
||||
return { ...s, actions: inserted };
|
||||
});
|
||||
return {
|
||||
steps: stepsDraft,
|
||||
dirtyEntities: new Set<string>([...state.dirtyEntities, stepId, actionId]),
|
||||
};
|
||||
}),
|
||||
const pruned = removeActionFromTree(s.actions, actionId);
|
||||
const inserted = insertActionIntoTree(
|
||||
pruned,
|
||||
actionToMove,
|
||||
newParentId,
|
||||
newIndex,
|
||||
);
|
||||
return { ...s, actions: inserted };
|
||||
});
|
||||
return {
|
||||
steps: stepsDraft,
|
||||
dirtyEntities: new Set<string>([
|
||||
...state.dirtyEntities,
|
||||
stepId,
|
||||
actionId,
|
||||
]),
|
||||
};
|
||||
}),
|
||||
|
||||
reorderAction: (stepId: string, from: number, to: number) =>
|
||||
get().moveAction(stepId, get().steps.find(s => s.id === stepId)?.actions[from]?.id!, null, to), // Legacy compat support (only works for root level reorder)
|
||||
reorderAction: (stepId: string, from: number, to: number) =>
|
||||
get().moveAction(
|
||||
stepId,
|
||||
get().steps.find((s) => s.id === stepId)?.actions[from]?.id!,
|
||||
null,
|
||||
to,
|
||||
), // Legacy compat support (only works for root level reorder)
|
||||
|
||||
setInsertionProjection: (projection) => set({ insertionProjection: projection }),
|
||||
setInsertionProjection: (projection) =>
|
||||
set({ insertionProjection: projection }),
|
||||
|
||||
/* -------------------------------- Dirty ---------------------------------- */
|
||||
markDirty: (id: string) =>
|
||||
set((state: DesignerState) => ({
|
||||
dirtyEntities: state.dirtyEntities.has(id)
|
||||
? state.dirtyEntities
|
||||
: new Set<string>([...state.dirtyEntities, id]),
|
||||
})),
|
||||
clearDirty: (id: string) =>
|
||||
set((state: DesignerState) => {
|
||||
if (!state.dirtyEntities.has(id)) return state;
|
||||
const next = new Set(state.dirtyEntities);
|
||||
next.delete(id);
|
||||
return { dirtyEntities: next };
|
||||
}),
|
||||
clearAllDirty: () => set({ dirtyEntities: new Set<string>() }),
|
||||
/* -------------------------------- Dirty ---------------------------------- */
|
||||
markDirty: (id: string) =>
|
||||
set((state: DesignerState) => ({
|
||||
dirtyEntities: state.dirtyEntities.has(id)
|
||||
? state.dirtyEntities
|
||||
: new Set<string>([...state.dirtyEntities, id]),
|
||||
})),
|
||||
clearDirty: (id: string) =>
|
||||
set((state: DesignerState) => {
|
||||
if (!state.dirtyEntities.has(id)) return state;
|
||||
const next = new Set(state.dirtyEntities);
|
||||
next.delete(id);
|
||||
return { dirtyEntities: next };
|
||||
}),
|
||||
clearAllDirty: () => set({ dirtyEntities: new Set<string>() }),
|
||||
|
||||
/* ------------------------------- Hashing --------------------------------- */
|
||||
recomputeHash: async (options?: { forceFull?: boolean }) => {
|
||||
const { steps, incremental } = get();
|
||||
if (steps.length === 0) {
|
||||
set({ currentDesignHash: undefined });
|
||||
return null;
|
||||
}
|
||||
set({ busyHashing: true });
|
||||
try {
|
||||
const result = await computeIncrementalDesignHash(
|
||||
steps,
|
||||
options?.forceFull ? undefined : incremental,
|
||||
);
|
||||
set({
|
||||
currentDesignHash: result.designHash,
|
||||
incremental: {
|
||||
actionHashes: result.actionHashes,
|
||||
stepHashes: result.stepHashes,
|
||||
},
|
||||
});
|
||||
return result;
|
||||
} finally {
|
||||
set({ busyHashing: false });
|
||||
}
|
||||
},
|
||||
|
||||
setPersistedHash: (hash: string) => set({ lastPersistedHash: hash }),
|
||||
setValidatedHash: (hash: string) => set({ lastValidatedHash: hash }),
|
||||
|
||||
/* ----------------------------- Validation -------------------------------- */
|
||||
setValidationIssues: (entityId: string, issues: ValidationIssue[]) =>
|
||||
set((state: DesignerState) => ({
|
||||
validationIssues: {
|
||||
...state.validationIssues,
|
||||
[entityId]: issues,
|
||||
},
|
||||
})),
|
||||
clearValidationIssues: (entityId: string) =>
|
||||
set((state: DesignerState) => {
|
||||
if (!state.validationIssues[entityId]) return state;
|
||||
const next = { ...state.validationIssues };
|
||||
delete next[entityId];
|
||||
return { validationIssues: next };
|
||||
}),
|
||||
clearAllValidationIssues: () => set({ validationIssues: {} }),
|
||||
|
||||
/* ------------------------- Action Signature Drift ------------------------ */
|
||||
setActionSignature: (actionId: string, signature: string) =>
|
||||
set((state: DesignerState) => {
|
||||
const index = new Map(state.actionSignatureIndex);
|
||||
index.set(actionId, signature);
|
||||
return { actionSignatureIndex: index };
|
||||
}),
|
||||
detectActionSignatureDrift: (
|
||||
action: ExperimentAction,
|
||||
latestSignature: string,
|
||||
) =>
|
||||
set((state: DesignerState) => {
|
||||
const current = state.actionSignatureIndex.get(action.id);
|
||||
if (!current) {
|
||||
const idx = new Map(state.actionSignatureIndex);
|
||||
idx.set(action.id, latestSignature);
|
||||
return { actionSignatureIndex: idx };
|
||||
/* ------------------------------- Hashing --------------------------------- */
|
||||
recomputeHash: async (options?: { forceFull?: boolean }) => {
|
||||
const { steps, incremental } = get();
|
||||
if (steps.length === 0) {
|
||||
set({ currentDesignHash: undefined });
|
||||
return null;
|
||||
}
|
||||
if (current === latestSignature) return {};
|
||||
const drift = new Set(state.actionSignatureDrift);
|
||||
drift.add(action.id);
|
||||
return { actionSignatureDrift: drift };
|
||||
}),
|
||||
clearActionSignatureDrift: (actionId: string) =>
|
||||
set((state: DesignerState) => {
|
||||
if (!state.actionSignatureDrift.has(actionId)) return state;
|
||||
const next = new Set(state.actionSignatureDrift);
|
||||
next.delete(actionId);
|
||||
return { actionSignatureDrift: next };
|
||||
}),
|
||||
set({ busyHashing: true });
|
||||
try {
|
||||
const result = await computeIncrementalDesignHash(
|
||||
steps,
|
||||
options?.forceFull ? undefined : incremental,
|
||||
);
|
||||
set({
|
||||
currentDesignHash: result.designHash,
|
||||
incremental: {
|
||||
actionHashes: result.actionHashes,
|
||||
stepHashes: result.stepHashes,
|
||||
},
|
||||
});
|
||||
return result;
|
||||
} finally {
|
||||
set({ busyHashing: false });
|
||||
}
|
||||
},
|
||||
|
||||
/* ------------------------------- Save Flow -------------------------------- */
|
||||
setPendingSave: (pending: boolean) => set({ pendingSave: pending }),
|
||||
recordConflict: (serverHash: string, localHash: string) =>
|
||||
set({
|
||||
conflict: { serverHash, localHash, at: new Date() },
|
||||
pendingSave: false,
|
||||
}),
|
||||
clearConflict: () => set({ conflict: undefined }),
|
||||
setVersionStrategy: (strategy: VersionStrategy) =>
|
||||
set({ versionStrategy: strategy }),
|
||||
setAutoSaveEnabled: (enabled: boolean) => set({ autoSaveEnabled: enabled }),
|
||||
setPersistedHash: (hash: string) => set({ lastPersistedHash: hash }),
|
||||
setValidatedHash: (hash: string) => set({ lastValidatedHash: hash }),
|
||||
|
||||
/* ------------------------------ Server Sync ------------------------------ */
|
||||
applyServerSync: (payload: {
|
||||
steps: ExperimentStep[];
|
||||
persistedHash?: string;
|
||||
validatedHash?: string;
|
||||
}) =>
|
||||
set((state: DesignerState) => {
|
||||
const syncedSteps = reindexSteps(cloneSteps(payload.steps));
|
||||
const dirty = new Set<string>();
|
||||
return {
|
||||
steps: syncedSteps,
|
||||
lastPersistedHash: payload.persistedHash ?? state.lastPersistedHash,
|
||||
lastValidatedHash: payload.validatedHash ?? state.lastValidatedHash,
|
||||
dirtyEntities: dirty,
|
||||
conflict: undefined,
|
||||
};
|
||||
}),
|
||||
}));
|
||||
/* ----------------------------- Validation -------------------------------- */
|
||||
setValidationIssues: (entityId: string, issues: ValidationIssue[]) =>
|
||||
set((state: DesignerState) => ({
|
||||
validationIssues: {
|
||||
...state.validationIssues,
|
||||
[entityId]: issues,
|
||||
},
|
||||
})),
|
||||
clearValidationIssues: (entityId: string) =>
|
||||
set((state: DesignerState) => {
|
||||
if (!state.validationIssues[entityId]) return state;
|
||||
const next = { ...state.validationIssues };
|
||||
delete next[entityId];
|
||||
return { validationIssues: next };
|
||||
}),
|
||||
clearAllValidationIssues: () => set({ validationIssues: {} }),
|
||||
|
||||
/* ------------------------- Action Signature Drift ------------------------ */
|
||||
setActionSignature: (actionId: string, signature: string) =>
|
||||
set((state: DesignerState) => {
|
||||
const index = new Map(state.actionSignatureIndex);
|
||||
index.set(actionId, signature);
|
||||
return { actionSignatureIndex: index };
|
||||
}),
|
||||
detectActionSignatureDrift: (
|
||||
action: ExperimentAction,
|
||||
latestSignature: string,
|
||||
) =>
|
||||
set((state: DesignerState) => {
|
||||
const current = state.actionSignatureIndex.get(action.id);
|
||||
if (!current) {
|
||||
const idx = new Map(state.actionSignatureIndex);
|
||||
idx.set(action.id, latestSignature);
|
||||
return { actionSignatureIndex: idx };
|
||||
}
|
||||
if (current === latestSignature) return {};
|
||||
const drift = new Set(state.actionSignatureDrift);
|
||||
drift.add(action.id);
|
||||
return { actionSignatureDrift: drift };
|
||||
}),
|
||||
clearActionSignatureDrift: (actionId: string) =>
|
||||
set((state: DesignerState) => {
|
||||
if (!state.actionSignatureDrift.has(actionId)) return state;
|
||||
const next = new Set(state.actionSignatureDrift);
|
||||
next.delete(actionId);
|
||||
return { actionSignatureDrift: next };
|
||||
}),
|
||||
|
||||
/* ------------------------------- Save Flow -------------------------------- */
|
||||
setPendingSave: (pending: boolean) => set({ pendingSave: pending }),
|
||||
recordConflict: (serverHash: string, localHash: string) =>
|
||||
set({
|
||||
conflict: { serverHash, localHash, at: new Date() },
|
||||
pendingSave: false,
|
||||
}),
|
||||
clearConflict: () => set({ conflict: undefined }),
|
||||
setVersionStrategy: (strategy: VersionStrategy) =>
|
||||
set({ versionStrategy: strategy }),
|
||||
setAutoSaveEnabled: (enabled: boolean) => set({ autoSaveEnabled: enabled }),
|
||||
|
||||
/* ------------------------------ Server Sync ------------------------------ */
|
||||
applyServerSync: (payload: {
|
||||
steps: ExperimentStep[];
|
||||
persistedHash?: string;
|
||||
validatedHash?: string;
|
||||
}) =>
|
||||
set((state: DesignerState) => {
|
||||
const syncedSteps = reindexSteps(cloneSteps(payload.steps));
|
||||
const dirty = new Set<string>();
|
||||
return {
|
||||
steps: syncedSteps,
|
||||
lastPersistedHash: payload.persistedHash ?? state.lastPersistedHash,
|
||||
lastValidatedHash: payload.validatedHash ?? state.lastValidatedHash,
|
||||
dirtyEntities: dirty,
|
||||
conflict: undefined,
|
||||
};
|
||||
}),
|
||||
}));
|
||||
|
||||
export const useDesignerStore = createDesignerStore({});
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Convenience Selectors */
|
||||
|
||||
@@ -49,12 +49,9 @@ export interface ValidationResult {
|
||||
/* Validation Rule Sets */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
const VALID_STEP_TYPES: StepType[] = [
|
||||
"sequential",
|
||||
"parallel",
|
||||
"conditional",
|
||||
"loop",
|
||||
];
|
||||
// Steps should ALWAYS execute sequentially
|
||||
// Parallel/conditional/loop execution happens at the ACTION level, not step level
|
||||
const VALID_STEP_TYPES: StepType[] = ["sequential", "conditional"];
|
||||
const VALID_TRIGGER_TYPES: TriggerType[] = [
|
||||
"trial_start",
|
||||
"participant_action",
|
||||
@@ -144,48 +141,8 @@ export function validateStructural(
|
||||
});
|
||||
}
|
||||
|
||||
// Conditional step must have conditions
|
||||
if (step.type === "conditional") {
|
||||
const conditionKeys = Object.keys(step.trigger.conditions || {});
|
||||
if (conditionKeys.length === 0) {
|
||||
issues.push({
|
||||
severity: "error",
|
||||
message: "Conditional step must define at least one condition",
|
||||
category: "structural",
|
||||
field: "trigger.conditions",
|
||||
stepId,
|
||||
suggestion: "Add conditions to define when this step should execute",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Loop step should have termination conditions
|
||||
if (step.type === "loop") {
|
||||
const conditionKeys = Object.keys(step.trigger.conditions || {});
|
||||
if (conditionKeys.length === 0) {
|
||||
issues.push({
|
||||
severity: "warning",
|
||||
message:
|
||||
"Loop step should define termination conditions to prevent infinite loops",
|
||||
category: "structural",
|
||||
field: "trigger.conditions",
|
||||
stepId,
|
||||
suggestion: "Add conditions to control when the loop should exit",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Parallel step should have multiple actions
|
||||
if (step.type === "parallel" && step.actions.length < 2) {
|
||||
issues.push({
|
||||
severity: "warning",
|
||||
message:
|
||||
"Parallel step has fewer than 2 actions - consider using sequential type",
|
||||
category: "structural",
|
||||
stepId,
|
||||
suggestion: "Add more actions or change to sequential execution",
|
||||
});
|
||||
}
|
||||
// All steps must be sequential type (parallel/conditional/loop removed)
|
||||
// Control flow and parallelism should be implemented at the ACTION level
|
||||
|
||||
// Action-level structural validation
|
||||
step.actions.forEach((action) => {
|
||||
@@ -234,6 +191,7 @@ export function validateStructural(
|
||||
}
|
||||
|
||||
// Plugin actions need plugin metadata
|
||||
/* VALIDATION DISABLED BY USER REQUEST
|
||||
if (action.source?.kind === "plugin") {
|
||||
if (!action.source.pluginId) {
|
||||
issues.push({
|
||||
@@ -258,6 +216,7 @@ export function validateStructural(
|
||||
});
|
||||
}
|
||||
}
|
||||
*/
|
||||
|
||||
// Execution descriptor validation
|
||||
if (!action.execution?.transport) {
|
||||
@@ -430,6 +389,34 @@ export function validateParameters(
|
||||
}
|
||||
break;
|
||||
|
||||
case "array":
|
||||
if (!Array.isArray(value)) {
|
||||
issues.push({
|
||||
severity: "error",
|
||||
message: `Parameter '${paramDef.name}' must be a list/array`,
|
||||
category: "parameter",
|
||||
field,
|
||||
stepId,
|
||||
actionId,
|
||||
suggestion: "Enter a list of values",
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case "json":
|
||||
if (typeof value !== "object" || value === null) {
|
||||
issues.push({
|
||||
severity: "error",
|
||||
message: `Parameter '${paramDef.name}' must be a valid object`,
|
||||
category: "parameter",
|
||||
field,
|
||||
stepId,
|
||||
actionId,
|
||||
suggestion: "Enter a valid JSON object",
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
// Unknown parameter type
|
||||
issues.push({
|
||||
@@ -532,10 +519,9 @@ export function validateSemantic(
|
||||
// Check for empty steps
|
||||
steps.forEach((step) => {
|
||||
if (step.actions.length === 0) {
|
||||
const severity = step.type === "parallel" ? "error" : "warning";
|
||||
issues.push({
|
||||
severity,
|
||||
message: `${step.type} step has no actions`,
|
||||
severity: "warning",
|
||||
message: "Step has no actions",
|
||||
category: "semantic",
|
||||
stepId: step.id,
|
||||
suggestion: "Add actions to this step or remove it",
|
||||
@@ -635,25 +621,9 @@ export function validateExecution(
|
||||
): ValidationIssue[] {
|
||||
const issues: ValidationIssue[] = [];
|
||||
|
||||
// Check for unreachable steps (basic heuristic)
|
||||
if (steps.length > 1) {
|
||||
const trialStartSteps = steps.filter(
|
||||
(s) => s.trigger.type === "trial_start",
|
||||
);
|
||||
if (trialStartSteps.length > 1) {
|
||||
trialStartSteps.slice(1).forEach((step) => {
|
||||
issues.push({
|
||||
severity: "info",
|
||||
message:
|
||||
"This step will start immediately at trial start. For sequential flow, use 'Previous Step' trigger.",
|
||||
category: "execution",
|
||||
field: "trigger.type",
|
||||
stepId: step.id,
|
||||
suggestion: "Change trigger to 'Previous Step' if this step should follow the previous one",
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
// Note: Trigger validation removed - convertDatabaseToSteps() automatically assigns
|
||||
// correct triggers (trial_start for first step, previous_step for others) based on orderIndex.
|
||||
// Manual trigger configuration is intentional for advanced workflows.
|
||||
|
||||
// Check for missing robot dependencies
|
||||
const robotActions = steps.flatMap((step) =>
|
||||
|
||||
343
src/components/experiments/designer/tabs/SettingsTab.tsx
Normal file
343
src/components/experiments/designer/tabs/SettingsTab.tsx
Normal file
@@ -0,0 +1,343 @@
|
||||
"use client";
|
||||
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import * as z from "zod";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "~/components/ui/form";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { Textarea } from "~/components/ui/textarea";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "~/components/ui/select";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "~/components/ui/card";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { toast } from "sonner";
|
||||
import { api } from "~/trpc/react";
|
||||
import { experimentStatusEnum } from "~/server/db/schema";
|
||||
import { Save, ExternalLink } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
const formSchema = z.object({
|
||||
name: z.string().min(2, {
|
||||
message: "Name must be at least 2 characters.",
|
||||
}),
|
||||
description: z.string().optional(),
|
||||
status: z.enum(experimentStatusEnum.enumValues),
|
||||
});
|
||||
|
||||
interface SettingsTabProps {
|
||||
experiment: {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
status: string;
|
||||
studyId: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
study: {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
};
|
||||
designStats?: {
|
||||
stepCount: number;
|
||||
actionCount: number;
|
||||
};
|
||||
}
|
||||
|
||||
export function SettingsTab({ experiment, designStats }: SettingsTabProps) {
|
||||
const utils = api.useUtils();
|
||||
const updateExperiment = api.experiments.update.useMutation({
|
||||
onSuccess: async () => {
|
||||
toast.success("Experiment settings saved successfully");
|
||||
// Invalidate experiments list to refresh data
|
||||
await utils.experiments.list.invalidate();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(`Error saving settings: ${error.message}`);
|
||||
},
|
||||
});
|
||||
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
name: experiment.name,
|
||||
description: experiment.description ?? "",
|
||||
status: experiment.status as z.infer<typeof formSchema>["status"],
|
||||
},
|
||||
});
|
||||
|
||||
function onSubmit(values: z.infer<typeof formSchema>) {
|
||||
updateExperiment.mutate({
|
||||
id: experiment.id,
|
||||
name: values.name,
|
||||
description: values.description,
|
||||
status: values.status,
|
||||
});
|
||||
}
|
||||
|
||||
const isDirty = form.formState.isDirty;
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-y-auto p-6">
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight">
|
||||
Experiment Settings
|
||||
</h2>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
Configure experiment metadata and status
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-3">
|
||||
{/* Left Column: Basic Information (Spans 2) */}
|
||||
<div className="space-y-6 md:col-span-2">
|
||||
<Card className="h-full">
|
||||
<CardHeader>
|
||||
<CardTitle>Basic Information</CardTitle>
|
||||
<CardDescription>
|
||||
The name and description help identify this experiment
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Experiment name" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
A clear, descriptive name for your experiment
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Description</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="Describe your experiment goals, methodology, and expected outcomes..."
|
||||
className="min-h-[300px] resize-none"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Detailed description of the experiment purpose and
|
||||
design
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Right Column: Status & Metadata (Spans 1) */}
|
||||
<div className="space-y-6">
|
||||
{/* Status Card */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Status</CardTitle>
|
||||
<CardDescription>Track lifecycle stage</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="status"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Current Status</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a status" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="draft">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="secondary">Draft</Badge>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
WIP
|
||||
</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="testing">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline">Testing</Badge>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
Validation
|
||||
</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="ready">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge
|
||||
variant="default"
|
||||
className="bg-green-500"
|
||||
>
|
||||
Ready
|
||||
</Badge>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
Live
|
||||
</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="deprecated">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="destructive">
|
||||
Deprecated
|
||||
</Badge>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
Retired
|
||||
</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Metadata Card */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Metadata</CardTitle>
|
||||
<CardDescription>Read-only information</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<p className="text-muted-foreground mb-1 text-xs font-medium">
|
||||
Study
|
||||
</p>
|
||||
<Link
|
||||
href={`/studies/${experiment.study.id}`}
|
||||
className="text-primary flex items-center gap-1 truncate text-sm hover:underline"
|
||||
>
|
||||
{experiment.study.name}
|
||||
<ExternalLink className="h-3 w-3 flex-shrink-0" />
|
||||
</Link>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground mb-1 text-xs font-medium">
|
||||
Experiment ID
|
||||
</p>
|
||||
<p className="bg-muted rounded p-1 font-mono text-xs select-all">
|
||||
{experiment.id.split("-")[0]}...
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<p className="text-muted-foreground mb-1 text-xs font-medium">
|
||||
Created
|
||||
</p>
|
||||
<p className="text-xs">
|
||||
{new Date(
|
||||
experiment.createdAt,
|
||||
).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground mb-1 text-xs font-medium">
|
||||
Updated
|
||||
</p>
|
||||
<p className="text-xs">
|
||||
{new Date(
|
||||
experiment.updatedAt,
|
||||
).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{designStats && (
|
||||
<div className="border-t pt-4">
|
||||
<p className="text-muted-foreground mb-2 text-xs font-medium">
|
||||
Statistics
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<div className="bg-muted/50 flex items-center gap-1.5 rounded px-2 py-1 text-xs">
|
||||
<span className="font-semibold">
|
||||
{designStats.stepCount}
|
||||
</span>
|
||||
<span className="text-muted-foreground">Steps</span>
|
||||
</div>
|
||||
<div className="bg-muted/50 flex items-center gap-1.5 rounded px-2 py-1 text-xs">
|
||||
<span className="font-semibold">
|
||||
{designStats.actionCount}
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
Actions
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Save Button */}
|
||||
<div className="flex justify-end border-t pt-4">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={updateExperiment.isPending || !isDirty}
|
||||
className="min-w-[120px]"
|
||||
>
|
||||
{updateExperiment.isPending ? (
|
||||
"Saving..."
|
||||
) : (
|
||||
<>
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
Save Changes
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
Edit,
|
||||
Eye,
|
||||
FlaskConical,
|
||||
LayoutTemplate,
|
||||
MoreHorizontal,
|
||||
Play,
|
||||
TestTube,
|
||||
@@ -27,6 +28,7 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "~/components/ui/dropdown-menu";
|
||||
import { api } from "~/trpc/react";
|
||||
|
||||
export type Experiment = {
|
||||
id: string;
|
||||
@@ -78,92 +80,55 @@ const statusConfig = {
|
||||
};
|
||||
|
||||
function ExperimentActionsCell({ experiment }: { experiment: Experiment }) {
|
||||
const handleDelete = async () => {
|
||||
const utils = api.useUtils();
|
||||
const deleteMutation = api.experiments.delete.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Experiment deleted successfully");
|
||||
utils.experiments.list.invalidate();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(`Failed to delete experiment: ${error.message}`);
|
||||
},
|
||||
});
|
||||
|
||||
const handleDelete = () => {
|
||||
if (
|
||||
window.confirm(`Are you sure you want to delete "${experiment.name}"?`)
|
||||
) {
|
||||
try {
|
||||
// TODO: Implement delete experiment mutation
|
||||
toast.success("Experiment deleted successfully");
|
||||
} catch {
|
||||
toast.error("Failed to delete experiment");
|
||||
}
|
||||
deleteMutation.mutate({ id: experiment.id });
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyId = () => {
|
||||
void navigator.clipboard.writeText(experiment.id);
|
||||
toast.success("Experiment ID copied to clipboard");
|
||||
};
|
||||
|
||||
const handleStartTrial = () => {
|
||||
// Navigate to new trial creation with this experiment pre-selected
|
||||
window.location.href = `/studies/${experiment.studyId}/trials/new?experimentId=${experiment.id}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||
<span className="sr-only">Open menu</span>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
asChild
|
||||
className="text-muted-foreground hover:text-primary h-8 w-8"
|
||||
title="Open Designer"
|
||||
>
|
||||
<Link
|
||||
href={`/studies/${experiment.studyId}/experiments/${experiment.id}/designer`}
|
||||
>
|
||||
<LayoutTemplate className="h-4 w-4" />
|
||||
<span className="sr-only">Design</span>
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
{experiment.canDelete && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleDelete}
|
||||
className="text-muted-foreground hover:text-destructive h-8 w-8"
|
||||
title="Delete Experiment"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
<span className="sr-only">Delete</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/studies/${experiment.studyId}/experiments/${experiment.id}`}>
|
||||
<Eye className="mr-2 h-4 w-4" />
|
||||
View Details
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/studies/${experiment.studyId}/experiments/${experiment.id}/designer`}>
|
||||
<FlaskConical className="mr-2 h-4 w-4" />
|
||||
Open Designer
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
|
||||
{experiment.canEdit && (
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/studies/${experiment.studyId}/experiments/${experiment.id}/edit`}>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
Edit Experiment
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
{experiment.status === "ready" && (
|
||||
<DropdownMenuItem onClick={handleStartTrial}>
|
||||
<Play className="mr-2 h-4 w-4" />
|
||||
Start New Trial
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
<DropdownMenuItem onClick={handleCopyId}>
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
Copy Experiment ID
|
||||
</DropdownMenuItem>
|
||||
|
||||
{experiment.canDelete && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={handleDelete}
|
||||
className="text-red-600 focus:text-red-600"
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Delete Experiment
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -315,20 +280,7 @@ export const experimentsColumns: ColumnDef<Experiment>[] = [
|
||||
},
|
||||
enableSorting: false,
|
||||
},
|
||||
{
|
||||
accessorKey: "createdAt",
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Created" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const date = row.getValue("createdAt");
|
||||
return (
|
||||
<div className="text-sm whitespace-nowrap">
|
||||
{formatDistanceToNow(date as Date, { addSuffix: true })}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
accessorKey: "updatedAt",
|
||||
header: ({ column }) => (
|
||||
|
||||
@@ -1,240 +1,398 @@
|
||||
"use client";
|
||||
|
||||
import React, { createContext, useContext, useEffect, useRef } from "react";
|
||||
import React, {
|
||||
createContext,
|
||||
useContext,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { driver, type Driver } from "driver.js";
|
||||
import "driver.js/dist/driver.css";
|
||||
import { useTheme } from "next-themes";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import Cookies from "js-cookie";
|
||||
|
||||
type TourType = "dashboard" | "study_creation" | "designer" | "wizard" | "full_platform";
|
||||
type TourType =
|
||||
| "dashboard"
|
||||
| "study_creation"
|
||||
| "participant_creation"
|
||||
| "designer"
|
||||
| "wizard"
|
||||
| "analytics"
|
||||
| "full_platform";
|
||||
|
||||
interface TourContextType {
|
||||
startTour: (tour: TourType) => void;
|
||||
startTour: (tour: TourType) => void;
|
||||
isTourActive: boolean;
|
||||
}
|
||||
|
||||
const TourContext = createContext<TourContextType | undefined>(undefined);
|
||||
|
||||
export function useTour() {
|
||||
const context = useContext(TourContext);
|
||||
if (!context) {
|
||||
throw new Error("useTour must be used within a TourProvider");
|
||||
}
|
||||
return context;
|
||||
const context = useContext(TourContext);
|
||||
if (!context) {
|
||||
throw new Error("useTour must be used within a TourProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
export function TourProvider({ children }: { children: React.ReactNode }) {
|
||||
const driverObj = useRef<Driver | null>(null);
|
||||
const { theme } = useTheme();
|
||||
const pathname = usePathname();
|
||||
const driverObj = useRef<Driver | null>(null);
|
||||
const [isTourActive, setIsTourActive] = useState(false);
|
||||
const { theme } = useTheme();
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
|
||||
// --- Multi-page Tour Logic ---
|
||||
useEffect(() => {
|
||||
// Check if we are in "Full Platform" mode (Local Storage OR Cookie)
|
||||
const localMode = localStorage.getItem("hristudio_tour_mode");
|
||||
const cookieMode = Cookies.get("hristudio_tour_mode");
|
||||
// --- Multi-page Tour Logic ---
|
||||
useEffect(() => {
|
||||
// Check if we are in "Full Platform" mode (Local Storage OR Cookie)
|
||||
const localMode = localStorage.getItem("hristudio_tour_mode");
|
||||
const cookieMode = Cookies.get("hristudio_tour_mode");
|
||||
|
||||
const tourMode = localMode === "full" || cookieMode === "full" ? "full" : null;
|
||||
const tourMode =
|
||||
localMode === "full" || cookieMode === "full" ? "full" : null;
|
||||
|
||||
if (tourMode === "full") {
|
||||
// Re-sync local storage if missing but cookie present
|
||||
if (localMode !== "full") localStorage.setItem("hristudio_tour_mode", "full");
|
||||
if (tourMode === "full") {
|
||||
// Re-sync local storage if missing but cookie present
|
||||
if (localMode !== "full")
|
||||
localStorage.setItem("hristudio_tour_mode", "full");
|
||||
|
||||
// Small delay to ensure DOM is ready
|
||||
const timer = setTimeout(() => {
|
||||
if (pathname === "/dashboard") {
|
||||
runTourSegment("dashboard");
|
||||
} else if (pathname.includes("/studies/new")) {
|
||||
runTourSegment("study_creation");
|
||||
} else if (pathname.includes("/designer")) {
|
||||
runTourSegment("designer");
|
||||
} else if (pathname.includes("/wizard")) {
|
||||
runTourSegment("wizard");
|
||||
}
|
||||
}, 500); // Reduced delay for snappier feel, but still safe for render
|
||||
return () => clearTimeout(timer);
|
||||
// Small delay to ensure DOM is ready
|
||||
const timer = setTimeout(() => {
|
||||
if (pathname === "/dashboard") {
|
||||
runTourSegment("dashboard");
|
||||
} else if (pathname.includes("/studies/new")) {
|
||||
runTourSegment("study_creation");
|
||||
} else if (pathname.includes("/participants/new")) {
|
||||
runTourSegment("participant_creation");
|
||||
} else if (pathname.includes("/designer")) {
|
||||
runTourSegment("designer");
|
||||
} else if (pathname.includes("/wizard")) {
|
||||
runTourSegment("wizard");
|
||||
}
|
||||
}, [pathname]);
|
||||
}, 500); // Reduced delay for snappier feel, but still safe for render
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [pathname]);
|
||||
|
||||
const runTourSegment = (segment: "dashboard" | "study_creation" | "designer" | "wizard") => {
|
||||
const isDark = theme === "dark";
|
||||
// We add a specific class to handle dark/light overrides reliably
|
||||
const themeClass = isDark ? "driverjs-theme-dark" : "driverjs-theme-light";
|
||||
|
||||
let steps: any[] = [];
|
||||
|
||||
if (segment === "dashboard") {
|
||||
steps = [
|
||||
{
|
||||
element: "#dashboard-header",
|
||||
popover: {
|
||||
title: "Overview",
|
||||
description: "Welcome to HRIStudio. This dashboard gives you a high-level view of your research activities, active studies, and data collection progress.",
|
||||
side: "bottom",
|
||||
align: "start",
|
||||
},
|
||||
},
|
||||
{
|
||||
element: "#tour-sidebar-overview",
|
||||
popover: {
|
||||
title: "Navigation: Overview",
|
||||
description: "Quickly return to this main dashboard from anywhere in the application.",
|
||||
side: "right",
|
||||
},
|
||||
},
|
||||
{
|
||||
element: "#tour-sidebar-studies",
|
||||
popover: {
|
||||
title: "Navigation: Studies",
|
||||
description: "Manage all your research studies, IRBs, and team permissions in one place.",
|
||||
side: "right",
|
||||
},
|
||||
},
|
||||
{
|
||||
element: "#tour-sidebar-study-selector",
|
||||
popover: {
|
||||
title: "Active Study Selector",
|
||||
description: "Switch between different studies here. Selecting a study unlocks study-specific tools like the Experiment Designer and Data Analytics.",
|
||||
side: "right",
|
||||
},
|
||||
},
|
||||
{
|
||||
element: "#tour-new-study",
|
||||
popover: {
|
||||
title: "Create a New Study",
|
||||
description: "Ready to start? Click here to initialize a new research project and define your protocol.",
|
||||
side: "right",
|
||||
},
|
||||
},
|
||||
];
|
||||
} else if (segment === "study_creation") {
|
||||
steps = [
|
||||
{
|
||||
element: "#tour-study-name",
|
||||
popover: {
|
||||
title: "Naming Your Study",
|
||||
description: "Choose a concise, descriptive name. This will properly namespace your data, logs, and robot configurations.",
|
||||
side: "right",
|
||||
}
|
||||
},
|
||||
{
|
||||
element: "#tour-study-description",
|
||||
popover: {
|
||||
title: "Research Protocol",
|
||||
description: "Add a short description of your methodology or research questions. This helps team members understand the context.",
|
||||
side: "right",
|
||||
}
|
||||
},
|
||||
{
|
||||
element: "#tour-study-submit",
|
||||
popover: {
|
||||
title: "Initialize Project",
|
||||
description: "Create the study to access the full suite of tools: Experiment Designer, Wizard Interface, and Analytics.",
|
||||
side: "top",
|
||||
}
|
||||
}
|
||||
];
|
||||
} else if (segment === "designer") {
|
||||
steps = [
|
||||
{
|
||||
element: "#tour-designer-blocks",
|
||||
popover: {
|
||||
title: "Action Library",
|
||||
description: "Drag and drop robot behaviors (Speech, Gestures, Movement) onto the canvas. Includes both core actions and those from installed plugins.",
|
||||
side: "right",
|
||||
},
|
||||
},
|
||||
{
|
||||
element: "#tour-designer-canvas",
|
||||
popover: {
|
||||
title: "Visual Flow Canvas",
|
||||
description: "Design your experiment logic here. Connect blocks to create sequences, branches, and loops for the robot to execute.",
|
||||
side: "top",
|
||||
},
|
||||
},
|
||||
{
|
||||
element: "#tour-designer-properties",
|
||||
popover: {
|
||||
title: "Properties Panel",
|
||||
description: "Select any block to configure its parameters—like speech text, speed, volume, or timeout durations.",
|
||||
side: "left",
|
||||
},
|
||||
},
|
||||
];
|
||||
} else if (segment === "wizard") {
|
||||
steps = [
|
||||
{
|
||||
element: "#tour-wizard-controls",
|
||||
popover: {
|
||||
title: "Wizard Dashboard",
|
||||
description: "The command center for running trials. Manually trigger robot actions or override autonomous behaviors in real-time.",
|
||||
side: "right",
|
||||
},
|
||||
},
|
||||
{
|
||||
element: "#tour-wizard-timeline",
|
||||
popover: {
|
||||
title: "Live Timeline",
|
||||
description: "See exactly what the robot is doing, what's coming next, and a history of all events in the current session.",
|
||||
side: "top",
|
||||
},
|
||||
},
|
||||
{
|
||||
element: "#tour-wizard-robot-status",
|
||||
popover: {
|
||||
title: "System Health",
|
||||
description: "Monitor critical telemetry: battery levels, joint temperatures, and network latency to ensure safety.",
|
||||
side: "left",
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
driverObj.current = driver({
|
||||
showProgress: true,
|
||||
animate: true,
|
||||
allowClose: true,
|
||||
steps: steps.map((step) => ({
|
||||
...step,
|
||||
popover: {
|
||||
...step.popover,
|
||||
popoverClass: `driver-popover-override ${themeClass}`,
|
||||
},
|
||||
})),
|
||||
onDestroyed: () => {
|
||||
// Persistence handled by localStorage state
|
||||
}
|
||||
});
|
||||
|
||||
driverObj.current.drive();
|
||||
useEffect(() => {
|
||||
// Listen for custom tour triggers (from components without context access)
|
||||
const handleTourTrigger = (e: Event) => {
|
||||
const detail = (e as CustomEvent).detail as TourType;
|
||||
if (detail) {
|
||||
startTour(detail);
|
||||
}
|
||||
};
|
||||
|
||||
const startTour = (tour: TourType) => {
|
||||
if (tour === "full_platform") {
|
||||
localStorage.setItem("hristudio_tour_mode", "full");
|
||||
Cookies.set("hristudio_tour_mode", "full", { expires: 7 }); // 7 days persistence
|
||||
document.addEventListener("hristudio-start-tour", handleTourTrigger);
|
||||
return () =>
|
||||
document.removeEventListener("hristudio-start-tour", handleTourTrigger);
|
||||
}, []);
|
||||
|
||||
// Trigger current page immediately
|
||||
if (pathname === "/dashboard") runTourSegment("dashboard");
|
||||
else if (pathname.includes("/studies/new")) runTourSegment("study_creation");
|
||||
else if (pathname.includes("/designer")) runTourSegment("designer");
|
||||
else if (pathname.includes("/wizard")) runTourSegment("wizard");
|
||||
else runTourSegment("dashboard"); // Fallback
|
||||
} else {
|
||||
localStorage.setItem("hristudio_tour_mode", "manual");
|
||||
Cookies.remove("hristudio_tour_mode");
|
||||
const runTourSegment = (
|
||||
segment:
|
||||
| "dashboard"
|
||||
| "study_creation"
|
||||
| "participant_creation"
|
||||
| "designer"
|
||||
| "wizard"
|
||||
| "analytics",
|
||||
) => {
|
||||
const isDark = theme === "dark";
|
||||
// We add a specific class to handle dark/light overrides reliably
|
||||
const themeClass = isDark ? "driverjs-theme-dark" : "driverjs-theme-light";
|
||||
|
||||
if (tour === "dashboard") runTourSegment("dashboard");
|
||||
if (tour === "study_creation") runTourSegment("study_creation");
|
||||
if (tour === "designer") runTourSegment("designer");
|
||||
if (tour === "wizard") runTourSegment("wizard");
|
||||
}
|
||||
};
|
||||
let steps: any[] = [];
|
||||
|
||||
return (
|
||||
<TourContext.Provider value={{ startTour }}>
|
||||
{children}
|
||||
<style jsx global>{`
|
||||
if (segment === "dashboard") {
|
||||
steps = [
|
||||
{
|
||||
element: "#dashboard-header",
|
||||
popover: {
|
||||
title: "Overview",
|
||||
description:
|
||||
"Welcome to HRIStudio. This dashboard gives you a high-level view of your research activities, active studies, and data collection progress.",
|
||||
side: "bottom",
|
||||
align: "start",
|
||||
},
|
||||
},
|
||||
{
|
||||
element: "#tour-sidebar-overview",
|
||||
popover: {
|
||||
title: "Navigation: Overview",
|
||||
description:
|
||||
"Quickly return to this main dashboard from anywhere in the application.",
|
||||
side: "right",
|
||||
},
|
||||
},
|
||||
{
|
||||
element: "#tour-sidebar-studies",
|
||||
popover: {
|
||||
title: "Navigation: Studies",
|
||||
description:
|
||||
"Manage all your research studies, IRBs, and team permissions in one place.",
|
||||
side: "right",
|
||||
},
|
||||
},
|
||||
{
|
||||
element: "#tour-sidebar-study-selector",
|
||||
popover: {
|
||||
title: "Active Study Selector",
|
||||
description:
|
||||
"Switch between different studies here. Selecting a study unlocks study-specific tools like the Experiment Designer and Data Analytics.",
|
||||
side: "right",
|
||||
},
|
||||
},
|
||||
{
|
||||
element: "#tour-new-study",
|
||||
popover: {
|
||||
title: "Create a New Study",
|
||||
description:
|
||||
"Ready to start? Click here to initialize a new research project and define your protocol.",
|
||||
side: "right",
|
||||
},
|
||||
},
|
||||
];
|
||||
} else if (segment === "study_creation") {
|
||||
steps = [
|
||||
{
|
||||
element: "#tour-study-name",
|
||||
popover: {
|
||||
title: "Naming Your Study",
|
||||
description:
|
||||
"Choose a concise, descriptive name. This will properly namespace your data, logs, and robot configurations.",
|
||||
side: "right",
|
||||
},
|
||||
},
|
||||
{
|
||||
element: "#tour-study-description",
|
||||
popover: {
|
||||
title: "Research Protocol",
|
||||
description:
|
||||
"Add a short description of your methodology or research questions. This helps team members understand the context.",
|
||||
side: "right",
|
||||
},
|
||||
},
|
||||
{
|
||||
element: "#tour-study-submit",
|
||||
popover: {
|
||||
title: "Initialize Project",
|
||||
description:
|
||||
"Create the study to access the full suite of tools: Experiment Designer, Wizard Interface, and Analytics.",
|
||||
side: "top",
|
||||
},
|
||||
},
|
||||
];
|
||||
} else if (segment === "participant_creation") {
|
||||
steps = [
|
||||
{
|
||||
element: "#tour-participant-code",
|
||||
popover: {
|
||||
title: "Participant ID",
|
||||
description:
|
||||
"Assign a unique code (e.g., P001) to identify this participant while maintaining anonymity.",
|
||||
side: "right",
|
||||
},
|
||||
},
|
||||
{
|
||||
element: "#tour-participant-name",
|
||||
popover: {
|
||||
title: "Name (Optional)",
|
||||
description:
|
||||
"You store their name for internal reference; analytics will use the ID.",
|
||||
side: "right",
|
||||
},
|
||||
},
|
||||
{
|
||||
element: "#tour-participant-study-container",
|
||||
popover: {
|
||||
title: "Study Association",
|
||||
description:
|
||||
"Link this participant to a specific research study to enable data collection.",
|
||||
side: "right",
|
||||
},
|
||||
},
|
||||
{
|
||||
element: "#tour-participant-consent",
|
||||
popover: {
|
||||
title: "Informed Consent",
|
||||
description:
|
||||
"Mandatory check to confirm you have obtained necessary ethical approvals and consent.",
|
||||
side: "top",
|
||||
},
|
||||
},
|
||||
{
|
||||
element: "#tour-participant-submit",
|
||||
popover: {
|
||||
title: "Register",
|
||||
description:
|
||||
"Create the participant record to begin scheduling trials.",
|
||||
side: "top",
|
||||
},
|
||||
},
|
||||
];
|
||||
} else if (segment === "designer") {
|
||||
steps = [
|
||||
{
|
||||
element: "#tour-designer-blocks",
|
||||
popover: {
|
||||
title: "Action Library",
|
||||
description:
|
||||
"Drag and drop robot behaviors (Speech, Gestures, Movement) onto the canvas. Includes both core actions and those from installed plugins.",
|
||||
side: "right",
|
||||
},
|
||||
},
|
||||
{
|
||||
element: "#tour-designer-canvas",
|
||||
popover: {
|
||||
title: "Visual Flow Canvas",
|
||||
description:
|
||||
"Design your experiment logic here. Connect blocks to create sequences, branches, and loops for the robot to execute.",
|
||||
side: "top",
|
||||
},
|
||||
},
|
||||
{
|
||||
element: "#tour-designer-properties",
|
||||
popover: {
|
||||
title: "Properties Panel",
|
||||
description:
|
||||
"Select any block to configure its parameters—like speech text, speed, volume, or timeout durations.",
|
||||
side: "left",
|
||||
},
|
||||
},
|
||||
];
|
||||
} else if (segment === "wizard") {
|
||||
steps = [
|
||||
{
|
||||
element: "#tour-wizard-controls",
|
||||
popover: {
|
||||
title: "Wizard Dashboard",
|
||||
description:
|
||||
"The command center for running trials. Manually trigger robot actions or override autonomous behaviors in real-time.",
|
||||
side: "right",
|
||||
},
|
||||
},
|
||||
{
|
||||
element: "#tour-wizard-timeline",
|
||||
popover: {
|
||||
title: "Live Timeline",
|
||||
description:
|
||||
"See exactly what the robot is doing, what's coming next, and a history of all events in the current session.",
|
||||
side: "top",
|
||||
},
|
||||
},
|
||||
{
|
||||
element: "#tour-wizard-robot-status",
|
||||
popover: {
|
||||
title: "System Health",
|
||||
description:
|
||||
"Monitor critical telemetry: battery levels, joint temperatures, and network latency to ensure safety.",
|
||||
side: "left",
|
||||
},
|
||||
},
|
||||
];
|
||||
} else if (segment === "analytics") {
|
||||
steps = [
|
||||
{
|
||||
element: "#tour-analytics-table",
|
||||
popover: {
|
||||
title: "Study Analytics",
|
||||
description:
|
||||
"View aggregate data across all participant sessions. Sort and filter to identify trends or specific trials.",
|
||||
side: "bottom",
|
||||
},
|
||||
},
|
||||
{
|
||||
element: "#tour-analytics-filter",
|
||||
popover: {
|
||||
title: "Filter Data",
|
||||
description:
|
||||
"Quickly find participants by ID or name using this search bar.",
|
||||
side: "bottom",
|
||||
},
|
||||
},
|
||||
{
|
||||
element: "#tour-trial-metrics",
|
||||
popover: {
|
||||
title: "Trial Metrics",
|
||||
description:
|
||||
"High-level KPIs for the selected trial: Duration, Robot Actions, and Intervention counts.",
|
||||
side: "bottom",
|
||||
},
|
||||
},
|
||||
{
|
||||
element: "#tour-trial-timeline",
|
||||
popover: {
|
||||
title: "Video & Timeline",
|
||||
description:
|
||||
"Watch the trial recording synced with the event timeline. Click any event to jump to that moment in the video.",
|
||||
side: "right",
|
||||
},
|
||||
},
|
||||
{
|
||||
element: "#tour-trial-events",
|
||||
popover: {
|
||||
title: "Event Log",
|
||||
description:
|
||||
"A detailed, searchable log of every system event, robot action, and wizard interaction.",
|
||||
side: "left",
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
driverObj.current = driver({
|
||||
showProgress: true,
|
||||
animate: true,
|
||||
allowClose: true,
|
||||
steps: steps.map((step) => ({
|
||||
...step,
|
||||
popover: {
|
||||
...step.popover,
|
||||
popoverClass: `driver-popover-override ${themeClass}`,
|
||||
},
|
||||
})),
|
||||
onDestroyed: () => {
|
||||
// Persistence handled by localStorage state
|
||||
setIsTourActive(false);
|
||||
},
|
||||
});
|
||||
|
||||
driverObj.current.drive();
|
||||
setIsTourActive(true);
|
||||
};
|
||||
|
||||
const startTour = (tour: TourType) => {
|
||||
if (tour === "full_platform") {
|
||||
localStorage.setItem("hristudio_tour_mode", "full");
|
||||
Cookies.set("hristudio_tour_mode", "full", { expires: 7 }); // 7 days persistence
|
||||
|
||||
if (pathname !== "/dashboard") {
|
||||
router.push("/dashboard");
|
||||
return;
|
||||
}
|
||||
|
||||
// We are already on dashboard, trigger it immediately
|
||||
runTourSegment("dashboard");
|
||||
} else {
|
||||
localStorage.setItem("hristudio_tour_mode", "manual");
|
||||
Cookies.remove("hristudio_tour_mode");
|
||||
|
||||
if (tour === "dashboard") runTourSegment("dashboard");
|
||||
if (tour === "study_creation") runTourSegment("study_creation");
|
||||
if (tour === "participant_creation")
|
||||
runTourSegment("participant_creation");
|
||||
if (tour === "designer") runTourSegment("designer");
|
||||
if (tour === "wizard") runTourSegment("wizard");
|
||||
if (tour === "analytics") runTourSegment("analytics");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<TourContext.Provider value={{ startTour, isTourActive }}>
|
||||
{children}
|
||||
<style jsx global>{`
|
||||
/*
|
||||
SHADCN/UI THEMING OVERRIDES
|
||||
CRITICAL: The global variables in globals.css use OKLCH/HSL values directly or with units.
|
||||
@@ -243,23 +401,23 @@ export function TourProvider({ children }: { children: React.ReactNode }) {
|
||||
*/
|
||||
|
||||
.driver-popover-override {
|
||||
padding: 1.25rem !important;
|
||||
border-radius: var(--radius) !important;
|
||||
box-shadow: var(--shadow-xl) !important;
|
||||
max-width: 420px !important;
|
||||
padding: 1.25rem !important;
|
||||
border-radius: var(--radius) !important;
|
||||
box-shadow: var(--shadow-xl) !important;
|
||||
max-width: 420px !important;
|
||||
|
||||
/* Background & Text - Match Card Aesthetic */
|
||||
background-color: var(--card) !important;
|
||||
color: var(--card-foreground) !important;
|
||||
border: 1px solid var(--border) !important;
|
||||
/* Background & Text - Match Card Aesthetic */
|
||||
background-color: var(--card) !important;
|
||||
color: var(--card-foreground) !important;
|
||||
border: 1px solid var(--border) !important;
|
||||
|
||||
/* Typography */
|
||||
font-family: var(--font-sans) !important;
|
||||
/* Typography */
|
||||
font-family: var(--font-sans) !important;
|
||||
}
|
||||
|
||||
/* Arrow Styling - Critical for transparent/card matching */
|
||||
.driver-popover-override .driver-popover-arrow {
|
||||
border-width: 8px !important;
|
||||
border-width: 8px !important;
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -267,93 +425,105 @@ export function TourProvider({ children }: { children: React.ReactNode }) {
|
||||
Using CSS variables requires a bit of trickery because border-color expects distinct values.
|
||||
We'll target the side classes driver.js adds.
|
||||
*/
|
||||
.driver-popover-override.driverjs-theme-dark .driver-popover-arrow-side-left.driver-popover-arrow {
|
||||
border-left-color: var(--card) !important;
|
||||
.driver-popover-override.driverjs-theme-dark
|
||||
.driver-popover-arrow-side-left.driver-popover-arrow {
|
||||
border-left-color: var(--card) !important;
|
||||
}
|
||||
.driver-popover-override.driverjs-theme-dark .driver-popover-arrow-side-right.driver-popover-arrow {
|
||||
border-right-color: var(--card) !important;
|
||||
.driver-popover-override.driverjs-theme-dark
|
||||
.driver-popover-arrow-side-right.driver-popover-arrow {
|
||||
border-right-color: var(--card) !important;
|
||||
}
|
||||
.driver-popover-override.driverjs-theme-dark .driver-popover-arrow-side-top.driver-popover-arrow {
|
||||
border-top-color: var(--card) !important;
|
||||
.driver-popover-override.driverjs-theme-dark
|
||||
.driver-popover-arrow-side-top.driver-popover-arrow {
|
||||
border-top-color: var(--card) !important;
|
||||
}
|
||||
.driver-popover-override.driverjs-theme-dark .driver-popover-arrow-side-bottom.driver-popover-arrow {
|
||||
border-bottom-color: var(--card) !important;
|
||||
.driver-popover-override.driverjs-theme-dark
|
||||
.driver-popover-arrow-side-bottom.driver-popover-arrow {
|
||||
border-bottom-color: var(--card) !important;
|
||||
}
|
||||
|
||||
/* Light mode fallbacks (using border color for definition, though card bg is usually sufficient) */
|
||||
.driver-popover-override.driverjs-theme-light .driver-popover-arrow-side-left.driver-popover-arrow {
|
||||
border-left-color: var(--card) !important;
|
||||
.driver-popover-override.driverjs-theme-light
|
||||
.driver-popover-arrow-side-left.driver-popover-arrow {
|
||||
border-left-color: var(--card) !important;
|
||||
}
|
||||
.driver-popover-override.driverjs-theme-light .driver-popover-arrow-side-right.driver-popover-arrow {
|
||||
border-right-color: var(--card) !important;
|
||||
.driver-popover-override.driverjs-theme-light
|
||||
.driver-popover-arrow-side-right.driver-popover-arrow {
|
||||
border-right-color: var(--card) !important;
|
||||
}
|
||||
.driver-popover-override.driverjs-theme-light .driver-popover-arrow-side-top.driver-popover-arrow {
|
||||
border-top-color: var(--card) !important;
|
||||
.driver-popover-override.driverjs-theme-light
|
||||
.driver-popover-arrow-side-top.driver-popover-arrow {
|
||||
border-top-color: var(--card) !important;
|
||||
}
|
||||
.driver-popover-override.driverjs-theme-light .driver-popover-arrow-side-bottom.driver-popover-arrow {
|
||||
border-bottom-color: var(--card) !important;
|
||||
.driver-popover-override.driverjs-theme-light
|
||||
.driver-popover-arrow-side-bottom.driver-popover-arrow {
|
||||
border-bottom-color: var(--card) !important;
|
||||
}
|
||||
|
||||
/* Title Styling */
|
||||
.driver-popover-override .driver-popover-title {
|
||||
color: var(--foreground) !important;
|
||||
font-size: 1.125rem !important; /* 18px */
|
||||
font-weight: 600 !important;
|
||||
margin-bottom: 0.5rem !important;
|
||||
letter-spacing: -0.015em !important;
|
||||
font-family: var(--font-sans) !important;
|
||||
color: var(--foreground) !important;
|
||||
font-size: 1.125rem !important; /* 18px */
|
||||
font-weight: 600 !important;
|
||||
margin-bottom: 0.5rem !important;
|
||||
letter-spacing: -0.015em !important;
|
||||
font-family: var(--font-sans) !important;
|
||||
}
|
||||
|
||||
/* Description Styling */
|
||||
.driver-popover-override .driver-popover-description {
|
||||
color: var(--muted-foreground) !important;
|
||||
font-size: 0.875rem !important; /* 14px */
|
||||
line-height: 1.6 !important;
|
||||
font-family: var(--font-sans) !important;
|
||||
color: var(--muted-foreground) !important;
|
||||
font-size: 0.875rem !important; /* 14px */
|
||||
line-height: 1.6 !important;
|
||||
font-family: var(--font-sans) !important;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.driver-popover-override .driver-popover-footer button {
|
||||
background-color: var(--primary) !important;
|
||||
color: var(--primary-foreground) !important;
|
||||
border-radius: calc(var(--radius) - 2px) !important;
|
||||
padding: 0.5rem 1rem !important;
|
||||
font-size: 0.875rem !important;
|
||||
font-weight: 500 !important;
|
||||
border: none !important;
|
||||
text-shadow: none !important;
|
||||
transition-all: 0.2s !important;
|
||||
font-family: var(--font-sans) !important;
|
||||
background-color: var(--primary) !important;
|
||||
color: var(--primary-foreground) !important;
|
||||
border-radius: calc(var(--radius) - 2px) !important;
|
||||
padding: 0.5rem 1rem !important;
|
||||
font-size: 0.875rem !important;
|
||||
font-weight: 500 !important;
|
||||
border: none !important;
|
||||
text-shadow: none !important;
|
||||
transition-all: 0.2s !important;
|
||||
font-family: var(--font-sans) !important;
|
||||
}
|
||||
|
||||
.driver-popover-override .driver-popover-footer button:hover {
|
||||
opacity: 0.9 !important;
|
||||
transform: translateY(-1px);
|
||||
opacity: 0.9 !important;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* Navigation Buttons (Previous/Next) specifically */
|
||||
.driver-popover-override .driver-popover-footer .driver-popover-prev-btn {
|
||||
background-color: transparent !important;
|
||||
color: var(--muted-foreground) !important;
|
||||
border: 1px solid var(--border) !important;
|
||||
.driver-popover-override
|
||||
.driver-popover-footer
|
||||
.driver-popover-prev-btn {
|
||||
background-color: transparent !important;
|
||||
color: var(--muted-foreground) !important;
|
||||
border: 1px solid var(--border) !important;
|
||||
}
|
||||
.driver-popover-override .driver-popover-footer .driver-popover-prev-btn:hover {
|
||||
background-color: var(--accent) !important;
|
||||
color: var(--accent-foreground) !important;
|
||||
.driver-popover-override
|
||||
.driver-popover-footer
|
||||
.driver-popover-prev-btn:hover {
|
||||
background-color: var(--accent) !important;
|
||||
color: var(--accent-foreground) !important;
|
||||
}
|
||||
|
||||
/* Close Button */
|
||||
.driver-popover-override .driver-popover-close-btn {
|
||||
color: var(--muted-foreground) !important;
|
||||
opacity: 0.7 !important;
|
||||
transition: opacity 0.2s !important;
|
||||
color: var(--muted-foreground) !important;
|
||||
opacity: 0.7 !important;
|
||||
transition: opacity 0.2s !important;
|
||||
}
|
||||
|
||||
.driver-popover-override .driver-popover-close-btn:hover {
|
||||
color: var(--foreground) !important;
|
||||
opacity: 1 !important;
|
||||
color: var(--foreground) !important;
|
||||
opacity: 1 !important;
|
||||
}
|
||||
`}</style>
|
||||
</TourContext.Provider>
|
||||
);
|
||||
</TourContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
230
src/components/participants/ConsentUploadForm.tsx
Normal file
230
src/components/participants/ConsentUploadForm.tsx
Normal file
@@ -0,0 +1,230 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Upload,
|
||||
X,
|
||||
FileText,
|
||||
CheckCircle,
|
||||
AlertCircle,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Progress } from "~/components/ui/progress";
|
||||
import { api } from "~/trpc/react";
|
||||
import { toast } from "sonner";
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
interface ConsentUploadFormProps {
|
||||
studyId: string;
|
||||
participantId: string;
|
||||
consentFormId: string;
|
||||
onSuccess: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function ConsentUploadForm({
|
||||
studyId,
|
||||
participantId,
|
||||
consentFormId,
|
||||
onSuccess,
|
||||
onCancel,
|
||||
}: ConsentUploadFormProps) {
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [uploadProgress, setUploadProgress] = useState(0);
|
||||
|
||||
// Mutations
|
||||
const getUploadUrlMutation =
|
||||
api.participants.getConsentUploadUrl.useMutation();
|
||||
const recordConsentMutation = api.participants.recordConsent.useMutation();
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.files && e.target.files[0]) {
|
||||
const selectedFile = e.target.files[0];
|
||||
// Validate size (10MB)
|
||||
if (selectedFile.size > 10 * 1024 * 1024) {
|
||||
toast.error("File too large", {
|
||||
description: "Maximum file size is 10MB",
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Validate type
|
||||
const allowedTypes = [
|
||||
"application/pdf",
|
||||
"image/png",
|
||||
"image/jpeg",
|
||||
"image/jpg",
|
||||
];
|
||||
if (!allowedTypes.includes(selectedFile.type)) {
|
||||
toast.error("Invalid file type", {
|
||||
description: "Please upload a PDF, PNG, or JPG file",
|
||||
});
|
||||
return;
|
||||
}
|
||||
setFile(selectedFile);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpload = async () => {
|
||||
if (!file) return;
|
||||
|
||||
try {
|
||||
setIsUploading(true);
|
||||
setUploadProgress(0);
|
||||
|
||||
// 1. Get Presigned URL
|
||||
const { url, key } = await getUploadUrlMutation.mutateAsync({
|
||||
studyId,
|
||||
participantId,
|
||||
filename: file.name,
|
||||
contentType: file.type,
|
||||
size: file.size,
|
||||
});
|
||||
|
||||
// 2. Upload to MinIO using XMLHttpRequest for progress
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open("PUT", url, true);
|
||||
xhr.setRequestHeader("Content-Type", file.type);
|
||||
|
||||
xhr.upload.onprogress = (event) => {
|
||||
if (event.lengthComputable) {
|
||||
const percentCompleted = Math.round(
|
||||
(event.loaded * 100) / event.total,
|
||||
);
|
||||
setUploadProgress(percentCompleted);
|
||||
}
|
||||
};
|
||||
|
||||
xhr.onload = () => {
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error(`Upload failed with status ${xhr.status}`));
|
||||
}
|
||||
};
|
||||
|
||||
xhr.onerror = () => reject(new Error("Network error during upload"));
|
||||
xhr.send(file);
|
||||
});
|
||||
|
||||
// 3. Record Consent in DB
|
||||
await recordConsentMutation.mutateAsync({
|
||||
participantId,
|
||||
consentFormId,
|
||||
storagePath: key,
|
||||
});
|
||||
|
||||
toast.success("Consent Recorded", {
|
||||
description:
|
||||
"The consent form has been uploaded and recorded successfully.",
|
||||
});
|
||||
|
||||
onSuccess();
|
||||
} catch (error) {
|
||||
console.error("Upload failed:", error);
|
||||
toast.error("Upload Failed", {
|
||||
description:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "An unexpected error occurred",
|
||||
});
|
||||
setIsUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{!file ? (
|
||||
<div className="bg-muted/5 hover:bg-muted/10 flex flex-col items-center justify-center rounded-lg border-2 border-dashed p-6 transition-colors">
|
||||
<Upload className="text-muted-foreground mb-4 h-8 w-8" />
|
||||
<h3 className="mb-1 text-sm font-semibold">Upload Signed Consent</h3>
|
||||
<p className="text-muted-foreground mb-4 text-center text-xs">
|
||||
Drag and drop or click to select
|
||||
<br />
|
||||
PDF, PNG, JPG up to 10MB
|
||||
</p>
|
||||
<input
|
||||
type="file"
|
||||
id="consent-file-upload"
|
||||
className="hidden"
|
||||
accept=".pdf,.png,.jpg,.jpeg"
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
document.getElementById("consent-file-upload")?.click()
|
||||
}
|
||||
>
|
||||
Select File
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-muted/5 rounded-lg border p-4">
|
||||
<div className="mb-4 flex items-start justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="bg-primary/10 flex h-10 w-10 items-center justify-center rounded">
|
||||
<FileText className="text-primary h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="line-clamp-1 text-sm font-medium break-all">
|
||||
{file.name}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{(file.size / 1024 / 1024).toFixed(2)} MB
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{!isUploading && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={() => setFile(null)}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isUploading && (
|
||||
<div className="mb-4 space-y-2">
|
||||
<div className="text-muted-foreground flex justify-between text-xs">
|
||||
<span>Uploading...</span>
|
||||
<span>{uploadProgress}%</span>
|
||||
</div>
|
||||
<Progress value={uploadProgress} className="h-2" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onCancel}
|
||||
disabled={isUploading}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleUpload} disabled={isUploading}>
|
||||
{isUploading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Uploading
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Upload className="mr-2 h-4 w-4" />
|
||||
Upload & Record
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
235
src/components/participants/DigitalSignatureModal.tsx
Normal file
235
src/components/participants/DigitalSignatureModal.tsx
Normal file
@@ -0,0 +1,235 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import SignatureCanvas from "react-signature-canvas";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "~/components/ui/dialog";
|
||||
import { PenBox, Eraser, Loader2, CheckCircle } from "lucide-react";
|
||||
import { api } from "~/trpc/react";
|
||||
import { toast } from "sonner";
|
||||
import { generatePdfBlobFromHtml } from "~/lib/pdf-generator";
|
||||
import { Editor, EditorContent, useEditor } from "@tiptap/react";
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
import { Markdown } from "tiptap-markdown";
|
||||
import { Table } from "@tiptap/extension-table";
|
||||
import TableRow from "@tiptap/extension-table-row";
|
||||
import TableCell from "@tiptap/extension-table-cell";
|
||||
import TableHeader from "@tiptap/extension-table-header";
|
||||
import { ScrollArea } from "~/components/ui/scroll-area";
|
||||
|
||||
interface DigitalSignatureModalProps {
|
||||
studyId: string;
|
||||
participantId: string;
|
||||
participantName?: string | null;
|
||||
participantCode: string;
|
||||
activeForm: { id: string; content: string; version: number };
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
export function DigitalSignatureModal({
|
||||
studyId,
|
||||
participantId,
|
||||
participantName,
|
||||
participantCode,
|
||||
activeForm,
|
||||
onSuccess,
|
||||
}: DigitalSignatureModalProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const sigCanvas = useRef<any>(null);
|
||||
|
||||
// Mutations
|
||||
const getUploadUrlMutation = api.participants.getConsentUploadUrl.useMutation();
|
||||
const recordConsentMutation = api.participants.recordConsent.useMutation();
|
||||
|
||||
// Create a preview version of the text
|
||||
let previewMd = activeForm.content;
|
||||
previewMd = previewMd.replace(/{{PARTICIPANT_NAME}}/g, participantName ?? "_________________");
|
||||
previewMd = previewMd.replace(/{{PARTICIPANT_CODE}}/g, participantCode);
|
||||
const today = new Date().toLocaleDateString();
|
||||
previewMd = previewMd.replace(/{{DATE}}/g, today);
|
||||
previewMd = previewMd.replace(/{{SIGNATURE_IMAGE}}/g, "_[Signature Here]_");
|
||||
|
||||
const previewEditor = useEditor({
|
||||
extensions: [StarterKit, Table, TableRow, TableHeader, TableCell, Markdown],
|
||||
content: previewMd,
|
||||
editable: false,
|
||||
immediatelyRender: false,
|
||||
});
|
||||
|
||||
const handleClear = () => {
|
||||
sigCanvas.current?.clear();
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (sigCanvas.current?.isEmpty()) {
|
||||
toast.error("Signature required", { description: "Please sign the document before submitting." });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsSubmitting(true);
|
||||
toast.loading("Generating Signed Document...", { id: "sig-upload" });
|
||||
|
||||
// 1. Get Signature Image Data URL
|
||||
const signatureDataUrl = sigCanvas.current.getTrimmedCanvas().toDataURL("image/png");
|
||||
|
||||
// 2. Prepare final Markdown and HTML
|
||||
let finalMd = activeForm.content;
|
||||
finalMd = finalMd.replace(/{{PARTICIPANT_NAME}}/g, participantName ?? "_________________");
|
||||
finalMd = finalMd.replace(/{{PARTICIPANT_CODE}}/g, participantCode);
|
||||
finalMd = finalMd.replace(/{{DATE}}/g, today);
|
||||
finalMd = finalMd.replace(/{{SIGNATURE_IMAGE}}/g, `<img src="${signatureDataUrl}" style="height: 60px; max-width: 250px;" />`);
|
||||
|
||||
const headlessEditor = new Editor({
|
||||
extensions: [StarterKit, Table, TableRow, TableHeader, TableCell, Markdown],
|
||||
content: finalMd,
|
||||
});
|
||||
const htmlContent = headlessEditor.getHTML();
|
||||
headlessEditor.destroy();
|
||||
|
||||
// 3. Generate PDF Blob
|
||||
const filename = `Signed_Consent_${participantCode}_v${activeForm.version}.pdf`;
|
||||
const pdfBlob = await generatePdfBlobFromHtml(htmlContent, { filename });
|
||||
const file = new File([pdfBlob], filename, { type: "application/pdf" });
|
||||
|
||||
// 4. Get Presigned URL
|
||||
toast.loading("Uploading Document...", { id: "sig-upload" });
|
||||
const { url, key } = await getUploadUrlMutation.mutateAsync({
|
||||
studyId,
|
||||
participantId,
|
||||
filename: file.name,
|
||||
contentType: file.type,
|
||||
size: file.size,
|
||||
});
|
||||
|
||||
// 5. Upload to MinIO
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open("PUT", url, true);
|
||||
xhr.setRequestHeader("Content-Type", file.type);
|
||||
xhr.onload = () => {
|
||||
if (xhr.status >= 200 && xhr.status < 300) resolve();
|
||||
else reject(new Error(`Upload failed with status ${xhr.status}`));
|
||||
};
|
||||
xhr.onerror = () => reject(new Error("Network error during upload"));
|
||||
xhr.send(file);
|
||||
});
|
||||
|
||||
// 6. Record Consent in DB
|
||||
toast.loading("Finalizing Consent...", { id: "sig-upload" });
|
||||
await recordConsentMutation.mutateAsync({
|
||||
participantId,
|
||||
consentFormId: activeForm.id,
|
||||
storagePath: key,
|
||||
});
|
||||
|
||||
toast.success("Consent Successfully Recorded!", { id: "sig-upload" });
|
||||
setIsOpen(false);
|
||||
onSuccess();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error("Failed to submit digital signature", {
|
||||
id: "sig-upload",
|
||||
description: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="default" size="sm" className="bg-primary/90 hover:bg-primary">
|
||||
<PenBox className="mr-2 h-4 w-4" />
|
||||
Sign Digitally
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-4xl h-[90vh] flex flex-col p-6">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Digital Consent Signature</DialogTitle>
|
||||
<DialogDescription>
|
||||
Please review the document below and provide your digital signature to consent to this study.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 min-h-0 grid grid-cols-1 md:grid-cols-2 gap-6 mt-4">
|
||||
{/* Document Preview (Left) */}
|
||||
<div className="flex flex-col border rounded-md overflow-hidden bg-muted/20">
|
||||
<div className="bg-muted px-4 py-2 border-b text-xs font-semibold text-muted-foreground uppercase tracking-wider">
|
||||
Document Preview
|
||||
</div>
|
||||
<ScrollArea className="flex-1 w-full bg-white p-6 shadow-inner">
|
||||
<div className="prose prose-sm max-w-none text-black">
|
||||
<EditorContent editor={previewEditor} />
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
{/* Signature Panel (Right) */}
|
||||
<div className="flex flex-col space-y-4">
|
||||
<div className="border rounded-md overflow-hidden bg-white shadow-sm flex flex-col">
|
||||
<div className="bg-muted px-4 py-2 border-b text-xs font-semibold text-muted-foreground uppercase tracking-wider">
|
||||
Digital Signature Pad
|
||||
</div>
|
||||
<div className="p-4 bg-muted/10 relative">
|
||||
<div className="absolute top-4 right-4">
|
||||
<Button variant="ghost" size="sm" onClick={handleClear} disabled={isSubmitting}>
|
||||
<Eraser className="h-4 w-4 mr-2" />
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
<div className="border-2 border-dashed border-input rounded-md bg-white mt-10" style={{ height: "250px" }}>
|
||||
<SignatureCanvas
|
||||
ref={sigCanvas}
|
||||
penColor="black"
|
||||
canvasProps={{ className: "w-full h-full cursor-crosshair rounded-md" }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-center text-xs text-muted-foreground mt-2">
|
||||
Draw your signature using your mouse or touch screen inside the box above.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
{/* Submission Actions */}
|
||||
<div className="flex flex-col space-y-3 p-4 bg-primary/5 rounded-lg border border-primary/20">
|
||||
<h4 className="flex items-center text-sm font-semibold text-primary">
|
||||
<CheckCircle className="h-4 w-4 mr-2" />
|
||||
Agreement
|
||||
</h4>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
By clicking "Submit Signed Document", you confirm that you have read and understood the information provided in the document preview, and you voluntarily agree to participate in this study.
|
||||
</p>
|
||||
<Button
|
||||
className="w-full mt-2"
|
||||
size="lg"
|
||||
onClick={handleSubmit}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Processing...
|
||||
</>
|
||||
) : (
|
||||
"Submit Signed Document"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
238
src/components/participants/ParticipantConsentManager.tsx
Normal file
238
src/components/participants/ParticipantConsentManager.tsx
Normal file
@@ -0,0 +1,238 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { api } from "~/trpc/react";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "~/components/ui/dialog";
|
||||
import { ConsentUploadForm } from "./ConsentUploadForm";
|
||||
import {
|
||||
FileText,
|
||||
Download,
|
||||
CheckCircle,
|
||||
AlertCircle,
|
||||
Upload,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { cn } from "~/lib/utils";
|
||||
import { downloadPdfFromHtml } from "~/lib/pdf-generator";
|
||||
import { Editor } from "@tiptap/core";
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
import { Markdown } from "tiptap-markdown";
|
||||
import { Table } from "@tiptap/extension-table";
|
||||
import TableRow from "@tiptap/extension-table-row";
|
||||
import TableCell from "@tiptap/extension-table-cell";
|
||||
import TableHeader from "@tiptap/extension-table-header";
|
||||
import { DigitalSignatureModal } from "./DigitalSignatureModal";
|
||||
|
||||
interface ParticipantConsentManagerProps {
|
||||
studyId: string;
|
||||
participantId: string;
|
||||
consentGiven: boolean;
|
||||
consentDate: Date | null;
|
||||
existingConsent: {
|
||||
id: string;
|
||||
storagePath: string | null;
|
||||
signedAt: Date;
|
||||
consentForm: {
|
||||
title: string;
|
||||
version: number;
|
||||
};
|
||||
} | null;
|
||||
}
|
||||
|
||||
export function ParticipantConsentManager({
|
||||
studyId,
|
||||
participantId,
|
||||
consentGiven,
|
||||
consentDate,
|
||||
existingConsent,
|
||||
participantName,
|
||||
participantCode,
|
||||
}: ParticipantConsentManagerProps & { participantName?: string | null; participantCode: string }) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const utils = api.useUtils();
|
||||
|
||||
// Fetch active consent forms to know which form to sign/upload against
|
||||
const { data: consentForms } = api.participants.getConsentForms.useQuery({
|
||||
studyId,
|
||||
});
|
||||
const activeForm = consentForms?.find((f) => f.active) ?? consentForms?.[0];
|
||||
|
||||
// Helper to get download URL
|
||||
const { refetch: fetchDownloadUrl } = api.files.getDownloadUrl.useQuery(
|
||||
{ storagePath: existingConsent?.storagePath ?? "" },
|
||||
{ enabled: false },
|
||||
);
|
||||
|
||||
const handleDownload = async () => {
|
||||
if (!existingConsent?.storagePath) return;
|
||||
try {
|
||||
const result = await fetchDownloadUrl();
|
||||
if (result.data?.url) {
|
||||
window.open(result.data.url, "_blank");
|
||||
} else {
|
||||
toast.error("Error", { description: "Could not retrieve document" });
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error("Error", { description: "Failed to get download URL" });
|
||||
}
|
||||
};
|
||||
|
||||
const handleSuccess = () => {
|
||||
setIsOpen(false);
|
||||
utils.participants.get.invalidate({ id: participantId });
|
||||
toast.success("Success", { description: "Consent recorded successfully" });
|
||||
};
|
||||
|
||||
const handleDownloadUnsigned = async () => {
|
||||
if (!activeForm) return;
|
||||
try {
|
||||
toast.loading("Generating custom document...", { id: "pdf-gen" });
|
||||
|
||||
// Substitute placeholders in markdown
|
||||
let customMd = activeForm.content;
|
||||
customMd = customMd.replace(/{{PARTICIPANT_NAME}}/g, participantName ?? "_________________");
|
||||
customMd = customMd.replace(/{{PARTICIPANT_CODE}}/g, participantCode);
|
||||
customMd = customMd.replace(/{{DATE}}/g, "_________________");
|
||||
customMd = customMd.replace(/{{SIGNATURE_IMAGE}}/g, ""); // Blank ready for physical signature
|
||||
|
||||
// Use headless Tiptap to parse MD to HTML via same extensions
|
||||
const editor = new Editor({
|
||||
extensions: [StarterKit, Table, TableRow, TableHeader, TableCell, Markdown],
|
||||
content: customMd,
|
||||
});
|
||||
|
||||
const htmlContent = editor.getHTML();
|
||||
editor.destroy();
|
||||
|
||||
await downloadPdfFromHtml(htmlContent, {
|
||||
filename: `Consent_${participantCode}_${activeForm.version}.pdf`,
|
||||
});
|
||||
|
||||
toast.success("Document Downloaded", { id: "pdf-gen" });
|
||||
} catch (e) {
|
||||
toast.error("Error generating customized PDF", { id: "pdf-gen" });
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-card text-card-foreground rounded-lg border shadow-sm">
|
||||
<div className="flex flex-row items-center justify-between space-y-0 p-6 pb-2">
|
||||
<div className="flex flex-col space-y-1.5">
|
||||
<h3 className="flex items-center gap-2 leading-none font-semibold tracking-tight">
|
||||
<FileText className="h-5 w-5" />
|
||||
Consent Status
|
||||
</h3>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Manage participant consent and forms.
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant={consentGiven ? "default" : "destructive"}>
|
||||
{consentGiven ? "Consent Given" : "Not Recorded"}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="p-6 pt-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
{consentGiven ? (
|
||||
<>
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
<CheckCircle className="h-4 w-4 text-green-600" />
|
||||
Signed on{" "}
|
||||
{consentDate
|
||||
? new Date(consentDate).toLocaleDateString()
|
||||
: "Unknown date"}
|
||||
</div>
|
||||
{existingConsent && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Form: {existingConsent.consentForm.title} (v
|
||||
{existingConsent.consentForm.version})
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="text-muted-foreground flex items-center gap-2 text-sm">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
No consent recorded for this participant.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{consentGiven && existingConsent?.storagePath && (
|
||||
<Button variant="outline" size="sm" onClick={handleDownload}>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Download PDF
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={consentGiven ? "secondary" : "default"}
|
||||
>
|
||||
<Upload className="mr-2 h-4 w-4" />
|
||||
{consentGiven ? "Update Consent" : "Upload Consent"}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
|
||||
{!consentGiven && activeForm && (
|
||||
<>
|
||||
<DigitalSignatureModal
|
||||
studyId={studyId}
|
||||
participantId={participantId}
|
||||
participantName={participantName}
|
||||
participantCode={participantCode}
|
||||
activeForm={activeForm}
|
||||
onSuccess={handleSuccess}
|
||||
/>
|
||||
<Button variant="outline" size="sm" onClick={handleDownloadUnsigned}>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Print Empty Form
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Upload Signed Consent Form</DialogTitle>
|
||||
<DialogDescription>
|
||||
Upload the signed PDF or image of the consent form for this
|
||||
participant.
|
||||
{activeForm && (
|
||||
<span className="text-foreground mt-1 block font-medium">
|
||||
Active Form: {activeForm.title} (v{activeForm.version})
|
||||
</span>
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{activeForm ? (
|
||||
<ConsentUploadForm
|
||||
studyId={studyId}
|
||||
participantId={participantId}
|
||||
consentFormId={activeForm.id}
|
||||
onSuccess={handleSuccess}
|
||||
onCancel={() => setIsOpen(false)}
|
||||
/>
|
||||
) : (
|
||||
<div className="text-muted-foreground py-4 text-center">
|
||||
No active consent form found for this study. Please create
|
||||
one first.
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -25,7 +25,10 @@ import {
|
||||
SelectValue,
|
||||
} from "~/components/ui/select";
|
||||
import { useStudyContext } from "~/lib/study-context";
|
||||
import { cn } from "~/lib/utils";
|
||||
import { api } from "~/trpc/react";
|
||||
import { useTour } from "~/components/onboarding/TourProvider";
|
||||
import { Button } from "~/components/ui/button";
|
||||
|
||||
type DemographicsData = {
|
||||
age?: number;
|
||||
@@ -80,6 +83,7 @@ export function ParticipantForm({
|
||||
studyId,
|
||||
}: ParticipantFormProps) {
|
||||
const router = useRouter();
|
||||
const { startTour } = useTour();
|
||||
const { selectedStudyId } = useStudyContext();
|
||||
const contextStudyId = studyId ?? selectedStudyId;
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
@@ -91,6 +95,7 @@ export function ParticipantForm({
|
||||
defaultValues: {
|
||||
consentGiven: false,
|
||||
studyId: contextStudyId ?? "",
|
||||
participantCode: "",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -180,6 +185,20 @@ export function ParticipantForm({
|
||||
}
|
||||
}, [contextStudyId, mode, form]);
|
||||
|
||||
// Fetch next participant code
|
||||
const { data: nextCode, isLoading: isNextCodeLoading } =
|
||||
api.participants.getNextCode.useQuery(
|
||||
{ studyId: contextStudyId! },
|
||||
{ enabled: mode === "create" && !!contextStudyId },
|
||||
);
|
||||
|
||||
// Update default value if we switch modes or remount
|
||||
useEffect(() => {
|
||||
if (mode === "create" && nextCode) {
|
||||
form.setValue("participantCode", nextCode, { shouldValidate: true });
|
||||
}
|
||||
}, [mode, nextCode, form]);
|
||||
|
||||
const createParticipantMutation = api.participants.create.useMutation();
|
||||
const updateParticipantMutation = api.participants.update.useMutation();
|
||||
const deleteParticipantMutation = api.participants.delete.useMutation();
|
||||
@@ -203,7 +222,9 @@ export function ParticipantForm({
|
||||
email: data.email ?? undefined,
|
||||
demographics,
|
||||
});
|
||||
router.push(`/studies/${data.studyId}/participants/${newParticipant.id}`);
|
||||
router.push(
|
||||
`/studies/${data.studyId}/participants/${newParticipant.id}`,
|
||||
);
|
||||
} else {
|
||||
const updatedParticipant = await updateParticipantMutation.mutateAsync({
|
||||
id: participantId!,
|
||||
@@ -212,7 +233,9 @@ export function ParticipantForm({
|
||||
email: data.email ?? undefined,
|
||||
demographics,
|
||||
});
|
||||
router.push(`/studies/${contextStudyId}/participants/${updatedParticipant.id}`);
|
||||
router.push(
|
||||
`/studies/${contextStudyId}/participants/${updatedParticipant.id}`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
setError(
|
||||
@@ -256,160 +279,154 @@ export function ParticipantForm({
|
||||
<>
|
||||
<FormSection
|
||||
title="Participant Information"
|
||||
description="Basic information about the research participant."
|
||||
description="Basic identity and study association."
|
||||
>
|
||||
<FormField>
|
||||
<Label htmlFor="participantCode">Participant Code *</Label>
|
||||
<Input
|
||||
id="participantCode"
|
||||
{...form.register("participantCode")}
|
||||
placeholder="e.g., P001, SUBJ_01, etc."
|
||||
className={
|
||||
form.formState.errors.participantCode ? "border-red-500" : ""
|
||||
}
|
||||
/>
|
||||
{form.formState.errors.participantCode && (
|
||||
<p className="text-sm text-red-600">
|
||||
{form.formState.errors.participantCode.message}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Unique identifier for this participant within the study
|
||||
</p>
|
||||
</FormField>
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-3">
|
||||
<FormField>
|
||||
<Label htmlFor="participantCode">Participant Code *</Label>
|
||||
<Input
|
||||
id="tour-participant-code"
|
||||
{...form.register("participantCode")}
|
||||
placeholder={isNextCodeLoading ? "Generating..." : "e.g., P001"}
|
||||
readOnly={true}
|
||||
className={cn(
|
||||
"bg-muted text-muted-foreground",
|
||||
form.formState.errors.participantCode ? "border-red-500" : ""
|
||||
)}
|
||||
/>
|
||||
{form.formState.errors.participantCode && (
|
||||
<p className="text-sm text-red-600">
|
||||
{form.formState.errors.participantCode.message}
|
||||
</p>
|
||||
)}
|
||||
</FormField>
|
||||
|
||||
<FormField>
|
||||
<Label htmlFor="name">Full Name</Label>
|
||||
<Input
|
||||
id="name"
|
||||
{...form.register("name")}
|
||||
placeholder="Optional: Participant's full name"
|
||||
className={form.formState.errors.name ? "border-red-500" : ""}
|
||||
/>
|
||||
{form.formState.errors.name && (
|
||||
<p className="text-sm text-red-600">
|
||||
{form.formState.errors.name.message}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Optional: Real name for contact purposes
|
||||
</p>
|
||||
</FormField>
|
||||
<FormField>
|
||||
<Label htmlFor="name">Full Name</Label>
|
||||
<Input
|
||||
id="tour-participant-name"
|
||||
{...form.register("name")}
|
||||
placeholder="Optional name"
|
||||
className={form.formState.errors.name ? "border-red-500" : ""}
|
||||
/>
|
||||
{form.formState.errors.name && (
|
||||
<p className="text-sm text-red-600">
|
||||
{form.formState.errors.name.message}
|
||||
</p>
|
||||
)}
|
||||
</FormField>
|
||||
|
||||
<FormField>
|
||||
<Label htmlFor="email">Email Address</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
{...form.register("email")}
|
||||
placeholder="participant@example.com"
|
||||
className={form.formState.errors.email ? "border-red-500" : ""}
|
||||
/>
|
||||
{form.formState.errors.email && (
|
||||
<p className="text-sm text-red-600">
|
||||
{form.formState.errors.email.message}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Optional: For scheduling and communication
|
||||
</p>
|
||||
</FormField>
|
||||
|
||||
<FormField>
|
||||
<Label htmlFor="studyId">Study *</Label>
|
||||
<Select
|
||||
value={form.watch("studyId")}
|
||||
onValueChange={(value) => form.setValue("studyId", value)}
|
||||
disabled={studiesLoading || mode === "edit"}
|
||||
>
|
||||
<SelectTrigger
|
||||
className={form.formState.errors.studyId ? "border-red-500" : ""}
|
||||
>
|
||||
<SelectValue
|
||||
placeholder={
|
||||
studiesLoading ? "Loading studies..." : "Select a study"
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{studiesData?.studies?.map((study) => (
|
||||
<SelectItem key={study.id} value={study.id}>
|
||||
{study.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{form.formState.errors.studyId && (
|
||||
<p className="text-sm text-red-600">
|
||||
{form.formState.errors.studyId.message}
|
||||
</p>
|
||||
)}
|
||||
{mode === "edit" && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Study cannot be changed after registration
|
||||
</p>
|
||||
)}
|
||||
</FormField>
|
||||
<FormField>
|
||||
<Label htmlFor="email">Email Address</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
{...form.register("email")}
|
||||
placeholder="participant@example.com"
|
||||
className={form.formState.errors.email ? "border-red-500" : ""}
|
||||
/>
|
||||
{form.formState.errors.email && (
|
||||
<p className="text-sm text-red-600">
|
||||
{form.formState.errors.email.message}
|
||||
</p>
|
||||
)}
|
||||
</FormField>
|
||||
</div>
|
||||
</FormSection>
|
||||
|
||||
<FormSection
|
||||
title="Demographics"
|
||||
description="Optional demographic information for research purposes."
|
||||
>
|
||||
<FormField>
|
||||
<Label htmlFor="age">Age</Label>
|
||||
<Input
|
||||
id="age"
|
||||
type="number"
|
||||
min="18"
|
||||
max="120"
|
||||
{...form.register("age", { valueAsNumber: true })}
|
||||
placeholder="e.g., 25"
|
||||
className={form.formState.errors.age ? "border-red-500" : ""}
|
||||
/>
|
||||
{form.formState.errors.age && (
|
||||
<p className="text-sm text-red-600">
|
||||
{form.formState.errors.age.message}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Optional: Age in years (minimum 18)
|
||||
</p>
|
||||
</FormField>
|
||||
<div className="my-6" />
|
||||
|
||||
<FormField>
|
||||
<Label htmlFor="gender">Gender</Label>
|
||||
<Select
|
||||
value={form.watch("gender") ?? ""}
|
||||
onValueChange={(value) =>
|
||||
form.setValue(
|
||||
"gender",
|
||||
value as
|
||||
| "male"
|
||||
| "female"
|
||||
| "non_binary"
|
||||
| "prefer_not_to_say"
|
||||
| "other",
|
||||
)
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select gender (optional)" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="male">Male</SelectItem>
|
||||
<SelectItem value="female">Female</SelectItem>
|
||||
<SelectItem value="non_binary">Non-binary</SelectItem>
|
||||
<SelectItem value="prefer_not_to_say">
|
||||
Prefer not to say
|
||||
</SelectItem>
|
||||
<SelectItem value="other">Other</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Optional: Gender identity for demographic analysis
|
||||
</p>
|
||||
</FormField>
|
||||
<FormSection
|
||||
title={contextStudyId ? "Demographics" : "Demographics & Study"}
|
||||
description={contextStudyId ? "Participant demographic details." : "Study association and demographic details."}
|
||||
>
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-3">
|
||||
{!contextStudyId && (
|
||||
<FormField>
|
||||
<Label htmlFor="studyId" id="tour-participant-study-label">
|
||||
Study *
|
||||
</Label>
|
||||
<div id="tour-participant-study-container">
|
||||
<Select
|
||||
value={form.watch("studyId")}
|
||||
onValueChange={(value) => form.setValue("studyId", value)}
|
||||
disabled={studiesLoading || mode === "edit"}
|
||||
>
|
||||
<SelectTrigger
|
||||
className={
|
||||
form.formState.errors.studyId ? "border-red-500" : ""
|
||||
}
|
||||
>
|
||||
<SelectValue
|
||||
placeholder={studiesLoading ? "Loading..." : "Select study"}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{studiesData?.studies?.map((study) => (
|
||||
<SelectItem key={study.id} value={study.id}>
|
||||
{study.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{form.formState.errors.studyId && (
|
||||
<p className="text-sm text-red-600">
|
||||
{form.formState.errors.studyId.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</FormField>
|
||||
)}
|
||||
|
||||
<FormField>
|
||||
<Label htmlFor="age">Age</Label>
|
||||
<Input
|
||||
id="age"
|
||||
type="number"
|
||||
min="18"
|
||||
max="120"
|
||||
{...form.register("age", { valueAsNumber: true })}
|
||||
placeholder="e.g., 25"
|
||||
className={form.formState.errors.age ? "border-red-500" : ""}
|
||||
/>
|
||||
{form.formState.errors.age && (
|
||||
<p className="text-sm text-red-600">
|
||||
{form.formState.errors.age.message}
|
||||
</p>
|
||||
)}
|
||||
</FormField>
|
||||
|
||||
<FormField>
|
||||
<Label htmlFor="gender">Gender</Label>
|
||||
<Select
|
||||
value={form.watch("gender") ?? ""}
|
||||
onValueChange={(value) =>
|
||||
form.setValue(
|
||||
"gender",
|
||||
value as
|
||||
| "male"
|
||||
| "female"
|
||||
| "non_binary"
|
||||
| "prefer_not_to_say"
|
||||
| "other",
|
||||
)
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select gender" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="male">Male</SelectItem>
|
||||
<SelectItem value="female">Female</SelectItem>
|
||||
<SelectItem value="non_binary">Non-binary</SelectItem>
|
||||
<SelectItem value="prefer_not_to_say">
|
||||
Prefer not to say
|
||||
</SelectItem>
|
||||
<SelectItem value="other">Other</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormField>
|
||||
</div>
|
||||
</FormSection>
|
||||
|
||||
{mode === "create" && (
|
||||
@@ -420,7 +437,7 @@ export function ParticipantForm({
|
||||
<FormField>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="consentGiven"
|
||||
id="tour-participant-consent"
|
||||
checked={form.watch("consentGiven")}
|
||||
onCheckedChange={(checked) =>
|
||||
form.setValue("consentGiven", !!checked)
|
||||
@@ -505,9 +522,25 @@ export function ParticipantForm({
|
||||
error={error}
|
||||
onDelete={mode === "edit" ? onDelete : undefined}
|
||||
isDeleting={isDeleting}
|
||||
|
||||
// sidebar={sidebar} // Removed for cleaner UI per user request
|
||||
sidebar={mode === "create" ? sidebar : undefined}
|
||||
submitText={mode === "create" ? "Register Participant" : "Save Changes"}
|
||||
submitButtonId="tour-participant-submit"
|
||||
extraActions={
|
||||
mode === "create" ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => startTour("participant_creation")}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground">Help</span>
|
||||
<div className="text-muted-foreground flex h-5 w-5 items-center justify-center rounded-full border text-xs">
|
||||
?
|
||||
</div>
|
||||
</div>
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{formFields}
|
||||
</EntityForm>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { type ColumnDef } from "@tanstack/react-table";
|
||||
import { ArrowUpDown, MoreHorizontal, Copy, Eye, Edit, Mail, Trash2 } from "lucide-react";
|
||||
import { ArrowUpDown, MoreHorizontal, Edit, Trash2 } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
@@ -24,6 +24,12 @@ import {
|
||||
} from "~/components/ui/dropdown-menu";
|
||||
import { useStudyContext } from "~/lib/study-context";
|
||||
import { api } from "~/trpc/react";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "~/components/ui/tooltip";
|
||||
|
||||
export type Participant = {
|
||||
id: string;
|
||||
@@ -101,16 +107,32 @@ export const columns: ColumnDef<Participant>[] = [
|
||||
const name = row.getValue("name");
|
||||
const email = row.original.email;
|
||||
return (
|
||||
<div>
|
||||
<div className="truncate font-medium">
|
||||
{String(name) || "No name provided"}
|
||||
</div>
|
||||
{email && (
|
||||
<div className="text-muted-foreground truncate text-sm">
|
||||
{email}
|
||||
<TooltipProvider>
|
||||
<div>
|
||||
<div className="max-w-[200px] truncate font-medium">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span>{String(name) || "No name provided"}</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{String(name) || "No name provided"}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{email && (
|
||||
<div className="text-muted-foreground max-w-[200px] truncate text-sm">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span>{email}</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{email}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
);
|
||||
},
|
||||
},
|
||||
@@ -120,11 +142,30 @@ export const columns: ColumnDef<Participant>[] = [
|
||||
cell: ({ row }) => {
|
||||
const consentGiven = row.getValue("consentGiven");
|
||||
|
||||
if (consentGiven) {
|
||||
return <Badge className="bg-green-100 text-green-800">Consented</Badge>;
|
||||
}
|
||||
|
||||
return <Badge className="bg-red-100 text-red-800">Pending</Badge>;
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
{consentGiven ? (
|
||||
<Badge className="bg-green-100 text-green-800 hover:bg-green-200">
|
||||
Consented
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge className="bg-red-100 text-red-800 hover:bg-red-200">
|
||||
Pending
|
||||
</Badge>
|
||||
)}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>
|
||||
{consentGiven
|
||||
? "Participant has signed the consent form."
|
||||
: "Consent form has not been recorded."}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -148,30 +189,7 @@ export const columns: ColumnDef<Participant>[] = [
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "createdAt",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
||||
>
|
||||
Created
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const date = row.getValue("createdAt");
|
||||
return (
|
||||
<div className="text-muted-foreground text-sm">
|
||||
{formatDistanceToNow(new Date(date as string | number | Date), {
|
||||
addSuffix: true,
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
id: "actions",
|
||||
enableHiding: false,
|
||||
@@ -195,30 +213,21 @@ export const columns: ColumnDef<Participant>[] = [
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigator.clipboard.writeText(participant.id)}
|
||||
>
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
Copy ID
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/studies/${studyId}/participants/${participant.id}/edit`}>
|
||||
<Link
|
||||
href={`/studies/${studyId}/participants/${participant.id}/edit`}
|
||||
>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
Edit participant
|
||||
</Link >
|
||||
</DropdownMenuItem >
|
||||
<DropdownMenuItem disabled>
|
||||
<Mail className="mr-2 h-4 w-4" />
|
||||
Send consent
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem className="text-red-600">
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Remove
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent >
|
||||
</DropdownMenu >
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
@@ -32,7 +32,7 @@ import {
|
||||
SelectValue,
|
||||
} from "~/components/ui/select";
|
||||
|
||||
import { Mail, Plus, UserPlus } from "lucide-react";
|
||||
import { Mail, Plus, UserPlus, Microscope, Wand2, Eye } from "lucide-react";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { useStudyManagement } from "~/hooks/useStudyManagement";
|
||||
|
||||
@@ -54,17 +54,17 @@ const roleDescriptions = {
|
||||
researcher: {
|
||||
label: "Researcher",
|
||||
description: "Can manage experiments, view all data, and invite members",
|
||||
icon: "🔬",
|
||||
icon: Microscope,
|
||||
},
|
||||
wizard: {
|
||||
label: "Wizard",
|
||||
description: "Can control trials and execute experiments",
|
||||
icon: "🎭",
|
||||
icon: Wand2,
|
||||
},
|
||||
observer: {
|
||||
label: "Observer",
|
||||
description: "Read-only access to view trials and data",
|
||||
icon: "👁️",
|
||||
icon: Eye,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -167,7 +167,10 @@ export function InviteMemberDialog({
|
||||
([value, config]) => (
|
||||
<SelectItem key={value} value={value}>
|
||||
<div className="flex items-center space-x-2">
|
||||
<span>{config.icon}</span>
|
||||
{(() => {
|
||||
const Icon = config.icon;
|
||||
return <Icon className="h-4 w-4" />;
|
||||
})()}
|
||||
<span>{config.label}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
@@ -180,8 +183,18 @@ export function InviteMemberDialog({
|
||||
<div className="mt-2 rounded-lg bg-slate-50 p-3">
|
||||
<div className="mb-1 flex items-center space-x-2">
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{roleDescriptions[field.value].icon}{" "}
|
||||
{roleDescriptions[field.value].label}
|
||||
{(() => {
|
||||
const Icon =
|
||||
roleDescriptions[
|
||||
field.value as keyof typeof roleDescriptions
|
||||
].icon;
|
||||
return <Icon className="mr-1 h-3.5 w-3.5" />;
|
||||
})()}
|
||||
{
|
||||
roleDescriptions[
|
||||
field.value as keyof typeof roleDescriptions
|
||||
].label
|
||||
}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-slate-600">
|
||||
|
||||
@@ -5,7 +5,14 @@ import { ArrowUpDown, MoreHorizontal } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { AlertCircle, Filter } from "lucide-react";
|
||||
import {
|
||||
AlertCircle,
|
||||
Filter,
|
||||
Activity,
|
||||
FileEdit,
|
||||
CheckCircle2,
|
||||
Archive,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { Alert, AlertDescription } from "~/components/ui/alert";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
@@ -14,12 +21,12 @@ import { Card, CardContent } from "~/components/ui/card";
|
||||
import { Checkbox } from "~/components/ui/checkbox";
|
||||
import { DataTable } from "~/components/ui/data-table";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "~/components/ui/dropdown-menu";
|
||||
import { api } from "~/trpc/react";
|
||||
|
||||
@@ -69,22 +76,22 @@ const statusConfig = {
|
||||
draft: {
|
||||
label: "Draft",
|
||||
className: "bg-gray-100 text-gray-800",
|
||||
icon: "📝",
|
||||
icon: FileEdit,
|
||||
},
|
||||
active: {
|
||||
label: "Active",
|
||||
className: "bg-green-100 text-green-800",
|
||||
icon: "🟢",
|
||||
icon: Activity,
|
||||
},
|
||||
completed: {
|
||||
label: "Completed",
|
||||
className: "bg-blue-100 text-blue-800",
|
||||
icon: "✅",
|
||||
icon: CheckCircle2,
|
||||
},
|
||||
archived: {
|
||||
label: "Archived",
|
||||
className: "bg-orange-100 text-orange-800",
|
||||
icon: "📦",
|
||||
icon: Archive,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -172,7 +179,7 @@ export const columns: ColumnDef<Study>[] = [
|
||||
const statusInfo = statusConfig[status as keyof typeof statusConfig];
|
||||
return (
|
||||
<Badge className={statusInfo.className}>
|
||||
<span className="mr-1">{statusInfo.icon}</span>
|
||||
<statusInfo.icon className="mr-1 h-3.5 w-3.5" />
|
||||
{statusInfo.label}
|
||||
</Badge>
|
||||
);
|
||||
@@ -186,7 +193,9 @@ export const columns: ColumnDef<Study>[] = [
|
||||
const isOwner = row.original.isOwner;
|
||||
|
||||
return (
|
||||
<Badge variant={isOwner ? "default" : "secondary"}>{String(userRole)}</Badge>
|
||||
<Badge variant={isOwner ? "default" : "secondary"}>
|
||||
{String(userRole)}
|
||||
</Badge>
|
||||
);
|
||||
},
|
||||
},
|
||||
@@ -215,7 +224,9 @@ export const columns: ColumnDef<Study>[] = [
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Badge className="bg-blue-100 text-blue-800">{Number(experimentCount)}</Badge>
|
||||
<Badge className="bg-blue-100 text-blue-800">
|
||||
{Number(experimentCount)}
|
||||
</Badge>
|
||||
);
|
||||
},
|
||||
},
|
||||
@@ -257,7 +268,9 @@ export const columns: ColumnDef<Study>[] = [
|
||||
return (
|
||||
<div className="max-w-[120px]">
|
||||
<div className="text-sm">
|
||||
{formatDistanceToNow(new Date(date as string | number | Date), { addSuffix: true })}
|
||||
{formatDistanceToNow(new Date(date as string | number | Date), {
|
||||
addSuffix: true,
|
||||
})}
|
||||
</div>
|
||||
<div className="text-muted-foreground truncate text-xs">
|
||||
by {createdBy}
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { CheckCircle2, Activity, FileEdit, Archive } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "~/components/ui/card";
|
||||
import { Separator } from "~/components/ui/separator";
|
||||
|
||||
@@ -45,22 +46,22 @@ const statusConfig = {
|
||||
draft: {
|
||||
label: "Draft",
|
||||
className: "bg-gray-100 text-gray-800 hover:bg-gray-200",
|
||||
icon: "📝",
|
||||
icon: FileEdit,
|
||||
},
|
||||
active: {
|
||||
label: "Active",
|
||||
className: "bg-green-100 text-green-800 hover:bg-green-200",
|
||||
icon: "🟢",
|
||||
icon: Activity,
|
||||
},
|
||||
completed: {
|
||||
label: "Completed",
|
||||
className: "bg-blue-100 text-blue-800 hover:bg-blue-200",
|
||||
icon: "✅",
|
||||
icon: CheckCircle2,
|
||||
},
|
||||
archived: {
|
||||
label: "Archived",
|
||||
className: "bg-orange-100 text-orange-800 hover:bg-orange-200",
|
||||
icon: "📦",
|
||||
icon: Archive,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -84,7 +85,7 @@ export function StudyCard({ study, userRole, isOwner }: StudyCardProps) {
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Badge className={statusInfo.className} variant="secondary">
|
||||
<span className="mr-1">{statusInfo.icon}</span>
|
||||
<statusInfo.icon className="mr-1 h-3.5 w-3.5" />
|
||||
{statusInfo.label}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "~/components/ui/select";
|
||||
import { Separator } from "~/components/ui/separator";
|
||||
import { Textarea } from "~/components/ui/textarea";
|
||||
import {
|
||||
EntityForm,
|
||||
@@ -30,10 +31,7 @@ import { Button } from "../ui/button";
|
||||
|
||||
const studySchema = z.object({
|
||||
name: z.string().min(1, "Study name is required").max(255, "Name too long"),
|
||||
description: z
|
||||
.string()
|
||||
.min(10, "Description must be at least 10 characters")
|
||||
.max(1000, "Description too long"),
|
||||
description: z.string().max(1000, "Description too long").optional(),
|
||||
institution: z
|
||||
.string()
|
||||
.min(1, "Institution is required")
|
||||
@@ -114,7 +112,7 @@ export function StudyForm({ mode, studyId }: StudyFormProps) {
|
||||
institution: data.institution,
|
||||
irbProtocol: data.irbProtocolNumber ?? undefined,
|
||||
});
|
||||
router.push(`/studies/${newStudy.id}`);
|
||||
router.push(`/studies/${newStudy.id}/participants/new`);
|
||||
} else {
|
||||
const updatedStudy = await updateStudyMutation.mutateAsync({
|
||||
id: studyId!,
|
||||
@@ -165,103 +163,126 @@ export function StudyForm({ mode, studyId }: StudyFormProps) {
|
||||
|
||||
// Form fields
|
||||
const formFields = (
|
||||
<FormSection
|
||||
title="Study Details"
|
||||
description="Basic information about your research study."
|
||||
>
|
||||
<FormField>
|
||||
<Label htmlFor="tour-study-name">Study Name *</Label>
|
||||
<Input
|
||||
id="tour-study-name"
|
||||
{...form.register("name")}
|
||||
placeholder="Enter study name..."
|
||||
className={form.formState.errors.name ? "border-red-500" : ""}
|
||||
/>
|
||||
{form.formState.errors.name && (
|
||||
<p className="text-sm text-red-600">
|
||||
{form.formState.errors.name.message}
|
||||
</p>
|
||||
)}
|
||||
</FormField>
|
||||
<div className="space-y-6">
|
||||
<FormSection
|
||||
title="Study Details"
|
||||
description="Basic information and status of your research study."
|
||||
>
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
<FormField>
|
||||
<Label htmlFor="tour-study-name">Study Name *</Label>
|
||||
<Input
|
||||
id="tour-study-name"
|
||||
{...form.register("name")}
|
||||
placeholder="Enter study name..."
|
||||
className={form.formState.errors.name ? "border-red-500" : ""}
|
||||
/>
|
||||
{form.formState.errors.name && (
|
||||
<p className="text-sm text-red-600">
|
||||
{form.formState.errors.name.message}
|
||||
</p>
|
||||
)}
|
||||
</FormField>
|
||||
|
||||
<FormField>
|
||||
<Label htmlFor="tour-study-description">Description *</Label>
|
||||
<Textarea
|
||||
id="tour-study-description"
|
||||
{...form.register("description")}
|
||||
placeholder="Describe the research objectives, methodology, and expected outcomes..."
|
||||
rows={4}
|
||||
className={form.formState.errors.description ? "border-red-500" : ""}
|
||||
/>
|
||||
{form.formState.errors.description && (
|
||||
<p className="text-sm text-red-600">
|
||||
{form.formState.errors.description.message}
|
||||
</p>
|
||||
)}
|
||||
</FormField>
|
||||
<FormField>
|
||||
<Label htmlFor="status">Status</Label>
|
||||
<Select
|
||||
value={form.watch("status")}
|
||||
onValueChange={(value) =>
|
||||
form.setValue(
|
||||
"status",
|
||||
value as "draft" | "active" | "completed" | "archived",
|
||||
)
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="draft">
|
||||
Draft - Study in preparation
|
||||
</SelectItem>
|
||||
<SelectItem value="active">
|
||||
Active - Currently recruiting/running
|
||||
</SelectItem>
|
||||
<SelectItem value="completed">
|
||||
Completed - Data collection finished
|
||||
</SelectItem>
|
||||
<SelectItem value="archived">
|
||||
Archived - Study concluded
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormField>
|
||||
|
||||
<FormField>
|
||||
<Label htmlFor="institution">Institution *</Label>
|
||||
<Input
|
||||
id="institution"
|
||||
{...form.register("institution")}
|
||||
placeholder="e.g., University of Technology"
|
||||
className={form.formState.errors.institution ? "border-red-500" : ""}
|
||||
/>
|
||||
{form.formState.errors.institution && (
|
||||
<p className="text-sm text-red-600">
|
||||
{form.formState.errors.institution.message}
|
||||
</p>
|
||||
)}
|
||||
</FormField>
|
||||
<div className="md:col-span-2">
|
||||
<FormField>
|
||||
<Label htmlFor="tour-study-description">Description</Label>
|
||||
<Textarea
|
||||
id="tour-study-description"
|
||||
{...form.register("description")}
|
||||
placeholder="Describe the research objectives, methodology, and expected outcomes..."
|
||||
rows={4}
|
||||
className={
|
||||
form.formState.errors.description ? "border-red-500" : ""
|
||||
}
|
||||
/>
|
||||
{form.formState.errors.description && (
|
||||
<p className="text-sm text-red-600">
|
||||
{form.formState.errors.description.message}
|
||||
</p>
|
||||
)}
|
||||
</FormField>
|
||||
</div>
|
||||
</div>
|
||||
</FormSection>
|
||||
|
||||
<FormField>
|
||||
<Label htmlFor="irbProtocolNumber">IRB Protocol Number</Label>
|
||||
<Input
|
||||
id="irbProtocolNumber"
|
||||
{...form.register("irbProtocolNumber")}
|
||||
placeholder="e.g., IRB-2024-001"
|
||||
className={
|
||||
form.formState.errors.irbProtocolNumber ? "border-red-500" : ""
|
||||
}
|
||||
/>
|
||||
{form.formState.errors.irbProtocolNumber && (
|
||||
<p className="text-sm text-red-600">
|
||||
{form.formState.errors.irbProtocolNumber.message}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Optional: Institutional Review Board protocol number if applicable
|
||||
</p>
|
||||
</FormField>
|
||||
<Separator />
|
||||
|
||||
<FormField>
|
||||
<Label htmlFor="status">Status</Label>
|
||||
<Select
|
||||
value={form.watch("status")}
|
||||
onValueChange={(value) =>
|
||||
form.setValue(
|
||||
"status",
|
||||
value as "draft" | "active" | "completed" | "archived",
|
||||
)
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="draft">Draft - Study in preparation</SelectItem>
|
||||
<SelectItem value="active">
|
||||
Active - Currently recruiting/running
|
||||
</SelectItem>
|
||||
<SelectItem value="completed">
|
||||
Completed - Data collection finished
|
||||
</SelectItem>
|
||||
<SelectItem value="archived">Archived - Study concluded</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormField>
|
||||
</FormSection>
|
||||
<FormSection
|
||||
title="Configuration"
|
||||
description="Institutional details and ethics approval."
|
||||
>
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
<FormField>
|
||||
<Label htmlFor="institution">Institution *</Label>
|
||||
<Input
|
||||
id="institution"
|
||||
{...form.register("institution")}
|
||||
placeholder="e.g., University of Technology"
|
||||
className={
|
||||
form.formState.errors.institution ? "border-red-500" : ""
|
||||
}
|
||||
/>
|
||||
{form.formState.errors.institution && (
|
||||
<p className="text-sm text-red-600">
|
||||
{form.formState.errors.institution.message}
|
||||
</p>
|
||||
)}
|
||||
</FormField>
|
||||
|
||||
<FormField>
|
||||
<Label htmlFor="irbProtocolNumber">IRB Protocol Number</Label>
|
||||
<Input
|
||||
id="irbProtocolNumber"
|
||||
{...form.register("irbProtocolNumber")}
|
||||
placeholder="e.g., IRB-2024-001"
|
||||
className={
|
||||
form.formState.errors.irbProtocolNumber ? "border-red-500" : ""
|
||||
}
|
||||
/>
|
||||
{form.formState.errors.irbProtocolNumber && (
|
||||
<p className="text-sm text-red-600">
|
||||
{form.formState.errors.irbProtocolNumber.message}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Optional: Institutional Review Board protocol number if applicable
|
||||
</p>
|
||||
</FormField>
|
||||
</div>
|
||||
</FormSection>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Sidebar content
|
||||
@@ -324,13 +345,19 @@ export function StudyForm({ mode, studyId }: StudyFormProps) {
|
||||
error={error}
|
||||
onDelete={mode === "edit" ? onDelete : undefined}
|
||||
isDeleting={isDeleting}
|
||||
sidebar={sidebar}
|
||||
sidebar={mode === "create" ? sidebar : undefined}
|
||||
submitButtonId="tour-study-submit"
|
||||
extraActions={
|
||||
<Button variant="ghost" size="sm" onClick={() => startTour("study_creation")}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => startTour("study_creation")}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground">Help</span>
|
||||
<div className="flex h-5 w-5 items-center justify-center rounded-full border text-xs text-muted-foreground">?</div>
|
||||
<div className="text-muted-foreground flex h-5 w-5 items-center justify-center rounded-full border text-xs">
|
||||
?
|
||||
</div>
|
||||
</div>
|
||||
</Button>
|
||||
}
|
||||
|
||||
@@ -4,10 +4,10 @@ import { Monitor, Moon, Sun } from "lucide-react";
|
||||
|
||||
import { Button } from "~/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "~/components/ui/dropdown-menu";
|
||||
import { useTheme } from "./theme-provider";
|
||||
|
||||
@@ -18,8 +18,8 @@ export function ThemeToggle() {
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="icon">
|
||||
<Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
|
||||
<Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
|
||||
<Sun className="h-[1.2rem] w-[1.2rem] scale-100 rotate-0 transition-all dark:scale-0 dark:-rotate-90" />
|
||||
<Moon className="absolute h-[1.2rem] w-[1.2rem] scale-0 rotate-90 transition-all dark:scale-100 dark:rotate-0" />
|
||||
<span className="sr-only">Toggle theme</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
EntityForm,
|
||||
FormField,
|
||||
FormSection,
|
||||
NextSteps,
|
||||
Tips,
|
||||
} from "~/components/ui/entity-form";
|
||||
import { Input } from "~/components/ui/input";
|
||||
@@ -27,10 +26,117 @@ import { Textarea } from "~/components/ui/textarea";
|
||||
import { useStudyContext } from "~/lib/study-context";
|
||||
import { api } from "~/trpc/react";
|
||||
|
||||
import { Calendar as CalendarIcon, Clock } from "lucide-react";
|
||||
import { format } from "date-fns";
|
||||
import { cn } from "~/lib/utils";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Calendar } from "~/components/ui/calendar";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "~/components/ui/popover";
|
||||
import { Controller } from "react-hook-form";
|
||||
|
||||
// Custom DatePickerTime component based on user request
|
||||
function DateTimePicker({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: Date | undefined;
|
||||
onChange: (date: Date | undefined) => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
// Parse time from value or default
|
||||
const timeValue = value ? format(value, "HH:mm") : "12:00";
|
||||
|
||||
const onDateSelect = (newDate: Date | undefined) => {
|
||||
if (!newDate) {
|
||||
onChange(undefined);
|
||||
setOpen(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Preserve existing time or use default
|
||||
const [hours, minutes] = timeValue.split(":").map(Number);
|
||||
const updatedDate = new Date(newDate);
|
||||
updatedDate.setHours(hours || 0);
|
||||
updatedDate.setMinutes(minutes || 0);
|
||||
updatedDate.setSeconds(0);
|
||||
|
||||
onChange(updatedDate);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const onTimeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newTime = e.target.value;
|
||||
if (!value) return; // Can't set time without date
|
||||
|
||||
const [hours, minutes] = newTime.split(":").map(Number);
|
||||
const updatedDate = new Date(value);
|
||||
updatedDate.setHours(hours || 0);
|
||||
updatedDate.setMinutes(minutes || 0);
|
||||
updatedDate.setSeconds(0);
|
||||
|
||||
onChange(updatedDate);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-end gap-2">
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="date-picker" className="text-xs">
|
||||
Date
|
||||
</Label>
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant={"outline"}
|
||||
id="date-picker"
|
||||
className={cn(
|
||||
"w-[240px] justify-start text-left font-normal",
|
||||
!value && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<CalendarIcon className="mr-2 h-4 w-4" />
|
||||
{value ? format(value, "PPP") : <span>Pick a date</span>}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={value}
|
||||
onSelect={onDateSelect}
|
||||
initialFocus
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-1.5">
|
||||
<Label htmlFor="time-picker" className="text-xs">
|
||||
Time
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="time-picker"
|
||||
type="time"
|
||||
value={timeValue}
|
||||
onChange={onTimeChange}
|
||||
disabled={!value}
|
||||
className="w-[120px]"
|
||||
/>
|
||||
<Clock className="text-muted-foreground pointer-events-none absolute top-2.5 right-3 h-4 w-4" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const trialSchema = z.object({
|
||||
experimentId: z.string().uuid("Please select an experiment"),
|
||||
participantId: z.string().uuid("Please select a participant"),
|
||||
scheduledAt: z.string().min(1, "Please select a date and time"),
|
||||
scheduledAt: z.date(),
|
||||
wizardId: z.string().uuid().optional(),
|
||||
notes: z.string().max(1000, "Notes cannot exceed 1000 characters").optional(),
|
||||
sessionNumber: z
|
||||
@@ -52,7 +158,6 @@ export function TrialForm({ mode, trialId, studyId }: TrialFormProps) {
|
||||
const { selectedStudyId } = useStudyContext();
|
||||
const contextStudyId = studyId ?? selectedStudyId;
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [isDeleting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const form = useForm<TrialFormData>({
|
||||
@@ -90,39 +195,55 @@ export function TrialForm({ mode, trialId, studyId }: TrialFormProps) {
|
||||
const { data: usersData, isLoading: usersLoading } =
|
||||
api.users.getWizards.useQuery();
|
||||
|
||||
// Auto-increment session number
|
||||
const selectedParticipantId = form.watch("participantId");
|
||||
const { data: latestSession } = api.trials.getLatestSession.useQuery(
|
||||
{ participantId: selectedParticipantId },
|
||||
{
|
||||
enabled: !!selectedParticipantId && mode === "create",
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (latestSession !== undefined && mode === "create") {
|
||||
form.setValue("sessionNumber", latestSession + 1);
|
||||
}
|
||||
}, [latestSession, mode, form]);
|
||||
|
||||
// Set breadcrumbs
|
||||
const breadcrumbs = [
|
||||
{ label: "Dashboard", href: "/dashboard" },
|
||||
{ label: "Studies", href: "/studies" },
|
||||
...(contextStudyId
|
||||
? [
|
||||
{
|
||||
label: "Study",
|
||||
href: `/studies/${contextStudyId}`,
|
||||
},
|
||||
{ label: "Trials", href: `/studies/${contextStudyId}/trials` },
|
||||
...(mode === "edit" && trial
|
||||
? [
|
||||
{
|
||||
label: `Trial ${trial.sessionNumber || trial.id.slice(-8)}`,
|
||||
href: `/studies/${contextStudyId}/trials/${trial.id}`,
|
||||
},
|
||||
{ label: "Edit" },
|
||||
]
|
||||
: [{ label: "New Trial" }]),
|
||||
]
|
||||
{
|
||||
label: "Study",
|
||||
href: `/studies/${contextStudyId}`,
|
||||
},
|
||||
{ label: "Trials", href: `/studies/${contextStudyId}/trials` },
|
||||
...(mode === "edit" && trial
|
||||
? [
|
||||
{
|
||||
label: `Trial ${trial.sessionNumber || trial.id.slice(-8)}`,
|
||||
href: `/studies/${contextStudyId}/trials/${trial.id}`,
|
||||
},
|
||||
{ label: "Edit" },
|
||||
]
|
||||
: [{ label: "New Trial" }]),
|
||||
]
|
||||
: [
|
||||
{ label: "Trials", href: `/studies/${contextStudyId}/trials` },
|
||||
...(mode === "edit" && trial
|
||||
? [
|
||||
{
|
||||
label: `Trial ${trial.sessionNumber || trial.id.slice(-8)}`,
|
||||
href: `/studies/${contextStudyId}/trials/${trial.id}`,
|
||||
},
|
||||
{ label: "Edit" },
|
||||
]
|
||||
: [{ label: "New Trial" }]),
|
||||
]),
|
||||
{ label: "Trials", href: `/studies/${contextStudyId}/trials` },
|
||||
...(mode === "edit" && trial
|
||||
? [
|
||||
{
|
||||
label: `Trial ${trial.sessionNumber || trial.id.slice(-8)}`,
|
||||
href: `/studies/${contextStudyId}/trials/${trial.id}`,
|
||||
},
|
||||
{ label: "Edit" },
|
||||
]
|
||||
: [{ label: "New Trial" }]),
|
||||
]),
|
||||
];
|
||||
|
||||
useBreadcrumbsEffect(breadcrumbs);
|
||||
@@ -134,8 +255,8 @@ export function TrialForm({ mode, trialId, studyId }: TrialFormProps) {
|
||||
experimentId: trial.experimentId,
|
||||
participantId: trial?.participantId ?? "",
|
||||
scheduledAt: trial.scheduledAt
|
||||
? new Date(trial.scheduledAt).toISOString().slice(0, 16)
|
||||
: "",
|
||||
? new Date(trial.scheduledAt)
|
||||
: undefined,
|
||||
wizardId: trial.wizardId ?? undefined,
|
||||
notes: trial.notes ?? "",
|
||||
sessionNumber: trial.sessionNumber ?? 1,
|
||||
@@ -153,24 +274,26 @@ export function TrialForm({ mode, trialId, studyId }: TrialFormProps) {
|
||||
|
||||
try {
|
||||
if (mode === "create") {
|
||||
const newTrial = await createTrialMutation.mutateAsync({
|
||||
await createTrialMutation.mutateAsync({
|
||||
experimentId: data.experimentId,
|
||||
participantId: data.participantId,
|
||||
scheduledAt: new Date(data.scheduledAt),
|
||||
scheduledAt: data.scheduledAt,
|
||||
wizardId: data.wizardId,
|
||||
sessionNumber: data.sessionNumber ?? 1,
|
||||
notes: data.notes ?? undefined,
|
||||
});
|
||||
router.push(`/studies/${contextStudyId}/trials/${newTrial!.id}`);
|
||||
// Redirect to trials table instead of detail page
|
||||
router.push(`/studies/${contextStudyId}/trials`);
|
||||
} else {
|
||||
const updatedTrial = await updateTrialMutation.mutateAsync({
|
||||
await updateTrialMutation.mutateAsync({
|
||||
id: trialId!,
|
||||
scheduledAt: new Date(data.scheduledAt),
|
||||
scheduledAt: data.scheduledAt,
|
||||
wizardId: data.wizardId,
|
||||
sessionNumber: data.sessionNumber ?? 1,
|
||||
notes: data.notes ?? undefined,
|
||||
});
|
||||
router.push(`/studies/${contextStudyId}/trials/${updatedTrial!.id}`);
|
||||
// Redirect to trials table on update too
|
||||
router.push(`/studies/${contextStudyId}/trials`);
|
||||
}
|
||||
} catch (error) {
|
||||
setError(
|
||||
@@ -181,9 +304,6 @@ export function TrialForm({ mode, trialId, studyId }: TrialFormProps) {
|
||||
}
|
||||
};
|
||||
|
||||
// Delete handler (trials cannot be deleted in this version)
|
||||
const onDelete = undefined;
|
||||
|
||||
// Loading state for edit mode
|
||||
if (mode === "edit" && isLoading) {
|
||||
return <div>Loading trial...</div>;
|
||||
@@ -194,233 +314,6 @@ export function TrialForm({ mode, trialId, studyId }: TrialFormProps) {
|
||||
return <div>Error loading trial: {fetchError.message}</div>;
|
||||
}
|
||||
|
||||
// Form fields
|
||||
const formFields = (
|
||||
<>
|
||||
<FormSection
|
||||
title="Trial Setup"
|
||||
description="Configure the basic details for this experimental trial."
|
||||
>
|
||||
<FormField>
|
||||
<Label htmlFor="experimentId">Experiment *</Label>
|
||||
<Select
|
||||
value={form.watch("experimentId")}
|
||||
onValueChange={(value) => form.setValue("experimentId", value)}
|
||||
disabled={experimentsLoading || mode === "edit"}
|
||||
>
|
||||
<SelectTrigger
|
||||
className={
|
||||
form.formState.errors.experimentId ? "border-red-500" : ""
|
||||
}
|
||||
>
|
||||
<SelectValue
|
||||
placeholder={
|
||||
experimentsLoading
|
||||
? "Loading experiments..."
|
||||
: "Select an experiment"
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{experimentsData?.map((experiment) => (
|
||||
<SelectItem key={experiment.id} value={experiment.id}>
|
||||
{experiment.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{form.formState.errors.experimentId && (
|
||||
<p className="text-sm text-red-600">
|
||||
{form.formState.errors.experimentId.message}
|
||||
</p>
|
||||
)}
|
||||
{mode === "edit" && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Experiment cannot be changed after creation
|
||||
</p>
|
||||
)}
|
||||
</FormField>
|
||||
|
||||
<FormField>
|
||||
<Label htmlFor="participantId">Participant *</Label>
|
||||
<Select
|
||||
value={form.watch("participantId")}
|
||||
onValueChange={(value) => form.setValue("participantId", value)}
|
||||
disabled={participantsLoading || mode === "edit"}
|
||||
>
|
||||
<SelectTrigger
|
||||
className={
|
||||
form.formState.errors.participantId ? "border-red-500" : ""
|
||||
}
|
||||
>
|
||||
<SelectValue
|
||||
placeholder={
|
||||
participantsLoading
|
||||
? "Loading participants..."
|
||||
: "Select a participant"
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{participantsData?.participants?.map((participant) => (
|
||||
<SelectItem key={participant.id} value={participant.id}>
|
||||
{participant.name ?? participant.participantCode} (
|
||||
{participant.participantCode})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{form.formState.errors.participantId && (
|
||||
<p className="text-sm text-red-600">
|
||||
{form.formState.errors.participantId.message}
|
||||
</p>
|
||||
)}
|
||||
{mode === "edit" && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Participant cannot be changed after creation
|
||||
</p>
|
||||
)}
|
||||
</FormField>
|
||||
|
||||
<FormField>
|
||||
<Label htmlFor="scheduledAt">Scheduled Date & Time *</Label>
|
||||
<Input
|
||||
id="scheduledAt"
|
||||
type="datetime-local"
|
||||
{...form.register("scheduledAt")}
|
||||
className={
|
||||
form.formState.errors.scheduledAt ? "border-red-500" : ""
|
||||
}
|
||||
/>
|
||||
{form.formState.errors.scheduledAt && (
|
||||
<p className="text-sm text-red-600">
|
||||
{form.formState.errors.scheduledAt.message}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-muted-foreground text-xs">
|
||||
When should this trial be conducted?
|
||||
</p>
|
||||
</FormField>
|
||||
|
||||
<FormField>
|
||||
<Label htmlFor="sessionNumber">Session Number</Label>
|
||||
<Input
|
||||
id="sessionNumber"
|
||||
type="number"
|
||||
min="1"
|
||||
{...form.register("sessionNumber", { valueAsNumber: true })}
|
||||
placeholder="1"
|
||||
className={
|
||||
form.formState.errors.sessionNumber ? "border-red-500" : ""
|
||||
}
|
||||
/>
|
||||
{form.formState.errors.sessionNumber && (
|
||||
<p className="text-sm text-red-600">
|
||||
{form.formState.errors.sessionNumber.message}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Session number for this participant (for multi-session studies)
|
||||
</p>
|
||||
</FormField>
|
||||
</FormSection>
|
||||
|
||||
<FormSection
|
||||
title="Assignment & Notes"
|
||||
description="Optional wizard assignment and trial-specific notes."
|
||||
>
|
||||
<FormField>
|
||||
<Label htmlFor="wizardId">Assigned Wizard</Label>
|
||||
<Select
|
||||
value={form.watch("wizardId") ?? "none"}
|
||||
onValueChange={(value) =>
|
||||
form.setValue("wizardId", value === "none" ? undefined : value)
|
||||
}
|
||||
disabled={usersLoading}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue
|
||||
placeholder={
|
||||
usersLoading
|
||||
? "Loading wizards..."
|
||||
: "Select a wizard (optional)"
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">No wizard assigned</SelectItem>
|
||||
{usersData?.map(
|
||||
(user: { id: string; name: string; email: string }) => (
|
||||
<SelectItem key={user.id} value={user.id}>
|
||||
{user.name} ({user.email})
|
||||
</SelectItem>
|
||||
),
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Optional: Assign a specific wizard to operate this trial
|
||||
</p>
|
||||
</FormField>
|
||||
|
||||
<FormField>
|
||||
<Label htmlFor="notes">Trial Notes</Label>
|
||||
<Textarea
|
||||
id="notes"
|
||||
{...form.register("notes")}
|
||||
placeholder="Special instructions, conditions, or notes for this trial..."
|
||||
rows={3}
|
||||
className={form.formState.errors.notes ? "border-red-500" : ""}
|
||||
/>
|
||||
{form.formState.errors.notes && (
|
||||
<p className="text-sm text-red-600">
|
||||
{form.formState.errors.notes.message}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Optional: Notes about special conditions, instructions, or context
|
||||
for this trial
|
||||
</p>
|
||||
</FormField>
|
||||
</FormSection>
|
||||
</>
|
||||
);
|
||||
|
||||
// Sidebar content
|
||||
const sidebar = (
|
||||
<>
|
||||
<NextSteps
|
||||
steps={[
|
||||
{
|
||||
title: "Execute Trial",
|
||||
description: "Use the wizard interface to run the trial",
|
||||
completed: mode === "edit",
|
||||
},
|
||||
{
|
||||
title: "Monitor Progress",
|
||||
description: "Track trial execution and data collection",
|
||||
},
|
||||
{
|
||||
title: "Review Data",
|
||||
description: "Analyze collected trial data and results",
|
||||
},
|
||||
{
|
||||
title: "Generate Reports",
|
||||
description: "Export data and create analysis reports",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<Tips
|
||||
tips={[
|
||||
"Schedule ahead: Allow sufficient time between trials for setup and data review.",
|
||||
"Assign wizards: Pre-assign experienced wizards to complex trials.",
|
||||
"Document conditions: Use notes to record any special circumstances or variations.",
|
||||
"Test connectivity: Verify robot and system connections before scheduled trials.",
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<EntityForm
|
||||
mode={mode}
|
||||
@@ -443,14 +336,196 @@ export function TrialForm({ mode, trialId, studyId }: TrialFormProps) {
|
||||
onSubmit={onSubmit}
|
||||
isSubmitting={isSubmitting}
|
||||
error={error}
|
||||
onDelete={
|
||||
mode === "edit" && trial?.status === "scheduled" ? onDelete : undefined
|
||||
}
|
||||
isDeleting={isDeleting}
|
||||
sidebar={sidebar}
|
||||
sidebar={undefined}
|
||||
submitText={mode === "create" ? "Schedule Trial" : "Save Changes"}
|
||||
layout="full-width"
|
||||
>
|
||||
{formFields}
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-3">
|
||||
{/* Left Column: Main Info (Spans 2) */}
|
||||
<div className="space-y-6 md:col-span-2">
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
<FormField>
|
||||
<Label htmlFor="experimentId">Experiment *</Label>
|
||||
<Select
|
||||
value={form.watch("experimentId")}
|
||||
onValueChange={(value) => form.setValue("experimentId", value)}
|
||||
disabled={experimentsLoading || mode === "edit"}
|
||||
>
|
||||
<SelectTrigger
|
||||
className={
|
||||
form.formState.errors.experimentId ? "border-red-500" : ""
|
||||
}
|
||||
>
|
||||
<SelectValue
|
||||
placeholder={
|
||||
experimentsLoading
|
||||
? "Loading experiments..."
|
||||
: "Select an experiment"
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{experimentsData?.map((experiment) => (
|
||||
<SelectItem key={experiment.id} value={experiment.id}>
|
||||
{experiment.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{form.formState.errors.experimentId && (
|
||||
<p className="text-sm text-red-600">
|
||||
{form.formState.errors.experimentId.message}
|
||||
</p>
|
||||
)}
|
||||
{mode === "edit" && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Experiment cannot be changed after creation
|
||||
</p>
|
||||
)}
|
||||
</FormField>
|
||||
|
||||
<FormField>
|
||||
<Label htmlFor="participantId">Participant *</Label>
|
||||
<Select
|
||||
value={form.watch("participantId")}
|
||||
onValueChange={(value) => form.setValue("participantId", value)}
|
||||
disabled={participantsLoading || mode === "edit"}
|
||||
>
|
||||
<SelectTrigger
|
||||
className={
|
||||
form.formState.errors.participantId ? "border-red-500" : ""
|
||||
}
|
||||
>
|
||||
<SelectValue
|
||||
placeholder={
|
||||
participantsLoading
|
||||
? "Loading participants..."
|
||||
: "Select a participant"
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{participantsData?.participants?.map((participant) => (
|
||||
<SelectItem key={participant.id} value={participant.id}>
|
||||
{participant.name ?? participant.participantCode} (
|
||||
{participant.participantCode})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{form.formState.errors.participantId && (
|
||||
<p className="text-sm text-red-600">
|
||||
{form.formState.errors.participantId.message}
|
||||
</p>
|
||||
)}
|
||||
{mode === "edit" && (
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Participant cannot be changed after creation
|
||||
</p>
|
||||
)}
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
<FormField>
|
||||
<Label htmlFor="scheduledAt">Scheduled Date & Time *</Label>
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="scheduledAt"
|
||||
render={({ field }) => (
|
||||
<DateTimePicker
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{form.formState.errors.scheduledAt && (
|
||||
<p className="text-sm text-red-600">
|
||||
{form.formState.errors.scheduledAt.message}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-muted-foreground text-xs">
|
||||
When should this trial be conducted?
|
||||
</p>
|
||||
</FormField>
|
||||
|
||||
<FormField>
|
||||
<Label htmlFor="sessionNumber">Session Number</Label>
|
||||
<Input
|
||||
id="sessionNumber"
|
||||
type="number"
|
||||
min="1"
|
||||
{...form.register("sessionNumber", { valueAsNumber: true })}
|
||||
placeholder="1"
|
||||
className={
|
||||
form.formState.errors.sessionNumber ? "border-red-500" : ""
|
||||
}
|
||||
/>
|
||||
{form.formState.errors.sessionNumber && (
|
||||
<p className="text-sm text-red-600">
|
||||
{form.formState.errors.sessionNumber.message}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Auto-incremented based on participant history
|
||||
</p>
|
||||
</FormField>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right Column: Assignment & Notes (Spans 1) */}
|
||||
<div className="space-y-6">
|
||||
<FormField>
|
||||
<Label htmlFor="wizardId">Assigned Wizard</Label>
|
||||
<Select
|
||||
value={form.watch("wizardId") ?? "none"}
|
||||
onValueChange={(value) =>
|
||||
form.setValue("wizardId", value === "none" ? undefined : value)
|
||||
}
|
||||
disabled={usersLoading}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue
|
||||
placeholder={
|
||||
usersLoading
|
||||
? "Loading wizards..."
|
||||
: "Select a wizard (optional)"
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">No wizard assigned</SelectItem>
|
||||
{usersData?.map(
|
||||
(user: { id: string; name: string; email: string }) => (
|
||||
<SelectItem key={user.id} value={user.id}>
|
||||
{user.name} ({user.email})
|
||||
</SelectItem>
|
||||
),
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
Who will operate the robot?
|
||||
</p>
|
||||
</FormField>
|
||||
|
||||
<FormField>
|
||||
<Label htmlFor="notes">Notes</Label>
|
||||
<Textarea
|
||||
id="notes"
|
||||
{...form.register("notes")}
|
||||
placeholder="Special instructions..."
|
||||
rows={5}
|
||||
className={form.formState.errors.notes ? "border-red-500" : ""}
|
||||
/>
|
||||
{form.formState.errors.notes && (
|
||||
<p className="text-sm text-red-600">
|
||||
{form.formState.errors.notes.message}
|
||||
</p>
|
||||
)}
|
||||
</FormField>
|
||||
</div>
|
||||
</div>
|
||||
</EntityForm>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import { type ColumnDef } from "@tanstack/react-table";
|
||||
import { ArrowUpDown, ChevronDown, MoreHorizontal, Copy, Eye, Play, Gamepad2, LineChart, Ban } from "lucide-react";
|
||||
import {
|
||||
ArrowUpDown,
|
||||
ChevronDown,
|
||||
MoreHorizontal,
|
||||
Play,
|
||||
Gamepad2,
|
||||
LineChart,
|
||||
Ban,
|
||||
Printer,
|
||||
} from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import { format, formatDistanceToNow } from "date-fns";
|
||||
@@ -108,12 +117,24 @@ export const columns: ColumnDef<Trial>[] = [
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const sessionNumber = row.getValue("sessionNumber");
|
||||
const status = row.original.status;
|
||||
const trialId = row.original.id;
|
||||
const studyId = row.original.studyId;
|
||||
|
||||
let href = `/studies/${studyId}/trials/${trialId}`; // Fallback
|
||||
if (status === "scheduled" || status === "in_progress") {
|
||||
href = `/studies/${studyId}/trials/${trialId}/wizard`;
|
||||
} else if (status === "completed") {
|
||||
href = `/studies/${studyId}/trials/${trialId}/analysis`;
|
||||
} else {
|
||||
// for aborted/failed, maybe still link to detail or nowhere?
|
||||
// Let's keep detail for now as a fallback for metadata
|
||||
href = `/studies/${studyId}/trials/${trialId}`;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="font-mono text-sm">
|
||||
<Link
|
||||
href={`/studies/${row.original.studyId}/trials/${row.original.id}`}
|
||||
className="hover:underline"
|
||||
>
|
||||
<Link href={href} className="hover:underline">
|
||||
#{Number(sessionNumber)}
|
||||
</Link>
|
||||
</div>
|
||||
@@ -225,12 +246,7 @@ export const columns: ColumnDef<Trial>[] = [
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Badge className={statusInfo.className}>
|
||||
{statusInfo.label}
|
||||
</Badge>
|
||||
);
|
||||
|
||||
return <Badge className={statusInfo.className}>{statusInfo.label}</Badge>;
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -331,33 +347,7 @@ export const columns: ColumnDef<Trial>[] = [
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "createdAt",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
||||
>
|
||||
Created
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const date = row.getValue("createdAt");
|
||||
if (!date)
|
||||
return <span className="text-muted-foreground text-sm">Unknown</span>;
|
||||
|
||||
return (
|
||||
<div className="text-muted-foreground text-sm">
|
||||
{formatDistanceToNow(new Date(date as string | number | Date), {
|
||||
addSuffix: true,
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
enableHiding: false,
|
||||
@@ -369,81 +359,60 @@ function ActionsCell({ row }: { row: { original: Trial } }) {
|
||||
const trial = row.original;
|
||||
// ActionsCell is a component rendered by the table.
|
||||
|
||||
// importing useRouter is fine.
|
||||
|
||||
const utils = api.useUtils();
|
||||
const duplicateMutation = api.trials.duplicate.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.trials.list.invalidate();
|
||||
// toast.success("Trial duplicated"); // We need toast
|
||||
},
|
||||
});
|
||||
|
||||
if (!trial?.id) {
|
||||
return <span className="text-muted-foreground text-sm">No actions</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||
<span className="sr-only">Open menu</span>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigator.clipboard.writeText(trial.id)}
|
||||
>
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
Copy ID
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/studies/${trial.studyId}/trials/${trial.id}`}>
|
||||
<Eye className="mr-2 h-4 w-4" />
|
||||
Details
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
{trial.status === "scheduled" && (
|
||||
<Button size="sm" asChild>
|
||||
<Link href={`/studies/${trial.studyId}/trials/${trial.id}/wizard`}>
|
||||
<Play className="mr-1.5 h-3.5 w-3.5" />
|
||||
Start
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
{trial.status === "scheduled" && (
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/studies/${trial.studyId}/trials/${trial.id}/wizard`}>
|
||||
<Play className="mr-2 h-4 w-4" />
|
||||
Start Trial
|
||||
</Button>
|
||||
)}
|
||||
{trial.status === "in_progress" && (
|
||||
<Button size="sm" variant="secondary" asChild>
|
||||
<Link href={`/studies/${trial.studyId}/trials/${trial.id}/wizard`}>
|
||||
<Gamepad2 className="mr-1.5 h-3.5 w-3.5" />
|
||||
Control
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
{trial.status === "completed" && (
|
||||
<>
|
||||
<Button size="sm" variant="outline" asChild>
|
||||
<Link
|
||||
href={`/studies/${trial.studyId}/trials/${trial.id}/analysis`}
|
||||
>
|
||||
<LineChart className="mr-1.5 h-3.5 w-3.5" />
|
||||
View
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{trial.status === "in_progress" && (
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/studies/${trial.studyId}/trials/${trial.id}/wizard`}>
|
||||
<Gamepad2 className="mr-2 h-4 w-4" />
|
||||
Control Trial
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" asChild>
|
||||
{/* We link to the analysis page with a query param to trigger print/export */}
|
||||
<Link
|
||||
href={`/studies/${trial.studyId}/trials/${trial.id}/analysis?export=true`}
|
||||
>
|
||||
<Printer className="mr-1.5 h-3.5 w-3.5" />
|
||||
Export
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{trial.status === "completed" && (
|
||||
<DropdownMenuItem asChild>
|
||||
<Link href={`/studies/${trial.studyId}/trials/${trial.id}/analysis`}>
|
||||
<LineChart className="mr-2 h-4 w-4" />
|
||||
Analysis
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => duplicateMutation.mutate({ id: trial.id })}>
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
Duplicate
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
{(trial.status === "scheduled" || trial.status === "failed") && (
|
||||
<DropdownMenuItem className="text-red-600">
|
||||
<Ban className="mr-2 h-4 w-4" />
|
||||
Cancel
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{(trial.status === "scheduled" || trial.status === "failed") && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="text-muted-foreground h-8 w-8 p-0 hover:text-red-600"
|
||||
>
|
||||
<Ban className="h-4 w-4" />
|
||||
<span className="sr-only">Cancel</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user