Remix + Prisma + MongoDB 全栈应用开发(三):CRUD、筛选 & 排序

327 阅读16分钟

欢迎来到本系列教程的第三篇文章,在本系列中你可以学到如何使用 MongoDB、Prisma 和 Remix 从零开始构建一个全栈应用!在本节中,将会构建出应用程序的主体部分,也就用来展示用户 kudos反馈及允许用户发送 kudos 给其他用户(类似社交软件)。

fullstack-mongodb-3.png

介绍

在本系列的上一节中,你已经构建了应用程序的注册和登录表单,并实现了基于会话的身份验证。你还更新了 Prisma schema,在 User 模型中声明一个新的嵌入文档,来保存用户的个人信息。

在这一部分你将构建应用程序的主要功能:kudos 反馈。每个用户都有一个其他用户发送给他的 kudos 反馈。用户同样也可以发送 kudos 给其他用户。

此外,你将实现一些搜索和筛选,以便在反馈列表中更容易地找到 kudos。

该项目的起点位于 GitHub 仓库的 part-2 分支中。如果你想看这部分的最终结果,请前往 part-3 分支。

开发环境

为了能够跟上提供的示例,你需要

  • 已安装好 Node.js。
  • 已安装好 Git。
  • 已安装好 TailwindCSS VSCode 插件(可选)。
  • 已安装好 Prisma VSCode 插件(可选)。

提示:可选装的插件会给 Tailwind 和 Prisma 带来非常优秀的智能感知和语法高亮功能。

构建 home 路由

应用的主要部分都将集中在 /home 路由。在 app/routes 文件夹下添加一个 home.tsx 文件来设置这个路由。

目前新文件应该导出一个名为 Home 的函数组件,以及一个 loader 函数,该函数用来重定向未登录用户到登录页。

// app/routes/home.tsx

import { LoaderFunction } from '@remix-run/node'
import { requireUserId } from '~/utils/auth.server'

export const loader: LoaderFunction = async ({ request }) => {
  await requireUserId(request)
  return null
}

export default function Home() {
  return <h2>Home Page</h2>
}

/home 路由将作为你应用的主页而不是根路由。

现在,app/routes/index.tsx 文件(/ 路由)渲染一个 React 组件。该路由应该仅用来重定向用户:到 /home/login 路由。在其位置设置一个资源路由来实现这个功能。

资源路由

资源路由是一个不渲染组件的路由,但可以用任何类型的响应来响应。你可以将其视作一个简单的 API 端点。在 / 路由场景下,你将希望它返回一个带有 302 状态码的 redirect 响应。

删除原有的 app/routes/index.tsx 文件,并用一个 index.ts 文件替代,在这里你可以定义资源路由:

// app/routes/index.ts

import { LoaderFunction, redirect } from '@remix-run/node'
import { requireUserId } from '~/utils/auth.server'

export const loader: LoaderFunction = async ({ request }) => {
  await requireUserId(request)
  return redirect('/home')
}

注意:文件后缀被改为 .ts 是因为该路由将永远不会渲染组件。

当一个用户访问 / 路由时,上面的 loader 将首先检测该用户是否已登录。当会话无效时,requireUserId 函数将重定向到 /login

当会话有效时,loader 则返回一个 redirect/home 页。

home-initial.png

添加用户列表面板

通过构建一个组件来开始你的主页,该组件将在屏幕左侧列出站点的用户。

app/components 文件夹新建一个名为 user-pannel.tex 的新文件:

// app/components/user-panel.tsx
export function UserPanel() {
  return (
    <div className="w-1/6 bg-gray-200 flex flex-col">
      <div className="text-center bg-gray-300 h-20 flex items-center justify-center">
        <h2 className="text-xl text-blue-600 font-semibold">My Team</h2>
      </div>
      <div className="flex-1 overflow-y-scroll py-4 flex flex-col gap-y-10">
        <p>Users go here</p>
      </div>
      <div className="text-center p-6 bg-gray-300">
        <button
          type="submit"
          className="rounded-xl bg-yellow-300 font-semibold text-blue-600 px-3 py-2 transition duration-300 ease-in-out hover:bg-yellow-400 hover:-translate-y-1"
        >
          Sign Out
        </button>
      </div>
    </div>
  )
}

这样就创建了一个侧边栏来包含用户列表。该组件是静态的,这意味着它不执行任何操作或以任何方式变化。

通过添加用户列表使该组件更动态化之前,先在 app/routes/home.tsx 页导入它并在此页面渲染它。

// app/routes/home.tsx
import { LoaderFunction } from '@remix-run/node'
import { requireUserId } from '~/utils/auth.server'
import { Layout } from '~/components/layout'
import { UserPanel } from '~/components/user-panel'

export const loader: LoaderFunction = async ({ request }) => {
  await requireUserId(request)
  return null // <- A loader always has to return some value, even if that is null
}

export default function Home() {
  return (
    <Layout>
      <div className="h-full flex">
        <UserPanel />
      </div>
    </Layout>
  )
}

以上代码导入一个新组件和 Layout 组件,然后在 Layout 中渲染新组件。

user-panel.png

查询所有用户并筛选结果

现在你需要在这个面板里实际展示用户的列表。你应该已经有了一个包含与用户相关函数的文件:app/utils/user.server.ts

在该文件中添加一个新函数来查询数据库中的所有用户。该函数接收一个 userId 参数,并按用户名升序对结果进行排序:

// app/utils/user.server.ts

//...

export const getOtherUsers = async (userId: string) => {
  return prisma.user.findMany({
    where: {
      id: { not: userId },
    },
    orderBy: {
      profile: {
        firstName: 'asc',
      },
    },
  })
}

where 过滤器排除 iduserId 参数相匹配的任何文档。这将被用来获取除当前登录用户外的所有用户。

提示:注意使用嵌入文档内的字段来排序是多么容易。

在 app/routes/home.tsx 中,导入这个新函数并在 loader 中引用它。然后使用 Remix 的 json 辅助函数来返回用户列表:

// app/routes/home.tsx

// ...

import { 
+  json, 
  LoaderFunction 
} from '@remix-run/node'
+ import { getOtherUsers } from '~/utils/user.server'


export const loader: LoaderFunction = async ({ request }) => {
-    await requireUserId(request)
-    return null
+    const userId = await requireUserId(request)
+    const users = await getOtherUsers(userId)
+    return json({ users })
}

