diff --git a/.eslintignore b/.eslintignore index 6b69ea050d3..dc039c8c090 100644 --- a/.eslintignore +++ b/.eslintignore @@ -5,3 +5,4 @@ templates/deno .tmp /playground +**/__tests__/fixtures diff --git a/packages/remix-dev/__tests__/cli-test.ts b/packages/remix-dev/__tests__/cli-test.ts index 46c52219c68..434549465f0 100644 --- a/packages/remix-dev/__tests__/cli-test.ts +++ b/packages/remix-dev/__tests__/cli-test.ts @@ -89,6 +89,7 @@ describe("remix CLI", () => { \`routes\` Options: --json Print the routes as JSON \`migrate\` Options: + --debug Show debugging logs --dry Dry run (no changes are made to files) --force Bypass Git safety checks and forcibly run migration --migration, -m Name of the migration to run diff --git a/packages/remix-dev/__tests__/fixtures/replace-remix-imports/app/db.server.ts b/packages/remix-dev/__tests__/fixtures/replace-remix-imports/app/db.server.ts new file mode 100644 index 00000000000..9a34a275c83 --- /dev/null +++ b/packages/remix-dev/__tests__/fixtures/replace-remix-imports/app/db.server.ts @@ -0,0 +1,22 @@ +import { PrismaClient } from "@prisma/client"; + +let prisma: PrismaClient; + +declare global { + var __db__: PrismaClient; +} + +// this is needed because in development we don't want to restart +// the server with every change, but we want to make sure we don't +// create a new connection to the DB with every change either. +// in production we'll have a single connection to the DB. +if (process.env.NODE_ENV === "production") { + prisma = new PrismaClient(); +} else { + if (!global.__db__) { + global.__db__ = new PrismaClient(); + } + prisma = global.__db__; +} + +export { prisma }; diff --git a/packages/remix-dev/__tests__/fixtures/replace-remix-imports/app/entry.client.tsx b/packages/remix-dev/__tests__/fixtures/replace-remix-imports/app/entry.client.tsx new file mode 100644 index 00000000000..a19979b2527 --- /dev/null +++ b/packages/remix-dev/__tests__/fixtures/replace-remix-imports/app/entry.client.tsx @@ -0,0 +1,4 @@ +import { hydrate } from "react-dom"; +import { RemixBrowser } from "remix"; + +hydrate(, document); diff --git a/packages/remix-dev/__tests__/fixtures/replace-remix-imports/app/entry.server.tsx b/packages/remix-dev/__tests__/fixtures/replace-remix-imports/app/entry.server.tsx new file mode 100644 index 00000000000..cae2067491d --- /dev/null +++ b/packages/remix-dev/__tests__/fixtures/replace-remix-imports/app/entry.server.tsx @@ -0,0 +1,21 @@ +import { renderToString } from "react-dom/server"; +import { RemixServer } from "remix"; +import type { EntryContext } from "remix"; + +export default function handleRequest( + request: Request, + responseStatusCode: number, + responseHeaders: Headers, + remixContext: EntryContext +) { + const markup = renderToString( + + ); + + responseHeaders.set("Content-Type", "text/html"); + + return new Response("" + markup, { + status: responseStatusCode, + headers: responseHeaders, + }); +} diff --git a/packages/remix-dev/__tests__/fixtures/replace-remix-imports/app/models/note.server.ts b/packages/remix-dev/__tests__/fixtures/replace-remix-imports/app/models/note.server.ts new file mode 100644 index 00000000000..ba56b532eb1 --- /dev/null +++ b/packages/remix-dev/__tests__/fixtures/replace-remix-imports/app/models/note.server.ts @@ -0,0 +1,53 @@ +import type { User, Note } from "@prisma/client"; + +import { prisma } from "~/db.server"; + +export type { Note } from "@prisma/client"; + +export function getNote({ + id, + userId, +}: Pick & { + userId: User["id"]; +}) { + return prisma.note.findFirst({ + where: { id, userId }, + }); +} + +export function getNoteListItems({ userId }: { userId: User["id"] }) { + return prisma.note.findMany({ + where: { userId }, + select: { id: true, title: true }, + orderBy: { updatedAt: "desc" }, + }); +} + +export function createNote({ + body, + title, + userId, +}: Pick & { + userId: User["id"]; +}) { + return prisma.note.create({ + data: { + title, + body, + user: { + connect: { + id: userId, + }, + }, + }, + }); +} + +export function deleteNote({ + id, + userId, +}: Pick & { userId: User["id"] }) { + return prisma.note.deleteMany({ + where: { id, userId }, + }); +} diff --git a/packages/remix-dev/__tests__/fixtures/replace-remix-imports/app/models/post.server.ts b/packages/remix-dev/__tests__/fixtures/replace-remix-imports/app/models/post.server.ts new file mode 100644 index 00000000000..66dc324f29b --- /dev/null +++ b/packages/remix-dev/__tests__/fixtures/replace-remix-imports/app/models/post.server.ts @@ -0,0 +1,17 @@ +import { prisma } from "~/db.server"; +import type { Post } from "@prisma/client"; +export type { Post }; + +export async function getPosts() { + return prisma.post.findMany(); +} + +export async function getPost(slug: string) { + return prisma.post.findUnique({ where: { slug } }); +} + +export async function createPost( + post: Pick +) { + return prisma.post.create({ data: post }); +} diff --git a/packages/remix-dev/__tests__/fixtures/replace-remix-imports/app/models/user.server.ts b/packages/remix-dev/__tests__/fixtures/replace-remix-imports/app/models/user.server.ts new file mode 100644 index 00000000000..645da047587 --- /dev/null +++ b/packages/remix-dev/__tests__/fixtures/replace-remix-imports/app/models/user.server.ts @@ -0,0 +1,59 @@ +import type { Password, User } from "@prisma/client"; +import bcrypt from "@node-rs/bcrypt"; + +import { prisma } from "~/db.server"; + +export type { User } from "@prisma/client"; + +export async function getUserById(id: User["id"]) { + return prisma.user.findUnique({ where: { id } }); +} + +export async function getUserByEmail(email: User["email"]) { + return prisma.user.findUnique({ where: { email } }); +} + +export async function createUser(email: User["email"], password: string) { + const hashedPassword = await bcrypt.hash(password, 10); + + return prisma.user.create({ + data: { + email, + password: { + create: { + hash: hashedPassword, + }, + }, + }, + }); +} + +export async function deleteUserByEmail(email: User["email"]) { + return prisma.user.delete({ where: { email } }); +} + +export async function verifyLogin( + email: User["email"], + password: Password["hash"] +) { + const userWithPassword = await prisma.user.findUnique({ + where: { email }, + include: { + password: true, + }, + }); + + if (!userWithPassword || !userWithPassword.password) { + return null; + } + + const isValid = await bcrypt.verify(password, userWithPassword.password.hash); + + if (!isValid) { + return null; + } + + const { password: _password, ...userWithoutPassword } = userWithPassword; + + return userWithoutPassword; +} diff --git a/packages/remix-dev/__tests__/fixtures/replace-remix-imports/app/root.tsx b/packages/remix-dev/__tests__/fixtures/replace-remix-imports/app/root.tsx new file mode 100644 index 00000000000..e38495ec7fc --- /dev/null +++ b/packages/remix-dev/__tests__/fixtures/replace-remix-imports/app/root.tsx @@ -0,0 +1,50 @@ +import { + json, + Links, + LiveReload, + Meta, + Outlet, + Scripts, + ScrollRestoration, +} from "remix"; +import type { LinksFunction, MetaFunction, LoaderFunction } from "remix"; + +import tailwindStylesheetUrl from "./styles/tailwind.css"; +import { getUser } from "./session.server"; + +export const links: LinksFunction = () => { + return [{ rel: "stylesheet", href: tailwindStylesheetUrl }]; +}; + +export const meta: MetaFunction = () => ({ + charset: "utf-8", + title: "Remix Notes", + viewport: "width=device-width,initial-scale=1", +}); + +type LoaderData = { + user: Awaited>; +}; + +export const loader: LoaderFunction = async ({ request }) => { + return json({ + user: await getUser(request), + }); +}; + +export default function App() { + return ( + + + + + + + + + + + + + ); +} diff --git a/packages/remix-dev/__tests__/fixtures/replace-remix-imports/app/routes/healthcheck.tsx b/packages/remix-dev/__tests__/fixtures/replace-remix-imports/app/routes/healthcheck.tsx new file mode 100644 index 00000000000..8ca82e9b3ec --- /dev/null +++ b/packages/remix-dev/__tests__/fixtures/replace-remix-imports/app/routes/healthcheck.tsx @@ -0,0 +1,23 @@ +// learn more: https://fly.io/docs/reference/configuration/#services-http_checks +import type { LoaderFunction } from "remix"; +import { prisma } from "~/db.server"; + +export const loader: LoaderFunction = async ({ request }) => { + const host = + request.headers.get("X-Forwarded-Host") ?? request.headers.get("host"); + + try { + // if we can connect to the database and make a simple query + // and make a HEAD request to ourselves, then we're good. + await Promise.all([ + prisma.user.count(), + fetch(`http://${host}`, { method: "HEAD" }).then((r) => { + if (!r.ok) return Promise.reject(r); + }), + ]); + return new Response("OK"); + } catch (error: unknown) { + console.log("healthcheck āŒ", { error }); + return new Response("ERROR", { status: 500 }); + } +}; diff --git a/packages/remix-dev/__tests__/fixtures/replace-remix-imports/app/routes/index.tsx b/packages/remix-dev/__tests__/fixtures/replace-remix-imports/app/routes/index.tsx new file mode 100644 index 00000000000..2abf6853232 --- /dev/null +++ b/packages/remix-dev/__tests__/fixtures/replace-remix-imports/app/routes/index.tsx @@ -0,0 +1,143 @@ +import { Link } from "remix"; +import { useOptionalUser } from "~/utils"; + +export default function Index() { + const user = useOptionalUser(); + return ( +
+
+
+
+
+ Sonic Youth On Stage +
+
+
+

+ + Indie Stack + +

+

+ Check the README.md file for instructions on how to get this + project deployed. +

+
+ {user ? ( + + View Notes for {user.email} + + ) : ( +
+ + Sign up + + + Log In + +
+ )} +
+ + Remix + +
+
+
+ +
+ + Blog Posts + +
+ +
+
+ {[ + { + src: "https://user-images.githubusercontent.com/1500684/157764397-ccd8ea10-b8aa-4772-a99b-35de937319e1.svg", + alt: "Fly.io", + href: "https://fly.io", + }, + { + src: "https://user-images.githubusercontent.com/1500684/157764395-137ec949-382c-43bd-a3c0-0cb8cb22e22d.svg", + alt: "SQLite", + href: "https://sqlite.org", + }, + { + src: "https://user-images.githubusercontent.com/1500684/157764484-ad64a21a-d7fb-47e3-8669-ec046da20c1f.svg", + alt: "Prisma", + href: "https://prisma.io", + }, + { + src: "https://user-images.githubusercontent.com/1500684/157764276-a516a239-e377-4a20-b44a-0ac7b65c8c14.svg", + alt: "Tailwind", + href: "https://tailwindcss.com", + }, + { + src: "https://user-images.githubusercontent.com/1500684/157764454-48ac8c71-a2a9-4b5e-b19c-edef8b8953d6.svg", + alt: "Cypress", + href: "https://www.cypress.io", + }, + { + src: "https://user-images.githubusercontent.com/1500684/157772386-75444196-0604-4340-af28-53b236faa182.svg", + alt: "MSW", + href: "https://mswjs.io", + }, + { + src: "https://user-images.githubusercontent.com/1500684/157772447-00fccdce-9d12-46a3-8bb4-fac612cdc949.svg", + alt: "Vitest", + href: "https://vitest.dev", + }, + { + src: "https://user-images.githubusercontent.com/1500684/157772662-92b0dd3a-453f-4d18-b8be-9fa6efde52cf.png", + alt: "Testing Library", + href: "https://testing-library.com", + }, + { + src: "https://user-images.githubusercontent.com/1500684/157772934-ce0a943d-e9d0-40f8-97f3-f464c0811643.svg", + alt: "Prettier", + href: "https://prettier.io", + }, + { + src: "https://user-images.githubusercontent.com/1500684/157772990-3968ff7c-b551-4c55-a25c-046a32709a8e.svg", + alt: "ESLint", + href: "https://eslint.org", + }, + { + src: "https://user-images.githubusercontent.com/1500684/157773063-20a0ed64-b9f8-4e0b-9d1e-0b65a3d4a6db.svg", + alt: "TypeScript", + href: "https://typescriptlang.org", + }, + ].map((img) => ( + + {img.alt} + + ))} +
+
+
+
+ ); +} diff --git a/packages/remix-dev/__tests__/fixtures/replace-remix-imports/app/routes/join.tsx b/packages/remix-dev/__tests__/fixtures/replace-remix-imports/app/routes/join.tsx new file mode 100644 index 00000000000..ad0ed8014b2 --- /dev/null +++ b/packages/remix-dev/__tests__/fixtures/replace-remix-imports/app/routes/join.tsx @@ -0,0 +1,179 @@ +import * as React from "react"; +import type { ActionFunction, LoaderFunction, MetaFunction } from "remix"; +import { + Form, + Link, + redirect, + useSearchParams, + json, + useActionData, +} from "remix"; + +import { getUserId, createUserSession } from "~/session.server"; + +import { createUser, getUserByEmail } from "~/models/user.server"; +import { validateEmail } from "~/utils"; + +export const loader: LoaderFunction = async ({ request }) => { + const userId = await getUserId(request); + if (userId) return redirect("/"); + return json({}); +}; + +interface ActionData { + errors: { + email?: string; + password?: string; + }; +} + +export const action: ActionFunction = async ({ request }) => { + const formData = await request.formData(); + const email = formData.get("email"); + const password = formData.get("password"); + const redirectTo = formData.get("redirectTo"); + + if (!validateEmail(email)) { + return json( + { errors: { email: "Email is invalid" } }, + { status: 400 } + ); + } + + if (typeof password !== "string") { + return json( + { errors: { password: "Password is required" } }, + { status: 400 } + ); + } + + if (password.length < 8) { + return json( + { errors: { password: "Password is too short" } }, + { status: 400 } + ); + } + + const existingUser = await getUserByEmail(email); + if (existingUser) { + return json( + { errors: { email: "A user already exists with this email" } }, + { status: 400 } + ); + } + + const user = await createUser(email, password); + + return createUserSession({ + request, + userId: user.id, + remember: false, + redirectTo: typeof redirectTo === "string" ? redirectTo : "/", + }); +}; + +export const meta: MetaFunction = () => { + return { + title: "Sign Up", + }; +}; + +export default function Join() { + const [searchParams] = useSearchParams(); + const redirectTo = searchParams.get("redirectTo") ?? undefined; + const actionData = useActionData() as ActionData; + const emailRef = React.useRef(null); + const passwordRef = React.useRef(null); + + React.useEffect(() => { + if (actionData?.errors?.email) { + emailRef.current?.focus(); + } else if (actionData?.errors?.password) { + passwordRef.current?.focus(); + } + }, [actionData]); + + return ( +
+
+
+
+ +
+ + {actionData?.errors?.email && ( +
+ {actionData.errors.email} +
+ )} +
+
+ +
+ +
+ + {actionData?.errors?.password && ( +
+ {actionData.errors.password} +
+ )} +
+
+ + + +
+
+ Already have an account?{" "} + + Log in + +
+
+
+
+
+ ); +} diff --git a/packages/remix-dev/__tests__/fixtures/replace-remix-imports/app/routes/login.tsx b/packages/remix-dev/__tests__/fixtures/replace-remix-imports/app/routes/login.tsx new file mode 100644 index 00000000000..ffd352cf2ca --- /dev/null +++ b/packages/remix-dev/__tests__/fixtures/replace-remix-imports/app/routes/login.tsx @@ -0,0 +1,192 @@ +import * as React from "react"; +import type { ActionFunction, LoaderFunction, MetaFunction } from "remix"; +import { + Form, + json, + Link, + useActionData, + redirect, + useSearchParams, +} from "remix"; + +import { createUserSession, getUserId } from "~/session.server"; +import { verifyLogin } from "~/models/user.server"; +import { validateEmail } from "~/utils"; + +export const loader: LoaderFunction = async ({ request }) => { + const userId = await getUserId(request); + if (userId) return redirect("/"); + return json({}); +}; + +interface ActionData { + errors?: { + email?: string; + password?: string; + }; +} + +export const action: ActionFunction = async ({ request }) => { + const formData = await request.formData(); + const email = formData.get("email"); + const password = formData.get("password"); + const redirectTo = formData.get("redirectTo"); + const remember = formData.get("remember"); + + if (!validateEmail(email)) { + return json( + { errors: { email: "Email is invalid" } }, + { status: 400 } + ); + } + + if (typeof password !== "string") { + return json( + { errors: { password: "Password is required" } }, + { status: 400 } + ); + } + + if (password.length < 8) { + return json( + { errors: { password: "Password is too short" } }, + { status: 400 } + ); + } + + const user = await verifyLogin(email, password); + + if (!user) { + return json( + { errors: { email: "Invalid email or password" } }, + { status: 400 } + ); + } + + return createUserSession({ + request, + userId: user.id, + remember: remember === "on" ? true : false, + redirectTo: typeof redirectTo === "string" ? redirectTo : "/notes", + }); +}; + +export const meta: MetaFunction = () => { + return { + title: "Login", + }; +}; + +export default function LoginPage() { + const [searchParams] = useSearchParams(); + const redirectTo = searchParams.get("redirectTo") || "/notes"; + const actionData = useActionData() as ActionData; + const emailRef = React.useRef(null); + const passwordRef = React.useRef(null); + + React.useEffect(() => { + if (actionData?.errors?.email) { + emailRef.current?.focus(); + } else if (actionData?.errors?.password) { + passwordRef.current?.focus(); + } + }, [actionData]); + + return ( +
+
+
+
+ +
+ + {actionData?.errors?.email && ( +
+ {actionData.errors.email} +
+ )} +
+
+ +
+ +
+ + {actionData?.errors?.password && ( +
+ {actionData.errors.password} +
+ )} +
+
+ + + +
+
+ + +
+
+ Don't have an account?{" "} + + Sign up + +
+
+
+
+
+ ); +} diff --git a/packages/remix-dev/__tests__/fixtures/replace-remix-imports/app/routes/logout.tsx b/packages/remix-dev/__tests__/fixtures/replace-remix-imports/app/routes/logout.tsx new file mode 100644 index 00000000000..17be85ff7f2 --- /dev/null +++ b/packages/remix-dev/__tests__/fixtures/replace-remix-imports/app/routes/logout.tsx @@ -0,0 +1,11 @@ +import type { ActionFunction, LoaderFunction } from "remix"; +import { redirect } from "remix"; +import { logout } from "~/session.server"; + +export const action: ActionFunction = async ({ request }) => { + return logout(request); +}; + +export const loader: LoaderFunction = async () => { + return redirect("/"); +}; diff --git a/packages/remix-dev/__tests__/fixtures/replace-remix-imports/app/routes/notes.tsx b/packages/remix-dev/__tests__/fixtures/replace-remix-imports/app/routes/notes.tsx new file mode 100644 index 00000000000..8a496a94f60 --- /dev/null +++ b/packages/remix-dev/__tests__/fixtures/replace-remix-imports/app/routes/notes.tsx @@ -0,0 +1,73 @@ +import { Form, json, useLoaderData, Outlet, Link, NavLink } from "remix"; +import type { LoaderFunction } from "remix"; + +import { requireUserId } from "~/session.server"; +import { useUser } from "~/utils"; +import { getNoteListItems } from "~/models/note.server"; + +type LoaderData = { + noteListItems: Awaited>; +}; + +export const loader: LoaderFunction = async ({ request }) => { + const userId = await requireUserId(request); + const noteListItems = await getNoteListItems({ userId }); + return json({ noteListItems }); +}; + +export default function NotesPage() { + const data = useLoaderData() as LoaderData; + const user = useUser(); + + return ( +
+
+

+ Notes +

+

{user.email}

+
+ +
+
+ +
+
+ + + New Note + + +
+ + {data.noteListItems.length === 0 ? ( +

No notes yet

+ ) : ( +
    + {data.noteListItems.map((note) => ( +
  1. + + `block border-b p-4 text-xl ${isActive ? "bg-white" : ""}` + } + to={note.id} + > + šŸ“ {note.title} + +
  2. + ))} +
+ )} +
+ +
+ +
+
+
+ ); +} diff --git a/packages/remix-dev/__tests__/fixtures/replace-remix-imports/app/routes/notes/$noteId.tsx b/packages/remix-dev/__tests__/fixtures/replace-remix-imports/app/routes/notes/$noteId.tsx new file mode 100644 index 00000000000..10b4e65fdd4 --- /dev/null +++ b/packages/remix-dev/__tests__/fixtures/replace-remix-imports/app/routes/notes/$noteId.tsx @@ -0,0 +1,68 @@ +import type { LoaderFunction, ActionFunction } from "remix"; +import { redirect } from "remix"; +import { json, useLoaderData, useCatch, Form } from "remix"; +import invariant from "tiny-invariant"; +import type { Note } from "~/models/note.server"; +import { deleteNote } from "~/models/note.server"; +import { getNote } from "~/models/note.server"; +import { requireUserId } from "~/session.server"; + +type LoaderData = { + note: Note; +}; + +export const loader: LoaderFunction = async ({ request, params }) => { + const userId = await requireUserId(request); + invariant(params.noteId, "noteId not found"); + + const note = await getNote({ userId, id: params.noteId }); + if (!note) { + throw new Response("Not Found", { status: 404 }); + } + return json({ note }); +}; + +export const action: ActionFunction = async ({ request, params }) => { + const userId = await requireUserId(request); + invariant(params.noteId, "noteId not found"); + + await deleteNote({ userId, id: params.noteId }); + + return redirect("/notes"); +}; + +export default function NoteDetailsPage() { + const data = useLoaderData() as LoaderData; + + return ( +
+

{data.note.title}

+

{data.note.body}

+
+
+ +
+
+ ); +} + +export function ErrorBoundary({ error }: { error: Error }) { + console.error(error); + + return
An unexpected error occurred: {error.message}
; +} + +export function CatchBoundary() { + const caught = useCatch(); + + if (caught.status === 404) { + return
Note not found
; + } + + throw new Error(`Unexpected caught response with status: ${caught.status}`); +} diff --git a/packages/remix-dev/__tests__/fixtures/replace-remix-imports/app/routes/notes/index.tsx b/packages/remix-dev/__tests__/fixtures/replace-remix-imports/app/routes/notes/index.tsx new file mode 100644 index 00000000000..30df34be502 --- /dev/null +++ b/packages/remix-dev/__tests__/fixtures/replace-remix-imports/app/routes/notes/index.tsx @@ -0,0 +1,12 @@ +import { Link } from "remix"; + +export default function NoteIndexPage() { + return ( +

+ No note selected. Select a note on the left, or{" "} + + create a new note. + +

+ ); +} diff --git a/packages/remix-dev/__tests__/fixtures/replace-remix-imports/app/routes/notes/new.tsx b/packages/remix-dev/__tests__/fixtures/replace-remix-imports/app/routes/notes/new.tsx new file mode 100644 index 00000000000..8b856d66a35 --- /dev/null +++ b/packages/remix-dev/__tests__/fixtures/replace-remix-imports/app/routes/notes/new.tsx @@ -0,0 +1,116 @@ +import * as React from "react"; +import { Form, json, redirect, useActionData } from "remix"; +import type { ActionFunction } from "remix"; +import Alert from "@reach/alert"; + +import { createNote } from "~/models/note.server"; +import { requireUserId } from "~/session.server"; + +type ActionData = { + errors?: { + title?: string; + body?: string; + }; +}; + +export const action: ActionFunction = async ({ request }) => { + const userId = await requireUserId(request); + + const formData = await request.formData(); + const title = formData.get("title"); + const body = formData.get("body"); + + if (typeof title !== "string" || title.length === 0) { + return json( + { errors: { title: "Title is required" } }, + { status: 400 } + ); + } + + if (typeof body !== "string" || body.length === 0) { + return json( + { errors: { body: "Body is required" } }, + { status: 400 } + ); + } + + const note = await createNote({ title, body, userId }); + + return redirect(`/notes/${note.id}`); +}; + +export default function NewNotePage() { + const actionData = useActionData() as ActionData; + const titleRef = React.useRef(null); + const bodyRef = React.useRef(null); + + React.useEffect(() => { + if (actionData?.errors?.title) { + titleRef.current?.focus(); + } else if (actionData?.errors?.body) { + bodyRef.current?.focus(); + } + }, [actionData]); + + return ( +
+
+ + {actionData?.errors?.title && ( + + {actionData.errors.title} + + )} +
+ +
+