various bug fixes and feature additions per Alec's requests
This commit is contained in:
parent
b649793013
commit
8dcbe7efee
27 changed files with 2384 additions and 2942 deletions
Binary file not shown.
|
|
@ -657,7 +657,7 @@ class FocusGroup:
|
|||
# Prepare the mode event
|
||||
mode_event = {
|
||||
"focus_group_id": focus_group_id,
|
||||
"event_type": event_type, # 'ai_mode_started' or 'manual_mode_started'
|
||||
"event_type": event_type, # 'ai_mode_started', 'manual_mode_started', or 'ai_session_concluded'
|
||||
"timestamp": datetime.utcnow(),
|
||||
"user_id": user_id, # None for system-initiated changes
|
||||
"created_at": datetime.utcnow()
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -700,6 +700,18 @@ class AIModeratorService:
|
|||
'status': 'completed'
|
||||
})
|
||||
|
||||
# Add mode event for AI auto-completion (only for auto_complete reason)
|
||||
if reason == 'auto_complete':
|
||||
mode_event_id = FocusGroup.add_mode_event(
|
||||
focus_group_id=focus_group_id,
|
||||
event_type='ai_session_concluded'
|
||||
)
|
||||
|
||||
if mode_event_id:
|
||||
print(f"🎯 Added AI session concluded mode event for focus group {focus_group_id}")
|
||||
else:
|
||||
print(f"Warning: Failed to add AI session concluded mode event for focus group {focus_group_id}")
|
||||
|
||||
print(f"🎬 Session ended for focus group {focus_group_id} with reason: {reason}")
|
||||
|
||||
return {
|
||||
|
|
|
|||
1
dist/assets/index-ByQ3S_f0.css
vendored
Normal file
1
dist/assets/index-ByQ3S_f0.css
vendored
Normal file
File diff suppressed because one or more lines are too long
1
dist/assets/index-CMEVr6tk.css
vendored
1
dist/assets/index-CMEVr6tk.css
vendored
File diff suppressed because one or more lines are too long
732
dist/assets/index-Dod4tGHl.js
vendored
732
dist/assets/index-Dod4tGHl.js
vendored
File diff suppressed because one or more lines are too long
723
dist/assets/index-ImyDGn9B.js
vendored
Normal file
723
dist/assets/index-ImyDGn9B.js
vendored
Normal file
File diff suppressed because one or more lines are too long
4
dist/index.html
vendored
4
dist/index.html
vendored
|
|
@ -7,8 +7,8 @@
|
|||
<meta name="description" content="Lovable Generated Project" />
|
||||
<meta name="author" content="Lovable" />
|
||||
<meta property="og:image" content="/og-image.png" />
|
||||
<script type="module" crossorigin src="/semblance/assets/index-Dod4tGHl.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/semblance/assets/index-CMEVr6tk.css">
|
||||
<script type="module" crossorigin src="/semblance/assets/index-ImyDGn9B.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/semblance/assets/index-ByQ3S_f0.css">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import PersonaProfile from "./components/persona/PersonaProfile";
|
|||
import Login from "./pages/Login";
|
||||
import ProtectedRoute from "./components/ProtectedRoute";
|
||||
import { AuthProvider } from "./contexts/AuthContext";
|
||||
import { NavigationProvider } from "./contexts/NavigationContext";
|
||||
|
||||
// CSS for consistent back button positioning
|
||||
import "./styles/backButton.css";
|
||||
|
|
@ -23,7 +24,8 @@ const App = () => (
|
|||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter basename="/semblance">
|
||||
<AuthProvider>
|
||||
<TooltipProvider>
|
||||
<NavigationProvider>
|
||||
<TooltipProvider>
|
||||
<Toaster />
|
||||
<Routes>
|
||||
<Route path="/" element={<Index />} />
|
||||
|
|
@ -71,7 +73,8 @@ const App = () => (
|
|||
{/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */}
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Routes>
|
||||
</TooltipProvider>
|
||||
</TooltipProvider>
|
||||
</NavigationProvider>
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
|
|
|
|||
|
|
@ -154,7 +154,7 @@ export default function AIRecruiter({ targetFolderId, targetFolderName }: AIRecr
|
|||
}
|
||||
|
||||
// Navigate directly back to synthetic users list
|
||||
navigate('/synthetic-users');
|
||||
navigate('/synthetic-users?mode=view');
|
||||
} else {
|
||||
throw new Error("No personas were generated");
|
||||
}
|
||||
|
|
@ -307,7 +307,7 @@ export default function AIRecruiter({ targetFolderId, targetFolderName }: AIRecr
|
|||
savePersonas(approved);
|
||||
|
||||
// Redirect to the persona library view instead of resetting
|
||||
navigate('/synthetic-users');
|
||||
navigate('/synthetic-users?mode=view');
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import { useNavigation } from '@/contexts/NavigationContext';
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
|
|
@ -75,7 +76,6 @@ import {
|
|||
import { Label } from "@/components/ui/label";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import UserCard from "@/components/UserCard";
|
||||
import PersonaDetailsModal from "@/components/PersonaDetailsModal";
|
||||
import { Persona } from "@/types/persona";
|
||||
|
||||
// Define folder interface
|
||||
|
|
@ -129,11 +129,14 @@ const sampleGuide = {
|
|||
interface FocusGroupModeratorProps {
|
||||
draftToEdit?: any | null;
|
||||
onDraftSaved?: () => void;
|
||||
preSelectedParticipants?: string[];
|
||||
}
|
||||
|
||||
export default function FocusGroupModerator({ draftToEdit, onDraftSaved }: FocusGroupModeratorProps = {}) {
|
||||
export default function FocusGroupModerator({ draftToEdit, onDraftSaved, preSelectedParticipants = [] }: FocusGroupModeratorProps = {}) {
|
||||
console.log('FocusGroupModerator component rendering, draftToEdit:', draftToEdit);
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { setPreviousRoute, navigationState, clearNavigationState } = useNavigation();
|
||||
const [activeTab, setActiveTab] = useState('setup');
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [guideGenerationComplete, setGuideGenerationComplete] = useState(false);
|
||||
|
|
@ -141,6 +144,9 @@ export default function FocusGroupModerator({ draftToEdit, onDraftSaved }: Focus
|
|||
const [discussionGuide, setDiscussionGuide] = useState<string | any | null>(null);
|
||||
const [draftFocusGroupId, setDraftFocusGroupId] = useState<string | null>(null);
|
||||
|
||||
// Track if discussion guide is being edited to prevent updates during editing
|
||||
const [isEditingGuide, setIsEditingGuide] = useState(false);
|
||||
|
||||
// Ref to access current discussionGuide in callbacks without adding it as dependency
|
||||
const discussionGuideRef = useRef(discussionGuide);
|
||||
discussionGuideRef.current = discussionGuide;
|
||||
|
|
@ -188,20 +194,32 @@ export default function FocusGroupModerator({ draftToEdit, onDraftSaved }: Focus
|
|||
ethnicity: [],
|
||||
});
|
||||
|
||||
// Persona details modal state
|
||||
const [isPersonaModalOpen, setIsPersonaModalOpen] = useState(false);
|
||||
const [selectedPersonaForModal, setSelectedPersonaForModal] = useState<Persona | null>(null);
|
||||
|
||||
// Handler for opening persona details modal
|
||||
const handleOpenPersonaModal = (persona: Persona) => {
|
||||
setSelectedPersonaForModal(persona);
|
||||
setIsPersonaModalOpen(true);
|
||||
};
|
||||
// Auto-save state management
|
||||
const [autoSaveStatus, setAutoSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle');
|
||||
const [lastSavedData, setLastSavedData] = useState<any>(null);
|
||||
const [saveRetryCount, setSaveRetryCount] = useState(0);
|
||||
const debouncedSaveTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const isSavingRef = useRef(false);
|
||||
const isLoadingDraftRef = useRef(false);
|
||||
|
||||
// Handler for closing persona details modal
|
||||
const handleClosePersonaModal = () => {
|
||||
setIsPersonaModalOpen(false);
|
||||
setSelectedPersonaForModal(null);
|
||||
// Handler for persona view details navigation
|
||||
const handlePersonaViewDetails = (persona: Persona) => {
|
||||
// Set navigation context with current focus group state
|
||||
setPreviousRoute('/focus-groups', {
|
||||
focusGroupId: draftFocusGroupId,
|
||||
focusGroupTab: 'participants',
|
||||
isNewFocusGroup: !draftToEdit,
|
||||
focusGroupData: {
|
||||
name: form.getValues('name'),
|
||||
description: form.getValues('description'),
|
||||
selectedParticipants: selectedParticipants,
|
||||
discussionGuide: discussionGuide,
|
||||
}
|
||||
});
|
||||
|
||||
// Navigate to persona profile page
|
||||
navigate(`/synthetic-users/${persona.id}`);
|
||||
};
|
||||
|
||||
// Function to collect unique filter options from personas
|
||||
|
|
@ -514,15 +532,167 @@ export default function FocusGroupModerator({ draftToEdit, onDraftSaved }: Focus
|
|||
},
|
||||
});
|
||||
console.log('Form initialized successfully');
|
||||
|
||||
// Simplified auto-save trigger function - only debounced save on changes
|
||||
const triggerAutoSave = () => {
|
||||
if (activeTab !== 'setup' || isLoadingDraftRef.current) return;
|
||||
|
||||
// Clear existing debounced timer
|
||||
if (debouncedSaveTimerRef.current) {
|
||||
clearTimeout(debouncedSaveTimerRef.current);
|
||||
}
|
||||
|
||||
// Schedule debounced save - create inline function to avoid dependencies
|
||||
debouncedSaveTimerRef.current = setTimeout(async () => {
|
||||
if (isSavingRef.current) return;
|
||||
|
||||
const values = form.getValues();
|
||||
const currentData = {
|
||||
name: values.focusGroupName || '',
|
||||
description: values.researchBrief || '',
|
||||
objective: values.researchBrief || '',
|
||||
topic: values.discussionTopics || '',
|
||||
duration: values.duration ? parseInt(values.duration) : 60,
|
||||
llm_model: values.llm_model || 'gemini-2.5-pro',
|
||||
participants: selectedParticipants,
|
||||
participants_count: selectedParticipants.length,
|
||||
status: 'draft',
|
||||
date: new Date().toISOString(),
|
||||
uploadedAssets: uploadedAssets.map(file => file.name)
|
||||
};
|
||||
|
||||
if (lastSavedData && JSON.stringify(currentData) === JSON.stringify(lastSavedData)) {
|
||||
return; // No changes
|
||||
}
|
||||
|
||||
if (!currentData.name && !currentData.description && !currentData.topic) {
|
||||
return; // Don't save empty form
|
||||
}
|
||||
|
||||
isSavingRef.current = true;
|
||||
setAutoSaveStatus('saving');
|
||||
|
||||
try {
|
||||
// Use draftFocusGroupId from state, or fall back to draftToEdit ID if available
|
||||
let focusGroupId = draftFocusGroupId || (draftToEdit?.id || draftToEdit?._id);
|
||||
console.log("Auto-save: draftFocusGroupId =", draftFocusGroupId);
|
||||
console.log("Auto-save: draftToEdit ID =", draftToEdit?.id || draftToEdit?._id);
|
||||
console.log("Auto-save: using focusGroupId =", focusGroupId);
|
||||
console.log("Auto-save: llm_model in currentData =", currentData.llm_model);
|
||||
console.log("Auto-save: duration in currentData =", currentData.duration);
|
||||
|
||||
if (!focusGroupId) {
|
||||
console.log("Auto-save: Creating NEW focus group (no existing ID)");
|
||||
const response = await focusGroupsApi.create(currentData);
|
||||
focusGroupId = response.data.focus_group_id || response.data.id || response.data._id;
|
||||
setDraftFocusGroupId(focusGroupId);
|
||||
console.log("Auto-save: Created new draft with ID:", focusGroupId);
|
||||
} else {
|
||||
console.log("Auto-save: Updating existing focus group:", focusGroupId);
|
||||
await focusGroupsApi.update(focusGroupId, currentData);
|
||||
console.log("Auto-save: Updated existing draft:", focusGroupId);
|
||||
}
|
||||
|
||||
setLastSavedData(currentData);
|
||||
setAutoSaveStatus('saved');
|
||||
setSaveRetryCount(0);
|
||||
|
||||
setTimeout(() => {
|
||||
setAutoSaveStatus('idle');
|
||||
}, 2000);
|
||||
|
||||
} catch (error) {
|
||||
console.error("Auto-save failed:", error);
|
||||
setAutoSaveStatus('error');
|
||||
setSaveRetryCount(prev => prev + 1);
|
||||
|
||||
if (saveRetryCount < 3) {
|
||||
const retryDelay = Math.pow(2, saveRetryCount) * 2000;
|
||||
setTimeout(() => {
|
||||
triggerAutoSave();
|
||||
}, retryDelay);
|
||||
} else {
|
||||
toast.error("Auto-save failed", {
|
||||
description: "Your changes may not be saved. Please check your connection.",
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
isSavingRef.current = false;
|
||||
}
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
// Watch for form field changes to trigger auto-save
|
||||
const watchedFields = form.watch();
|
||||
|
||||
// Use refs to track previous values to prevent unnecessary saves
|
||||
const prevWatchedFieldsRef = useRef<string>('');
|
||||
const prevSelectedParticipantsRef = useRef<string>('');
|
||||
const prevUploadedAssetsRef = useRef<string>('');
|
||||
|
||||
// Effect to handle form field changes and trigger auto-save
|
||||
useEffect(() => {
|
||||
const currentWatchedFields = JSON.stringify(watchedFields);
|
||||
if (activeTab === 'setup' && currentWatchedFields !== prevWatchedFieldsRef.current) {
|
||||
prevWatchedFieldsRef.current = currentWatchedFields;
|
||||
triggerAutoSave();
|
||||
}
|
||||
}, [watchedFields, activeTab]);
|
||||
|
||||
// Effect to handle participant changes
|
||||
useEffect(() => {
|
||||
const currentParticipants = JSON.stringify(selectedParticipants);
|
||||
if (activeTab === 'setup' && currentParticipants !== prevSelectedParticipantsRef.current) {
|
||||
prevSelectedParticipantsRef.current = currentParticipants;
|
||||
triggerAutoSave();
|
||||
}
|
||||
}, [selectedParticipants, activeTab]);
|
||||
|
||||
// Effect to handle uploaded assets changes
|
||||
useEffect(() => {
|
||||
const currentAssets = JSON.stringify(uploadedAssets.map(f => f.name));
|
||||
if (activeTab === 'setup' && currentAssets !== prevUploadedAssetsRef.current) {
|
||||
prevUploadedAssetsRef.current = currentAssets;
|
||||
triggerAutoSave();
|
||||
}
|
||||
}, [uploadedAssets, activeTab]);
|
||||
|
||||
// Effect to clear timers when leaving setup tab or component unmounts
|
||||
useEffect(() => {
|
||||
if (activeTab !== 'setup') {
|
||||
// Clear debounced timer when leaving setup tab
|
||||
if (debouncedSaveTimerRef.current) {
|
||||
clearTimeout(debouncedSaveTimerRef.current);
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup timer on unmount
|
||||
return () => {
|
||||
if (debouncedSaveTimerRef.current) {
|
||||
clearTimeout(debouncedSaveTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, [activeTab]);
|
||||
|
||||
// Effect to load draft data when editing an existing draft
|
||||
useEffect(() => {
|
||||
console.log("Draft loading effect - draftToEdit:", draftToEdit, "draftLoadedRef.current:", draftLoadedRef.current);
|
||||
|
||||
// Reset loaded flag when draftToEdit changes
|
||||
if (!draftToEdit) {
|
||||
draftLoadedRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (draftToEdit && !draftLoadedRef.current) {
|
||||
console.log("Loading draft focus group:", draftToEdit);
|
||||
isLoadingDraftRef.current = true; // Prevent auto-save during loading
|
||||
draftLoadedRef.current = true; // Mark as loaded to prevent re-loading
|
||||
|
||||
// Set the draft ID
|
||||
setDraftFocusGroupId(draftToEdit.id || draftToEdit._id);
|
||||
const draftId = draftToEdit.id || draftToEdit._id;
|
||||
setDraftFocusGroupId(draftId);
|
||||
console.log("Setting draft ID from draftToEdit:", draftId);
|
||||
|
||||
// Load form data if available
|
||||
if (draftToEdit.name) {
|
||||
|
|
@ -537,12 +707,17 @@ export default function FocusGroupModerator({ draftToEdit, onDraftSaved }: Focus
|
|||
if (draftToEdit.duration) {
|
||||
form.setValue('duration', draftToEdit.duration.toString());
|
||||
}
|
||||
if (draftToEdit.llm_model) {
|
||||
form.setValue('llm_model', draftToEdit.llm_model);
|
||||
}
|
||||
|
||||
// Load discussion guide if available
|
||||
if (draftToEdit.discussionGuide) {
|
||||
setDiscussionGuide(draftToEdit.discussionGuide);
|
||||
// If we have a discussion guide, start on the review tab
|
||||
setActiveTab('review');
|
||||
// If we have a discussion guide and no navigation state override, start on the review tab
|
||||
if (!navigationState.focusGroupTab || navigationState.previousRoute !== '/focus-groups') {
|
||||
setActiveTab('review');
|
||||
}
|
||||
}
|
||||
|
||||
// Load selected participants if available
|
||||
|
|
@ -550,12 +725,85 @@ export default function FocusGroupModerator({ draftToEdit, onDraftSaved }: Focus
|
|||
setSelectedParticipants(draftToEdit.participants);
|
||||
}
|
||||
|
||||
// Set lastSavedData to current draft state to prevent immediate auto-save
|
||||
const currentDraftData = {
|
||||
name: draftToEdit.name || '',
|
||||
description: draftToEdit.description || draftToEdit.objective || '',
|
||||
objective: draftToEdit.description || draftToEdit.objective || '',
|
||||
topic: draftToEdit.topic || '',
|
||||
duration: draftToEdit.duration || 60,
|
||||
llm_model: draftToEdit.llm_model || 'gemini-2.5-pro',
|
||||
participants: draftToEdit.participants || [],
|
||||
participants_count: (draftToEdit.participants || []).length,
|
||||
status: 'draft',
|
||||
date: draftToEdit.date || new Date().toISOString(),
|
||||
uploadedAssets: []
|
||||
};
|
||||
setLastSavedData(currentDraftData);
|
||||
console.log("Set lastSavedData to current draft:", currentDraftData);
|
||||
|
||||
toast.success("Draft focus group loaded", {
|
||||
description: "Continue editing your focus group setup"
|
||||
});
|
||||
|
||||
// Allow auto-save after loading is complete
|
||||
setTimeout(() => {
|
||||
isLoadingDraftRef.current = false;
|
||||
}, 1000); // Give it a second to settle
|
||||
}
|
||||
}, [draftToEdit, form]);
|
||||
|
||||
// Effect to handle pre-selected participants from persona list
|
||||
useEffect(() => {
|
||||
if (preSelectedParticipants.length > 0) {
|
||||
console.log("Pre-selected participants received:", preSelectedParticipants);
|
||||
setSelectedParticipants(preSelectedParticipants);
|
||||
// Auto-switch to participants tab to show the pre-selected personas
|
||||
setActiveTab('participants');
|
||||
}
|
||||
}, [preSelectedParticipants]);
|
||||
|
||||
// Handle navigation state to set the correct tab when returning from persona details
|
||||
useEffect(() => {
|
||||
if (navigationState.focusGroupTab && navigationState.previousRoute === '/focus-groups') {
|
||||
// Use setTimeout to ensure this runs after other tab-setting logic
|
||||
setTimeout(() => {
|
||||
setActiveTab(navigationState.focusGroupTab);
|
||||
// Clear navigation state after using it
|
||||
clearNavigationState();
|
||||
}, 0);
|
||||
}
|
||||
}, [navigationState.focusGroupTab, draftToEdit, clearNavigationState]); // Also depend on draftToEdit so this runs after draft loading
|
||||
|
||||
// Initialize refs on mount for new focus groups (not editing drafts)
|
||||
useEffect(() => {
|
||||
if (!draftToEdit) {
|
||||
setTimeout(() => {
|
||||
isLoadingDraftRef.current = false;
|
||||
}, 500); // Allow initial render to complete
|
||||
}
|
||||
}, [draftToEdit]);
|
||||
|
||||
|
||||
// Save Status Indicator Component
|
||||
const SaveStatusIndicator = () => {
|
||||
if (autoSaveStatus === 'idle') return null;
|
||||
|
||||
const statusConfig = {
|
||||
saving: { text: 'Saving...', className: 'text-blue-600 bg-blue-50' },
|
||||
saved: { text: 'All changes saved', className: 'text-green-600 bg-green-50' },
|
||||
error: { text: 'Save failed - retrying...', className: 'text-red-600 bg-red-50' }
|
||||
};
|
||||
|
||||
const config = statusConfig[autoSaveStatus];
|
||||
|
||||
return (
|
||||
<div className={`fixed top-16 left-1/2 transform -translate-x-1/2 z-50 px-3 py-1 rounded-md text-sm font-medium border shadow-sm ${config.className}`}>
|
||||
{config.text}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Function to generate a discussion guide via the API
|
||||
const generateDiscussionGuide = async (values: z.infer<typeof formSchema>, focusGroupId?: string): Promise<string> => {
|
||||
// Reset states
|
||||
|
|
@ -902,41 +1150,12 @@ true;
|
|||
|
||||
console.log("New selection:", newSelection);
|
||||
|
||||
// Auto-save participant selection if we have a draft
|
||||
if (draftFocusGroupId && discussionGuide) {
|
||||
saveDraftParticipants(newSelection);
|
||||
}
|
||||
// Auto-save will be triggered by the useEffect watching selectedParticipants
|
||||
|
||||
return newSelection;
|
||||
});
|
||||
};
|
||||
|
||||
// Function to auto-save participant changes to draft
|
||||
const saveDraftParticipants = async (participants: string[]) => {
|
||||
if (!draftFocusGroupId) return;
|
||||
|
||||
try {
|
||||
const values = form.getValues();
|
||||
const draftData = {
|
||||
name: values.focusGroupName,
|
||||
status: 'draft',
|
||||
participants: participants,
|
||||
participants_count: participants.length,
|
||||
date: new Date().toISOString(),
|
||||
duration: parseInt(values.duration),
|
||||
topic: values.discussionTopics.split(',')[0].trim().toLowerCase().replace(/\s+/g, '-'),
|
||||
description: values.researchBrief,
|
||||
objective: values.researchBrief,
|
||||
discussionGuide: discussionGuide
|
||||
};
|
||||
|
||||
await focusGroupsApi.update(draftFocusGroupId, draftData);
|
||||
console.log("Participant selection auto-saved to draft");
|
||||
} catch (error) {
|
||||
console.error("Failed to auto-save participant selection:", error);
|
||||
// Don't show toast for auto-save failures to avoid spam
|
||||
}
|
||||
};
|
||||
|
||||
const handleAssetUpload = (files: FileList | null) => {
|
||||
if (files && files.length > 0) {
|
||||
|
|
@ -1012,15 +1231,34 @@ true;
|
|||
// Stable callback for saving discussion guide changes
|
||||
const handleSaveDiscussionGuide = useCallback(async (updatedGuide: any) => {
|
||||
console.log('📝 handleSaveDiscussionGuide called with:', updatedGuide);
|
||||
setDiscussionGuide(updatedGuide);
|
||||
toast.success('Discussion guide updated', {
|
||||
description: 'Your changes have been saved.'
|
||||
});
|
||||
// Only update the discussion guide state if we're not currently editing
|
||||
// This prevents re-renders that would cause focus loss during editing
|
||||
if (!isEditingGuide) {
|
||||
setDiscussionGuide(updatedGuide);
|
||||
toast.success('Discussion guide updated', {
|
||||
description: 'Your changes have been saved.'
|
||||
});
|
||||
} else {
|
||||
// During editing, just update the ref so the latest version is available
|
||||
discussionGuideRef.current = updatedGuide;
|
||||
console.log('📝 Skipping discussionGuide state update during editing to preserve focus');
|
||||
}
|
||||
}, [isEditingGuide]);
|
||||
|
||||
// Handle editing state changes from DiscussionGuideViewer
|
||||
const handleEditingStateChange = useCallback((editing: boolean) => {
|
||||
console.log('📝 Discussion guide editing state changed:', editing);
|
||||
setIsEditingGuide(editing);
|
||||
|
||||
// When editing ends, update the discussion guide state with the latest version
|
||||
if (!editing && discussionGuideRef.current) {
|
||||
console.log('📝 Updating discussionGuide state after editing ended');
|
||||
setDiscussionGuide(discussionGuideRef.current);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Stable dummy callbacks for optional props
|
||||
const handleSectionSelect = useCallback(() => {}, []);
|
||||
const handleSetPosition = useCallback(() => {}, []);
|
||||
|
||||
const handleStartFocusGroup = async () => {
|
||||
// Validate form data
|
||||
|
|
@ -1105,11 +1343,15 @@ true;
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="glass-panel rounded-xl p-6">
|
||||
<div className="flex items-center gap-2 mb-6">
|
||||
<MessageSquare className="h-5 w-5 text-primary" />
|
||||
<h2 className="font-sf text-xl font-semibold">AI Focus Group Moderator</h2>
|
||||
</div>
|
||||
<>
|
||||
{/* Auto-save Status Indicator */}
|
||||
<SaveStatusIndicator />
|
||||
|
||||
<div className="glass-panel rounded-xl p-6">
|
||||
<div className="flex items-center gap-2 mb-6">
|
||||
<MessageSquare className="h-5 w-5 text-primary" />
|
||||
<h2 className="font-sf text-xl font-semibold">AI Focus Group Moderator</h2>
|
||||
</div>
|
||||
|
||||
{/* Progress Bar - Consistent top placement for discussion guide generation */}
|
||||
{isGenerating && (
|
||||
|
|
@ -1201,7 +1443,7 @@ true;
|
|||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Duration (minutes)</FormLabel>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||
<Select onValueChange={field.onChange} value={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select duration" />
|
||||
|
|
@ -1229,7 +1471,7 @@ true;
|
|||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>AI Model</FormLabel>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||
<Select onValueChange={field.onChange} value={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select AI model" />
|
||||
|
|
@ -1339,9 +1581,9 @@ true;
|
|||
onSave={handleSaveDiscussionGuide}
|
||||
onDownload={handleDownloadDiscussionGuide}
|
||||
onSectionSelect={handleSectionSelect}
|
||||
onSetPosition={handleSetPosition}
|
||||
isDownloading={isDownloadingGuide}
|
||||
focusGroupId={draftFocusGroupId}
|
||||
onEditingChange={handleEditingStateChange}
|
||||
/>
|
||||
) : (
|
||||
<div className="bg-slate-50 p-4 rounded border text-center text-slate-600">
|
||||
|
|
@ -1657,8 +1899,7 @@ true;
|
|||
}}
|
||||
selected={selectedParticipants.includes(personaId)}
|
||||
onSelectionToggle={() => handleParticipantSelection(personaId)}
|
||||
showModalInsteadOfNavigate={true}
|
||||
onOpenPersonaModal={handleOpenPersonaModal}
|
||||
onViewDetails={handlePersonaViewDetails}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
|
@ -1819,13 +2060,7 @@ true;
|
|||
</Dialog>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{/* Persona Details Modal */}
|
||||
<PersonaDetailsModal
|
||||
persona={selectedPersonaForModal}
|
||||
isOpen={isPersonaModalOpen}
|
||||
onClose={handleClosePersonaModal}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,8 +21,7 @@ interface UserCardProps {
|
|||
onSelectionToggle?: (e: React.MouseEvent) => void;
|
||||
showAddToFolderButton?: boolean;
|
||||
onAddToFolder?: (e: React.MouseEvent) => void;
|
||||
showModalInsteadOfNavigate?: boolean;
|
||||
onOpenPersonaModal?: (persona: Persona) => void;
|
||||
onViewDetails?: (persona: Persona) => void;
|
||||
}
|
||||
|
||||
export default function UserCard({
|
||||
|
|
@ -33,8 +32,7 @@ export default function UserCard({
|
|||
onSelectionToggle,
|
||||
showAddToFolderButton = false,
|
||||
onAddToFolder,
|
||||
showModalInsteadOfNavigate = false,
|
||||
onOpenPersonaModal
|
||||
onViewDetails
|
||||
}: UserCardProps) {
|
||||
const navigate = useNavigate();
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
|
|
@ -92,8 +90,8 @@ export default function UserCard({
|
|||
const handleViewDetailsClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
|
||||
if (showModalInsteadOfNavigate && onOpenPersonaModal) {
|
||||
onOpenPersonaModal(currentPersona);
|
||||
if (onViewDetails) {
|
||||
onViewDetails(currentPersona);
|
||||
} else {
|
||||
handleViewDetails(e);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -521,7 +521,7 @@ export default function UserCreator({ targetFolderId, targetFolderName }: UserCr
|
|||
|
||||
// Navigate back to the synthetic users page after successful creation
|
||||
setTimeout(() => {
|
||||
navigate('/synthetic-users');
|
||||
navigate('/synthetic-users?mode=view');
|
||||
}, 300);
|
||||
} catch (error: unknown) {
|
||||
console.error("Error creating personas:", error);
|
||||
|
|
|
|||
|
|
@ -381,49 +381,52 @@ export default function AIRecruiterForm({ onSubmit, isGenerating }: AIRecruiterF
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* LLM Model Selection */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="llm_model"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>AI Model</FormLabel>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||
{/* AI Model and Persona Count - Half Width Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* LLM Model Selection */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="llm_model"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>AI Model</FormLabel>
|
||||
<Select onValueChange={field.onChange} defaultValue={field.value}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select AI model" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="gemini-2.5-pro">Gemini 2.5 Pro</SelectItem>
|
||||
<SelectItem value="gpt-4.1">GPT-4.1</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>
|
||||
Choose which AI model to use for generating personas
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Number of Personas to Generate */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="personaCount"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Number of Personas to Generate</FormLabel>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select AI model" />
|
||||
</SelectTrigger>
|
||||
<Input type="number" min="1" max="20" {...field} />
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="gemini-2.5-pro">Gemini 2.5 Pro</SelectItem>
|
||||
<SelectItem value="gpt-4.1">GPT-4.1</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>
|
||||
Choose which AI model to use for generating personas
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Number of Personas to Generate */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="personaCount"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Number of Personas to Generate</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="number" min="1" max="20" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
How many synthetic users do you need for your research?
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormDescription>
|
||||
How many synthetic users do you need for your research?
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-end">
|
||||
<Button
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useState } from 'react';
|
||||
import React, { useState, useRef, useCallback } from 'react';
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
|
|
@ -39,12 +39,15 @@ const CollapsibleDiscussionGuide: React.FC<CollapsibleDiscussionGuideProps> = ({
|
|||
className,
|
||||
onEditingChange
|
||||
}) => {
|
||||
console.log('📋 CollapsibleDiscussionGuide render', {
|
||||
isOpen,
|
||||
hasModerator: !!moderatorStatus,
|
||||
guideTitle: discussionGuide?.title,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
// Track editing state to prevent re-renders
|
||||
const isEditingRef = useRef(false);
|
||||
|
||||
// Wrapper for onEditingChange to track editing state
|
||||
const handleEditingChange = useCallback((editing: boolean) => {
|
||||
isEditingRef.current = editing;
|
||||
onEditingChange?.(editing);
|
||||
}, [onEditingChange]);
|
||||
|
||||
// Download state
|
||||
const [isDownloadingGuide, setIsDownloadingGuide] = useState(false);
|
||||
|
|
@ -135,7 +138,9 @@ const CollapsibleDiscussionGuide: React.FC<CollapsibleDiscussionGuideProps> = ({
|
|||
onSave={onSave}
|
||||
showProgress={true}
|
||||
collapsible={true}
|
||||
defaultExpanded={true}
|
||||
focusGroupId={focusGroupId}
|
||||
onEditingChange={handleEditingChange}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
|
@ -147,35 +152,4 @@ const CollapsibleDiscussionGuide: React.FC<CollapsibleDiscussionGuideProps> = ({
|
|||
);
|
||||
};
|
||||
|
||||
// Simplified memo comparison function
|
||||
const CollapsibleDiscussionGuideMemo = React.memo(CollapsibleDiscussionGuide, (prevProps, nextProps) => {
|
||||
// Check all props to determine if we should re-render
|
||||
const propsToCompare = ['discussionGuide', 'onSectionSelect', 'onSave', 'focusGroupId', 'isOpen', 'onToggle', 'className'];
|
||||
|
||||
let hasNonModeratorChanges = false;
|
||||
for (const prop of propsToCompare) {
|
||||
if (prevProps[prop] !== nextProps[prop]) {
|
||||
hasNonModeratorChanges = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// If only moderatorStatus changed, skip re-render
|
||||
if (prevProps.moderatorStatus !== nextProps.moderatorStatus && !hasNonModeratorChanges) {
|
||||
return true; // Skip re-render
|
||||
}
|
||||
|
||||
// If no props changed at all, skip re-render
|
||||
if (!hasNonModeratorChanges && prevProps.moderatorStatus === nextProps.moderatorStatus) {
|
||||
return true; // Skip re-render
|
||||
}
|
||||
|
||||
// Props changed, re-render
|
||||
console.log('🔄 Will re-render (props changed)', {
|
||||
changedProps: propsToCompare.filter(prop => prevProps[prop] !== nextProps[prop]),
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
return false; // Re-render
|
||||
});
|
||||
|
||||
export default CollapsibleDiscussionGuideMemo;
|
||||
export default CollapsibleDiscussionGuide;
|
||||
|
|
@ -1,954 +0,0 @@
|
|||
import React, { useState, useCallback, useEffect, useRef } from 'react';
|
||||
import {
|
||||
GripVertical,
|
||||
Plus,
|
||||
Trash2,
|
||||
Edit3,
|
||||
Check,
|
||||
X,
|
||||
Clock,
|
||||
MessageCircle,
|
||||
Activity,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Save
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
DndContext,
|
||||
closestCenter,
|
||||
closestCorners,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
DragEndEvent,
|
||||
} from '@dnd-kit/core';
|
||||
import {
|
||||
arrayMove,
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
verticalListSortingStrategy,
|
||||
} from '@dnd-kit/sortable';
|
||||
import {
|
||||
useSortable,
|
||||
} from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface DiscussionGuideItem {
|
||||
id: string;
|
||||
type: string;
|
||||
content: string;
|
||||
time_limit?: number;
|
||||
probes?: string[];
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
interface DiscussionGuideSection {
|
||||
id: string;
|
||||
title: string;
|
||||
content?: string;
|
||||
questions?: DiscussionGuideItem[];
|
||||
activities?: DiscussionGuideItem[];
|
||||
subsections?: any[];
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
interface StructuredDiscussionGuide {
|
||||
title: string;
|
||||
total_duration: number;
|
||||
sections: DiscussionGuideSection[];
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
interface DiscussionGuideEditorProps {
|
||||
discussionGuide: StructuredDiscussionGuide;
|
||||
onChange: (guide: StructuredDiscussionGuide) => void;
|
||||
onSave: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
const DiscussionGuideEditor: React.FC<DiscussionGuideEditorProps> = React.memo(({
|
||||
discussionGuide,
|
||||
onChange,
|
||||
onSave,
|
||||
onCancel
|
||||
}) => {
|
||||
|
||||
|
||||
const [editingItem, setEditingItem] = useState<string | null>(null);
|
||||
const [openSections, setOpenSections] = useState<Set<string>>(new Set());
|
||||
|
||||
// Drag and drop sensors
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
})
|
||||
);
|
||||
|
||||
// Track which item is being edited
|
||||
|
||||
// Helper function to update the guide
|
||||
const updateGuide = useCallback((updater: (guide: StructuredDiscussionGuide) => StructuredDiscussionGuide) => {
|
||||
const newGuide = updater({ ...discussionGuide });
|
||||
onChange(newGuide);
|
||||
}, [discussionGuide, onChange]);
|
||||
|
||||
// Toggle section open/closed
|
||||
const toggleSection = (sectionId: string) => {
|
||||
const newOpenSections = new Set(openSections);
|
||||
if (newOpenSections.has(sectionId)) {
|
||||
newOpenSections.delete(sectionId);
|
||||
} else {
|
||||
newOpenSections.add(sectionId);
|
||||
}
|
||||
setOpenSections(newOpenSections);
|
||||
};
|
||||
|
||||
// Update guide title
|
||||
const updateTitle = (newTitle: string) => {
|
||||
updateGuide(guide => ({
|
||||
...guide,
|
||||
title: newTitle
|
||||
}));
|
||||
};
|
||||
|
||||
// Update total duration
|
||||
const updateTotalDuration = (newDuration: number) => {
|
||||
updateGuide(guide => ({
|
||||
...guide,
|
||||
total_duration: newDuration
|
||||
}));
|
||||
};
|
||||
|
||||
// Add new section
|
||||
const addSection = () => {
|
||||
const newSection: DiscussionGuideSection = {
|
||||
id: `section_${Date.now()}`,
|
||||
title: 'New Section',
|
||||
questions: [],
|
||||
activities: []
|
||||
};
|
||||
|
||||
updateGuide(guide => ({
|
||||
...guide,
|
||||
sections: [...guide.sections, newSection]
|
||||
}));
|
||||
};
|
||||
|
||||
// Update section
|
||||
const updateSection = useCallback((sectionId: string, updates: Partial<DiscussionGuideSection>) => {
|
||||
updateGuide(guide => ({
|
||||
...guide,
|
||||
sections: guide.sections.map(section =>
|
||||
section.id === sectionId ? { ...section, ...updates } : section
|
||||
)
|
||||
}));
|
||||
}, [updateGuide]);
|
||||
|
||||
// Delete section
|
||||
const deleteSection = useCallback((sectionId: string) => {
|
||||
updateGuide(guide => ({
|
||||
...guide,
|
||||
sections: guide.sections.filter(section => section.id !== sectionId)
|
||||
}));
|
||||
}, [updateGuide]);
|
||||
|
||||
// Move section
|
||||
const moveSection = useCallback((fromIndex: number, toIndex: number) => {
|
||||
updateGuide(guide => {
|
||||
const newSections = [...guide.sections];
|
||||
const [movedSection] = newSections.splice(fromIndex, 1);
|
||||
newSections.splice(toIndex, 0, movedSection);
|
||||
return { ...guide, sections: newSections };
|
||||
});
|
||||
}, [updateGuide]);
|
||||
|
||||
// Move item within section
|
||||
const moveItem = useCallback((sectionId: string, itemType: 'question' | 'activity', fromIndex: number, toIndex: number) => {
|
||||
updateGuide(guide => ({
|
||||
...guide,
|
||||
sections: guide.sections.map(section => {
|
||||
if (section.id === sectionId) {
|
||||
const itemsKey = itemType === 'question' ? 'questions' : 'activities';
|
||||
const items = section[itemsKey] || [];
|
||||
const newItems = arrayMove(items, fromIndex, toIndex);
|
||||
return { ...section, [itemsKey]: newItems };
|
||||
}
|
||||
return section;
|
||||
})
|
||||
}));
|
||||
}, [updateGuide]);
|
||||
|
||||
// Handle drag end for all draggable items
|
||||
const handleDragEnd = useCallback((event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
|
||||
if (!over || active.id === over.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
const activeId = String(active.id);
|
||||
const overId = String(over.id);
|
||||
|
||||
// Check if dragging sections
|
||||
if (activeId.startsWith('section-') && overId.startsWith('section-')) {
|
||||
const oldIndex = discussionGuide.sections.findIndex(section => `section-${section.id}` === activeId);
|
||||
const newIndex = discussionGuide.sections.findIndex(section => `section-${section.id}` === overId);
|
||||
|
||||
if (oldIndex !== -1 && newIndex !== -1) {
|
||||
moveSection(oldIndex, newIndex);
|
||||
}
|
||||
}
|
||||
// Check if dragging items within a section
|
||||
else if (activeId.includes('-item-') && overId.includes('-item-')) {
|
||||
// Parse IDs to extract section, type, and item info
|
||||
const activeMatch = activeId.match(/^(.*?)-(question|activity)-item-(.*)$/);
|
||||
const overMatch = overId.match(/^(.*?)-(question|activity)-item-(.*)$/);
|
||||
|
||||
if (activeMatch && overMatch) {
|
||||
const [, activeSectionId, activeItemType] = activeMatch;
|
||||
const [, overSectionId, overItemType] = overMatch;
|
||||
|
||||
// Only allow reordering within the same section and same item type
|
||||
if (activeSectionId === overSectionId && activeItemType === overItemType) {
|
||||
const section = discussionGuide.sections.find(s => s.id === activeSectionId);
|
||||
if (section) {
|
||||
const itemsKey = activeItemType === 'question' ? 'questions' : 'activities';
|
||||
const items = section[itemsKey] || [];
|
||||
const oldIndex = items.findIndex(item => `${activeSectionId}-${activeItemType}-item-${item.id}` === activeId);
|
||||
const newIndex = items.findIndex(item => `${overSectionId}-${overItemType}-item-${item.id}` === overId);
|
||||
|
||||
if (oldIndex !== -1 && newIndex !== -1) {
|
||||
moveItem(activeSectionId, activeItemType as 'question' | 'activity', oldIndex, newIndex);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [discussionGuide.sections, moveSection, moveItem]);
|
||||
|
||||
// Add item to section
|
||||
const addItem = useCallback((sectionId: string, itemType: 'question' | 'activity') => {
|
||||
const newItem: DiscussionGuideItem = {
|
||||
id: `${itemType}_${Date.now()}`,
|
||||
type: itemType === 'question' ? 'open_question' : 'moderator_statement',
|
||||
content: `New ${itemType}`,
|
||||
probes: itemType === 'question' ? [] : undefined
|
||||
};
|
||||
|
||||
updateGuide(guide => ({
|
||||
...guide,
|
||||
sections: guide.sections.map(section =>
|
||||
section.id === sectionId
|
||||
? {
|
||||
...section,
|
||||
[itemType === 'question' ? 'questions' : 'activities']: [
|
||||
...(section[itemType === 'question' ? 'questions' : 'activities'] || []),
|
||||
newItem
|
||||
]
|
||||
}
|
||||
: section
|
||||
)
|
||||
}));
|
||||
}, [updateGuide]);
|
||||
|
||||
// Update item
|
||||
const updateItem = useCallback((sectionId: string, itemId: string, updates: Partial<DiscussionGuideItem>, itemType: 'question' | 'activity') => {
|
||||
updateGuide(guide => ({
|
||||
...guide,
|
||||
sections: guide.sections.map(section =>
|
||||
section.id === sectionId
|
||||
? {
|
||||
...section,
|
||||
[itemType === 'question' ? 'questions' : 'activities']: section[itemType === 'question' ? 'questions' : 'activities']?.map(item =>
|
||||
item.id === itemId ? { ...item, ...updates } : item
|
||||
)
|
||||
}
|
||||
: section
|
||||
)
|
||||
}));
|
||||
}, [updateGuide]);
|
||||
|
||||
// Delete item
|
||||
const deleteItem = useCallback((sectionId: string, itemId: string, itemType: 'question' | 'activity') => {
|
||||
updateGuide(guide => ({
|
||||
...guide,
|
||||
sections: guide.sections.map(section =>
|
||||
section.id === sectionId
|
||||
? {
|
||||
...section,
|
||||
[itemType === 'question' ? 'questions' : 'activities']: section[itemType === 'question' ? 'questions' : 'activities']?.filter(item => item.id !== itemId)
|
||||
}
|
||||
: section
|
||||
)
|
||||
}));
|
||||
}, [updateGuide]);
|
||||
|
||||
// Functions to manage editing state
|
||||
const startEditingItem = useCallback((itemId: string) => {
|
||||
setEditingItem(itemId);
|
||||
}, []);
|
||||
|
||||
const stopEditingItem = useCallback(() => {
|
||||
setEditingItem(null);
|
||||
}, []);
|
||||
|
||||
// Helper function to check if content is default placeholder text
|
||||
const isDefaultPlaceholderContent = (content: string, itemType: 'question' | 'activity'): boolean => {
|
||||
return content === `New ${itemType}`;
|
||||
};
|
||||
|
||||
// Sortable Item wrapper
|
||||
const SortableItem: React.FC<{
|
||||
id: string;
|
||||
item: DiscussionGuideItem;
|
||||
sectionId: string;
|
||||
itemType: 'question' | 'activity';
|
||||
isEditing: boolean;
|
||||
initialContent: string;
|
||||
initialProbes: string;
|
||||
onStartEdit: () => void;
|
||||
onSave: (updates: Partial<DiscussionGuideItem>) => void;
|
||||
onCancel: () => void;
|
||||
onDelete: () => void;
|
||||
onUpdateItem: (updates: Partial<DiscussionGuideItem>) => void;
|
||||
}> = ({ id, item, sectionId, itemType, isEditing, initialContent, initialProbes, onStartEdit, onSave, onCancel, onDelete, onUpdateItem }) => {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id });
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
zIndex: isDragging ? 1000 : 'auto',
|
||||
cursor: isDragging ? 'grabbing' : 'grab',
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={setNodeRef} style={style}>
|
||||
<EditableItem
|
||||
item={item}
|
||||
sectionId={sectionId}
|
||||
itemType={itemType}
|
||||
isEditing={isEditing}
|
||||
initialContent={initialContent}
|
||||
initialProbes={initialProbes}
|
||||
onStartEdit={onStartEdit}
|
||||
onSave={onSave}
|
||||
onCancel={onCancel}
|
||||
onDelete={onDelete}
|
||||
onUpdateItem={onUpdateItem}
|
||||
dragHandleProps={{ ...attributes, ...listeners }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// EditableItem component - moved outside to prevent re-mounting issues
|
||||
const EditableItem: React.FC<{
|
||||
item: DiscussionGuideItem;
|
||||
sectionId: string;
|
||||
itemType: 'question' | 'activity';
|
||||
isEditing: boolean;
|
||||
initialContent: string;
|
||||
initialProbes: string;
|
||||
onStartEdit: () => void;
|
||||
onSave: (updates: Partial<DiscussionGuideItem>) => void;
|
||||
onCancel: () => void;
|
||||
onDelete: () => void;
|
||||
onUpdateItem: (updates: Partial<DiscussionGuideItem>) => void;
|
||||
dragHandleProps?: Record<string, unknown>;
|
||||
}> = React.memo(({
|
||||
item,
|
||||
sectionId,
|
||||
itemType,
|
||||
isEditing,
|
||||
initialContent,
|
||||
initialProbes,
|
||||
onStartEdit,
|
||||
onSave,
|
||||
onCancel,
|
||||
onDelete,
|
||||
onUpdateItem,
|
||||
dragHandleProps
|
||||
}) => {
|
||||
// Local state for editing to prevent parent re-renders
|
||||
const [localEditContent, setLocalEditContent] = useState(initialContent);
|
||||
const [localEditProbes, setLocalEditProbes] = useState(initialProbes);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
// Check if this is a default placeholder item
|
||||
const isPlaceholder = isDefaultPlaceholderContent(initialContent, itemType);
|
||||
|
||||
// Update local state when editing starts
|
||||
useEffect(() => {
|
||||
if (isEditing) {
|
||||
// If this is a placeholder item, start with empty content so user can type immediately
|
||||
setLocalEditContent(isPlaceholder ? '' : initialContent);
|
||||
setLocalEditProbes(initialProbes);
|
||||
// Focus the textarea when editing starts
|
||||
setTimeout(() => {
|
||||
textareaRef.current?.focus();
|
||||
}, 0);
|
||||
}
|
||||
}, [isEditing, initialContent, initialProbes, isPlaceholder]);
|
||||
const handleSave = () => {
|
||||
const updates = {
|
||||
content: localEditContent,
|
||||
probes: localEditProbes.trim() ? localEditProbes.split('\n').filter(p => p.trim()) : undefined
|
||||
};
|
||||
onSave(updates);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
onCancel();
|
||||
};
|
||||
|
||||
return (
|
||||
<Card key={item.id} className="mb-3">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div
|
||||
className="flex-shrink-0 cursor-grab hover:cursor-grab active:cursor-grabbing hover:text-slate-700 transition-colors p-1.5 rounded hover:bg-slate-200 border border-transparent hover:border-slate-300"
|
||||
{...dragHandleProps}
|
||||
title="Drag to reorder this item"
|
||||
>
|
||||
<GripVertical className="h-4 w-4 text-slate-500 hover:text-slate-700" />
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{itemType === 'activity' ? (
|
||||
<>
|
||||
<Activity className="h-3 w-3 mr-1" />
|
||||
{item.type.replace('_', ' ')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<MessageCircle className="h-3 w-3 mr-1" />
|
||||
{item.type.replace('_', ' ')}
|
||||
</>
|
||||
)}
|
||||
</Badge>
|
||||
|
||||
<Select
|
||||
value={item.type}
|
||||
onValueChange={(value) => onUpdateItem({ type: value })}
|
||||
>
|
||||
<SelectTrigger className="w-40 h-7 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{itemType === 'question' ? (
|
||||
<>
|
||||
<SelectItem value="open_question">Open Question</SelectItem>
|
||||
<SelectItem value="probe_question">Probe Question</SelectItem>
|
||||
<SelectItem value="follow_up">Follow Up</SelectItem>
|
||||
<SelectItem value="ranking">Ranking</SelectItem>
|
||||
<SelectItem value="comparison">Comparison</SelectItem>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<SelectItem value="moderator_statement">Moderator Statement</SelectItem>
|
||||
<SelectItem value="instruction">Instruction</SelectItem>
|
||||
<SelectItem value="creative_exercise">Creative Exercise</SelectItem>
|
||||
<SelectItem value="discussion_prompt">Discussion Prompt</SelectItem>
|
||||
</>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{item.time_limit && (
|
||||
<div className="flex items-center gap-1 text-xs text-slate-500">
|
||||
<Clock className="h-3 w-3" />
|
||||
<Input
|
||||
type="number"
|
||||
value={item.time_limit}
|
||||
onChange={(e) => onUpdateItem({ time_limit: parseInt(e.target.value) })}
|
||||
className="w-16 h-6 text-xs"
|
||||
/>
|
||||
min
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isEditing ? (
|
||||
<div className="space-y-3">
|
||||
<Textarea
|
||||
ref={textareaRef}
|
||||
value={localEditContent}
|
||||
onChange={(e) => {
|
||||
setLocalEditContent(e.target.value);
|
||||
}}
|
||||
placeholder={isPlaceholder ? initialContent : "Enter content..."}
|
||||
className="min-h-[80px]"
|
||||
/>
|
||||
|
||||
{itemType === 'question' && (
|
||||
<div>
|
||||
<label className="text-sm font-medium text-slate-700 mb-1 block">
|
||||
Probe Questions (one per line)
|
||||
</label>
|
||||
<Textarea
|
||||
value={localEditProbes}
|
||||
onChange={(e) => setLocalEditProbes(e.target.value)}
|
||||
placeholder="Enter probe questions, one per line..."
|
||||
className="min-h-[60px]"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" onClick={handleSave}>
|
||||
<Check className="h-3 w-3 mr-1" />
|
||||
Save
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={handleCancel}>
|
||||
<X className="h-3 w-3 mr-1" />
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<p className="text-sm text-slate-700 mb-2">{item.content}</p>
|
||||
|
||||
{item.probes && item.probes.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-medium text-slate-600">Probes:</p>
|
||||
<ul className="text-xs text-slate-600 space-y-1">
|
||||
{item.probes.map((probe, idx) => (
|
||||
<li key={idx} className="flex items-start gap-1">
|
||||
<span className="text-slate-400">•</span>
|
||||
<span>{probe}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-shrink-0 flex gap-1">
|
||||
{!isEditing && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={onStartEdit}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<Edit3 className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={onDelete}
|
||||
className="h-8 w-8 p-0 text-red-600 hover:text-red-700"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}, (prevProps, nextProps) => {
|
||||
// Custom comparison to prevent unnecessary re-renders
|
||||
// Only re-render if key props change, not on every content edit
|
||||
const shouldSkipRender = prevProps.item.id === nextProps.item.id &&
|
||||
prevProps.isEditing === nextProps.isEditing &&
|
||||
prevProps.initialContent === nextProps.initialContent &&
|
||||
prevProps.initialProbes === nextProps.initialProbes &&
|
||||
prevProps.sectionId === nextProps.sectionId &&
|
||||
prevProps.itemType === nextProps.itemType &&
|
||||
JSON.stringify(prevProps.item) === JSON.stringify(nextProps.item);
|
||||
|
||||
|
||||
return shouldSkipRender;
|
||||
});
|
||||
|
||||
// EditableSection component - moved outside to prevent re-mounting
|
||||
const EditableSection: React.FC<{
|
||||
section: DiscussionGuideSection;
|
||||
sectionIndex: number;
|
||||
isOpen: boolean;
|
||||
editingItem: string | null;
|
||||
onToggleSection: (sectionId: string) => void;
|
||||
onUpdateSection: (sectionId: string, updates: Partial<DiscussionGuideSection>) => void;
|
||||
onDeleteSection: (sectionId: string) => void;
|
||||
onAddItem: (sectionId: string, itemType: 'question' | 'activity') => void;
|
||||
onStartEditingItem: (itemId: string) => void;
|
||||
onStopEditingItem: () => void;
|
||||
onUpdateItem: (sectionId: string, itemId: string, updates: Partial<DiscussionGuideItem>, itemType: 'question' | 'activity') => void;
|
||||
onDeleteItem: (sectionId: string, itemId: string, itemType: 'question' | 'activity') => void;
|
||||
dragHandleProps?: Record<string, unknown>;
|
||||
}> = React.memo(({
|
||||
section,
|
||||
sectionIndex,
|
||||
isOpen,
|
||||
editingItem,
|
||||
onToggleSection,
|
||||
onUpdateSection,
|
||||
onDeleteSection,
|
||||
onAddItem,
|
||||
onStartEditingItem,
|
||||
onStopEditingItem,
|
||||
onUpdateItem,
|
||||
onDeleteItem,
|
||||
dragHandleProps
|
||||
}) => {
|
||||
|
||||
const [editingTitle, setEditingTitle] = useState(false);
|
||||
const [tempTitle, setTempTitle] = useState(section.title);
|
||||
|
||||
const handleTitleSave = () => {
|
||||
onUpdateSection(section.id, { title: tempTitle });
|
||||
setEditingTitle(false);
|
||||
};
|
||||
|
||||
const handleTitleCancel = () => {
|
||||
setTempTitle(section.title);
|
||||
setEditingTitle(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card key={section.id} className="mb-4">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3 flex-1">
|
||||
<div
|
||||
className="cursor-grab hover:cursor-grab active:cursor-grabbing hover:text-slate-700 transition-colors p-1.5 rounded hover:bg-slate-200 border border-transparent hover:border-slate-300"
|
||||
{...dragHandleProps}
|
||||
title="Drag to reorder section"
|
||||
>
|
||||
<GripVertical className="h-5 w-5 text-slate-500 hover:text-slate-700" />
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex items-center gap-2">
|
||||
{editingTitle ? (
|
||||
<div className="flex items-center gap-2 flex-1">
|
||||
<Input
|
||||
value={tempTitle}
|
||||
onChange={(e) => setTempTitle(e.target.value)}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button size="sm" onClick={handleTitleSave}>
|
||||
<Check className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={handleTitleCancel}>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<h3
|
||||
className="font-semibold text-lg cursor-pointer hover:text-blue-600"
|
||||
onClick={() => setEditingTitle(true)}
|
||||
>
|
||||
{section.title}
|
||||
</h3>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setEditingTitle(true)}
|
||||
className="h-6 w-6 p-0"
|
||||
>
|
||||
<Edit3 className="h-3 w-3" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => onDeleteSection(section.id)}
|
||||
className="h-8 w-8 p-0 text-red-600 hover:text-red-700"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => onToggleSection(section.id)}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
{isOpen ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<Collapsible open={isOpen} onOpenChange={() => onToggleSection(section.id)}>
|
||||
<CollapsibleContent>
|
||||
<CardContent className="pt-0">
|
||||
{section.content && (
|
||||
<div className="mb-4">
|
||||
<label className="text-sm font-medium text-slate-700 mb-1 block">
|
||||
Section Description
|
||||
</label>
|
||||
<Textarea
|
||||
value={section.content}
|
||||
onChange={(e) => onUpdateSection(section.id, { content: e.target.value })}
|
||||
className="min-h-[60px]"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Activities */}
|
||||
<div className="mb-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h4 className="font-medium text-slate-700 flex items-center gap-2">
|
||||
<Activity className="h-4 w-4" />
|
||||
Activities
|
||||
</h4>
|
||||
<Button size="sm" onClick={() => onAddItem(section.id, 'activity')}>
|
||||
<Plus className="h-3 w-3 mr-1" />
|
||||
Add Activity
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<SortableContext
|
||||
items={section.activities?.map(activity => `${section.id}-activity-item-${activity.id}`) || []}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
{section.activities?.map(activity =>
|
||||
<SortableItem
|
||||
key={activity.id}
|
||||
id={`${section.id}-activity-item-${activity.id}`}
|
||||
item={activity}
|
||||
sectionId={section.id}
|
||||
itemType="activity"
|
||||
isEditing={editingItem === activity.id}
|
||||
initialContent={activity.content}
|
||||
initialProbes={''}
|
||||
onStartEdit={() => onStartEditingItem(activity.id)}
|
||||
onSave={(updates) => {
|
||||
onUpdateItem(section.id, activity.id, updates, 'activity');
|
||||
onStopEditingItem();
|
||||
}}
|
||||
onCancel={() => onStopEditingItem()}
|
||||
onDelete={() => onDeleteItem(section.id, activity.id, 'activity')}
|
||||
onUpdateItem={(updates) => onUpdateItem(section.id, activity.id, updates, 'activity')}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</div>
|
||||
|
||||
{/* Questions */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h4 className="font-medium text-slate-700 flex items-center gap-2">
|
||||
<MessageCircle className="h-4 w-4" />
|
||||
Questions
|
||||
</h4>
|
||||
<Button size="sm" onClick={() => onAddItem(section.id, 'question')}>
|
||||
<Plus className="h-3 w-3 mr-1" />
|
||||
Add Question
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<SortableContext
|
||||
items={section.questions?.map(question => `${section.id}-question-item-${question.id}`) || []}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
{section.questions?.map(question =>
|
||||
<SortableItem
|
||||
key={question.id}
|
||||
id={`${section.id}-question-item-${question.id}`}
|
||||
item={question}
|
||||
sectionId={section.id}
|
||||
itemType="question"
|
||||
isEditing={editingItem === question.id}
|
||||
initialContent={question.content}
|
||||
initialProbes={question.probes?.join('\n') || ''}
|
||||
onStartEdit={() => onStartEditingItem(question.id)}
|
||||
onSave={(updates) => {
|
||||
onUpdateItem(section.id, question.id, updates, 'question');
|
||||
onStopEditingItem();
|
||||
}}
|
||||
onCancel={() => onStopEditingItem()}
|
||||
onDelete={() => onDeleteItem(section.id, question.id, 'question')}
|
||||
onUpdateItem={(updates) => onUpdateItem(section.id, question.id, updates, 'question')}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</div>
|
||||
</CardContent>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</Card>
|
||||
);
|
||||
}, (prevProps, nextProps) => {
|
||||
// Skip re-render if props haven't changed
|
||||
const shouldSkipRender =
|
||||
prevProps.section.id === nextProps.section.id &&
|
||||
JSON.stringify(prevProps.section) === JSON.stringify(nextProps.section) &&
|
||||
prevProps.sectionIndex === nextProps.sectionIndex &&
|
||||
prevProps.isOpen === nextProps.isOpen &&
|
||||
prevProps.editingItem === nextProps.editingItem;
|
||||
|
||||
|
||||
return shouldSkipRender;
|
||||
});
|
||||
|
||||
// Sortable Section wrapper
|
||||
const SortableSection: React.FC<{
|
||||
id: string;
|
||||
section: DiscussionGuideSection;
|
||||
sectionIndex: number;
|
||||
isOpen: boolean;
|
||||
editingItem: string | null;
|
||||
onToggleSection: (sectionId: string) => void;
|
||||
onUpdateSection: (sectionId: string, updates: Partial<DiscussionGuideSection>) => void;
|
||||
onDeleteSection: (sectionId: string) => void;
|
||||
onAddItem: (sectionId: string, itemType: 'question' | 'activity') => void;
|
||||
onStartEditingItem: (itemId: string) => void;
|
||||
onStopEditingItem: () => void;
|
||||
onUpdateItem: (sectionId: string, itemId: string, updates: Partial<DiscussionGuideItem>, itemType: 'question' | 'activity') => void;
|
||||
onDeleteItem: (sectionId: string, itemId: string, itemType: 'question' | 'activity') => void;
|
||||
}> = ({ id, section, sectionIndex, isOpen, editingItem, onToggleSection, onUpdateSection, onDeleteSection, onAddItem, onStartEditingItem, onStopEditingItem, onUpdateItem, onDeleteItem }) => {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id });
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
zIndex: isDragging ? 1000 : 'auto',
|
||||
cursor: isDragging ? 'grabbing' : 'grab',
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={setNodeRef} style={style}>
|
||||
<EditableSection
|
||||
section={section}
|
||||
sectionIndex={sectionIndex}
|
||||
isOpen={isOpen}
|
||||
editingItem={editingItem}
|
||||
onToggleSection={onToggleSection}
|
||||
onUpdateSection={onUpdateSection}
|
||||
onDeleteSection={onDeleteSection}
|
||||
onAddItem={onAddItem}
|
||||
onStartEditingItem={onStartEditingItem}
|
||||
onStopEditingItem={onStopEditingItem}
|
||||
onUpdateItem={onUpdateItem}
|
||||
onDeleteItem={onDeleteItem}
|
||||
dragHandleProps={{ ...attributes, ...listeners }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-slate-700 mb-1 block">
|
||||
Discussion Guide Title
|
||||
</label>
|
||||
<Input
|
||||
value={discussionGuide.title}
|
||||
onChange={(e) => updateTitle(e.target.value)}
|
||||
className="w-64"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-medium text-slate-700 mb-1 block">
|
||||
Total Duration (minutes)
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
value={discussionGuide.total_duration}
|
||||
onChange={(e) => updateTotalDuration(parseInt(e.target.value))}
|
||||
className="w-24"
|
||||
readOnly
|
||||
disabled
|
||||
title="Duration cannot be modified after generation"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Sections */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-xl font-semibold">Sections</h2>
|
||||
<Button onClick={addSection}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add Section
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
items={discussionGuide.sections.map(section => `section-${section.id}`)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
{discussionGuide.sections.map((section, index) =>
|
||||
<SortableSection
|
||||
key={section.id}
|
||||
id={`section-${section.id}`}
|
||||
section={section}
|
||||
sectionIndex={index}
|
||||
isOpen={openSections.has(section.id)}
|
||||
editingItem={editingItem}
|
||||
onToggleSection={toggleSection}
|
||||
onUpdateSection={updateSection}
|
||||
onDeleteSection={deleteSection}
|
||||
onAddItem={addItem}
|
||||
onStartEditingItem={startEditingItem}
|
||||
onStopEditingItem={stopEditingItem}
|
||||
onUpdateItem={updateItem}
|
||||
onDeleteItem={deleteItem}
|
||||
/>
|
||||
)}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default DiscussionGuideEditor;
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -59,6 +59,11 @@ const DiscussionPanel = ({
|
|||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
// Track the last persona who responded for round-robin approach
|
||||
const [lastRespondentIndex, setLastRespondentIndex] = useState(-1);
|
||||
// Track when we're expecting new messages to arrive
|
||||
const [isExpectingMessage, setIsExpectingMessage] = useState(false);
|
||||
const previousMessageCountRef = useRef(0);
|
||||
const loadingStartTimeRef = useRef<number | null>(null);
|
||||
const minimumLoadingDurationRef = useRef(10000); // 10 seconds minimum
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
// Disabled auto-scroll by default
|
||||
const [autoScroll, setAutoScroll] = useState(false);
|
||||
|
|
@ -142,6 +147,45 @@ const DiscussionPanel = ({
|
|||
};
|
||||
}, [isAiModeActive, focusGroupId]);
|
||||
|
||||
// Initialize message count reference
|
||||
useEffect(() => {
|
||||
previousMessageCountRef.current = messages.length;
|
||||
}, []);
|
||||
|
||||
// Detect when new messages arrive and clear loading state
|
||||
useEffect(() => {
|
||||
const currentMessageCount = messages.length;
|
||||
const previousCount = previousMessageCountRef.current;
|
||||
|
||||
// If we're expecting a message and the count increased, check minimum duration
|
||||
if (isExpectingMessage && currentMessageCount > previousCount) {
|
||||
const now = Date.now();
|
||||
const loadingStartTime = loadingStartTimeRef.current;
|
||||
|
||||
if (loadingStartTime && (now - loadingStartTime) >= minimumLoadingDurationRef.current) {
|
||||
// Minimum duration has passed, safe to clear loading
|
||||
setIsTyping(false);
|
||||
setIsExpectingMessage(false);
|
||||
loadingStartTimeRef.current = null;
|
||||
} else if (loadingStartTime) {
|
||||
// Wait for minimum duration to complete
|
||||
const remainingTime = minimumLoadingDurationRef.current - (now - loadingStartTime);
|
||||
setTimeout(() => {
|
||||
setIsTyping(false);
|
||||
setIsExpectingMessage(false);
|
||||
loadingStartTimeRef.current = null;
|
||||
}, Math.max(0, remainingTime));
|
||||
} else {
|
||||
// No start time recorded, clear immediately
|
||||
setIsTyping(false);
|
||||
setIsExpectingMessage(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Update the reference for next comparison
|
||||
previousMessageCountRef.current = currentMessageCount;
|
||||
}, [messages.length, isExpectingMessage]);
|
||||
|
||||
// Get persona info by ID
|
||||
const getPersona = (id: string) => {
|
||||
return personas.find(p => p.id === id || p._id === id);
|
||||
|
|
@ -238,6 +282,8 @@ const DiscussionPanel = ({
|
|||
setUserInput('');
|
||||
setCurrentMentions(null);
|
||||
setIsTyping(true);
|
||||
setIsExpectingMessage(true);
|
||||
loadingStartTimeRef.current = Date.now();
|
||||
|
||||
try {
|
||||
// Handle file upload if a file is selected
|
||||
|
|
@ -328,14 +374,14 @@ const DiscussionPanel = ({
|
|||
);
|
||||
}, 500);
|
||||
} else {
|
||||
// Just stop the typing indicator without auto-responding
|
||||
setTimeout(() => {
|
||||
setIsTyping(false);
|
||||
}, 1000);
|
||||
// No mentions to process - let useEffect clear loading state when message appears
|
||||
// (Don't manually clear isTyping here since message might be delayed by polling)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error sending message:', error);
|
||||
setIsTyping(false);
|
||||
setIsExpectingMessage(false);
|
||||
loadingStartTimeRef.current = null;
|
||||
|
||||
// Create fallback message with original text
|
||||
const fallbackMessage: Message = {
|
||||
|
|
@ -520,6 +566,8 @@ const DiscussionPanel = ({
|
|||
|
||||
try {
|
||||
setIsTyping(true);
|
||||
setIsExpectingMessage(true);
|
||||
loadingStartTimeRef.current = Date.now();
|
||||
|
||||
toast.info("Advancing discussion...", {
|
||||
description: "Moving to the next question in the discussion guide."
|
||||
|
|
@ -621,9 +669,12 @@ const DiscussionPanel = ({
|
|||
toast.error("Failed to advance discussion", {
|
||||
description: error.message || "There was a problem advancing to the next question."
|
||||
});
|
||||
} finally {
|
||||
// Clear loading state immediately on error since no message is expected
|
||||
setIsTyping(false);
|
||||
setIsExpectingMessage(false);
|
||||
loadingStartTimeRef.current = null;
|
||||
}
|
||||
// Note: Don't clear loading in finally block - let message arrival handle it
|
||||
};
|
||||
|
||||
// Start autonomous conversation
|
||||
|
|
@ -825,6 +876,8 @@ const DiscussionPanel = ({
|
|||
|
||||
try {
|
||||
setIsTyping(true);
|
||||
setIsExpectingMessage(true);
|
||||
loadingStartTimeRef.current = Date.now();
|
||||
|
||||
toast.info("Generating responses from mentioned participants...", {
|
||||
description: `Generating responses from ${mentionedParticipantIds.length} mentioned participant(s).`
|
||||
|
|
@ -875,9 +928,12 @@ const DiscussionPanel = ({
|
|||
} catch (error) {
|
||||
console.error('Error generating mentioned responses:', error);
|
||||
toast.error('Failed to generate responses from mentioned participants');
|
||||
} finally {
|
||||
// Clear loading state immediately on error since no messages are expected
|
||||
setIsTyping(false);
|
||||
setIsExpectingMessage(false);
|
||||
loadingStartTimeRef.current = null;
|
||||
}
|
||||
// Note: Don't clear loading in finally block - let message arrival handle it
|
||||
};
|
||||
|
||||
// Generate an AI response using intelligent participant selection
|
||||
|
|
@ -894,6 +950,8 @@ const DiscussionPanel = ({
|
|||
|
||||
try {
|
||||
setIsTyping(true);
|
||||
setIsExpectingMessage(true);
|
||||
loadingStartTimeRef.current = Date.now();
|
||||
|
||||
// Use AI to decide which participant should respond next
|
||||
toast.info("AI is selecting participant...", {
|
||||
|
|
@ -1014,9 +1072,12 @@ const DiscussionPanel = ({
|
|||
toast.error("Failed to generate AI response", {
|
||||
description: "There was a problem connecting to the server."
|
||||
});
|
||||
} finally {
|
||||
// Clear loading state immediately on error since no message is expected
|
||||
setIsTyping(false);
|
||||
setIsExpectingMessage(false);
|
||||
loadingStartTimeRef.current = null;
|
||||
}
|
||||
// Note: Don't clear loading in finally block - let message arrival handle it
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@ const ModeSwitchMarker = ({ modeEvent }: ModeSwitchMarkerProps) => {
|
|||
return 'AI Mode Started';
|
||||
case 'manual_mode_started':
|
||||
return 'Manual Moderation Enabled';
|
||||
case 'ai_session_concluded':
|
||||
return 'AI Discussion Concluded';
|
||||
default:
|
||||
return 'Mode Changed';
|
||||
}
|
||||
|
|
@ -26,6 +28,8 @@ const ModeSwitchMarker = ({ modeEvent }: ModeSwitchMarkerProps) => {
|
|||
return 'text-blue-600';
|
||||
case 'manual_mode_started':
|
||||
return 'text-slate-600';
|
||||
case 'ai_session_concluded':
|
||||
return 'text-green-600';
|
||||
default:
|
||||
return 'text-gray-600';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -72,7 +72,7 @@ export interface Note {
|
|||
export interface ModeEvent {
|
||||
id: string;
|
||||
focus_group_id: string;
|
||||
event_type: 'ai_mode_started' | 'manual_mode_started';
|
||||
event_type: 'ai_mode_started' | 'manual_mode_started' | 'ai_session_concluded';
|
||||
timestamp: Date;
|
||||
user_id?: string | null;
|
||||
created_at: Date;
|
||||
|
|
|
|||
69
src/contexts/NavigationContext.tsx
Normal file
69
src/contexts/NavigationContext.tsx
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
|
||||
|
||||
export interface NavigationState {
|
||||
previousRoute?: string;
|
||||
focusGroupId?: string;
|
||||
focusGroupTab?: 'setup' | 'review' | 'participants';
|
||||
isNewFocusGroup?: boolean;
|
||||
focusGroupData?: any;
|
||||
}
|
||||
|
||||
interface NavigationContextType {
|
||||
navigationState: NavigationState;
|
||||
setNavigationState: (state: NavigationState) => void;
|
||||
clearNavigationState: () => void;
|
||||
setPreviousRoute: (route: string, additionalData?: Partial<NavigationState>) => void;
|
||||
}
|
||||
|
||||
const NavigationContext = createContext<NavigationContextType | undefined>(undefined);
|
||||
|
||||
const NAVIGATION_STORAGE_KEY = 'synthetic-society-navigation-state';
|
||||
|
||||
export const NavigationProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
|
||||
const [navigationState, setNavigationState] = useState<NavigationState>(() => {
|
||||
try {
|
||||
const stored = localStorage.getItem(NAVIGATION_STORAGE_KEY);
|
||||
return stored ? JSON.parse(stored) : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem(NAVIGATION_STORAGE_KEY, JSON.stringify(navigationState));
|
||||
}, [navigationState]);
|
||||
|
||||
const setPreviousRoute = (route: string, additionalData?: Partial<NavigationState>) => {
|
||||
setNavigationState({
|
||||
...navigationState,
|
||||
previousRoute: route,
|
||||
...additionalData
|
||||
});
|
||||
};
|
||||
|
||||
const clearNavigationState = () => {
|
||||
setNavigationState({});
|
||||
localStorage.removeItem(NAVIGATION_STORAGE_KEY);
|
||||
};
|
||||
|
||||
return (
|
||||
<NavigationContext.Provider
|
||||
value={{
|
||||
navigationState,
|
||||
setNavigationState,
|
||||
clearNavigationState,
|
||||
setPreviousRoute
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</NavigationContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useNavigation = (): NavigationContextType => {
|
||||
const context = useContext(NavigationContext);
|
||||
if (!context) {
|
||||
throw new Error('useNavigation must be used within a NavigationProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
|
@ -5,6 +5,7 @@ import { GENERATED_PERSONAS_KEY } from '@/hooks/usePersonaStorage';
|
|||
import { useParams, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { toast } from 'sonner';
|
||||
import { personasApi } from '@/lib/api';
|
||||
import { useNavigation } from '@/contexts/NavigationContext';
|
||||
|
||||
// Sample user data for fallback/demo purposes
|
||||
const sampleUsers: Persona[] = [
|
||||
|
|
@ -549,6 +550,7 @@ export function usePersonaDetails() {
|
|||
const { id } = useParams<{ id: string }>();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const { navigationState, clearNavigationState } = useNavigation();
|
||||
const [currentPersona, setCurrentPersona] = useState<Persona | undefined>(undefined);
|
||||
const [isFromReview, setIsFromReview] = useState(false);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
|
|
@ -621,9 +623,24 @@ export function usePersonaDetails() {
|
|||
}, [id, location.search]);
|
||||
|
||||
const handleGoBack = () => {
|
||||
if (isFromReview) {
|
||||
// Check if we came from focus group editing
|
||||
if (navigationState.previousRoute === '/focus-groups' && navigationState.focusGroupTab) {
|
||||
// Navigate back to focus group editing with the specified tab
|
||||
if (navigationState.isNewFocusGroup) {
|
||||
// For new focus groups, go to focus-groups page in create mode with the specified tab
|
||||
navigate(`/focus-groups?mode=create&tab=${navigationState.focusGroupTab}`);
|
||||
} else if (navigationState.focusGroupId) {
|
||||
// For existing focus groups, go to the specific focus group editing with the tab
|
||||
navigate(`/focus-groups?mode=edit&id=${navigationState.focusGroupId}&tab=${navigationState.focusGroupTab}`);
|
||||
} else {
|
||||
// Fallback to focus groups page in create mode with participants tab
|
||||
navigate('/focus-groups?mode=create&tab=participants');
|
||||
}
|
||||
} else if (isFromReview) {
|
||||
// Legacy behavior for review mode
|
||||
navigate('/synthetic-users?mode=create&tab=ai&step=review');
|
||||
} else {
|
||||
// Default behavior - go to synthetic users page
|
||||
navigate('/synthetic-users');
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -182,6 +182,11 @@
|
|||
@apply ml-7 text-sm text-muted-foreground;
|
||||
}
|
||||
|
||||
.sidebar-sub-item::before {
|
||||
content: "•";
|
||||
@apply text-slate-400 mr-2 -ml-3;
|
||||
}
|
||||
|
||||
/* Persona card overlay styles */
|
||||
.persona-card {
|
||||
@apply relative overflow-hidden;
|
||||
|
|
|
|||
|
|
@ -50,6 +50,11 @@ const FocusGroupSession = () => {
|
|||
const [isEditingDiscussionGuide, setIsEditingDiscussionGuide] = useState(false);
|
||||
const isEditingDiscussionGuideRef = useRef(false);
|
||||
|
||||
// Track discussion guide editing state to prevent focus loss
|
||||
const [isEditingGuideContent, setIsEditingGuideContent] = useState(false);
|
||||
const focusGroupRef = useRef(focusGroup);
|
||||
focusGroupRef.current = focusGroup;
|
||||
|
||||
// Notes-related state
|
||||
const [notes, setNotes] = useState<Note[]>([]);
|
||||
|
||||
|
|
@ -1202,6 +1207,12 @@ const FocusGroupSession = () => {
|
|||
|
||||
// Handler for saving discussion guide changes
|
||||
const handleDiscussionGuideSave = useCallback(async (updatedGuide: any) => {
|
||||
console.log('💾 handleDiscussionGuideSave called:', {
|
||||
hasId: !!id,
|
||||
isEditingGuideContent,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
if (!id) return;
|
||||
|
||||
try {
|
||||
|
|
@ -1209,17 +1220,48 @@ const FocusGroupSession = () => {
|
|||
discussionGuide: updatedGuide
|
||||
});
|
||||
|
||||
// Update local state
|
||||
setFocusGroup(prev => prev ? {
|
||||
...prev,
|
||||
discussionGuide: updatedGuide
|
||||
} : null);
|
||||
// Only update local state if we're not currently editing to prevent focus loss
|
||||
if (!isEditingGuideContent) {
|
||||
console.log('🔄 Updating focus group state (not editing)');
|
||||
setFocusGroup(prev => prev ? {
|
||||
...prev,
|
||||
discussionGuide: updatedGuide
|
||||
} : null);
|
||||
} else {
|
||||
// During editing, update the ref so we have the latest version
|
||||
if (focusGroupRef.current) {
|
||||
focusGroupRef.current = {
|
||||
...focusGroupRef.current,
|
||||
discussionGuide: updatedGuide
|
||||
};
|
||||
}
|
||||
console.log('⚠️ Skipping focus group state update during editing to preserve focus');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error saving discussion guide:', error);
|
||||
throw error; // Re-throw to let the component handle the error display
|
||||
}
|
||||
}, [id]);
|
||||
}, [id, isEditingGuideContent]);
|
||||
|
||||
// Handle editing state changes from DiscussionGuideViewer
|
||||
const handleGuideEditingStateChange = useCallback((editing: boolean) => {
|
||||
console.log('🔄 handleGuideEditingStateChange called:', {
|
||||
editing,
|
||||
timestamp: new Date().toISOString(),
|
||||
currentIsEditingGuideContent: isEditingGuideContent
|
||||
});
|
||||
|
||||
// Update both editing states
|
||||
setIsEditingDiscussionGuide(editing); // For scroll prevention
|
||||
setIsEditingGuideContent(editing); // For focus preservation
|
||||
|
||||
// When editing ends, update the focus group state with the latest version
|
||||
if (!editing && focusGroupRef.current) {
|
||||
console.log('📝 Updating focus group state after editing ended');
|
||||
setFocusGroup(focusGroupRef.current);
|
||||
}
|
||||
}, [isEditingGuideContent]);
|
||||
|
||||
// Handler for toggling discussion guide
|
||||
const handleToggleDiscussionGuide = useCallback(() => {
|
||||
|
|
@ -1549,7 +1591,7 @@ const FocusGroupSession = () => {
|
|||
focusGroupId={id || ''}
|
||||
isOpen={isDiscussionGuideOpen}
|
||||
onToggle={handleToggleDiscussionGuide}
|
||||
onEditingChange={setIsEditingDiscussionGuide}
|
||||
onEditingChange={handleGuideEditingStateChange}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col lg:flex-row gap-6 h-[calc(100vh-12rem)]">
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import Navigation from '@/components/Navigation';
|
||||
import FocusGroupModerator from '@/components/FocusGroupModerator';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
|
@ -99,6 +99,8 @@ const FocusGroups = () => {
|
|||
const [isDeletingGroups, setIsDeletingGroups] = useState(false);
|
||||
const [draftToEdit, setDraftToEdit] = useState<FocusGroup | null>(null);
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [preSelectedParticipants, setPreSelectedParticipants] = useState<string[]>([]);
|
||||
|
||||
// Use a ref to track component mounted state
|
||||
const isMounted = useRef(true);
|
||||
|
|
@ -146,6 +148,20 @@ const FocusGroups = () => {
|
|||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Fetch a specific focus group for editing
|
||||
const fetchFocusGroupForEdit = async (focusGroupId: string) => {
|
||||
try {
|
||||
const response = await focusGroupsApi.getById(focusGroupId);
|
||||
if (response && response.data) {
|
||||
setDraftToEdit(response.data);
|
||||
setMode('create'); // Use create mode for editing
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching focus group for edit:', error);
|
||||
toastService.error("Failed to load focus group for editing");
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
console.log('useEffect running - about to fetch focus groups');
|
||||
|
|
@ -168,6 +184,50 @@ const FocusGroups = () => {
|
|||
}
|
||||
}, [mode]);
|
||||
|
||||
// Handle navigation state for pre-selected participants
|
||||
useEffect(() => {
|
||||
const state = location.state as { mode?: string; preSelectedParticipants?: string[] } | null;
|
||||
if (state?.mode === 'create' && state?.preSelectedParticipants) {
|
||||
setPreSelectedParticipants(state.preSelectedParticipants);
|
||||
setMode('create');
|
||||
// Clear the navigation state to prevent re-triggering
|
||||
navigate(location.pathname, { replace: true, state: null });
|
||||
}
|
||||
}, [location.state, location.pathname, navigate]);
|
||||
|
||||
// Handle URL query parameters for navigation from persona details
|
||||
useEffect(() => {
|
||||
const searchParams = new URLSearchParams(location.search);
|
||||
const urlMode = searchParams.get('mode');
|
||||
const focusGroupId = searchParams.get('id');
|
||||
const tab = searchParams.get('tab');
|
||||
|
||||
if (urlMode === 'create') {
|
||||
// For new focus group creation, switch to create mode
|
||||
setMode('create');
|
||||
setDraftToEdit(null);
|
||||
} else if (urlMode === 'edit' && focusGroupId) {
|
||||
// For editing existing focus group, find and load the draft
|
||||
const foundGroup = focusGroups.find(group =>
|
||||
(group._id || group.id) === focusGroupId
|
||||
);
|
||||
|
||||
if (foundGroup) {
|
||||
setDraftToEdit(foundGroup);
|
||||
setMode('create'); // Use create mode for editing
|
||||
} else {
|
||||
// If group not found, try to fetch it from API
|
||||
fetchFocusGroupForEdit(focusGroupId);
|
||||
}
|
||||
}
|
||||
|
||||
// Clear URL parameters after processing to avoid re-triggering
|
||||
if (urlMode || focusGroupId || tab) {
|
||||
const newUrl = location.pathname;
|
||||
navigate(newUrl, { replace: true });
|
||||
}
|
||||
}, [location.search, focusGroups, navigate, location.pathname]);
|
||||
|
||||
const filteredGroups = focusGroups.filter(group =>
|
||||
group.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
group.topic.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
|
|
@ -466,9 +526,11 @@ const FocusGroups = () => {
|
|||
) : (
|
||||
<FocusGroupModerator
|
||||
draftToEdit={draftToEdit}
|
||||
preSelectedParticipants={preSelectedParticipants}
|
||||
onDraftSaved={() => {
|
||||
setDraftToEdit(null);
|
||||
setMode('view');
|
||||
setPreSelectedParticipants([]);
|
||||
fetchFocusGroups();
|
||||
}}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import Navigation from '@/components/Navigation';
|
||||
import AIRecruiter from '@/components/AIRecruiter';
|
||||
import UserCreator from '@/components/UserCreator';
|
||||
|
|
@ -8,7 +8,7 @@ import UserCard from '@/components/UserCard';
|
|||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Search, Filter, Users, FolderPlus, Folder, MoreHorizontal, Plus, Check, X, Trash2, Download } from 'lucide-react';
|
||||
import { Search, Filter, Users, FolderPlus, Folder, MoreHorizontal, Plus, Check, X, Trash2, Download, MessageSquare } from 'lucide-react';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
|
|
@ -59,6 +59,7 @@ interface FilterState {
|
|||
location: string[];
|
||||
techSavviness: string[];
|
||||
ethnicity: string[];
|
||||
folderStatus: string[];
|
||||
}
|
||||
|
||||
const SyntheticUsers = () => {
|
||||
|
|
@ -70,6 +71,7 @@ const SyntheticUsers = () => {
|
|||
}
|
||||
}, []); // Empty dependency array because it has no external dependencies
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const { loadPersonas } = usePersonaStorage();
|
||||
|
||||
const [mode, setMode] = useState<'view' | 'create'>('view');
|
||||
|
|
@ -79,6 +81,14 @@ const SyntheticUsers = () => {
|
|||
const [selectedFolder, setSelectedFolder] = useState<string>(DEFAULT_FOLDER_ID);
|
||||
const [isCreatingFolder, setIsCreatingFolder] = useState(false);
|
||||
const [newFolderName, setNewFolderName] = useState('');
|
||||
|
||||
// Handle URL parameters to set mode
|
||||
useEffect(() => {
|
||||
const modeParam = searchParams.get('mode');
|
||||
if (modeParam === 'view' || modeParam === 'create') {
|
||||
setMode(modeParam);
|
||||
}
|
||||
}, [searchParams]);
|
||||
const [allPersonas, setAllPersonas] = useState<Persona[]>([]);
|
||||
const [folders, setFolders] = useState<Folder[]>([]);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
|
|
@ -100,6 +110,7 @@ const SyntheticUsers = () => {
|
|||
location: [],
|
||||
techSavviness: [],
|
||||
ethnicity: [],
|
||||
folderStatus: [],
|
||||
});
|
||||
// Working copy of filter state for the dialog
|
||||
const [workingFilters, setWorkingFilters] = useState<FilterState>({
|
||||
|
|
@ -109,6 +120,7 @@ const SyntheticUsers = () => {
|
|||
location: [],
|
||||
techSavviness: [],
|
||||
ethnicity: [],
|
||||
folderStatus: [],
|
||||
});
|
||||
// Progress monitoring state for persona summary generation
|
||||
const [isSummaryGenerating, setIsSummaryGenerating] = useState(false);
|
||||
|
|
@ -180,6 +192,7 @@ const SyntheticUsers = () => {
|
|||
location: [],
|
||||
techSavviness: [],
|
||||
ethnicity: [],
|
||||
folderStatus: [],
|
||||
});
|
||||
};
|
||||
|
||||
|
|
@ -203,6 +216,7 @@ const SyntheticUsers = () => {
|
|||
location: [],
|
||||
techSavviness: [],
|
||||
ethnicity: [],
|
||||
folderStatus: [],
|
||||
});
|
||||
};
|
||||
|
||||
|
|
@ -867,7 +881,17 @@ const SyntheticUsers = () => {
|
|||
persona.techSavviness < 70 ? 'Medium (31-70)' :
|
||||
'High (71-100)'
|
||||
))) &&
|
||||
true;
|
||||
// Match folder status filter
|
||||
(activeFilters.folderStatus.length === 0 ||
|
||||
// If both options selected, show all personas (OR logic)
|
||||
(activeFilters.folderStatus.includes('hasFolder') && activeFilters.folderStatus.includes('noFolder')) ||
|
||||
// If only "hasFolder" selected
|
||||
(activeFilters.folderStatus.includes('hasFolder') && !activeFilters.folderStatus.includes('noFolder') &&
|
||||
persona.folderId && persona.folderId !== DEFAULT_FOLDER_ID) ||
|
||||
// If only "noFolder" selected
|
||||
(activeFilters.folderStatus.includes('noFolder') && !activeFilters.folderStatus.includes('hasFolder') &&
|
||||
(!persona.folderId || persona.folderId === DEFAULT_FOLDER_ID))
|
||||
);
|
||||
|
||||
// First check if the selected folder is "All Personas"
|
||||
if (selectedFolder === DEFAULT_FOLDER_ID) {
|
||||
|
|
@ -1343,6 +1367,23 @@ true;
|
|||
e.preventDefault();
|
||||
}}
|
||||
>
|
||||
<DropdownMenuItem
|
||||
className="flex items-center gap-2 cursor-pointer"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const selectedIds = Array.from(selectedPersonas);
|
||||
navigate('/focus-groups', {
|
||||
state: {
|
||||
mode: 'create',
|
||||
preSelectedParticipants: selectedIds
|
||||
}
|
||||
});
|
||||
}}
|
||||
>
|
||||
<MessageSquare className="h-4 w-4" />
|
||||
Create Focus Group with selected Personas
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="flex items-center gap-2 cursor-pointer"
|
||||
onClick={(e) => {
|
||||
|
|
@ -1817,6 +1858,39 @@ true;
|
|||
3
|
||||
)}
|
||||
|
||||
{/* Folder Assignment */}
|
||||
<div className="mb-6">
|
||||
<h3 className="text-sm font-medium mb-3">Folder Assignment</h3>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="folderStatus-hasFolder"
|
||||
checked={workingFilters.folderStatus.includes('hasFolder')}
|
||||
onCheckedChange={() => toggleFilter('folderStatus', 'hasFolder')}
|
||||
/>
|
||||
<Label
|
||||
htmlFor="folderStatus-hasFolder"
|
||||
className="truncate overflow-hidden"
|
||||
>
|
||||
Has folder assignment
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="folderStatus-noFolder"
|
||||
checked={workingFilters.folderStatus.includes('noFolder')}
|
||||
onCheckedChange={() => toggleFilter('folderStatus', 'noFolder')}
|
||||
/>
|
||||
<Label
|
||||
htmlFor="folderStatus-noFolder"
|
||||
className="truncate overflow-hidden"
|
||||
>
|
||||
No folder assignment
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Boolean Attributes */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue