semblance/src/components/FocusGroupModerator.tsx
michael 22b3ec19a5 Refactor FocusGroupModerator into smaller components and hooks
Extract business logic and UI into reusable pieces:

Custom Hooks:
- useFocusGroupAutoSave: debounced auto-save with retry logic
- useFolderManagement: folder CRUD operations
- usePersonaFiltering: filter state and persona filtering
- useDiscussionGuideGeneration: guide generation and progress

UI Components:
- SaveStatusIndicator: auto-save status display
- FolderSidebar: folder list and management
- PersonaFilterDialog: persona filter modal
- CopyGuideDialog: copy guide from other focus groups

Tab Components:
- SetupTab: form and asset uploader
- ReviewTab: discussion guide viewer
- ParticipantsTab: persona selection grid

Reduces FocusGroupModerator from 2,396 to ~600 lines (75% reduction).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-04 09:11:21 -06:00

600 lines
23 KiB
TypeScript

import React, { useState, useEffect, useCallback, useRef } from 'react';
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";
import { MessageSquare } from 'lucide-react';
import { toast } from 'sonner';
import { personasApi, focusGroupsApi } from '@/lib/api';
import ProgressModal from '@/components/ui/ProgressModal';
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Persona } from "@/types/persona";
// Custom hooks
import { useFocusGroupAutoSave } from '@/hooks/useFocusGroupAutoSave';
import { useFolderManagement } from '@/hooks/useFolderManagement';
import { usePersonaFiltering } from '@/hooks/usePersonaFiltering';
import { useDiscussionGuideGeneration } from '@/hooks/useDiscussionGuideGeneration';
// Components
import { SaveStatusIndicator } from '@/components/ui/SaveStatusIndicator';
import { FolderSidebar } from '@/components/focus-group-session/FolderSidebar';
import { PersonaFilterDialog } from '@/components/focus-group-session/PersonaFilterDialog';
import { CopyGuideDialog } from '@/components/focus-group-session/CopyGuideDialog';
import { SetupTab } from '@/components/focus-group-session/SetupTab';
import { ReviewTab } from '@/components/focus-group-session/ReviewTab';
import { ParticipantsTab } from '@/components/focus-group-session/ParticipantsTab';
// Form schema
const formSchema = z.object({
researchBrief: z.string().min(10, {
message: "Research brief must be at least 10 characters.",
}),
focusGroupName: z.string().min(3, {
message: "Focus group name must be at least 3 characters.",
}),
discussionTopics: z.string().min(10, {
message: "Discussion topics are required.",
}),
duration: z.string().min(1, {
message: "Duration is required.",
}),
llm_model: z.string().optional(),
reasoning_effort: z.string().optional(),
verbosity: z.string().optional(),
});
interface FocusGroupModeratorProps {
draftToEdit?: any | null;
onDraftSaved?: () => void;
preSelectedParticipants?: string[];
}
export default function FocusGroupModerator({
draftToEdit,
onDraftSaved,
preSelectedParticipants = []
}: FocusGroupModeratorProps = {}) {
const navigate = useNavigate();
const { setPreviousRoute, navigationState, clearNavigationState } = useNavigation();
// Tab state
const [activeTab, setActiveTab] = useState('setup');
// Core state
const [draftFocusGroupId, setDraftFocusGroupId] = useState<string | null>(null);
const [selectedParticipants, setSelectedParticipants] = useState<string[]>([]);
const [backendAssets, setBackendAssets] = useState<any[]>([]);
const [personas, setPersonas] = useState<any[]>([]);
const [isLoadingPersonas, setIsLoadingPersonas] = useState(false);
// Copy guide dialog state
const [isCopyGuideModalOpen, setIsCopyGuideModalOpen] = useState(false);
// Track if draft has been loaded
const draftLoadedRef = useRef(false);
// Initialize form
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
researchBrief: "",
focusGroupName: "",
discussionTopics: "",
duration: "60",
llm_model: "gemini-3-pro-preview",
reasoning_effort: "medium",
verbosity: "medium",
},
});
// Custom hooks
const folderManagement = useFolderManagement();
const filtering = usePersonaFiltering({
personas,
selectedFolder: folderManagement.selectedFolder,
});
const guideGeneration = useDiscussionGuideGeneration({
form,
});
const autoSave = useFocusGroupAutoSave({
form,
selectedParticipants,
backendAssets,
draftFocusGroupId,
draftToEdit,
activeTab,
onDraftIdChange: setDraftFocusGroupId,
});
// Fetch personas on mount
useEffect(() => {
const fetchPersonas = async () => {
setIsLoadingPersonas(true);
try {
const response = await personasApi.getAll();
if (Array.isArray(response.data) && response.data.length > 0) {
setPersonas(response.data);
} else {
toast.warning("No participants available");
}
} catch (error) {
console.error("Error fetching personas:", error);
toast.error("Failed to load participants");
} finally {
setIsLoadingPersonas(false);
}
};
fetchPersonas();
}, []);
// Fetch backend assets
const fetchBackendAssets = useCallback(async (focusGroupId: string) => {
try {
const response = await focusGroupsApi.getAssets(focusGroupId);
setBackendAssets(response.data.assets || []);
} catch (error) {
console.error("Error fetching backend assets:", error);
toast.error("Failed to load asset information");
}
}, []);
// Load draft data when editing
useEffect(() => {
if (!draftToEdit) {
draftLoadedRef.current = false;
return;
}
if (draftToEdit && !draftLoadedRef.current) {
autoSave.setIsLoadingDraft(true);
draftLoadedRef.current = true;
const draftId = draftToEdit.id || draftToEdit._id;
setDraftFocusGroupId(draftId);
if (draftId) {
fetchBackendAssets(draftId);
}
// Load form data
if (draftToEdit.name) form.setValue('focusGroupName', draftToEdit.name);
if (draftToEdit.description || draftToEdit.objective) {
form.setValue('researchBrief', draftToEdit.description || draftToEdit.objective || '');
}
if (draftToEdit.topic) form.setValue('discussionTopics', draftToEdit.topic);
if (draftToEdit.duration) form.setValue('duration', draftToEdit.duration.toString());
if (draftToEdit.llm_model) form.setValue('llm_model', draftToEdit.llm_model);
if (draftToEdit.reasoning_effort) form.setValue('reasoning_effort', draftToEdit.reasoning_effort);
if (draftToEdit.verbosity) form.setValue('verbosity', draftToEdit.verbosity);
// Load discussion guide
if (draftToEdit.discussionGuide) {
guideGeneration.setDiscussionGuide(draftToEdit.discussionGuide);
if (!navigationState.focusGroupTab || navigationState.previousRoute !== '/focus-groups') {
setActiveTab('review');
}
}
// Load participants
if (draftToEdit.participants && Array.isArray(draftToEdit.participants)) {
setSelectedParticipants(draftToEdit.participants);
}
// Set last saved data
autoSave.setLastSavedData({
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-3-pro-preview',
reasoning_effort: draftToEdit.reasoning_effort || 'medium',
verbosity: draftToEdit.verbosity || 'medium',
participants: draftToEdit.participants || [],
participants_count: (draftToEdit.participants || []).length,
status: 'draft',
date: draftToEdit.date || new Date().toISOString(),
uploadedAssets: backendAssets.map(a => a.filename || a.original_name || 'unknown')
});
toast.success("Draft focus group loaded", {
description: "Continue editing your focus group setup"
});
setTimeout(() => {
autoSave.setIsLoadingDraft(false);
}, 1000);
}
}, [draftToEdit, form, fetchBackendAssets, navigationState, autoSave, guideGeneration, backendAssets]);
// Handle pre-selected participants
useEffect(() => {
if (preSelectedParticipants.length > 0) {
setSelectedParticipants(preSelectedParticipants);
setActiveTab('participants');
}
}, [preSelectedParticipants]);
// Handle navigation state for returning from persona details
useEffect(() => {
if (navigationState.focusGroupTab && navigationState.previousRoute === '/focus-groups') {
setTimeout(() => {
setActiveTab(navigationState.focusGroupTab);
clearNavigationState();
}, 0);
}
}, [navigationState.focusGroupTab, draftToEdit, clearNavigationState]);
// Revert to setup tab when generation is cancelled
useEffect(() => {
if (!guideGeneration.guideGenerationState.isGenerating &&
!guideGeneration.guideGenerationState.isCancelling &&
guideGeneration.guideGenerationState.taskId === null &&
activeTab === 'review' &&
!guideGeneration.discussionGuide) {
setActiveTab('setup');
}
}, [guideGeneration.guideGenerationState, activeTab, guideGeneration.discussionGuide]);
// Handle participant selection
const handleParticipantSelection = useCallback((id: string) => {
setSelectedParticipants(prev => {
const isSelected = prev.includes(id);
return isSelected ? prev.filter(pId => pId !== id) : [...prev, id];
});
}, []);
// Handle persona view details
const handlePersonaViewDetails = useCallback((persona: Persona) => {
setPreviousRoute('/focus-groups', {
focusGroupId: draftFocusGroupId,
focusGroupTab: 'participants',
isNewFocusGroup: !draftToEdit,
focusGroupData: {
name: form.getValues('focusGroupName'),
description: form.getValues('researchBrief'),
selectedParticipants: selectedParticipants,
discussionGuide: guideGeneration.discussionGuide,
}
});
navigate(`/synthetic-users/${persona.id}`);
}, [setPreviousRoute, draftFocusGroupId, draftToEdit, form, selectedParticipants, guideGeneration.discussionGuide, navigate]);
// Handle form submission (generate discussion guide)
const handleSubmit = useCallback(async (e: React.FormEvent) => {
e.preventDefault();
const isValid = await form.trigger();
if (!isValid) return;
const values = form.getValues();
try {
let focusGroupId = draftFocusGroupId;
if (!focusGroupId) {
const draftData = {
name: values.focusGroupName,
status: 'draft',
participants: selectedParticipants,
participants_count: selectedParticipants.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,
llm_model: values.llm_model,
reasoning_effort: values.reasoning_effort,
verbosity: values.verbosity,
};
const savedDraft = await focusGroupsApi.create(draftData);
focusGroupId = savedDraft.data.focus_group_id || savedDraft.data.id || savedDraft.data._id;
setDraftFocusGroupId(focusGroupId);
}
// Update focus group before generating
if (focusGroupId) {
const preUpdateData = {
name: values.focusGroupName,
participants: selectedParticipants,
participants_count: selectedParticipants.length,
duration: parseInt(values.duration),
topic: values.discussionTopics.split(',')[0].trim().toLowerCase().replace(/\s+/g, '-'),
description: values.researchBrief,
objective: values.researchBrief,
llm_model: values.llm_model,
reasoning_effort: values.reasoning_effort,
verbosity: values.verbosity
};
await focusGroupsApi.update(focusGroupId, preUpdateData);
}
// Generate discussion guide
const guide = await guideGeneration.generateDiscussionGuide(values, focusGroupId);
if (!guide || (typeof guide === 'string' && guide.trim() === '')) {
return; // Cancelled
}
guideGeneration.setDiscussionGuide(guide);
// Save with discussion guide
if (focusGroupId) {
const updateData = {
name: values.focusGroupName,
status: 'draft',
participants: selectedParticipants,
participants_count: selectedParticipants.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,
llm_model: values.llm_model,
reasoning_effort: values.reasoning_effort,
verbosity: values.verbosity,
discussionGuide: guide
};
await focusGroupsApi.update(focusGroupId, updateData);
toast.success("Progress saved as draft");
}
setActiveTab('review');
toast.success("Discussion guide generated", {
description: "Review and edit before proceeding",
});
} catch (error: any) {
console.error("Error in focus group creation flow:", error);
toast.error("Discussion guide generation failed", {
description: "Please go back to the setup tab and try generating again.",
duration: 8000,
});
}
}, [draftFocusGroupId, form, selectedParticipants, guideGeneration]);
// Handle copying discussion guide from another focus group
const handleCopyDiscussionGuide = useCallback(async (sourceFocusGroup: any) => {
if (!sourceFocusGroup || !sourceFocusGroup.discussionGuide) {
toast.error("Selected focus group does not have a discussion guide");
return;
}
guideGeneration.setDiscussionGuide(sourceFocusGroup.discussionGuide);
// Update draft with copied guide
if (draftFocusGroupId) {
try {
const values = form.getValues();
const updateData = {
name: values.focusGroupName,
status: 'draft',
participants: selectedParticipants,
participants_count: selectedParticipants.length,
duration: parseInt(values.duration || '60'),
topic: values.discussionTopics || '',
description: values.researchBrief || '',
objective: values.researchBrief || '',
llm_model: values.llm_model || 'gemini-3-pro-preview',
reasoning_effort: values.reasoning_effort || 'medium',
verbosity: values.verbosity || 'medium',
discussionGuide: sourceFocusGroup.discussionGuide
};
await focusGroupsApi.update(draftFocusGroupId, updateData);
} catch (error) {
console.error("Failed to update focus group with copied discussion guide:", error);
toast.error("Failed to save copied discussion guide");
}
}
setIsCopyGuideModalOpen(false);
setActiveTab('review');
toast.success("Discussion guide copied successfully", {
description: `Copied from "${sourceFocusGroup.name}"`,
});
}, [draftFocusGroupId, form, selectedParticipants, guideGeneration]);
// Handle starting focus group
const handleStartFocusGroup = useCallback(async () => {
if (!form.getValues().focusGroupName) {
toast.error("Missing focus group name");
return;
}
if (!guideGeneration.discussionGuide) {
toast.error("Missing discussion guide");
return;
}
if (selectedParticipants.length < 1) {
toast.error("Not enough participants", {
description: "Please select at least one participant",
});
return;
}
try {
toast.loading("Creating focus group...");
const values = form.getValues();
let focusGroupId = draftFocusGroupId;
if (focusGroupId) {
const updatedData = {
name: values.focusGroupName,
status: 'in-progress',
participants: selectedParticipants,
participants_count: selectedParticipants.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: guideGeneration.discussionGuide
};
await focusGroupsApi.update(focusGroupId, updatedData);
if (onDraftSaved) onDraftSaved();
} else {
const focusGroupData = {
name: values.focusGroupName,
status: 'in-progress',
participants: selectedParticipants,
participants_count: selectedParticipants.length,
date: new Date().toISOString(),
duration: parseInt(values.duration),
topic: values.discussionTopics.split(',')[0].trim().toLowerCase().replace(/\s+/g, '-'),
discussionGuide: guideGeneration.discussionGuide
};
const response = await focusGroupsApi.create(focusGroupData);
focusGroupId = response.data.focus_group_id;
}
toast.dismiss();
toast.success("Focus group created successfully");
navigate(`/focus-groups/${focusGroupId}`);
} catch (error) {
toast.dismiss();
console.error("Failed to start focus group:", error);
toast.error("Failed to create focus group");
}
}, [form, guideGeneration.discussionGuide, selectedParticipants, draftFocusGroupId, onDraftSaved, navigate]);
return (
<>
<SaveStatusIndicator status={autoSave.autoSaveStatus} />
<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 Modal */}
<ProgressModal
isOpen={guideGeneration.isGuideProgressModalOpen}
onClose={() => guideGeneration.setIsGuideProgressModalOpen(false)}
isActive={guideGeneration.guideGenerationState.isGenerating}
isComplete={guideGeneration.guideGenerationState.isComplete}
hasError={guideGeneration.guideGenerationState.hasError}
isCancelling={guideGeneration.guideGenerationState.isCancelling}
taskId={guideGeneration.guideGenerationState.taskId}
title="Generating Discussion Guide"
description="Creating your discussion guide based on the research objectives. This typically takes 30-60 seconds."
onCancel={guideGeneration.guideGenerationControls.cancelGeneration}
onComplete={guideGeneration.handleGuideProgressComplete}
/>
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className="grid w-full grid-cols-3 mb-6">
<TabsTrigger value="setup">Setup</TabsTrigger>
<TabsTrigger value="review">Review & Edit</TabsTrigger>
<TabsTrigger value="participants">Participants</TabsTrigger>
</TabsList>
<TabsContent value="setup">
<SetupTab
form={form}
onSubmit={handleSubmit}
isGenerating={guideGeneration.guideGenerationState.isGenerating}
draftFocusGroupId={draftFocusGroupId}
backendAssets={backendAssets}
onAssetsChange={setBackendAssets}
onCopyGuideClick={() => setIsCopyGuideModalOpen(true)}
/>
</TabsContent>
<TabsContent value="review">
<ReviewTab
discussionGuide={guideGeneration.discussionGuide}
backendAssets={backendAssets}
draftFocusGroupId={draftFocusGroupId}
onSaveGuide={guideGeneration.handleSaveDiscussionGuide}
onDownloadGuide={guideGeneration.handleDownloadDiscussionGuide}
isDownloading={guideGeneration.isDownloadingGuide}
guideGenerationState={guideGeneration.guideGenerationState}
onEditingChange={guideGeneration.handleEditingStateChange}
onNavigateToSetup={() => setActiveTab('setup')}
onNavigateToParticipants={() => setActiveTab('participants')}
isJsonFormat={guideGeneration.isJsonFormat}
/>
</TabsContent>
<TabsContent value="participants">
<ParticipantsTab
personas={personas}
filteredPersonas={filtering.filteredPersonas}
selectedParticipants={selectedParticipants}
onParticipantToggle={handleParticipantSelection}
onViewDetails={handlePersonaViewDetails}
searchTerm={filtering.searchTerm}
onSearchChange={filtering.setSearchTerm}
activeFilters={filtering.activeFilters}
onOpenFilter={filtering.openFilterDialog}
isLoading={isLoadingPersonas}
folderSidebar={
<FolderSidebar
folders={folderManagement.folders}
selectedFolder={folderManagement.selectedFolder}
onSelectFolder={folderManagement.setSelectedFolder}
personas={personas}
isCreatingFolder={folderManagement.isCreatingFolder}
onStartCreateFolder={() => folderManagement.setIsCreatingFolder(true)}
newFolderName={folderManagement.newFolderName}
onNewFolderNameChange={folderManagement.setNewFolderName}
onConfirmCreateFolder={folderManagement.createNewFolder}
onCancelCreateFolder={folderManagement.cancelFolderCreation}
folderToRename={folderManagement.folderToRename}
renameFolderName={folderManagement.renameFolderName}
onRenameFolderNameChange={folderManagement.setRenameFolderName}
onStartRenameFolder={folderManagement.startRenameFolder}
onConfirmRenameFolder={folderManagement.completeRenameFolder}
onCancelRenameFolder={folderManagement.cancelRenameFolder}
/>
}
onNavigateToReview={() => setActiveTab('review')}
onStartFocusGroup={handleStartFocusGroup}
canStart={selectedParticipants.length >= 1 && !!guideGeneration.discussionGuide}
/>
{/* Filter Dialog */}
<PersonaFilterDialog
open={filtering.isFilterOpen}
onOpenChange={(open) => {
if (open) {
filtering.openFilterDialog();
} else {
filtering.setIsFilterOpen(false);
}
}}
workingFilters={filtering.workingFilters}
personas={personas}
getFilterOptions={filtering.getFilterOptions}
getFilteredOptions={filtering.getFilteredOptions}
onToggleFilter={filtering.toggleFilter}
onApply={filtering.applyFilters}
onReset={filtering.resetFilters}
/>
</TabsContent>
</Tabs>
{/* Copy Guide Dialog */}
<CopyGuideDialog
open={isCopyGuideModalOpen}
onOpenChange={setIsCopyGuideModalOpen}
onCopyGuide={handleCopyDiscussionGuide}
excludeFocusGroupId={draftFocusGroupId}
/>
</div>
</>
);
}