// ...

提示:loader 函数中运行的任何代码都不会被暴露给客户端代码。你可以感谢 Remix 所做的优秀功能。

如果在你的数据库中有任何用户并在 loader 中导出 users 变量,你应该会看到除你自己之外的所有用户列表。

users-logged.png

提示:整个 profile 嵌入文档被作为嵌套对象检索,而无需显式包含它。

你现在应该已经有了可用的数据。是时候用它做点什么了。

为用户面板提供用户数据

UserPanel 组件的 prop 中设置一个新的 users

// app/components/user-panel.tsx
import { User } from '@prisma/client'

export function UserPanel({ users }: { users: User[] }) {
  // ...
}

这里所用的 User 类型是 Prisma 生成的,可以通过 Prisma Client 获得。Remix 和 Prisma 配合得非常好,因为它非常容易在全栈框架中实现端到端类型安全。

提示:在整个堆栈中的类型随着数据形状的变化而保持同步时,就会发生端到端类型安全。

app/routes/home.tsx 中,你现在可以将用户提供给 UserPanel 组件了。导入Remix 提供的 useLoaderData 钩子,它能让你访问 loader 函数返回的任何数据,使用它访问 users 数据:

// app/routes/home.tsx
import { useLoaderData } from '@remix-run/react'
// ...
export default function Home() {
  const { users } = useLoaderData()
  return (
    <Layout>
      <div className="h-full flex">
        <UserPanel users={users} />
        <div className="flex-1"></div>
      </div>
    </Layout>
  )
}
// ...

该组件现在已经有用户数据可用了,现在要做的就是来展示它们。

构建用户展示组件

现在用户列表将被展示为一个用户姓和名的首字母组成的圆环。

app/components 中创建一个名为 user-circle.tsx 的新文件,并添加以下组件:

// app/components/user-circle.tsx
import { Profile } from '@prisma/client'

interface props {
  profile: Profile
  className?: string
  onClick?: (...args: any) => any
}

export function UserCircle({ profile, onClick, className }: props) {
  return (
    <div
      className={`${className} cursor-pointer bg-gray-400 rounded-full flex justify-center items-center`}
      onClick={onClick}
    >
      <h2>
        {profile.firstName.charAt(0).toUpperCase()}
        {profile.lastName.charAt(0).toUpperCase()}
      </h2>
    </div>
  )
}

该组件使用 Prisma 生成的 Profile 类型,因为你只传入了来自用户文档的 profile 数据。

它还有一些可配置项,允许你提供点击操作及添加额外的类来定制化样式。

app/components/user-panel.tsx 中,导入这个新组件并替代 <p>Users go here</p> 在每个用户中渲染它:

// app/components/user-panel.tsx
import { User } from '@prisma/client'
+ import { UserCircle } from '~/components/user-circle'

export function UserPanel({ users }: { users: User[] }) {
  return (
    {/* ... */}
-   <p>Users go here</p>
+   {users.map(user => (
+      <UserCircle key={user.id} profile={user.profile} className="h-24 w-24 mx-auto flex-shrink-0" />
+   ))}
    {/* ... */}
  )
}

漂亮!在主页的左侧,用户现在被渲染为一个漂亮的列。现在侧边栏里唯一一个没有功能性的部分就是注销按钮。

user-list.png

添加注销功能

app/routes 中添加另外一个名为 logout.ts 的资源路由,当调用它时将执行一个注销操作:

// app/routes/logout.ts
import type { ActionFunction, LoaderFunction } from "@remix-run/node";
import { redirect } from "@remix-run/node";
import { logout } from "~/utils/auth.server";

export const action: ActionFunction = async ({ request }) => logout(request);
export const loader: LoaderFunction = async () => redirect("/");

该路由处理两种可能的操作:POSTGET

  • POST:这将触发该系列中之前部分编写的 logout 函数。
  • GET:如果是 GET 请求,用户将被返回到主页。

app/components/user-panel.ts 中添加一个 form 来包括注销按钮,这样当提交时将会请求到该路由上。

// app/components/user-panel.ts

// ...

export function UserPanel({ users }: props) {
    return (
        <div className="w-1/6 bg-gray-200 flex flex-col">
            {/* ... */}
            <div className="text-center p-6 bg-gray-300">
+                <form action="/logout" method="post">
                    <button type="submit" className="rounded-xl bg-yellow-300 font-semibold text-blue-600 px-3 py-2 transition duration-300 ease-in-out hover:bg-yellow-400 hover:-translate-y-1">
                        Sign Out
                    </button>
+                </form>
            </div>
        </div>
    )
}

用户现在可以在应用中注销账号了!POST 请求所关联的用户会话也将被一同注销并销毁。

添加 kudos 发送功能

当点击列表中的用户头像时,应该弹出一个包含表单的弹窗。提交表单将在数据库中保存一个 kudo。

该表单有以下功能:

  • 显示你要发送 kudos 的对象用户。
  • 一个文本框,你可以在其中填写给用户的消息。
  • 样式设置,允许你选择文章的背景颜色和文字颜色。
  • emoji 选择器,你可以添加 emoji 到文章里。
  • 精确预览文章。

更新 Prisma schema

有一些你将要保存和展示的数据点还没有在 schema 中定义。以下就是需要更新的列表:

  1. 添加一个含有嵌入文档的 Kudo 模型,用来保存自定义的样式。
  2. User 模型上添加一个一对多关系,来定义一个用户是 kudos 的作者。还要添加一个相似的关系来定义一个用户是 kudos 的接收者。
  3. 为表情、部门、颜色添加 enum,来定义可用的选项。
// prisma/schema.prisma

// ...

enum Emoji {
  THUMBSUP
  PARTY
  HANDSUP
}

enum Department {
  MARKETING
  SALES
  ENGINEERING
  HR
}

enum Color {
  RED
  GREEN
  YELLOW
  BLUE
  WHITE
}

type KudoStyle {
  backgroundColor Color @default(YELLOW)
  textColor       Color @default(WHITE)
  emoji           Emoji @default(THUMBSUP)
}

model Kudo {
  id          String     @id @default(auto()) @map("_id") @db.ObjectId
  message     String
  createdAt   DateTime   @default(now())
  style       KudoStyle?
}
// prisma/schema.prisma

