diff --git a/.env b/.env index 1603226..87b0984 100644 --- a/.env +++ b/.env @@ -5,8 +5,8 @@ DATABASE_URL="postgresql://postgres:jusxah-jufrew-niwjY5@db:5432/hristudio" # Clerk -NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_cmVmaW5lZC1kcnVtLTIzLmNsZXJrLmFjY291bnRzLmRldiQ -CLERK_SECRET_KEY=sk_test_3qESERGxZqHpROHzFe7nYxjfqfVhpHWS1UVDQt86v8 +NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_YWxsb3dlZC1zYWxtb24tNjMuY2xlcmsuYWNjb3VudHMuZGV2JA +CLERK_SECRET_KEY=sk_test_nUKl0GTM5ibgUH12WbTH6pNVHAyRshlSFi64IrEeWD NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-in NEXT_PUBLIC_CLERK_SIGN_UP_URL=/sign-up diff --git a/.gitignore b/.gitignore index 40f873f..eadb5b3 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ ### NextJS ### # dependencies /node_modules +.pnpm-store/ /.pnp .pnp.js diff --git a/docker-compose.yml b/docker-compose.yml index 5d7db53..1073ca7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,3 @@ -# Use Docker Compose file format version 3.9 -# version: '3.9' - services: app: build: @@ -8,13 +5,15 @@ services: dockerfile: Dockerfile ports: - '3000:3000' # Node.js - - '4983:4983' # Drizzle Studio + # - '4983:4983' # Drizzle Studio volumes: - .:/app - /app/node_modules environment: NODE_ENV: development - command: ["pnpm", "run", "dev"] + command: ["sh", "-c", "pnpm db:push && pnpm run dev"] + depends_on: + - db db: image: postgres @@ -33,6 +32,6 @@ services: restart: always ports: - 8080:8080 - + volumes: postgres: diff --git a/package.json b/package.json index c793ae1..074d96b 100644 --- a/package.json +++ b/package.json @@ -18,8 +18,12 @@ "@radix-ui/react-avatar": "^1.1.0", "@radix-ui/react-dialog": "^1.1.1", "@radix-ui/react-icons": "^1.3.0", + "@radix-ui/react-label": "^2.1.0", + "@radix-ui/react-select": "^2.1.1", "@radix-ui/react-separator": "^1.1.0", "@radix-ui/react-slot": "^1.1.0", + "@radix-ui/react-toast": "^1.2.1", + "@radix-ui/react-tooltip": "^1.1.2", "@t3-oss/env-nextjs": "^0.10.1", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", @@ -30,6 +34,7 @@ "next": "^14.2.12", "next-themes": "^0.3.0", "postgres": "^3.4.4", + "radix-ui": "^1.0.1", "react": "^18.3.1", "react-dom": "^18.3.1", "react-icons": "^5.3.0", diff --git a/src/app/api/participants/[id]/route.ts b/src/app/api/participants/[id]/route.ts new file mode 100644 index 0000000..e3eea2e --- /dev/null +++ b/src/app/api/participants/[id]/route.ts @@ -0,0 +1,37 @@ +import { db } from "~/server/db"; +import { participants } from "~/server/db/schema"; +import { NextResponse } from "next/server"; +import { eq } from "drizzle-orm"; + +export async function DELETE( + request: Request, + { params }: { params: { id: string } } +) { + console.log('DELETE route hit, params:', params); + const id = parseInt(params.id); + + if (isNaN(id)) { + console.log('Invalid ID:', id); + return NextResponse.json({ error: 'Invalid ID' }, { status: 400 }); + } + + try { + console.log('Attempting to delete participant with ID:', id); + const deletedParticipant = await db.delete(participants) + .where(eq(participants.id, id)) + .returning(); + + console.log('Deleted participant:', deletedParticipant); + + if (deletedParticipant.length === 0) { + console.log('Participant not found'); + return NextResponse.json({ error: 'Participant not found' }, { status: 404 }); + } + + console.log('Participant deleted successfully'); + return NextResponse.json({ message: "Participant deleted successfully" }); + } catch (error) { + console.error('Error deleting participant:', error); + return NextResponse.json({ error: 'Failed to delete participant', details: String(error) }, { status: 500 }); + } +} \ No newline at end of file diff --git a/src/app/api/participants/route.ts b/src/app/api/participants/route.ts new file mode 100644 index 0000000..6a01750 --- /dev/null +++ b/src/app/api/participants/route.ts @@ -0,0 +1,25 @@ +import { db } from "~/server/db"; +import { participants } from "~/server/db/schema"; +import { NextResponse } from "next/server"; +import { eq } from "drizzle-orm"; + +export async function GET(request: Request) { + const { searchParams } = new URL(request.url); + const studyId = searchParams.get('studyId'); + + if (!studyId) { + return NextResponse.json({ error: 'Study ID is required' }, { status: 400 }); + } + + const allParticipants = await db.select().from(participants).where(eq(participants.studyId, parseInt(studyId))); + return NextResponse.json(allParticipants); +} + +export async function POST(request: Request) { + const { name, studyId } = await request.json(); + if (!name || !studyId) { + return NextResponse.json({ error: 'Name and Study ID are required' }, { status: 400 }); + } + const newParticipant = await db.insert(participants).values({ name, studyId }).returning(); + return NextResponse.json(newParticipant[0]); +} \ No newline at end of file diff --git a/src/app/api/studies/[id]/route.ts b/src/app/api/studies/[id]/route.ts new file mode 100644 index 0000000..3faeec0 --- /dev/null +++ b/src/app/api/studies/[id]/route.ts @@ -0,0 +1,86 @@ +import { db } from "~/server/db"; +import { studies } from "~/server/db/schema"; +import { NextResponse } from "next/server"; +import { eq, and } from "drizzle-orm"; +import { auth } from "@clerk/nextjs/server"; + +export async function GET( + request: Request, + { params }: { params: { id: string } } +) { + const { userId } = auth(); + if (!userId) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const id = parseInt(params.id); + if (isNaN(id)) { + return NextResponse.json({ error: 'Invalid ID' }, { status: 400 }); + } + + const study = await db.select().from(studies).where(and(eq(studies.id, id), eq(studies.userId, userId))).limit(1); + + if (study.length === 0) { + return NextResponse.json({ error: 'Study not found' }, { status: 404 }); + } + + return NextResponse.json(study[0]); +} + +export async function PUT( + request: Request, + { params }: { params: { id: string } } +) { + const { userId } = auth(); + if (!userId) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const id = parseInt(params.id); + if (isNaN(id)) { + return NextResponse.json({ error: 'Invalid ID' }, { status: 400 }); + } + + const { title, description } = await request.json(); + if (!title) { + return NextResponse.json({ error: 'Title is required' }, { status: 400 }); + } + + const updatedStudy = await db + .update(studies) + .set({ title, description }) + .where(and(eq(studies.id, id), eq(studies.userId, userId))) + .returning(); + + if (updatedStudy.length === 0) { + return NextResponse.json({ error: 'Study not found or unauthorized' }, { status: 404 }); + } + + return NextResponse.json(updatedStudy[0]); +} + +export async function DELETE( + request: Request, + { params }: { params: { id: string } } +) { + const { userId } = auth(); + if (!userId) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const id = parseInt(params.id); + if (isNaN(id)) { + return NextResponse.json({ error: 'Invalid ID' }, { status: 400 }); + } + + const deletedStudy = await db + .delete(studies) + .where(and(eq(studies.id, id), eq(studies.userId, userId))) + .returning(); + + if (deletedStudy.length === 0) { + return NextResponse.json({ error: 'Study not found or unauthorized' }, { status: 404 }); + } + + return NextResponse.json({ message: "Study deleted successfully" }); +} diff --git a/src/app/api/studies/route.ts b/src/app/api/studies/route.ts index f7d08c9..5111865 100644 --- a/src/app/api/studies/route.ts +++ b/src/app/api/studies/route.ts @@ -2,30 +2,29 @@ import { db } from "~/server/db"; import { studies } from "~/server/db/schema"; import { NextResponse } from "next/server"; import { eq } from "drizzle-orm"; +import { auth } from "@clerk/nextjs/server"; -export async function GET() { - const allStudies = await db.select().from(studies); +export async function GET(request: Request) { + const { userId } = auth(); + if (!userId) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const allStudies = await db.select().from(studies).where(eq(studies.userId, userId)); return NextResponse.json(allStudies); } export async function POST(request: Request) { + const { userId } = auth(); + if (!userId) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + const { title, description } = await request.json(); - const newStudy = await db.insert(studies).values({ title, description }).returning(); + if (!title) { + return NextResponse.json({ error: 'Title is required' }, { status: 400 }); + } + + const newStudy = await db.insert(studies).values({ title, description, userId }).returning(); return NextResponse.json(newStudy[0]); -} - -export async function PUT(request: Request) { - const { id, title, description } = await request.json(); - const updatedStudy = await db - .update(studies) - .set({ title, description }) - .where(eq(studies.id, id)) - .returning(); - return NextResponse.json(updatedStudy[0]); -} - -export async function DELETE(request: Request) { - const { id } = await request.json(); - await db.delete(studies).where(eq(studies.id, id)); - return NextResponse.json({ message: "Study deleted" }); } \ No newline at end of file diff --git a/src/app/api/users/route.ts b/src/app/api/users/route.ts new file mode 100644 index 0000000..2dd2050 --- /dev/null +++ b/src/app/api/users/route.ts @@ -0,0 +1,21 @@ +import { db } from "~/server/db"; +import { users } from "~/server/db/schema"; +import { NextResponse } from "next/server"; + +export async function POST(request: Request) { + try { + const { email } = await request.json(); + + // Check if email is provided + if (!email) { + return NextResponse.json({ error: "Email is required" }, { status: 400 }); + } + + // Insert the new user into the database + const newUser = await db.insert(users).values({ email }).returning(); + return NextResponse.json(newUser[0]); + } catch (error) { + console.error("Error creating user:", error); + return NextResponse.json({ error: "Failed to create user" }, { status: 500 }); + } +} \ No newline at end of file diff --git a/src/app/dash/layout.tsx b/src/app/dash/layout.tsx deleted file mode 100644 index f0a43f7..0000000 --- a/src/app/dash/layout.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { type PropsWithChildren } from "react" -import { Sidebar } from "~/components/sidebar" -import { Inter } from "next/font/google" - -import "~/styles/globals.css" - -const inter = Inter({ - subsets: ["latin"], - display: "swap", - variable: "--font-sans", -}) - -export default function RootLayout({ children }: PropsWithChildren) { - return ( - - -
- -
- {children} -
-
- - - ) -} \ No newline at end of file diff --git a/src/app/dash/page.tsx b/src/app/dash/page.tsx index e66226f..cb6622a 100644 --- a/src/app/dash/page.tsx +++ b/src/app/dash/page.tsx @@ -1,21 +1,45 @@ -import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '~/components/ui/card'; -import { Button } from '~/components/ui/button'; -import { Studies } from "~/components/Studies"; +import Layout from "~/components/layout"; +import { Card, CardHeader, CardTitle, CardContent } from "~/components/ui/card"; -const HomePage: React.FC = () => { - return ( -
-
-
-

Welcome to the HRIStudio Dashboard!

-

- Manage your Human-Robot Interaction projects and experiments -

-
- -
-
- ); +const DashboardPage: React.FC = () => { + return ( + +
+ + + Platform Information + + + {/* Add content for Platform Information */} + + + + + Participants + + + {/* Add content for Participants */} + + + + + Project Members + + + {/* Add content for Project Members */} + + + + + Completed Trials + + + {/* Add content for Completed Trials */} + + +
+
+ ); }; -export default HomePage; \ No newline at end of file +export default DashboardPage; \ No newline at end of file diff --git a/src/app/layout.tsx b/src/app/layout.tsx index c56279c..6d1ae34 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,5 +1,6 @@ import { ClerkProvider } from '@clerk/nextjs' import { Inter } from "next/font/google" +import { StudyProvider } from '~/context/StudyContext' import "~/styles/globals.css" @@ -10,7 +11,7 @@ const inter = Inter({ }) export const metadata = { - title: "T3 App", + title: "HRIStudio", description: "Created with create-t3-app", icons: [{ rel: "icon", url: "/favicon.ico" }], } @@ -18,13 +19,13 @@ export const metadata = { export default function RootLayout({ children }: React.PropsWithChildren) { return ( - {/* */} + {children} - {/* */} + ) } \ No newline at end of file diff --git a/src/app/participants/page.tsx b/src/app/participants/page.tsx new file mode 100644 index 0000000..69c1bcc --- /dev/null +++ b/src/app/participants/page.tsx @@ -0,0 +1,12 @@ +import Layout from "~/components/layout"; +import { Participants } from "~/components/participant/Participants"; + +const ParticipantsPage = () => { + return ( + + + + ); +}; + +export default ParticipantsPage; \ No newline at end of file diff --git a/src/app/sign-up/[[...sign-up]]/page.tsx b/src/app/sign-up/[[...sign-up]]/page.tsx index 8b35c9c..8b3b523 100644 --- a/src/app/sign-up/[[...sign-up]]/page.tsx +++ b/src/app/sign-up/[[...sign-up]]/page.tsx @@ -29,6 +29,20 @@ export default function SignUpPage() { if (result.status === "complete") { await setActive({ session: result.createdSessionId }) + + // Create a user entry in the database + const response = await fetch('/api/users', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email: emailAddress }), + }) + + if (!response.ok) { + const errorData = await response.json() + console.error("Error creating user in database:", errorData.error) + return // Optionally handle the error (e.g., show a message to the user) + } + router.push("/dash") } } catch (err) { diff --git a/src/app/studies/page.tsx b/src/app/studies/page.tsx new file mode 100644 index 0000000..8c1c7c4 --- /dev/null +++ b/src/app/studies/page.tsx @@ -0,0 +1,10 @@ +import Layout from "~/components/layout"; +import { Studies } from "~/components/study/Studies"; + +export default function StudiesPage() { + return ( + + + + ); +} \ No newline at end of file diff --git a/src/components/Studies.tsx b/src/components/Studies.tsx deleted file mode 100644 index 2b4888f..0000000 --- a/src/components/Studies.tsx +++ /dev/null @@ -1,122 +0,0 @@ -"use client"; - -import React, { useState, useEffect } from 'react'; -import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card"; -import { Button } from "~/components/ui/button"; -import { Input } from "~/components/ui/input"; -import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "~/components/ui/dialog"; - -interface Study { - id: number; - title: string; - description: string; -} - -export function Studies() { - const [studies, setStudies] = useState([]); - const [newStudy, setNewStudy] = useState({ title: '', description: '' }); - const [editingStudy, setEditingStudy] = useState(null); - - useEffect(() => { - fetchStudies(); - }, []); - - const fetchStudies = async () => { - const response = await fetch('/api/studies'); - const data = await response.json(); - setStudies(data); - }; - - const createStudy = async () => { - const response = await fetch('/api/studies', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(newStudy), - }); - const createdStudy = await response.json(); - setStudies([...studies, createdStudy]); - setNewStudy({ title: '', description: '' }); - }; - - const updateStudy = async () => { - if (!editingStudy) return; - const response = await fetch('/api/studies', { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(editingStudy), - }); - const updatedStudy = await response.json(); - setStudies(studies.map(s => s.id === updatedStudy.id ? updatedStudy : s)); - setEditingStudy(null); - }; - - const deleteStudy = async (id: number) => { - await fetch('/api/studies', { - method: 'DELETE', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ id }), - }); - setStudies(studies.filter(s => s.id !== id)); - }; - - return ( -
-

Studies

- - - - - - - Create New Study - - setNewStudy({ ...newStudy, title: e.target.value })} - /> - setNewStudy({ ...newStudy, description: e.target.value })} - /> - - - - {studies.map((study) => ( - - - {study.title} - - -

{study.description}

-
- - - - - - - Edit Study - - setEditingStudy({ ...editingStudy!, title: e.target.value })} - /> - setEditingStudy({ ...editingStudy!, description: e.target.value })} - /> - - - - -
-
-
- ))} -
- ); -} \ No newline at end of file diff --git a/src/components/layout.tsx b/src/components/layout.tsx new file mode 100644 index 0000000..0233804 --- /dev/null +++ b/src/components/layout.tsx @@ -0,0 +1,19 @@ +import { PropsWithChildren } from "react"; +import { Sidebar } from "~/components/sidebar"; +import { StudyHeader } from "~/components/study/StudyHeader"; + +const Layout = ({ children }: PropsWithChildren) => { + return ( +
+ +
+
+ + {children} +
+
+
+ ); +}; + +export default Layout; \ No newline at end of file diff --git a/src/components/participant/CreateParticipantDialog.tsx b/src/components/participant/CreateParticipantDialog.tsx new file mode 100644 index 0000000..fb5f317 --- /dev/null +++ b/src/components/participant/CreateParticipantDialog.tsx @@ -0,0 +1,52 @@ +import React, { useState } from 'react'; +import { Button } from "~/components/ui/button"; +import { Input } from "~/components/ui/input"; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "~/components/ui/dialog"; +import { Label } from "~/components/ui/label"; +import { PlusCircle } from 'lucide-react'; + +interface CreateParticipantDialogProps { + onCreateParticipant: (name: string) => void; +} + +export function CreateParticipantDialog({ onCreateParticipant }: CreateParticipantDialogProps) { + const [newParticipant, setNewParticipant] = useState({ name: '' }); + const [isOpen, setIsOpen] = useState(false); + + const handleCreate = () => { + if (newParticipant.name) { + onCreateParticipant(newParticipant.name); + setNewParticipant({ name: '' }); + setIsOpen(false); + } + }; + + return ( + + + + + + + Add New Participant + +
+
+ + setNewParticipant({ name: e.target.value })} + className="col-span-3" + /> +
+
+ +
+
+ ); +} \ No newline at end of file diff --git a/src/components/participant/Participants.tsx b/src/components/participant/Participants.tsx new file mode 100644 index 0000000..def6d41 --- /dev/null +++ b/src/components/participant/Participants.tsx @@ -0,0 +1,112 @@ +"use client"; + +import React, { useState, useEffect } from 'react'; +import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card"; +import { Button } from "~/components/ui/button"; +import { useStudyContext } from '../../context/StudyContext'; +import { Participant } from '../../types/Participant'; +import { CreateParticipantDialog } from './CreateParticipantDialog'; +import { Trash2 } from 'lucide-react'; +import { useToast } from '~/hooks/use-toast'; + +export function Participants() { + const [participants, setParticipants] = useState([]); + const { selectedStudy } = useStudyContext(); + const { toast } = useToast(); + + useEffect(() => { + if (selectedStudy) { + fetchParticipants(); + } + }, [selectedStudy]); + + const fetchParticipants = async () => { + if (!selectedStudy) return; + const response = await fetch(`/api/participants?studyId=${selectedStudy.id}`); + const data = await response.json(); + setParticipants(data); + }; + + const createParticipant = async (name: string) => { + if (!selectedStudy) return; + const response = await fetch('/api/participants', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name, studyId: selectedStudy.id }), + }); + const createdParticipant = await response.json(); + setParticipants([...participants, createdParticipant]); + }; + + const deleteParticipant = async (id: number) => { + if (!selectedStudy) return; + try { + console.log(`Attempting to delete participant with ID: ${id}`); + const response = await fetch(`/api/participants/${id}`, { + method: 'DELETE', + }); + console.log('Delete response:', response); + + const contentType = response.headers.get("content-type"); + if (contentType && contentType.indexOf("application/json") !== -1) { + const result = await response.json(); + console.log('Delete result:', result); + + if (!response.ok) { + throw new Error(result.error || `Failed to delete participant. Status: ${response.status}`); + } + + setParticipants(participants.filter(p => p.id !== id)); + toast({ + title: "Success", + description: "Participant deleted successfully", + }); + } else { + const text = await response.text(); + console.error('Unexpected response:', text); + throw new Error(`Unexpected response from server. Status: ${response.status}`); + } + } catch (error) { + console.error('Error deleting participant:', error); + toast({ + title: "Error", + description: error instanceof Error ? error.message : 'Failed to delete participant', + variant: "destructive", + }); + } + }; + + if (!selectedStudy) { + return
Please select a study to manage participants.
; + } + + return ( + + + Participants for {selectedStudy.title} + + + + {participants.length > 0 ? ( +
    + {participants.map(participant => ( +
  • + {participant.name} + +
  • + ))} +
+ ) : ( +

No participants added yet.

+ )} +
+
+ ); +} \ No newline at end of file diff --git a/src/components/sidebar.tsx b/src/components/sidebar.tsx index 2ffd6e5..e4b9798 100644 --- a/src/components/sidebar.tsx +++ b/src/components/sidebar.tsx @@ -19,8 +19,8 @@ import { cn } from "~/lib/utils" const navItems = [ { name: "Dashboard", href: "/dash", icon: LayoutDashboard }, - { name: "Projects", href: "/projects", icon: FolderIcon }, - { name: "Experiments", href: "/experiments", icon: BeakerIcon }, + { name: "Studies", href: "/studies", icon: FolderIcon }, + { name: "Participants", href: "/participants", icon: BeakerIcon }, { name: "Data Analysis", href: "/analysis", icon: BarChartIcon }, { name: "Settings", href: "/settings", icon: Settings }, ]; diff --git a/src/components/study/CreateStudyDialog.tsx b/src/components/study/CreateStudyDialog.tsx new file mode 100644 index 0000000..3526974 --- /dev/null +++ b/src/components/study/CreateStudyDialog.tsx @@ -0,0 +1,90 @@ +import { useState } from 'react'; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "~/components/ui/dialog"; +import { Button } from "~/components/ui/button"; +import { Input } from "~/components/ui/input"; +import { Textarea } from "~/components/ui/textarea"; +import { Label } from "~/components/ui/label"; +import { PlusCircle } from 'lucide-react'; +import { Study } from '../../types/Study'; + +interface CreateStudyDialogProps { + onCreateStudy: (study: Omit) => void; +} + +export function CreateStudyDialog({ onCreateStudy }: CreateStudyDialogProps) { + const [isOpen, setIsOpen] = useState(false); + const [newStudy, setNewStudy] = useState({ title: '', description: '' }); + const [touched, setTouched] = useState({ title: false, description: false }); + + const handleInputChange = (e: React.ChangeEvent) => { + const { name, value } = e.target; + setNewStudy({ ...newStudy, [name]: value }); + setTouched({ ...touched, [name]: true }); + }; + + const isFieldInvalid = (field: 'title' | 'description') => { + return field === 'title' ? (touched.title && !newStudy.title) : false; + }; + + const handleCreateStudy = () => { + setTouched({ title: true, description: true }); + + if (!newStudy.title) { + return; + } + + onCreateStudy({ + title: newStudy.title, + description: newStudy.description || undefined + }); + + setNewStudy({ title: '', description: '' }); + setTouched({ title: false, description: false }); + setIsOpen(false); + }; + + return ( + + + + + + + Create New Study + +
+
+ + +
+ {isFieldInvalid('title') && ( +

Title is required

+ )} +
+ +