feat: Add admin experiment management pages and components
- Implemented ExperimentPage for viewing and editing individual experiments. - Created ExperimentsAdminPage for managing all experiments with a list and a modal for creating new experiments. - Developed AnnotatePage for annotating experiments with different views based on annotation tools. - Added CreateExperimentModal for creating new experiments with dataset selection. - Introduced DeleteExperimentButton for confirming and executing experiment deletions. - Built EditableExperimentHeader for editing experiment details. - Created ExperimentsList component to display a list of experiments with links to their details.
This commit is contained in:
206
src/components/CreateExperimentModal.tsx
Normal file
206
src/components/CreateExperimentModal.tsx
Normal file
@@ -0,0 +1,206 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { createExperiment } from '@/app/actions/experiment';
|
||||
import Modal from '@/components/Modal';
|
||||
import { Dataset } from '@/lib/model/dataset';
|
||||
import { getAllDatasets } from '@/app/actions/datasets';
|
||||
|
||||
interface CreateExperimentModalProps {
|
||||
datasetId?: number;
|
||||
}
|
||||
|
||||
export default function CreateExperimentModal({ datasetId }: CreateExperimentModalProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [name, setName] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [selectedDatasetId, setSelectedDatasetId] = useState<number | ''>(datasetId || '');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [datasetsLoading, setDatasetsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [datasets, setDatasets] = useState<Dataset[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && !datasetId) {
|
||||
// Fetch datasets if not pre-selected
|
||||
fetchDatasets();
|
||||
}
|
||||
}, [isOpen, datasetId]);
|
||||
|
||||
async function fetchDatasets() {
|
||||
try {
|
||||
setDatasetsLoading(true);
|
||||
const data = await getAllDatasets();
|
||||
setDatasets(data);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch datasets:', err);
|
||||
setError('Failed to load datasets');
|
||||
} finally {
|
||||
setDatasetsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setSuccess(false);
|
||||
|
||||
if (!name.trim()) {
|
||||
setError('Experiment name is required');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!selectedDatasetId) {
|
||||
setError('Dataset is required');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
await createExperiment({
|
||||
name: name.trim(),
|
||||
description: description.trim() || undefined,
|
||||
datasetId: Number(selectedDatasetId)
|
||||
});
|
||||
setName('');
|
||||
setDescription('');
|
||||
setSelectedDatasetId(datasetId || '');
|
||||
setSuccess(true);
|
||||
setTimeout(() => {
|
||||
setSuccess(false);
|
||||
setIsOpen(false);
|
||||
// Refresh the page to show the new experiment
|
||||
window.location.reload();
|
||||
}, 1500);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to create experiment');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
if (!loading) {
|
||||
setIsOpen(false);
|
||||
setName('');
|
||||
setDescription('');
|
||||
setSelectedDatasetId(datasetId || '');
|
||||
setError(null);
|
||||
setSuccess(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setIsOpen(true)}
|
||||
className="mb-6 px-4 py-2 bg-green-500 text-white rounded-md hover:bg-green-600 transition-colors"
|
||||
>
|
||||
+ Create New Experiment
|
||||
</button>
|
||||
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={handleClose}
|
||||
title="Create New Experiment"
|
||||
actions={[
|
||||
{
|
||||
label: 'Cancel',
|
||||
onClick: handleClose,
|
||||
variant: 'secondary',
|
||||
disabled: loading,
|
||||
},
|
||||
{
|
||||
label: loading ? 'Creating...' : 'Create',
|
||||
onClick: () => {
|
||||
const form = document.getElementById('experimentForm') as HTMLFormElement;
|
||||
form?.dispatchEvent(new Event('submit', { bubbles: true }));
|
||||
},
|
||||
variant: 'primary',
|
||||
disabled: loading,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<form id="experimentForm" onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="name" className="block text-sm font-medium mb-2">
|
||||
Experiment Name
|
||||
</label>
|
||||
<input
|
||||
id="name"
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Enter experiment name"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
disabled={loading}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="description" className="block text-sm font-medium mb-2">
|
||||
Description (Optional)
|
||||
</label>
|
||||
<textarea
|
||||
id="description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="Enter experiment description"
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 resize-none"
|
||||
rows={3}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!datasetId && (
|
||||
<div>
|
||||
<label htmlFor="datasetId" className="block text-sm font-medium mb-2">
|
||||
Dataset
|
||||
</label>
|
||||
{datasetsLoading ? (
|
||||
<div className="w-full px-3 py-2 border border-gray-300 rounded-md bg-gray-50 text-gray-500">
|
||||
Loading datasets...
|
||||
</div>
|
||||
) : datasets.length === 0 ? (
|
||||
<div className="w-full px-3 py-2 border border-gray-300 rounded-md bg-gray-50 text-gray-500">
|
||||
No datasets available
|
||||
</div>
|
||||
) : (
|
||||
<select
|
||||
id="datasetId"
|
||||
value={selectedDatasetId}
|
||||
onChange={(e) => setSelectedDatasetId(e.target.value ? Number(e.target.value) : '')}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
disabled={loading}
|
||||
>
|
||||
<option value="">-- Select a Dataset --</option>
|
||||
{datasets.map((ds) => (
|
||||
<option key={ds.id} value={ds.id}>
|
||||
{ds.name} (ID: {ds.id})
|
||||
{ds.description ? ` - ${ds.description}` : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
<p className="text-xs text-gray-500 mt-1">Select the dataset for this experiment</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="p-3 bg-red-100 border border-red-400 text-red-700 rounded text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{success && (
|
||||
<div className="p-3 bg-green-100 border border-green-400 text-green-700 rounded text-sm">
|
||||
Experiment created successfully!
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
81
src/components/DeleteExperimentButton.tsx
Normal file
81
src/components/DeleteExperimentButton.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { deleteExperiment } from '@/app/actions/experiment';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
interface DeleteExperimentButtonProps {
|
||||
experimentId: number;
|
||||
experimentName: string;
|
||||
}
|
||||
|
||||
export default function DeleteExperimentButton({ experimentId, experimentName }: DeleteExperimentButtonProps) {
|
||||
const [isConfirming, setIsConfirming] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const router = useRouter();
|
||||
|
||||
async function handleConfirm() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await deleteExperiment(experimentId);
|
||||
setIsConfirming(false);
|
||||
// Redirect to experiments page after deletion
|
||||
router.push('/admin/experiments');
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to delete experiment';
|
||||
setError(message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (isConfirming) {
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg shadow-lg p-6 max-w-sm">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
Delete experiment "{experimentName}"?
|
||||
</h3>
|
||||
<p className="text-gray-600 mb-4">
|
||||
This will permanently delete the experiment. This action cannot be undone.
|
||||
</p>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-red-100 border border-red-400 text-red-700 rounded text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2 justify-end">
|
||||
<button
|
||||
onClick={() => setIsConfirming(false)}
|
||||
disabled={loading}
|
||||
className="px-4 py-2 bg-gray-300 text-gray-700 rounded-md hover:bg-gray-400 disabled:bg-gray-200"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleConfirm}
|
||||
disabled={loading}
|
||||
className="px-4 py-2 bg-red-500 text-white rounded-md hover:bg-red-600 disabled:bg-gray-400"
|
||||
>
|
||||
{loading ? 'Deleting...' : 'Delete'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={() => setIsConfirming(true)}
|
||||
className="px-4 py-2 bg-red-500 text-white rounded-md hover:bg-red-600"
|
||||
>
|
||||
Delete Experiment
|
||||
</button>
|
||||
);
|
||||
}
|
||||
168
src/components/EditableExperimentHeader.tsx
Normal file
168
src/components/EditableExperimentHeader.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { updateExperiment } from '@/app/actions/experiment';
|
||||
import { Experiment } from '@/lib/model/experiment';
|
||||
|
||||
interface EditableExperimentHeaderProps {
|
||||
experiment: Experiment;
|
||||
}
|
||||
|
||||
const ANNOTATION_TOOLS = [
|
||||
{ value: 'single-choice', label: 'Single Choice' },
|
||||
];
|
||||
|
||||
export default function EditableExperimentHeader({
|
||||
experiment,
|
||||
}: EditableExperimentHeaderProps) {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [name, setName] = useState(experiment.name);
|
||||
const [description, setDescription] = useState(experiment.description || '');
|
||||
const [annotationTool, setAnnotationTool] = useState(experiment.annotationTool || 'single-choice');
|
||||
const [published, setPublished] = useState(experiment.published || false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function handleSave() {
|
||||
if (!name.trim()) {
|
||||
setError('Experiment name cannot be empty');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await updateExperiment(experiment.id, {
|
||||
name: name.trim(),
|
||||
description: description.trim() || undefined,
|
||||
annotationTool: annotationTool,
|
||||
published: published
|
||||
});
|
||||
setIsEditing(false);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to update experiment');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
setName(experiment.name);
|
||||
setAnnotationTool(experiment.annotationTool || 'single-choice');
|
||||
setPublished(experiment.published || false);
|
||||
setDescription(experiment.description || '');
|
||||
setError(null);
|
||||
setIsEditing(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow p-6 mb-6">
|
||||
{isEditing ? (
|
||||
<div>
|
||||
<label htmlFor="name" className="block text-sm font-medium mb-2">
|
||||
Experiment Name
|
||||
</label>
|
||||
<input
|
||||
id="name"
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 mb-4"
|
||||
disabled={loading}
|
||||
/>
|
||||
|
||||
<label htmlFor="description" className="block text-sm font-medium mb-2">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
id="description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 resize-none mb-4"
|
||||
rows={3}
|
||||
disabled={loading}
|
||||
/>
|
||||
|
||||
<label htmlFor="annotationTool" className="block text-sm font-medium mb-2">
|
||||
Annotation Tool
|
||||
</label>
|
||||
<select
|
||||
id="annotationTool"
|
||||
value={annotationTool}
|
||||
onChange={(e) => setAnnotationTool(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 mb-4"
|
||||
disabled={loading}
|
||||
>
|
||||
{ANNOTATION_TOOLS.map((tool) => (
|
||||
<option key={tool.value} value={tool.value}>
|
||||
{tool.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<div className="flex items-center mb-4">
|
||||
<input
|
||||
id="published"
|
||||
type="checkbox"
|
||||
checked={published}
|
||||
onChange={(e) => setPublished(e.target.checked)}
|
||||
className="w-4 h-4 text-blue-500 rounded focus:ring-2 focus:ring-blue-500"
|
||||
disabled={loading}
|
||||
/>
|
||||
<label htmlFor="published" className="ml-2 text-sm font-medium text-gray-700">
|
||||
Published
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-red-100 border border-red-400 text-red-700 rounded text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={loading}
|
||||
className="px-4 py-2 bg-blue-500 text-white rounded-md hover:bg-blue-600 disabled:bg-gray-400"
|
||||
>
|
||||
{loading ? 'Saving...' : 'Save'}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCancel}
|
||||
disabled={loading}
|
||||
className="px-4 py-2 bg-gray-300 text-gray-700 rounded-md hover:bg-gray-400 disabled:bg-gray-200"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold mb-2">{experiment.name}</h1>
|
||||
<p className="text-lg text-gray-600 mb-4">{experiment.description}</p>
|
||||
<p className="text-sm text-gray-500 mb-4">
|
||||
ID: {experiment.id} | Dataset ID: {experiment.datasetId}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 mb-4">
|
||||
Annotation Tool: {ANNOTATION_TOOLS.find(t => t.value === (experiment.annotationTool || 'single-choice'))?.label || 'Single Choice'}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 mb-4">
|
||||
Status: {experiment.published ? 'Published' : 'Draft'}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 mb-4">
|
||||
Created: {new Date(experiment.createdAt).toLocaleDateString()} |
|
||||
Updated: {new Date(experiment.updatedAt).toLocaleDateString()}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setIsEditing(true)}
|
||||
className="px-4 py-2 bg-blue-500 text-white rounded-md hover:bg-blue-600"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
41
src/components/ExperimentsList.tsx
Normal file
41
src/components/ExperimentsList.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
'use server';
|
||||
|
||||
import db from '@/lib/db';
|
||||
import { experiment } from '@/lib/model/experiment';
|
||||
import { dataset } from '@/lib/model/dataset';
|
||||
import Link from 'next/link';
|
||||
|
||||
export default async function ExperimentsList() {
|
||||
const experiments = await db
|
||||
.select()
|
||||
.from(experiment)
|
||||
.orderBy(experiment.createdAt);
|
||||
|
||||
return (
|
||||
<div className="mt-8">
|
||||
<h2 className="text-2xl font-bold mb-4">Experiments</h2>
|
||||
{experiments.length === 0 ? (
|
||||
<p className="text-gray-500">No experiments found.</p>
|
||||
) : (
|
||||
<div className="grid gap-4">
|
||||
{experiments.map((exp) => (
|
||||
<Link
|
||||
key={exp.id}
|
||||
href={`/admin/experiments/${exp.id}`}
|
||||
className="border border-gray-300 rounded-lg p-4 bg-white shadow-sm hover:shadow-md transition-shadow block cursor-pointer"
|
||||
>
|
||||
<h3 className="text-lg font-semibold">{exp.name}</h3>
|
||||
<p className="text-md text-gray-600">{exp.description}</p>
|
||||
<p className="text-sm text-gray-600 mt-2">
|
||||
ID: {exp.id} | Dataset ID: {exp.datasetId}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
Created: {new Date(exp.createdAt).toLocaleDateString()}
|
||||
</p>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user