model User {
  // ...
+  authoredKudos Kudo[]  @relation("AuthoredKudos")
+  kudos         Kudo[]  @relation("RecievedKudos")
}

model Kudo {
  // ...
+  author      User       @relation(references: [id], fields: [authorId], "AuthoredKudos")
+  authorId    String     @db.ObjectId
+  recipient   User       @relation(references: [id], fields: [recipientId], "RecievedKudos")
+  recipientId String     @db.ObjectId
}

提示:当一个字段应用 @default 后,如果你的集合中的记录没有新的必填字段,下次读取它时,它将被更新为包含默认值的字段。

这就是你现在需要更新的所有内容。运行 npx prisma db push,将会自动重新生成 PrismaClient

嵌套路由

你将使用一个嵌套路由来创建一个模态来保存你的表单。这将允许你设置一个子路由,这个你定义的 Outlet 子路由将在父路由上渲染。

当用户访问这个嵌套路由时,一个模态将会被渲染到页面上,而且不用对整个页面进行重新渲染。

要创建嵌套路由,首先要在 app/routes 里添加一个名为 home 的新文件夹。

注意:文件夹的命名很重要。因为你已经有一个 home.tsx 文件,Remix 会识别新的 home 文件夹下的所有文件,并把它们当作是 /home 的子路由。

在新的 app/routes/home 文件夹中,创建一个名为 kudos.$userId.tsx 的新文件。这将允许你处理模态组件,就好像它是它自己的路由一样。

sub-routes.png

这个文件名的 $userId 部分是一个路由参数,它作为一个动态值,你可以通过 URL 来提供给你的应用程序。 Remix 将会把这个文件名转换为路由:/home/kudos/$userId$userId 可以是任何值。

dynamic-filename.png

在此新文件中导出一个 loader 函数和一个 React 组件,该组件渲染一些文本以确保动态值有效:

// app/routes/home/kudo.$userId.tsx

import { json, LoaderFunction } from '@remix-run/node'
import { useLoaderData } from '@remix-run/react'

// 1
export const loader: LoaderFunction = async ({ request, params }) => {
  // 2
  const { userId } = params
  return json({ userId })
}

export default function KudoModal() {
  // 3
  const data = useLoaderData()
  return <h2> User: {data.userId} </h2>
}

以上代码做了这些事:

  1. loader 函数中提取 params 字段。
  2. 然后获取 userId 的值。
  3. 最后,使用 Remix 的 userLoaderData 钩子从 loader 函数中获取数据,并把 userId 渲染到页面上。

因为这是嵌套路由,为了展示它,你需要定义它应该在其父级的哪里输出。

app/routes/home.tsx 中,使用 Remix 的 Outlet 组件来指明你希望将子路由渲染为 Layout 组件的直接子级:

// app/routes/home.tsx
// ...
import {
  useLoaderData,
+ Outlet
} from '@remix-run/react';

// ...

export default function Home() {
    const { users } = useLoaderData()
    return <Layout>
+       <Outlet />
        {/* ... */}
    </Layout>
}

如果你打开 http://localhost:3000/home/kudos/123,你应该看到文字“User: 123”展示在页面的最上方。如果你把 URL 中的值改为 123 以外的其他值,你应该看到这个改变将被反应在屏幕上。

nested-route.png

通过 id 获取用户

你的嵌套路由正在运行,但是你还需要通过 userId 获取用户数据。在 app/utils/user.server.ts 中创建一个新函数,基于它们的 id 来返回单个用户:

// app/utils/user.server.ts

// ...

export const getUserById = async (userId: string) => {
  return await prisma.user.findUnique({
    where: {
      id: userId,
    },
  })
}

上述查询使用传入的 id 在数据库中查询唯一记录。findUnique 函数允许你使用唯一标识字段或数据库记录中必须唯一的值来过滤查询。

接下来:

  1. app/routes/home/kudo.$userId.tsx 中导出的 loader 中调用该函数。
  2. loader 中使用 json 函数返回结果。
// app/routes/home/kudo.$userId.tsx
import { json, LoaderFunction, redirect } from '@remix-run/node'
import { useLoaderData } from '@remix-run/react'
import { getUserById } from '~/utils/user.server'

export const loader: LoaderFunction = async ({ request, params }) => {
  const { userId } = params

  if (typeof userId !== 'string') {
    return redirect('/home')
  }

  const recipient = await getUserById(userId)
  return json({ recipient })
}
// ...

接下来,你需要一个方法导航到具有有效 id 的嵌套路由。

在用来渲染用户列表的 app/components/user-panel.tsx 中,导入 Remix 提供的 useNavigation 钩子,当用户被点击时用这个钩子导航到嵌套路由。

// app/components/user-panel.tsx

+ import { useNavigate } from '@remix-run/react'


// ...

export function UserPanel({ users }: props) {
+    const navigate = useNavigate()
    return (
      {/*...*/}
       <UserCircle
          key={user.id}
          profile={user.profile}
          className="h-24 w-24 mx-auto flex-shrink-0"
+          onClick={() => navigate(`kudo/${user.id}`)}
      />
      {/*...*/}
    )
}

现在当你点击这个面板中的其他用户时,将会被导航到带有用户信息的一个子路由上。

AnimatedImage.gif

如果一起看起来都不错,下一步就是要构建将在表单中展示的模态组件。

打开 portal

要构建此模态,你首先需要构建一个辅助组件来创建一个 portal,它允许你在父组件的文档对象模型(DOM)分支之外的某处渲染子组件,同时仍允许父组件像管理直接子组件一样对其进行管理。

portal-diagram.png

提示:此 portal 将会非常重要,因为它允许你在没有任何继承样式或父级定位可能影响模态定位的位置渲染模态。

用以下内容在 /app/components 中创建一个名为 portal.ts 的新文件:

// app/components/portal.tsx

import { createPortal } from 'react-dom'
import { useState, useEffect } from 'react'

interface props {
  children: React.ReactNode
  wrapperId: string
}

// 1
const createWrapper = (wrapperId: string) => {
  const wrapper = document.createElement('div')
  wrapper.setAttribute('id', wrapperId)
  document.body.appendChild(wrapper)
  return wrapper
}

