From 55f3f53cae9dbd85c31043e77d42dfca1400afb3 Mon Sep 17 00:00:00 2001
From: Igor Papandinas <26460174+ipapandinas@users.noreply.github.com>
Date: Sat, 11 Jan 2025 13:27:33 +0100
Subject: [PATCH] feat: task form UI

---
 app/create/task/_components/form.tsx | 182 +++++++++++++++++++++++++++
 app/create/task/page.tsx             |  15 +++
 components/auth/auth-menu.tsx        |  12 ++
 package.json                         |   1 +
 pnpm-lock.yaml                       |  35 ++++++
 5 files changed, 245 insertions(+)
 create mode 100644 app/create/task/_components/form.tsx
 create mode 100644 app/create/task/page.tsx

diff --git a/app/create/task/_components/form.tsx b/app/create/task/_components/form.tsx
new file mode 100644
index 0000000..f60fd39
--- /dev/null
+++ b/app/create/task/_components/form.tsx
@@ -0,0 +1,182 @@
+"use client";
+
+import React from "react";
+import { Button } from "@nextui-org/button";
+import { Input, Textarea } from "@nextui-org/input";
+import { Select, SelectItem } from "@nextui-org/select";
+import { Form } from "@nextui-org/form";
+import { container } from "@/components/primitives";
+import { Project } from "@/types/project";
+import { getIconSrc } from "@/utils/icons";
+import { Avatar } from "@nextui-org/avatar";
+
+export default function TaskForm({ projects }: { projects: Project[] }) {
+  const [submitted, setSubmitted] = React.useState<any>(null);
+  const [errors, setErrors] = React.useState<any>({});
+
+  const onSubmit = (e: any) => {
+    e.preventDefault();
+    const data = Object.fromEntries(new FormData(e.currentTarget));
+
+    // Custom validation checks
+    const newErrors: any = {};
+
+    // Username validation
+    if (data.title === "admin") {
+      newErrors.title = "Nice try! Choose a different username";
+    }
+
+    if (Object.keys(newErrors).length > 0) {
+      setErrors(newErrors);
+
+      return;
+    }
+
+    // Clear errors and submit
+    setErrors({});
+    setSubmitted(data);
+  };
+
+  return (
+    <Form
+      className={`${container()} max-w-[768px]`}
+      validationBehavior="native"
+      validationErrors={errors}
+      onReset={() => setSubmitted(null)}
+      onSubmit={onSubmit}
+    >
+      <div>
+        <div className="border-default-100 border-b-1 pb-2 mb-2">
+          <h1
+            id="guidelines"
+            className="text-foreground text-2xl font-semibold"
+          >
+            Create a new task
+          </h1>
+          <span className="text-default-200">
+            Submit a task manually for a project you maintain or{" "}
+            <a className="text-primary underline" href="/import/github-task">
+              import directly from a GitHub issue
+            </a>{" "}
+            for faster input.
+          </span>
+        </div>
+        <span className="italic">
+          Required fields are marked with an asterisk (*).
+        </span>
+      </div>
+      <div className="flex flex-col gap-4 w-full mt-4">
+        <div className="flex gap-4">
+          <Select
+            className="basis-1/4"
+            isRequired
+            items={projects}
+            label="Project"
+            labelPlacement="outside"
+            name="project"
+            placeholder="Select project"
+            classNames={{
+              value: "capitalize",
+              trigger: "data-[hover=true]:bg-opacity-hover",
+            }}
+            renderValue={(items) =>
+              items.map((item) => {
+                if (item.data == null) {
+                  return undefined;
+                }
+                const { avatar, id, name, slug } = item.data;
+                const avatarSrc = getIconSrc(slug, avatar) ?? undefined;
+                return (
+                  <div key={id} className="flex items-center gap-2">
+                    <Avatar
+                      alt={`${item.data?.name}'s avatar`}
+                      className="bg-foreground p-0.5 w-5 h-5"
+                      classNames={{
+                        img: "rounded-full",
+                      }}
+                      src={avatarSrc}
+                    />
+                    <span>{name}</span>
+                  </div>
+                );
+              })
+            }
+          >
+            {({ avatar, id, name, slug }) => {
+              const avatarSrc = getIconSrc(slug, avatar) ?? undefined;
+              return (
+                <SelectItem
+                  key={id}
+                  value={slug}
+                  startContent={
+                    <Avatar
+                      alt={`${name}'s avatar`}
+                      className="bg-foreground p-0.5 w-5 h-5"
+                      classNames={{
+                        img: "rounded-full",
+                      }}
+                      src={avatarSrc}
+                    />
+                  }
+                  classNames={{
+                    title: "whitespace-break-spaces",
+                  }}
+                >
+                  {name}
+                </SelectItem>
+              );
+            }}
+          </Select>
+
+          <Input
+            isRequired
+            errorMessage={({ validationDetails }) => {
+              if (validationDetails.valueMissing) {
+                return "Please enter the task's title";
+              }
+
+              return errors.name;
+            }}
+            label="Title"
+            labelPlacement="outside"
+            name="title"
+            placeholder="Enter task title"
+            type="text"
+            classNames={{
+              base: "basis-3/4",
+              inputWrapper: "data-[hover=true]:bg-opacity-hover",
+            }}
+          />
+        </div>
+
+        <Textarea
+          isRequired
+          errorMessage={({ validationDetails }) => {
+            if (validationDetails.valueMissing) {
+              return "Please enter the task's description";
+            }
+          }}
+          classNames={{
+            inputWrapper: "data-[hover=true]:bg-opacity-hover",
+          }}
+          label="Description"
+          labelPlacement="outside"
+          name="description"
+          placeholder="What's this task about?"
+        />
+
+        <div className="flex gap-4 justify-end">
+          <Button color="primary" type="submit">
+            Submit
+          </Button>
+        </div>
+      </div>
+
+      {submitted && (
+        <div className="text-small text-default-500 mt-4">
+          Submitted data: <pre>{JSON.stringify(submitted, null, 2)}</pre>
+        </div>
+      )}
+    </Form>
+  );
+}
diff --git a/app/create/task/page.tsx b/app/create/task/page.tsx
new file mode 100644
index 0000000..d5d9759
--- /dev/null
+++ b/app/create/task/page.tsx
@@ -0,0 +1,15 @@
+import { getAllProjects } from "@/lib/api/projects";
+import TaskForm from "./_components/form";
+
+export default async function CreateTask() {
+  const projects = await getAllProjects().catch((error) => {
+    console.error("Error fetching all projects:", error);
+    return [];
+  });
+
+  return (
+    <>
+      <TaskForm projects={projects} />
+    </>
+  );
+}
diff --git a/components/auth/auth-menu.tsx b/components/auth/auth-menu.tsx
index 81c8ac2..0164718 100644
--- a/components/auth/auth-menu.tsx
+++ b/components/auth/auth-menu.tsx
@@ -1,6 +1,8 @@
 "use client";
 
 import { signIn, signOut, useSession } from "next-auth/react";
