feat: add todo example

pull/302/head
Yangshun Tay 2 years ago
parent 06bdab6440
commit 6d212b4561

@ -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",

@ -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;

@ -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
}

@ -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;

@ -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 (
<>
<main className="flex-1 overflow-y-auto">
{/* Primary column */}
<section
aria-labelledby="primary-heading"
className="flex h-full min-w-0 flex-1 flex-col lg:order-last">
<h1 className="sr-only" id="primary-heading">
Photos
</h1>
<div className="pt-6 text-2xl text-blue-500 flex justify-center items-center w-full">
{hello.data ? <p>{hello.data.greeting}</p> : <p>Loading..</p>}
</div>
<pre className="w-1/2">{JSON.stringify(getAll.data, null, 2)}</pre>
</section>
</main>
{/* Secondary column (hidden on smaller screens) */}
<aside className="hidden w-96 overflow-y-auto border-l border-gray-200 bg-white lg:block">
{/* Your content */}
</aside>
</>
);
}

@ -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<HTMLFormElement | null>(null);
return (
<>
<Head>
<title>Todo List</title>
</Head>
<div className="mx-auto px-4 py-8 sm:px-6 lg:px-8">
<div className="sm:flex sm:items-center">
<div className="sm:flex-auto">
<h1 className="text-xl font-semibold text-gray-900">Todos</h1>
<p className="mt-2 text-sm text-gray-700">
A list of all Todos added by everyone.
</p>
</div>
<div className="mt-4 sm:mt-0 sm:ml-16 sm:flex-none">
<Link
className="inline-flex items-center justify-center rounded-md border border-transparent bg-indigo-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:w-auto"
href="/todos/new">
Add Todo
</Link>
</div>
</div>
{todosQuery.isLoading ? (
<div>Loading...</div>
) : (
<div className="mt-8 flex flex-col">
<div className="-my-2 -mx-4 overflow-x-auto sm:-mx-6 lg:-mx-8">
<div className="inline-block min-w-full py-2 align-middle md:px-6 lg:px-8">
<div className="overflow-hidden shadow ring-1 ring-black ring-opacity-5 md:rounded-lg">
<table className="min-w-full divide-y divide-gray-300">
<thead className="bg-gray-50">
<tr className="divide-x divide-gray-200">
<th
className="py-3.5 pl-4 pr-4 text-left text-sm font-semibold text-gray-900 sm:pl-6"
scope="col">
Description
</th>
<th
className="px-4 py-3.5 text-left text-sm font-semibold text-gray-900"
scope="col">
Creator
</th>
<th
className="px-4 py-3.5 text-left text-sm font-semibold text-gray-900"
scope="col">
Last Updated
</th>
<th
className="py-3.5 pl-4 pr-4 text-left text-sm font-semibold text-gray-900 sm:pr-6"
scope="col">
Status
</th>
<th
className="py-3.5 pl-4 pr-4 text-left text-sm font-semibold text-gray-900 sm:pr-6"
scope="col">
Actions
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200 bg-white">
{todosQuery.data?.map((todo) => (
<tr key={todo.id} className="divide-x divide-gray-200">
<td className="whitespace-nowrap py-4 pl-4 pr-4 text-sm text-gray-500 sm:pl-6">
{todo.id === currentlyEditingTodo ? (
<form
ref={formRef}
onSubmit={async (event) => {
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);
}}>
<input
autoFocus={true}
className="block w-full min-w-0 flex-1 rounded-md border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"
defaultValue={todo.text}
name="text"
type="text"
/>
</form>
) : (
todo.text
)}
</td>
<td className="whitespace-nowrap p-4 text-sm text-gray-500">
{todo.user.name}
</td>
<td className="whitespace-nowrap p-4 text-sm text-gray-500">
{todo.updatedAt.toLocaleString('en-US', {
dateStyle: 'long',
timeStyle: 'medium',
})}
</td>
<td className="whitespace-nowrap py-4 pl-4 pr-4 text-sm text-gray-500 sm:pr-6">
<input
checked={todo.status === 'COMPLETE'}
className="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
type="checkbox"
onChange={() => {
todoUpdateMutation.mutate({
id: todo.id,
status:
todo.status === 'COMPLETE'
? 'INCOMPLETE'
: 'COMPLETE',
});
}}
/>
</td>
<td className="space-x-4 whitespace-nowrap py-4 pl-4 pr-4 text-sm text-gray-500 sm:pr-6">
{data?.user?.id === todo.userId && (
<>
{currentlyEditingTodo === todo.id ? (
<a
className="text-indigo-600 hover:text-indigo-900"
href="#"
onClick={() => {
setCurrentlyEditingTodo(null);
}}>
Cancel
</a>
) : (
<a
className="text-indigo-600 hover:text-indigo-900"
href="#"
onClick={async () => {
setCurrentlyEditingTodo(todo.id);
}}>
Edit
</a>
)}
<a
className="text-indigo-600 hover:text-indigo-900"
href="#"
onClick={async () => {
const confirmDelete = window.confirm(
'Are you sure you want to delete this Todo?',
);
if (!confirmDelete) {
return;
}
todoDeleteMutation.mutate({
id: todo.id,
});
}}>
Delete
</a>
</>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
</div>
)}
</div>
</>
);
}

@ -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<HTMLFormElement | null>(null);
return (
<>
<Head>
<title>Add Todo</title>
</Head>
<main className="flex-1 overflow-y-auto">
{/* Primary column */}
<section
aria-labelledby="primary-heading"
className="flex h-full min-w-0 flex-1 flex-col lg:order-last">
<div className="mx-auto w-96 space-y-4 py-8">
<h1 className="text-4xl font-bold" id="primary-heading">
Add Todo
</h1>
<form
ref={formRef}
className="w-full space-y-8 divide-y divide-gray-200"
onSubmit={async (event) => {
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');
}}>
<div className="mt-6">
<label
className="block text-sm font-medium text-gray-700"
htmlFor="text">
Text
</label>
<div className="mt-1 flex rounded-md shadow-sm">
<input
autoFocus={true}
className="block w-full min-w-0 flex-1 rounded-md border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm"
id="text"
name="text"
type="text"
value={todoText}
onChange={(event) => setTodoText(event.target.value)}
/>
</div>
</div>
<div className="pt-5">
<div className="flex justify-end">
<Link
className="rounded-md border border-gray-300 bg-white py-2 px-4 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
href="/todos">
Cancel
</Link>
<button
className="ml-3 inline-flex justify-center rounded-md border border-transparent bg-indigo-600 py-2 px-4 text-sm font-medium text-white shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
type="submit">
Save
</button>
</div>
</div>
</form>
</div>
</section>
</main>
</>
);
}

@ -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;
},
});

@ -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;

@ -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,
},
});
},
});

@ -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',
},
});
},
});
Loading…
Cancel
Save