export const Portal: React.FC<props> = ({ children, wrapperId }) => {
  const [wrapper, setWrapper] = useState<HTMLElement | null>(null)

  useEffect(() => {
    // 2
    let element = document.getElementById(wrapperId)
    let created = false

    if (!element) {
      created = true
      element = createWrapper(wrapperId)
    }

    setWrapper(element)

    // 3
    return () => {
      if (created && element?.parentNode) {
        element.parentNode.removeChild(element)
      }
    }
  }, [wrapperId])

  if (wrapper === null) return null

  // 4
  return createPortal(children, wrapper)
}

以下是对此组件中发生的事情的解释:

  1. 定义了一个函数来生成一个带有 iddiv。然后将该元素附加到文档的正文中。
  2. 如果带有提供的 id 的元素不存在,则调用 createWrapper 函数来创建一个。
  3. Portal 组件还未挂载时,将销毁该元素。
  4. 为新生成的 div 创建 一个 portal。

结果将是包含在此 Portal 中的任何元素或者组件都将渲染为 body 标记的直接子级,而不是在当前 DOM 分支中作为其父级的子级。

试一试,看看它的实际效果。在 app/routes/home/kudos.$userId.tsx 中,导入新的 Portal 组件,并用它包装返回的组件:

// app/routes/home/kudo.$userId.tsx
// ...

+ import { Portal } from '~/components/portal'


// ... loader ...

export default function KudoModal() {
  const { recipient } = useLoaderData()
-  return ( /* ... */ )
+  return <Portal wrapperId="kudo-modal">{/* ... */}</Portal>
}

如果你导航到嵌套路由,你将看到带有 id 为“kudos-modal” 的 div 现在被渲染为 body 的直接子级,而不是嵌套路由在 DOM 树中渲染的位置。

AnimatedImage.gif

构建模态组件

现在你已经有了 portal,开始构建模态组件本身。此应用程序中将有两种模态,因此以可复用的方式构建组件。

创建一个新文件 app/components.modal.tsx。该文件应该导出一个带有一下 props 的组件:

  • children:在模态中要渲染的子元素。
  • isOpen:决定模态是否展示的标记。
  • ariaLabel:(可选)用作 aria 标签的字符串。
  • className:(可选)允许你向模态内容添加额外 class 的字符串。

在创建的 Modal 组件中添加到以下代码:

// app/components/modal.tsx
import { Portal } from './portal'
import { useNavigate } from '@remix-run/react'

interface props {
  children: React.ReactNode
  isOpen: boolean
  ariaLabel?: string
  className?: string
}

export const Modal: React.FC<props> = ({ children, isOpen, ariaLabel, className }) => {
  const navigate = useNavigate()
  if (!isOpen) return null

  return (
    <Portal wrapperId="modal">
      <div
        className="fixed inset-0 overflow-y-auto bg-gray-600 bg-opacity-80"
        aria-labelledby={ariaLabel ?? 'modal-title'}
        role="dialog"
        aria-modal="true"
        onClick={() => navigate('/home')}
      ></div>
      <div className="fixed inset-0 pointer-events-none flex justify-center items-center max-h-screen overflow-scroll">
        <div className={`${className} p-4 bg-gray-200 pointer-events-auto max-h-screen md:rounded-xl`}>
          {/* This is where the modal content is rendered  */}
          {children}
        </div>
      </div>
    </Portal>
  )
}

Portal 组件被导入并包裹了整个模态,以确保它在安全的位置渲染。

然后使用各种 TailwindCSS 辅助器将模态定义为屏幕上具有不透明背景的固定元素。

当点击背景(模态本身之外的任何地方)时,用户将被导航到 /home 路由导致关闭模态。

构建表单

在 app/routes/home/kudo.$userId.tsx 中导入新的 Modal 组件,用 Modal 替代现在被渲染的 Portal

// app/routes/home/kudo.$userId.tsx
- import { Portal } from '~/components/portal';
+ import { Modal } from '~/components/modal';


// ...

export default function KudoModal() {
  // ...
  return (
-    <Portal wrapperId="kudo-modal">
+    <Modal isOpen={true} className="w-2/3 p-10">
        <h2> User: {recipient.profile.firstName} {recipient.profile.lastName} </h2>
+    </Modal>
-    </Portal>
  )
}

当从侧边栏点击一个用户时,模态应该被立即打开。

AnimatedImage.gif

当表单展示历史消息时,需要已登录用户的信息,所以在构建表单前,需要通过 loader 函数添加用户数据到响应中:

// app/routes/home/kudo.$userId.tsx

+ import { getUser } from '~/utils/auth.server'
// ...
export const loader: LoaderFunction = async ({ request, params }) => {
    // ...
+   const user = await getUser(request)
-   return json({ recipient })
+   return json({ recipient, user })
}
// ...

然后在该文件的 KudoModal 函数中修改以下信息:

// app/routes/home/kudo.$userId.tsx

// 1
import { 
  useLoaderData, 
  useActionData 
} from '@remix-run/react'
import { UserCircle } from '~/components/user-circle'
import { useState } from 'react'
import { KudoStyle } from '@prisma/client'

// ...

export default function KudoModal() {
// 2
const actionData = useActionData()
const [formError] = useState(actionData?.error || '')
const [formData, setFormData] = useState({
  message: '',
  style: {
    backgroundColor: 'RED',
    textColor: 'WHITE',
    emoji: 'THUMBSUP',
  } as KudoStyle,
})

  // 3
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>, field: string) => {
  setFormData(data => ({ ...data, [field]: e.target.value }))
}

  const { 
    recipient, 
    user 
  } = useLoaderData()

  // 4
  return (
    <Modal isOpen={true} className="w-2/3 p-10">
      <div className="text-xs font-semibold text-center tracking-wide text-red-500 w-full mb-2">{formError}</div>
      <form method="post">
        <input type="hidden" value={recipient.id} name="recipientId" />
        <div className="flex flex-col md:flex-row gap-y-2 md:gap-y-0">
          <div className="text-center flex flex-col items-center gap-y-2 pr-8">
            <UserCircle profile={recipient.profile} className="h-24 w-24" />
            <p className="text-blue-300">
              {recipient.profile.firstName} {recipient.profile.lastName}
            </p>
            {recipient.profile.department && (
              <span className="px-2 py-1 bg-gray-300 rounded-xl text-blue-300 w-auto">
                {recipient.profile.department[0].toUpperCase() + recipient.profile.department.toLowerCase().slice(1)}
              </span>
            )}
          </div>
          <div className="flex-1 flex flex-col gap-y-4">
            <textarea
              name="message"
              className="w-full rounded-xl h-40 p-4"
              value={formData.message}
              onChange={e => handleChange(e, 'message')}
              placeholder={`Say something nice about ${recipient.profile.firstName}...`}
            />
            <div className="flex flex-col items-center md:flex-row md:justify-start gap-x-4">
              {/* Select Boxes Go Here */}
            </div>
          </div>
        </div>
        <br />
        <p className="text-blue-600 font-semibold mb-2">Preview</p>
        <div className="flex flex-col items-center md:flex-row gap-x-24 gap-y-2 md:gap-y-0">
          {/* The Preview Goes Here */}
          <div className="flex-1" />
          <button
            type="submit"
            className="rounded-xl bg-yellow-300 font-semibold text-blue-600 w-80 h-12 transition duration-300 ease-in-out hover:bg-yellow-400 hover:-translate-y-1"
          >
            Send
          </button>
        </div>
      </form>
    </Modal>
  )
}