+import Link from "next/link";
+import { Link as NuiLink } from "@nextui-org/link";
 import { Avatar } from "@nextui-org/avatar";
 import { Button } from "@nextui-org/button";
 import {
@@ -40,6 +42,16 @@ const AuthMenu: React.FC = () => {
                 <p className="font-semibold">{session.user?.name}</p>
               </DropdownItem>
             }
+            <DropdownItem key="create-task" onPress={() => signOut()}>
+              <NuiLink
+                href="/create/task"
+                color="foreground"
+                title="Create a new task"
+                as={Link}
+              >
+                Create Task
+              </NuiLink>
+            </DropdownItem>
             <DropdownItem key="logout" color="danger" onPress={() => signOut()}>
               Log Out
             </DropdownItem>
diff --git a/package.json b/package.json
index 568d8b8..5ddc820 100644
--- a/package.json
+++ b/package.json
@@ -19,6 +19,7 @@
     "@nextui-org/chip": "^2.2.5",
     "@nextui-org/divider": "^2.2.5",
     "@nextui-org/dropdown": "^2.3.8",
+    "@nextui-org/form": "^2.1.8",
     "@nextui-org/image": "^2.2.4",
     "@nextui-org/input": "^2.4.7",
     "@nextui-org/link": "2.2.6",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index f587aa9..98e67b4 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -35,6 +35,9 @@ importers:
       '@nextui-org/dropdown':
         specifier: ^2.3.8
         version: 2.3.8(@nextui-org/system@2.4.5(@nextui-org/theme@2.4.4(tailwindcss@3.4.17))(framer-motion@11.15.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(@nextui-org/theme@2.4.4(tailwindcss@3.4.17))(framer-motion@11.15.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
+      '@nextui-org/form':
+        specifier: ^2.1.8
+        version: 2.1.8(@nextui-org/system@2.4.5(@nextui-org/theme@2.4.4(tailwindcss@3.4.17))(framer-motion@11.15.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(@nextui-org/theme@2.4.4(tailwindcss@3.4.17))(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
       '@nextui-org/image':
         specifier: ^2.2.4
         version: 2.2.4(@nextui-org/system@2.4.5(@nextui-org/theme@2.4.4(tailwindcss@3.4.17))(framer-motion@11.15.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(@nextui-org/theme@2.4.4(tailwindcss@3.4.17))(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
@@ -527,6 +530,14 @@ packages:
       react: '>=18'
       react-dom: '>=18'
 
+  '@nextui-org/form@2.1.8':
+    resolution: {integrity: sha512-Xn/dUO5zDG7zukbql1MDYh4Xwe1vnIVMRTHgckbkBtXXVNqgoTU09TTfy8WOJ0pMDX4GrZSBAZ86o37O+IHbaA==}
+    peerDependencies:
+      '@nextui-org/system': '>=2.4.0'
+      '@nextui-org/theme': '>=2.4.0'
+      react: '>=18'
+      react-dom: '>=18'
+
   '@nextui-org/framer-utils@2.1.5':
     resolution: {integrity: sha512-z/dM29nwngCFhNCuxtCEqMbmMXG4xtXEMZh4N8FxOBEimye+2/6DIt7v0KwCY/Tx2t2URpgjCT22I8Now/SaAA==}
     peerDependencies:
@@ -619,6 +630,11 @@ packages:
     peerDependencies:
       react: '>=18 || >=19.0.0-rc.0'
 
+  '@nextui-org/react-utils@2.1.3':
+    resolution: {integrity: sha512-o61fOS+S8p3KtgLLN7ub5gR0y7l517l9eZXJabUdnVcZzZjTqEijWjzjIIIyAtYAlL4d+WTXEOROuc32sCmbqw==}
+    peerDependencies:
+      react: '>=18 || >=19.0.0-rc.0'
+
   '@nextui-org/ripple@2.2.6':
     resolution: {integrity: sha512-8bdE+nPZdT/U8H0fRaZBOAB3npfKGnv1KO2JWL2jSnN7wkO4kXZwcHqZT3aRFxqWvdsPXj8IajMpnMoM7LNYkA==}
     peerDependencies:
@@ -3289,6 +3305,19 @@ snapshots:
       react: 19.0.0
       react-dom: 19.0.0(react@19.0.0)
 
+  '@nextui-org/form@2.1.8(@nextui-org/system@2.4.5(@nextui-org/theme@2.4.4(tailwindcss@3.4.17))(framer-motion@11.15.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(@nextui-org/theme@2.4.4(tailwindcss@3.4.17))(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
+    dependencies:
+      '@nextui-org/react-utils': 2.1.3(react@19.0.0)
+      '@nextui-org/shared-utils': 2.1.2
+      '@nextui-org/system': 2.4.5(@nextui-org/theme@2.4.4(tailwindcss@3.4.17))(framer-motion@11.15.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
+      '@nextui-org/theme': 2.4.4(tailwindcss@3.4.17)
+      '@react-aria/utils': 3.26.0(react@19.0.0)
+      '@react-stately/form': 3.1.0(react@19.0.0)
+      '@react-types/form': 3.7.8(react@19.0.0)
+      '@react-types/shared': 3.26.0(react@19.0.0)
+      react: 19.0.0
+      react-dom: 19.0.0(react@19.0.0)
+
   '@nextui-org/framer-utils@2.1.5(@nextui-org/theme@2.4.4(tailwindcss@3.4.17))(framer-motion@11.15.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
     dependencies:
       '@nextui-org/shared-utils': 2.1.2
@@ -3486,6 +3515,12 @@ snapshots:
       '@nextui-org/shared-utils': 2.1.2
       react: 19.0.0
 
+  '@nextui-org/react-utils@2.1.3(react@19.0.0)':
+    dependencies:
+      '@nextui-org/react-rsc-utils': 2.1.1(react@19.0.0)
+      '@nextui-org/shared-utils': 2.1.2
+      react: 19.0.0
+
   '@nextui-org/ripple@2.2.6(@nextui-org/system@2.4.5(@nextui-org/theme@2.4.4(tailwindcss@3.4.17))(framer-motion@11.15.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(@nextui-org/theme@2.4.4(tailwindcss@3.4.17))(framer-motion@11.15.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
     dependencies:
       '@nextui-org/dom-animation': 2.1.1(framer-motion@11.15.0(react-dom@19.0.0(react@19.0.0))(react@19.0.0))