diff --git a/apps/portal/.env.local.example b/apps/portal/.env.example similarity index 100% rename from apps/portal/.env.local.example rename to apps/portal/.env.example diff --git a/apps/portal/package.json b/apps/portal/package.json index 7bb2775c..ef80dd06 100644 --- a/apps/portal/package.json +++ b/apps/portal/package.json @@ -8,8 +8,7 @@ "start": "next start", "lint": "next lint", "tsc": "tsc", - "postinstall": "prisma generate", - "prisma": "prisma" + "postinstall": "prisma generate" }, "dependencies": { "@headlessui/react": "^1.7.2", diff --git a/apps/portal/prisma/migrations/20221002033341_add_todos/migration.sql b/apps/portal/prisma/migrations/20221002033341_add_todos/migration.sql new file mode 100644 index 00000000..77d3e120 --- /dev/null +++ b/apps/portal/prisma/migrations/20221002033341_add_todos/migration.sql @@ -0,0 +1,26 @@ +/* + Warnings: + + - You are about to drop the `Example` table. If the table is not empty, all the data it contains will be lost. + +*/ +-- CreateEnum +CREATE TYPE "TodoStatus" AS ENUM ('INCOMPLETE', 'COMPLETE'); + +-- DropTable +DROP TABLE "Example"; + +-- CreateTable +CREATE TABLE "Todo" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "text" TEXT NOT NULL, + "status" "TodoStatus" NOT NULL DEFAULT 'INCOMPLETE', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Todo_pkey" PRIMARY KEY ("id") +); + +-- AddForeignKey +ALTER TABLE "Todo" ADD CONSTRAINT "Todo_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/apps/portal/prisma/schema.prisma b/apps/portal/prisma/schema.prisma index dd5cd88d..38d2e599 100644 --- a/apps/portal/prisma/schema.prisma +++ b/apps/portal/prisma/schema.prisma @@ -14,10 +14,6 @@ datasource db { url = env("DATABASE_URL") } -model Example { - id String @id @default(cuid()) -} - // Necessary for Next auth model Account { id String @id @default(cuid()) @@ -53,6 +49,7 @@ model User { image String? accounts Account[] sessions Session[] + todos Todo[] } model VerificationToken { @@ -62,3 +59,18 @@ model VerificationToken { @@unique([identifier, token]) } + +model Todo { + id String @id @default(cuid()) + userId String + text String @db.Text + status TodoStatus @default(INCOMPLETE) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + user User @relation(fields: [userId], references: [id], onDelete: Cascade) +} + +enum TodoStatus { + INCOMPLETE + COMPLETE +} diff --git a/apps/portal/src/pages/api/examples.ts b/apps/portal/src/pages/api/examples.ts deleted file mode 100644 index b6332887..00000000 --- a/apps/portal/src/pages/api/examples.ts +++ /dev/null @@ -1,11 +0,0 @@ -// Src/pages/api/examples.ts -import type { NextApiRequest, NextApiResponse } from 'next'; - -import { prisma } from '../../server/db/client'; - -const examples = async (req: NextApiRequest, res: NextApiResponse) => { - const examplesFromDb = await prisma.example.findMany(); - res.status(200).json(examplesFromDb); -}; - -export default examples; diff --git a/apps/portal/src/pages/example.tsx b/apps/portal/src/pages/example.tsx deleted file mode 100644 index 3b675af9..00000000 --- a/apps/portal/src/pages/example.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import { trpc } from '~/utils/trpc'; - -export default function Example() { - const hello = trpc.useQuery(['example.hello', { text: 'from tRPC!' }]); - const getAll = trpc.useQuery(['example.getAll']); - - return ( - <> -
- {/* Primary column */} -
-

- Photos -

-
- {hello.data ?

{hello.data.greeting}

:

Loading..

} -
-
{JSON.stringify(getAll.data, null, 2)}
-
-
- - {/* Secondary column (hidden on smaller screens) */} - - - ); -} diff --git a/apps/portal/src/pages/todos/index.tsx b/apps/portal/src/pages/todos/index.tsx new file mode 100644 index 00000000..7f69d5b7 --- /dev/null +++ b/apps/portal/src/pages/todos/index.tsx @@ -0,0 +1,213 @@ +import Head from 'next/head'; +import Link from 'next/link'; +import { useSession } from 'next-auth/react'; +import { useRef, useState } from 'react'; + +import { trpc } from '~/utils/trpc'; + +export default function TodoList() { + const { data } = useSession(); + + // Fetches todos when the component mounts. + const todosQuery = trpc.useQuery(['todos.list']); + // Context to be initialized here because of the rules of hooks. + const trpcContext = trpc.useContext(); + + const todoUpdateMutation = trpc.useMutation(['todos.user.update'], { + onSuccess: () => { + // Since a Todo has been updated, we invalidate the query for + // all the todos to trigger a refetch. + trpcContext.invalidateQueries(['todos.list']); + }, + }); + const todoDeleteMutation = trpc.useMutation(['todos.user.delete']); + const [currentlyEditingTodo, setCurrentlyEditingTodo] = useState< + string | null + >(null); + const formRef = useRef(null); + + return ( + <> + + Todo List + +
+
+
+

Todos

+

+ A list of all Todos added by everyone. +

+
+
+ + Add Todo + +
+
+ {todosQuery.isLoading ? ( +
Loading...
+ ) : ( +
+
+
+
+ + + + + + + + + + + + {todosQuery.data?.map((todo) => ( + + + + + + + + ))} + +
+ Description + + Creator + + Last Updated + + Status + + Actions +
+ {todo.id === currentlyEditingTodo ? ( +
{ + event.preventDefault(); + if (!formRef.current) { + return; + } + + const formData = new FormData( + formRef.current, + ); + + // Trim todo text before submission. + const text = ( + formData?.get('text') as string | null + )?.trim(); + + // Ignore if the text is empty. + if (!text) { + return; + } + + await todoUpdateMutation.mutate({ + id: todo.id, + text, + }); + + setCurrentlyEditingTodo(null); + }}> + +
+ ) : ( + todo.text + )} +
+ {todo.user.name} + + {todo.updatedAt.toLocaleString('en-US', { + dateStyle: 'long', + timeStyle: 'medium', + })} + + { + todoUpdateMutation.mutate({ + id: todo.id, + status: + todo.status === 'COMPLETE' + ? 'INCOMPLETE' + : 'COMPLETE', + }); + }} + /> + + {data?.user?.id === todo.userId && ( + <> + {currentlyEditingTodo === todo.id ? ( + { + setCurrentlyEditingTodo(null); + }}> + Cancel + + ) : ( + { + setCurrentlyEditingTodo(todo.id); + }}> + Edit + + )} + { + const confirmDelete = window.confirm( + 'Are you sure you want to delete this Todo?', + ); + + if (!confirmDelete) { + return; + } + + todoDeleteMutation.mutate({ + id: todo.id, + }); + }}> + Delete + + + )} +
+
+
+
+
+ )} +
+ + ); +} diff --git a/apps/portal/src/pages/todos/new.tsx b/apps/portal/src/pages/todos/new.tsx new file mode 100644 index 00000000..6e3d566b --- /dev/null +++ b/apps/portal/src/pages/todos/new.tsx @@ -0,0 +1,91 @@ +import Head from 'next/head'; +import Link from 'next/link'; +import { useRouter } from 'next/router'; +import { useRef, useState } from 'react'; + +import { trpc } from '~/utils/trpc'; + +export default function TodosCreate() { + const todoCreateMutation = trpc.useMutation('todos.user.create'); + const [todoText, setTodoText] = useState(''); + const router = useRouter(); + const formRef = useRef(null); + + return ( + <> + + Add Todo + +
+ {/* Primary column */} +
+
+

+ Add Todo +

+
{ + event.preventDefault(); + if (!formRef.current) { + return; + } + + const formData = new FormData(formRef.current); + // Trim todo text before submission. + const text = (formData?.get('text') as string | null)?.trim(); + + // Ignore if the text is empty. + if (!text) { + return; + } + + await todoCreateMutation.mutate({ + text, + }); + + // Redirect back to the todo list page after it has been added. + router.push('/todos'); + }}> +
+ +
+ setTodoText(event.target.value)} + /> +
+
+
+
+ + Cancel + + +
+
+
+
+
+
+ + ); +} diff --git a/apps/portal/src/server/router/example.ts b/apps/portal/src/server/router/example.ts deleted file mode 100644 index 7e2068ee..00000000 --- a/apps/portal/src/server/router/example.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { z } from 'zod'; - -import { createRouter } from './context'; - -export const exampleRouter = createRouter() - .query('hello', { - input: z - .object({ - text: z.string().nullish(), - }) - .nullish(), - resolve({ input }) { - return { - greeting: `Hello ${input?.text ?? 'world'}`, - }; - }, - }) - .query('getAll', { - async resolve({ ctx }) { - const items = await ctx.prisma.example.findMany(); - return items; - }, - }); diff --git a/apps/portal/src/server/router/index.ts b/apps/portal/src/server/router/index.ts index 65d264b7..050f95ea 100644 --- a/apps/portal/src/server/router/index.ts +++ b/apps/portal/src/server/router/index.ts @@ -1,14 +1,18 @@ -// Src/server/router/index.ts import superjson from 'superjson'; import { createRouter } from './context'; -import { exampleRouter } from './example'; import { protectedExampleRouter } from './protected-example-router'; +import { todosRouter } from './todos'; +import { todosUserRouter } from './todos-user-router'; export const appRouter = createRouter() .transformer(superjson) - .merge('example.', exampleRouter) - .merge('auth.', protectedExampleRouter); + + // All keys should be delimited by a period and end with a period. + // Example routers. Learn more about tRPC routers: https://trpc.io/docs/v9/router + .merge('auth.', protectedExampleRouter) + .merge('todos.', todosRouter) + .merge('todos.user.', todosUserRouter); // Export type definition of API export type AppRouter = typeof appRouter; diff --git a/apps/portal/src/server/router/todos-user-router.ts b/apps/portal/src/server/router/todos-user-router.ts new file mode 100644 index 00000000..82cad7b8 --- /dev/null +++ b/apps/portal/src/server/router/todos-user-router.ts @@ -0,0 +1,55 @@ +import { z } from 'zod'; +import { TodoStatus } from '@prisma/client'; + +import { createProtectedRouter } from './context'; + +// Example Todos router that can only be hit if the user requesting is signed in. +export const todosUserRouter = createProtectedRouter() + .mutation('create', { + // Validate that the creation payload is of this shape using Zod: https://zod.dev. + // This adds typesafety to the useMutation payloads on the client. + input: z.object({ + text: z.string(), + }), + async resolve({ ctx, input }) { + const userId = ctx.session?.user?.id; + return await ctx.prisma.todo.create({ + data: { + text: input.text, + userId, + }, + }); + }, + }) + .mutation('update', { + input: z.object({ + id: z.string(), + status: z.nativeEnum(TodoStatus).optional(), + text: z.string().optional(), + }), + async resolve({ ctx, input }) { + // TODO: Check if session user owns this Todo. + return await ctx.prisma.todo.update({ + data: { + status: input.status, + text: input.text, + }, + where: { + id: input.id, + }, + }); + }, + }) + .mutation('delete', { + input: z.object({ + id: z.string(), + }), + async resolve({ ctx, input }) { + // TODO: Check if session user owns this Todo. + return await ctx.prisma.todo.delete({ + where: { + id: input.id, + }, + }); + }, + }); diff --git a/apps/portal/src/server/router/todos.ts b/apps/portal/src/server/router/todos.ts new file mode 100644 index 00000000..8454a81d --- /dev/null +++ b/apps/portal/src/server/router/todos.ts @@ -0,0 +1,18 @@ +import { createRouter } from './context'; + +export const todosRouter = createRouter().query('list', { + async resolve({ ctx }) { + return await ctx.prisma.todo.findMany({ + include: { + user: { + select: { + name: true, + }, + }, + }, + orderBy: { + createdAt: 'desc', + }, + }); + }, +});