这是一大块新代码,所以看看都做了哪些改变:

  1. 导入一些你将用到新组件和钩子方法。
  2. 设置好你需要的各种表单变量来处理表单数据和错误。
  3. 创建处理输入变化的函数。
  4. 在原来 <h2> 标签的位置渲染表单组件的基础布局。

允许用户自定义他们的 kudo

此表单还需要允许用户通过选择框来选择自定义样式。

app/components 中创建一个名为 select-box.tsx 的新文件,导出一个 SelectBox 组件:

// app/components/select-box.tsx

interface props {
  options: {
    name: string
    value: any
  }[]
  className?: string
  containerClassName?: string
  id?: string
  name?: string
  label?: string
  value?: any
  onChange?: (...args: any) => any
}

export function SelectBox({
  options = [],
  onChange = () => {},
  className = '',
  containerClassName = '',
  name,
  id,
  value,
  label,
}: props) {
  return (
    <div>
      <label htmlFor={id} className="text-blue-600 font-semibold">
        {label}
      </label>
      <div className={`flex items-center ${containerClassName} my-2`}>
        <select className={`${className} appearance-none`} id={id} name={name} onChange={onChange} value={value || ''}>
          {options.map(option => (
            <option key={option.name} value={option.value}>
              {option.name}
            </option>
          ))}
        </select>
        <svg
          className="w-4 h-4 fill-current text-gray-400 -ml-7 mt-1 pointer-events-none"
          viewBox="0 0 140 140"
          xmlns="http://www.w3.org/2000/svg"
        >
          <g>
            <path d="m121.3,34.6c-1.6-1.6-4.2-1.6-5.8,0l-51,51.1-51.1-51.1c-1.6-1.6-4.2-1.6-5.8,0-1.6,1.6-1.6,4.2 0,5.8l53.9,53.9c0.8,0.8 1.8,1.2 2.9,1.2 1,0 2.1-0.4 2.9-1.2l53.9-53.9c1.7-1.6 1.7-4.2 0.1-5.8z" />
          </g>
        </svg>
      </div>
    </div>
  )
}

此组件和 FormField 组件相似,都是可控组件,即接收一些配置并允许父级管理它的状态。

选择框将需要用颜色及表情选项填充。在 app/utils/constants.ts 中创建一个辅助文件来保存可能的选项:

// app/utils/constants.ts

export const colorMap = {
  RED: 'text-red-400',
  GREEN: 'text-green-400',
  BLUE: 'text-blue-400',
  WHITE: 'text-white',
  YELLOW: 'text-yellow-300',
}

export const backgroundColorMap = {
  RED: 'bg-red-400',
  GREEN: 'bg-green-400',
  BLUE: 'bg-blue-400',
  WHITE: 'bg-white',
  YELLOW: 'bg-yellow-300',
}

export const emojiMap = {
  THUMBSUP: '👍',
  PARTY: '🎉',
  HANDSUP: '🙌🏻',
}

现在,在 app/routes/home/kudos.$userId.tsx 中,导入 SelectBox 组件和 constants。还要添加将它们连接到表单状态所需的变量和函数,并渲染 SelectBox 组件以替代 {/* Select Boxes Go Here */} 注释:

// app/routes/home/kudo.$userId.tsx

//...

+ import { SelectBox } from '~/components/select-box'
+ import { colorMap, emojiMap } from "~/utils/constants";


// ...

export default function KudoModal() {

  // ...

+  const handleStyleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>, field: string) => {
+      setFormData(data => ({
+          ...data, style: {
+              ...data.style,
+              [field]: e.target.value
+          }
+      }))
+  }


+  const getOptions = (data: any) => Object.keys(data).reduce((acc: any[], curr) => {
+      acc.push({
+          name: curr.charAt(0).toUpperCase() + curr.slice(1).toLowerCase(),
+          value: curr
+      })
+      return acc
+  }, [])


+  const colors = getOptions(colorMap)
+  const emojis = getOptions(emojiMap)


  return (
      {/* ... */}
-      {/* Select Boxes Go Here */}
+      <SelectBox
+          options={colors}
+          name="backgroundColor"
+          value={formData.style.backgroundColor}
+          onChange={e => handleStyleChange(e, 'backgroundColor')}
+          label="Background Color"
+          containerClassName="w-36"
+          className="w-full rounded-xl px-3 py-2 text-gray-400"
+      />
+      <SelectBox
+          options={colors}
+          name="textColor"
+          value={formData.style.textColor}
+          onChange={e => handleStyleChange(e, 'textColor')}
+          label="Text Color"
+          containerClassName="w-36"
+          className="w-full rounded-xl px-3 py-2 text-gray-400"
+      />
+      <SelectBox
+          options={emojis}
+          label="Emoji"
+          name="emoji"
+          value={formData.style.emoji}
+          onChange={e => handleStyleChange(e, 'emoji')}
+          containerClassName="w-36"
+          className="w-full rounded-xl px-3 py-2 text-gray-400"
+      />
      {/* ... */}
  )
}

选择框现在将展示所有可能的选项。

select-boxes.png

添加 kudo 展示组件

此表单将有一个预览部分,用户可以在其中预览收件人将要看到的实际渲染的组件。

app/components中创建一个名为 kudo.tsx 的新文件:

// app/components/kudo.tsx

import { UserCircle } from '~/components/user-circle'
import { Profile, Kudo as IKudo } from '@prisma/client'
import { colorMap, backgroundColorMap, emojiMap } from '~/utils/constants'

export function Kudo({ profile, kudo }: { profile: Profile; kudo: Partial<IKudo> }) {
  return (
    <div
      className={`flex ${
        backgroundColorMap[kudo.style?.backgroundColor || 'RED']
      } p-4 rounded-xl w-full gap-x-2 relative`}
    >
      <div>
        <UserCircle profile={profile} className="h-16 w-16" />
      </div>
      <div className="flex flex-col">
        <p className={`${colorMap[kudo.style?.textColor || 'WHITE']} font-bold text-lg whitespace-pre-wrap break-all`}>
          {profile.firstName} {profile.lastName}
        </p>
        <p className={`${colorMap[kudo.style?.textColor || 'WHITE']} whitespace-pre-wrap break-all`}>{kudo.message}</p>
      </div>
      <div className="absolute bottom-4 right-4 bg-white rounded-full h-10 w-10 flex items-center justify-center text-2xl">
        {emojiMap[kudo.style?.emoji || 'THUMBSUP']}
      </div>
    </div>
  )
}

该组件接收的属性:

  • profile:接收消息的 user 文档中的 profile 数据。
  • kudo:Kudo 消息及样式选项。

导入并使用带有颜色和表情选项的常量来渲染自定义的样式。

现在你可以把该组件导入 app/routes/home/kudo.$userId.tsx,并在 {/* The Preview Goes Here */} 注释的地方渲染它。

// app/routes/home/kudo.$userId.tsx

// ...

+ import { Kudo } from '~/components/kudo'


// ...

export default function KudoModal() {
  // ...

  return (
    <Modal isOpen={true} className="w-2/3 p-10">
      {/* ... */}
-      {/* The Preview Goes Here */}
+      <Kudo profile={user.profile} kudo={formData} />
      {/* ... */}
    </Modal>
  )
}

现在将展示预览,展示当前登录用户的信息和他们将要发送的带有样式的消息。

AnimatedImage.gif

构建发送 kudos 功能

表单现在看起来已经完整了,唯一剩下的就是完善它的功能!

app/utils 中创建一个名为 kudos.server.ts 的新文件,在这里编写与查询或存储 kudos 相关的所有函数。

在此文件中,导出一个 createKudo 方法,用来接收 kudo 表单数据、发送者 id 及接收者 id。然后使用 Prisma 存储这些数据:

// app/utils/kudos.server.ts

import { prisma } from './prisma.server'
import { KudoStyle } from '@prisma/client'

export const createKudo = async (message: string, userId: string, recipientId: string, style: KudoStyle) => {
  await prisma.kudo.create({
    data: {
      // 1
      message,
      style,
      // 2
      author: {
        connect: {
          id: userId,
        },
      },
      recipient: {
        connect: {
          id: recipientId,
        },
      },
    },
  })
}

以上查询语句做了这些事:

  1. 传递 message 字符串和 style 嵌入文档。
  2. 使用函数传递进来的 id,把新的 kudo 与发送者、接收者连接起来。

app/routes/home/kudo.$userId.tsx 文件中导入此新函数,创建一个 action 函数来处理表单数据并调用 createKudo 函数:

// app/routes/home/kudo.$userId.tsx

// 1
import { 
+  ActionFunction, 
  json, 
  LoaderFunction, 
  redirect 
} from '@remix-run/node'
import { 
+    Color, 
+    Emoji, 
    KudoStyle 
} from '@prisma/client'
+ import { requireUserId } from '~/utils/auth.server'
+ import { createKudo } from '~/utils/kudos.server'


// ...

+ export const action: ActionFunction = async ({ request }) => {
+  const userId = await requireUserId(request)
+
+  // 2
+  const form = await request.formData()
+  const message = form.get('message')
+  const backgroundColor = form.get('backgroundColor')
+  const textColor = form.get('textColor')
+  const emoji = form.get('emoji')
+  const recipientId = form.get('recipientId')
+
+  // 3
+  if (
+    typeof message !== 'string' ||
+    typeof recipientId !== 'string' ||
+    typeof backgroundColor !== 'string' ||
+    typeof textColor !== 'string' ||
+    typeof emoji !== 'string'
+  ) {
+    return json({ error: `Invalid Form Data` }, { status: 400 })
+  }
+
+  if (!message.length) {
+    return json({ error: `Please provide a message.` }, { status: 400 })
+  }
+
+  if (!recipientId.length) {
+    return json({ error: `No recipient found...` }, { status: 400 })
+  }
+
+  // 4
+  await createKudo(message, userId, recipientId, {
+    backgroundColor: backgroundColor as Color,
+    textColor: textColor as Color,
+    emoji: emoji as Emoji,
+  })
+
+  // 5
+  return redirect('/home')
+ }

// ...

以下是上述代码片段的概述:

  1. 导入这个新的 createKudo 函数,同时导入一些 Prisma 生成的类型、Remix 中的 ActionFunction 类型,以及你之前编写的 requireUserId 函数。
  2. 从请求头中提取所有表单数据及字段。
  3. 验证所有表单数据,如果有错误发生,则返回合适的错误信息给表单来展示。
  4. 使用 createKudo 函数来创建一个新的 kudo
  5. 重定向用户到 /home 路由,引发模态关闭。

构建 kudos 提要

现在用户可以彼此发送 kudos 了,你需要一种方式来在 /home 页上的用户提要中展示哪些 kudos。

你已经出构建了 kudo 展示组件,所以你只需要在主页上检索并渲染一个 kudo 列表。

app/utils/kudos.server.ts 中创建并导入一个名为 getFilteredKudos 的新函数。

// app/utils/kudos.server.ts

// 👇 Added the Prisma namespace in the import
import { KudoStyle, Prisma } from '@prisma/client'

// ...

export const getFilteredKudos = async (
  userId: string,
  sortFilter: Prisma.KudoOrderByWithRelationInput,
  whereFilter: Prisma.KudoWhereInput,
) => {
  return await prisma.kudo.findMany({
    select: {
      id: true,
      style: true,
      message: true,
      author: {
        select: {
          profile: true,
        },
      },
    },
    orderBy: {
      ...sortFilter,
    },
    where: {
      recipientId: userId,
      ...whereFilter,
    },
  })
}

以上的函数接收一些不同的参数。都有这些:

  • userId:需要查询 kudos 的用户的 id
  • sortFilter:在查询中传给 orderBy 选项的对象,用来给结果排序。
  • wherFilter:在查询中传给 where 选项的对象,用来筛选结果。

提示:Prisma 生成的类型可以被用作查询语句的安全类型部分,就像上面用到的 Prisma.KudoWhereInput

现在,在 app/routes/home.tsx 中,导入那个函数并在 loader 函数中引用它。同时导入 Kudo 函数及必需的类型来渲染出 Kudos 提要。

// app/routes/home.tsx
import { getFilteredKudos } from '~/utils/kudos.server'
import { Kudo } from '~/components/kudo'
import { Kudo as IKudo, Profile } from '@prisma/client'

interface KudoWithProfile extends IKudo {
  author: {
    profile: Profile
  }
}

export const loader: LoaderFunction = async ({ request }) => {
  // ...
  const kudos = await getFilteredKudos(userId, {}, {})
  return json({ users, kudos })
}

export default function Home() {
  const { users, kudos } = useLoaderData()
  return (
    <Layout>
      <Outlet />
      <div className="h-full flex">
        <UserPanel users={users} />
        <div className="flex-1 flex flex-col">
          {/* Search Bar Goes Here */}
          <div className="flex-1 flex">
            <div className="w-full p-10 flex flex-col gap-y-4">
              {kudos.map((kudo: KudoWithProfile) => (
                <Kudo key={kudo.id} kudo={kudo} profile={kudo.author.profile} />
              ))}
            </div>
            {/* Recent Kudos Goes Here */}
          </div>
        </div>
      </div>
    </Layout>
  )
}

Prisma 生成的 KudoProfile 类型被合并创建为 KudoWithProfile 类型。这是必要的,因为 kudo 数组包含了从作者里来的用户信息数据。

如果你给一个账号发送了一些 kudo 并登录那个账号,在你的提要里应该立即可以看到一个渲染好的 kudo 列表。

kudo-feed.png

你也许注意到当 getFilteredKudos 调用时提供给排序和筛选项参数的都是空对象。这是因为在界面上还没有提供筛选或排序提要的途径。接下来,你将在提要的顶部创建一个搜索栏来处理这些问题。

构建搜索栏

app/components 中创建一个名为 search-bar.tsx 的新文件。此组件将向 /home 页提交一个表单,传递查询参数,这些参数将用于构建你需要的排序和筛选对象。

// app/components/search-bar.tsx

import { useNavigate, useSearchParams } from '@remix-run/react'

export function SearchBar() {
  const navigate = useNavigate()
  let [searchParams] = useSearchParams()

  const clearFilters = () => {
    searchParams.delete('filter')
    navigate('/home')
  }

  return (
    <form className="w-full px-6 flex items-center gap-x-4 border-b-4 border-b-blue-900 border-opacity-30 h-20">
      <div className={`flex items-center w-2/5`}>
        <input
          type="text"
          name="filter"
          className="w-full rounded-xl px-3 py-2"
          placeholder="Search a message or name"
        />
        <svg
          className="w-4 h-4 fill-current text-gray-400 -ml-8"
          xmlns="http://www.w3.org/2000/svg"
          viewBox="0 0 24 24"
        >
          <path d="M0 0h24v24H0V0z" fill="none" />
          <path d="M15.5 14h-.79l-.28-.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z" />
        </svg>
      </div>
      <button
        type="submit"
        className="rounded-xl bg-yellow-300 font-semibold text-blue-600 px-3 py-2 transition duration-300 ease-in-out hover:bg-yellow-400 hover:-translate-y-1"
      >
        Search
      </button>
      {searchParams.get('filter') && (
        <button
          onClick={clearFilters}
          className="rounded-xl bg-red-300 font-semibold text-blue-600 px-3 py-2 transition duration-300 ease-in-out hover:bg-yellow-400 hover:-translate-y-1"
        >
          Clear Filters
        </button>
      )}
      <div className="flex-1" />
    </form>
  )
}

在上述代码中,inputbutton 被添加进来用以处理文字过滤和搜索参数的提交。

当 URL 中出现 filter 变量时,按钮会变为 “清除过滤”按钮而不是“搜索”按钮。

app/routes/home.tsx 中导入该文件,并替代 {/* Search Bar Goes Here */} 组件。

// app/routes/home.tsx
// ...

+ import { SearchBar } from '~/components/search-bar'


// ...

export default function Home() {
    const { users, kudos } = useLoaderData()
    return (
        <Layout>
            <Outlet />
            <div className="h-full flex">
                <UserPanel users={users} />
                <div className="flex-1 flex flex-col">
-                   {/* Search Bar Goes Here */}
+                   <SearchBar />
                    {/* ... */}
                </div>
            </div>
        </Layout>
    )
}

search-bar.png

这些变化将处理筛选提要,但是,你还想按各个列对提要进行排序。

app/utils/constants.ts 中添加 sortOptions 常量,用以定义列。

// app/utils/constants.ts
// ...
export const sortOptions = [
  {
    name: 'Date',
    value: 'date',
  },
  {
    name: 'Sender Name',
    value: 'sender',
  },
  {
    name: 'Emoji',
    value: 'emoji',
  },
]

现在,在 app/components/search-bar.tsx 文件中导入该常量和 SelectBox 组件,在 button 元素之前用那些选项渲染 SelectBox

// app/components/search-bar.tsx
  import { useNavigate, useSearchParams } from "@remix-run/react"
+ import { SelectBox } from "./select-box"
+ import { sortOptions } from "~/utils/constants"

 export function SearchBar() {
   // ...

    const clearFilters = () => {
         searchParams.delete('filter')
+        searchParams.delete('sort')
         navigate('/home')
    }

    return (
        <form className="w-full px-6 flex items-center gap-x-4 border-b-4 border-b-blue-900 border-opacity-30 h-20">
             {/* ... */}
+            <SelectBox
+                className="w-full rounded-xl px-3 py-2 text-gray-400"
+                containerClassName='w-40'
+                name="sort"
+                options={sortOptions}
+            />
            {/* <button ... > */}
        </form>
    )
}

现在你应该在搜索栏看到一个带有选项的下拉框。

search-bar-sort.png

构建搜索栏行为

当搜索表单被提交时,将使用 URL 中传递的过滤和排序数据向 /home 发起 GET 请求。在 app/routes/home.tsx 中导出的 loader 函数中,从 URL 中提取 sortfilter 数据并使用结果构建查询:

// app/routes/home.tsx
// ...

import { 
  Kudo as IKudo, 
  Profile, 
+  Prisma 
} from '@prisma/client'

export const loader: LoaderFunction = async ({ request }) => {
  // ...

  // 1
+  const url = new URL(request.url)
+  const sort = url.searchParams.get('sort')
+  const filter = url.searchParams.get('filter')

  // 2
+  let sortOptions: Prisma.KudoOrderByWithRelationInput = {}
+  if (sort) {
+    if (sort === 'date') {
+      sortOptions = { createdAt: 'desc' }
+    }
+    if (sort === 'sender') {
+      sortOptions = { author: { profile: { firstName: 'asc' } } }
+    }
+    if (sort === 'emoji') {
+      sortOptions = { style: { emoji: 'asc' } }
+    }
+  }

  // 3
+  let textFilter: Prisma.KudoWhereInput = {}
+  if (filter) {
+    textFilter = {
+      OR: [
+        { message: { mode: 'insensitive', contains: filter } },
+        {
+          author: {
+            OR: [
+              { profile: { is: { firstName: { mode: 'insensitive', contains: filter } } } },
+              { profile: { is: { lastName: { mode: 'insensitive', contains: filter } } } },
+            ],
+          },
+        },
+      ],
+    }
+  }
+
  // 4
-  const kudos = await getFilteredKudos(userId, {}, {})
+  const kudos = await getFilteredKudos(userId, sortOptions, textFilter)
  return json({ users, kudos })
}

// ...

以上代码:

  1. 提取出 URL 参数。
  2. 构建一个 sortOptions 对象以传递给你的 Prisma 查询,该对象可能会因 URL 中传递的数据而异。
  3. 构建一个 textFilter 对象以传递给你的 Prisma 查询,该对象可能会因 URL 中传递的数据而异。
  4. 更新 getFilteredKudos 引用以包含新的过滤器。

现在,如果你提交表单,你应该看到结果已经被反映到提要!

AnimatedImage.gif

展示最近的 kudo

你的提要中要做的最后一件事就是需要有一种途径来展示最近发送的 kudo。此组件将为最近的三个 kudo 接收者展示 UserCircle 组件。

使用以下代码在 app/components 中创建一个名为 recent-bar.tsx 的新文件:

// app/components/recent-bar.tsx

import { User, Kudo } from '@prisma/client'
import { UserCircle } from './user-circle'
import { emojiMap } from '~/utils/constants'

interface KudoWithRecipient extends Kudo {
  recipient: User
}

export function RecentBar({ kudos }: { kudos: KudoWithRecipient[] }) {
  return (
    <div className="w-1/5 border-l-4 border-l-yellow-300 flex flex-col items-center">
      <h2 className="text-xl text-yellow-300 font-semibold my-6">Recent Kudos</h2>
      <div className="h-full flex flex-col gap-y-10 mt-10">
        {kudos.map(kudo => (
          <div className="h-24 w-24 relative" key={kudo.recipient.id}>
            <UserCircle profile={kudo.recipient.profile} className="w-20 h-20" />
            <div className="h-8 w-8 text-3xl bottom-2 right-4 rounded-full absolute flex justify-center items-center">
              {emojiMap[kudo?.style?.emoji || 'THUMBSUP']}
            </div>
          </div>
        ))}
      </div>
    </div>
  )
}

此组件接收一个包含三个最近接收的 kudo 列表,并将它们渲染在面板中。

现在,你需要编写一个查询来获取这个数据。在 app/utils/kudos.server.ts 中添加一个名为 getRecentKudos 的函数,返回以下查询:

// app/utils/kudos.server.ts

// ...

export const getRecentKudos = async () => {
  return await prisma.kudo.findMany({
    take: 3,
    orderBy: {
      createdAt: 'desc',
    },
    select: {
      style: {
        select: {
          emoji: true,
        },
      },
      recipient: {
        select: {
          id: true,
          profile: true,
        },
      },
    },
  })
}

该查询:

  1. createdAt 降序给结果排序,以获取从最新到最老的记录。
  2. 仅从该列表中获取前三个最近的文档。

现在你将需要:

  • RecentBar 组件和 getRecentKuods 函数导入到 app/routes/home.tsx 文件中。
  • 在该文件的 loader 函数中调用 getRecentKusos
  • RecentBar 组件渲染到主页上,替代 {/* Recent Kudos Goes Here */} 注释。
// app/routes/home.tsx

// ...

+ import { RecentBar } from '~/components/recent-bar'
import { 
  getFilteredKudos, 
+  getRecentKudos 
} from '~/utils/kudos.server'

export const loader: LoaderFunction = async ({ request }) => {
  // ...
+  const recentKudos = await getRecentKudos()
-  return json({ users, kudos })
+  return json({ users, kudos, recentKudos })
}

export default function Home() {
  const { 
    users, 
    kudos, 
+    recentKudos 
  } = useLoaderData()

  return (
    {/* ... */}
-   {/* Recent Kudos Goes Here */}
+   <RecentBar kudos={recentKudos} />
    {/* ... */}
  )
}

有了这些,你的主页就已经完成了,你应该在应用里看到一个包含最近发送的三条 kudo 的列表!

recent-kudos.png

总结 & 下一步

在本文中你构建了此应用的主体部分功能,并在此过程中学到了很多概念,包括:

  • 在 Remix 中重定向
  • 使用资源路由
  • 用 Prisma Client 进行筛选和排序
  • 在 Prisma schema 中使用嵌入文档
  • … 还有更多!

在本系列的下一节中,你将通过构建站点的个人信息设置部分并创建图片上传组件来管理个人头像,完成此应用程序。

原文标题:Build A Fullstack App with Remix, Prisma & MongoDB: CRUD, Filtering & Sorting

原文作者:Sabin Adams

发布时间:2022年4月27日

原文连接:www.prisma.io/blog/fullst…