Remix + Prisma + MongoDB 全栈应用开发(四):参照完整性 & 图片上传

369 阅读19分钟

欢迎来到本系列教程的第四篇文章,在本系列中你可以学到如何使用 MongoDB Prisma 和 Remix 从零开始构建一个全栈应用!在本节中,将会构建出应用程序的个人资料设置部分,包括图片上传组件,并在配置 schema 以在你的数据中提供参照完整性。

fullstack-mongodb-4.png

介绍

在本系列的上一节中,你已经构建了应用程序的主体部分,包括 kudo 提要、用户列表、最近的 kudo 列表及 kudo 发送表单。

在这一部分中,你将通过构建一种让用户更新个人信息和上传个人头像的方法来结束这个应用的开发。

你也将对 schema 做一些修改,这样才能为你的数据库提供参照完整性。

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

开发环境

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

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

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

构建个人信息设置模态

你的应用程序中个人资料设置页将被展示在一个模态中,该模态通过点击页面右上角的个人资料设置按钮进入。

app/componenets/search-bar.tsx 中:

  • 在导出的名为 profile 的组件上添加一个新的属性,该属性是由通过 Prisma 生成的 Profile 类型。
  • 导入 UserCircle 组件。
  • form 内容的最后渲染 UserCircle 组件,并将新的 profile 属性数据传递给它。这将充当你的个人资料设置按钮。
// app/components/search-bar.tsx
// ...
+ import { UserCircle } from "./user-circle"
+ import type { Profile } from "@prisma/client"


+ interface props {
+    profile: Profile
+ }


- export function SearchBar() {
+ export function SearchBar({ profile }: props) {
   // ...
   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">
         {/* ... */}
+         <UserCircle
+            className="h-14 w-14 transition duration-300 ease-in-out hover:scale-110 hover:border-2 hover:border-yellow-300"
+            profile={profile}
+         />
      </form>
   )
}

如果你的开发服务已经运行,这将会导致你的主页抛出一个错误,SearchBar 组件现在缺少个人资料数据。

app/routes/home.tsx 文件中,使用在本系列第二节中的 app/utils/auth.server.ts 中编写的 getUser 函数。使用该函数在 loader 函数中加载登录用户的数据。然后把数据提供给 SearchBar 组件。

// app/routes/home.tsx
// ...
import { 
  requireUserId, 
+  getUser 
} from '~/utils/auth.server'

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

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

SearchBar 现在已经可以接收到它所需要的 profile 数据了。如果你之前因缺少此数据而收到错误,那么在浏览器中重新刷新页面,在页面的右上角就应该成功显示出个人资料设置按钮了。

usercircle-header.png

创建模态

我们的目标是当个人资料按设置按钮被点击时,打开个人资料设置模态。和本系列的前一章节中构建的 kudo 模态相似,你将需要设置一个嵌套路由,在此路由你将渲染一个新模态。

app/routes/home 中添加一个名为 profile.tsx 的新文件,其中包含以下内容来启动它:

// app/routes/home/profile.tsx

import { json, LoaderFunction } from "@remix-run/node"
import { useLoaderData } from "@remix-run/react"
import { Modal } from "~/components/modal";
import { getUser } from "~/utils/auth.server";

export const loader: LoaderFunction = async ({ request }) => {
    const user = await getUser(request)
    return json({ user })
}

export default function ProfileSettings() {
    const { user } = useLoaderData()

    return (
        <Modal isOpen={true} className="w-1/3">
            <div className="p-3">
                <h2 className="text-4xl font-semibold text-blue-600 text-center mb-4">Your Profile</h2>
            </div>
        </Modal>
    )
}

以上的代码片段。。。

  • ...在新的 ProfileSettings 组件中渲染一个模态。
  • …在 loader 函数里检索并返回登录用户的数据。
  • …使用 useLoaderData 钩子来接收从 loader 函数中返回的 user 数据。

为打开一个新模态,在 app/components/search-bar.tsx 中将 onClick 处理器添加到 UserCircle 组件中,该组件使用 Remix 的 useNavigate 钩子方法将用户导航到 /home/profile 子路由上。

// app/components/search-bar.tsx

// ...
<UserCircle
   className="h-14 w-14 transition duration-300 ease-in-out hover:scale-110 hover:border-2 hover:border-yellow-300"
   profile={profile}
+   onClick={() => navigate('profile')}
/>
// ...

如果你现在点击个人资料设置按钮,你应该看到屏幕上弹出一个新模态。

empty-profile-form.png

构建表单

你将构建一个有三个字段的表单,此表单允许用户去更改他们的个人详细资料:名字、姓和部门。

通过添加姓名输入框来开始构建表单:

// app/routes/home/profile.tsx
// ...
// 1
+ import { useState } from "react";
+ import { FormField } from '~/components/form-field'


// loader ...


export default function ProfileSettings() {
   const { user } = useLoaderData()


   // 2
+   const [formData, setFormData] = useState({
+      firstName: user?.profile?.firstName,
+      lastName: user?.profile?.lastName,
+   })


   // 3
+   const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>, field: string) => {
+      setFormData(form => ({ ...form, [field]: event.target.value }))
+   }


   // 4
   return (
      <Modal isOpen={true} className="w-1/3">
         <div className="p-3">
          <h2 className="text-4xl font-semibold text-blue-600 text-center mb-4">Your Profile</h2>
+         <div className="flex">
+           <div className="flex-1">
+             <form method="post">
+               <FormField htmlFor="firstName" label="First Name" value={formData.firstName} onChange={e => handleInputChange(e, 'firstName')} />
+               <FormField htmlFor="lastName" label="Last Name" value={formData.lastName} onChange={e => handleInputChange(e, 'lastName')} />
+               <div className="w-full text-right mt-4">
+                 <button className="rounded-xl bg-yellow-300 font-semibold text-blue-600 px-16 py-2 transition duration-300 ease-in-out hover:bg-yellow-400 hover:-translate-y-1">
+                   Save
+                  </button>
+               </div>
+            </form>
+         </div>
+       </div>
      </div>
    </Modal>
   )
}

以下是上面添加的代码的概述:

  1. 在所做的更改中添加了所需的导入。
  2. 在状态中创建一个 formData 对象来保存表单的值。用已登录用户的现有个人资料数据作为这些值的默认值。
  3. 创建一个函数,该函数接收HTML change 事件和字段名称作为参数。当输入框中的值变化时,这些参数将用于更新组件中的 formData 状态。
  4. 渲染表单的基础布局以及两个输入框。

这时,错误处理程序还没有到位并且表单什么都不做。在你添加这些部分内容前,你还需要添加一个部门下拉框。

app/utils/constants.ts 中添加一个新的 department 常量来保存你在 Prisma schema 中定义的可能的选项。添加以下导出代码到你的文件中:

// app/utils/constants.ts
// ...
export const departments = [
  { name: "HR", value: "HR" },
  { name: "Engineering", value: "ENGINEERING" },
  { name: "Sales", value: "SALES" },
  { name: "Marketing", value: "MARKETING" },
];

将部门常量和 SelectBox 组件一起导入到你的 app/routes/home/profile.tsx 文件中,并使用它们向表单添加新的输入框:

// app/routes/home/profile.tsx
// ...
+ import { departments } from "~/utils/constants";
+ import { SelectBox } from "~/components/select-box";

// ...
export default function ProfileSettings() {
   // ...
   const [formData, setFormData] = useState({
      firstName: user?.profile?.firstName,
      lastName: user?.profile?.lastName,
+     department: (user?.profile?.department || 'MARKETING'),
   })
   // ...

   return (
      {/* ... */}
      <form method="post">
         {/* ... */}
+        <SelectBox 
+            className="w-full rounded-xl px-3 py-2 text-gray-400" 
+            id="department" 
+            label="Department" 
+            name="department" 
+            options={departments} 
+            value={formData.department} 
+            onChange={e => handleInputChange(e, 'department')} 
+         />
         {/* Save button div */}
      </form>
      {/* ... */}
   )
}

此时,你的表单应该正确渲染输入框和它们的选项了。输入框的默认值是与当前登录用户的个人资料中对应的当前值。

profile-form-fields.png

允许用户提交表单

下一部分你将构建的是 action 函数,这将使表单真正发挥作用。

app/routes/home/profile.tsx 文件中,添加一个 action 函数从 request 对象中获取表单数据并验证 firstNamelastNamedepartment 字段:

// app/routes/home/profile.tsx
// ...
import { validateName } from "~/utils/validators.server";
// Added the ActionFunction and redirect imports 👇
import { LoaderFunction, ActionFunction, redirect, json } from "@remix-run/node";

export const action: ActionFunction = async ({ request }) => {
   const form = await request.formData();
   // 1
   let firstName = form.get('firstName')
   let lastName = form.get('lastName')
   let department = form.get('department')

   // 2
   if (
      typeof firstName !== 'string'
      || typeof lastName !== 'string'
      || typeof department !== 'string'
   ) {
      return json({ error: `Invalid Form Data` }, { status: 400 });
   }

   // 3
   const errors = {
      firstName: validateName(firstName),
      lastName: validateName(lastName),
      department: validateName(department)
   }

   if (Object.values(errors).some(Boolean))
      return json({ errors, fields: { department, firstName, lastName } }, { status: 400 });

   // Update the user here...

   // 4
   return redirect('/home')
}
// ...

以上的 action 函数执行以下操作:

  1. request 对象中提取你需要的表单数据点。
  2. 确保你关心的数据的每一部分都是 string 数据类型。
  3. 使用之前编写的 validateName 函数验证数据。
  4. 重定向到 /home,关闭设置模态。

当各种验证失败时,上述代码片段也会引发相关错误。为了使用经过验证的数据,编写一个允许你更新用户的函数。

app/utils/user.server.ts 中,导出以下的函数:

// app/utils/user.server.ts
import { Profile } from "@prisma/client";
// ...
export const updateUser = async (userId: string, profile: Partial<Profile>) => {
  await prisma.user.update({
    where: {
      id: userId,
    },
    data: {
      profile: {
        update: profile,
      },
    },
  });
};

此函数允许你传递任何 profile 数据,并更新自身 id 与提供的 userId 相匹配的用户。

回到 app/routes/home/profile.tsx 文件中,导入这个新函数并在 action 函数中使用它更新已登陆用户:

// app/routes/home/profile.tsx
import { 
  getUser, 
+  requireUserId 
} from "~/utils/auth.server";
import { updateUser } from "~/utils/user.server";
import type { Department } from "@prisma/client";

// ...
export const action: ActionFunction = async ({ request }) => {
+  const userId = await requireUserId(request);


  // ...
   
+  await updateUser(userId, {
+    firstName,
+    lastName,
+    department: department as Department
+  })

  return redirect('/home')
}
// ...

现在,当一个用户点击保存按钮,他们更新的个人资料数据将会被保存,模态也将被关闭。

添加图片上传组件

设置 AWS 账号

用户现在已经可以更新他们的个人资料中的一些关键信息了,但是还有一件更棒的事就是允许用户设置个人头像,这样用户就能更容易识别他们。

要做到这些,你将设置一个 AWS S3 文件存储桶来保存这些上传的图片。如果你还没有 AWS 账号,你可以在这里注册一个。

注意:Amazon 提供了一个免费套餐,让你免费访问 S3。

创建一个 IAM 用户

一旦你有了账号,你将需要在 AWS 中设置身份访问管理(IAM)用户,以便生成访问密钥 ID 和密钥,这两者都是与 S3 交互所必需的。

注意:如果你已经有了 IAM 用户和它们的密钥,可以跳过这一步。

前往 AWS 控制台主页。在页面右上角,点击带有你的用户名标签的下拉列表并选择 安全凭证

security-credentials.png

一旦进入该部分,点击左侧菜单栏 Access Management(访问管理) 中的 Users(用户) 选项。

aws-user.png

在这一页,点击右上角的 Add users 按钮。

aws-add-user.png

这将引导你完成一个简短的向导,允许你配置你的用户。请按照以下步骤操作:

aws-details.png

第一部分要求:

  1. 用户名:提供任何用户名。
  2. 选择 AWS 访问类型:选择 Access key - Programmatic access 选项,它可以生成访问访问密钥 ID 和密钥。

aws-permissions.png

在向导的第二步中,进行以下选择:

  1. 选择 “Attach existing policies directly” 选项。
  2. 搜索术语 “S3”。
  3. 点击标记为 AmazonS3FullAccess 的选项旁边的复选标记。
  4. 点击表单底部的下一步。

aws-tags.png

如果你想给你的用户添加标签,帮助你更轻松地管理和组织你账号中的用户,请在向导的第三步中的此处添加。完成此页面后,点击下一步

aws-overview.png

如果页面上的摘要看起来没问题,那么点击页面底部的 Create user(创建用户)按钮。

点击此按钮后,你将来到一个带有你的访问密钥 ID 和密钥的页面。复制这些并将它们保存到你能轻松访问的地方,因为你很快就会使用它们。

设置 S3 桶

现在,你已经有了用户和访问密钥,前往 AWS S3 仪表盘,在这你将设置文件存储的桶。

在页面右上角,点击 Create bucket(创建桶) 按钮。

aws-s3.png

系统将要求你输入存储桶的名称和区域。填写这些细节并使用你之前保存的访问密钥 ID 和密钥保存你选择的值。稍后你也将需要这些。

完成这些填写后,点击表单最下面的 Create bucket(创建桶)。

当存储桶被创建好时,你将会被发送到 Objects 选项卡上的桶仪表盘页面。切换到 Permissions 选项卡。

aws-bucket-permissions.png

在这个选项卡,点击 **Block public access(阻止公共访问)**部分的 Edit(编辑) 按钮。在这个表单中,取消 Block all public access(阻止所有公共访问) 复选框并点击 Save changes(保存)。这将把你的桶设置为公开,即允许你的应用程序访问这些图片。

aws-bucket-access.png

在这部分内容下面,你将看到一个 Bucket policy(桶策略) 部分。粘贴以下策略,并确保用你自己的桶名替换 <bucket-name>。该策略将允许你的图片被公开访问:

{
  "Version": "2008-10-17",
  "Statement": [
    {
      "Sid": "AllowPublicRead",
      "Effect": "Allow",
      "Principal": {
        "AWS": "*"
      },
      "Action": "s3:GetObject",
      "Resource": "arn:aws:s3:::<bucket-name>/*"
    }
  ]
}

aws-bucket-policy.png

现在,你已经设置好了 AWS 用户和 S3 存储桶。接下来,你需要在 .env 文件中保存密钥和桶配置,因为紧接着你就要用到它们了。

// .env
# ...
KUDOS_ACCESS_KEY_ID="<access key ID>"
KUDOS_SECRET_ACCESS_KEY="<secret key>"
KUDOS_BUCKET_NAME="<s3 bucket name>"
KUDOS_BUCKET_REGION="<s3 bucket region>"

更新 Prisma schema

现在你将在数据库中创建一个字段,来存储已上传图片的链接。它将被存储在 Profile 嵌入文档中,所以在你的 Profile 类型块中添加一个新字段。

要用这些更改来更新 Prisma Client,运行 npx prisma generate

构建图片上传组件

app/components 中,使用以下内容创建一个名为 image-uploader.tsx 的组件:

// app/components/image-uploader.tsx

import React, { useRef, useState } from "react";

interface props {
    onChange: (file: File) => any,
    imageUrl?: string
}

export const ImageUploader = ({ onChange, imageUrl }: props) => {
    const [draggingOver, setDraggingOver] = useState(false)
    const fileInputRef = useRef<HTMLInputElement | null>(null)
    const dropRef = useRef(null)

    // 1
    const preventDefaults = (e: React.DragEvent<HTMLDivElement>) => {
        e.preventDefault()
        e.stopPropagation()
    }

    // 2
    const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
        preventDefaults(e)
        if (e.dataTransfer.files && e.dataTransfer.files[0]) {
            onChange(e.dataTransfer.files[0])
            e.dataTransfer.clearData()
        }
    }

    // 3
    const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
        if (event.currentTarget.files && event.currentTarget.files[0]) {
            onChange(event.currentTarget.files[0])
        }
    }

    // 4
    return (
        <div ref={dropRef}
            className={`${draggingOver ? 'border-4 border-dashed border-yellow-300 border-rounded' : ''} group rounded-full relative w-24 h-24 flex justify-center items-center bg-gray-400 transition duration-300 ease-in-out hover:bg-gray-500 cursor-pointer`}
            style={{
                backgroundSize: "cover",
                ...(imageUrl ? { backgroundImage: `url(${imageUrl})` } : {}),
            }}
            onDragEnter={() => setDraggingOver(true)}
            onDragLeave={() => setDraggingOver(false)}
            onDrag={preventDefaults}
            onDragStart={preventDefaults}
            onDragEnd={preventDefaults}
            onDragOver={preventDefaults}
            onDrop={handleDrop}
            onClick={() => fileInputRef.current?.click()}
        >
            {
                imageUrl &&
                <div className="absolute w-full h-full bg-blue-400 opacity-50 rounded-full transition duration-300 ease-in-out group-hover:opacity-0" />
            }
            {
                <p className="font-extrabold text-4xl text-gray-200 cursor-pointer select-none transition duration-300 ease-in-out group-hover:opacity-0 pointer-events-none z-10">+</p>
            }
            <input type="file" ref={fileInputRef} onChange={handleChange} className="hidden" />
        </div>
    )
}

以上代码片段就是完整的图片上传组件。以下就是正在发生的事情的概述:

  1. 在组件中定义一个 preventDefault 函数来处理文件输入框的变化。
  2. 在组件中定义一个 handleDrop 函数来处理文件输入框上的 drop 事件。
  3. 在组件中定义一个 handleChange 函数来处理文件输入框上的任何 change 事件。
  4. 渲染一个带有定义好的各种事件处器的 div,允许它对文件放置、拖拽事件和点击做出响应。当且仅当 div 元素上接收拖动事件时,这些处理器才被用于触发图片上传及样式变化。

每当组件中的 input 值发生变化时,props 中的 onChange 函数会被调用,并传递文件数据。该数据将被上传到 S3。

接下来,我们将要创建处理图片上传的服务。

构建图片上传服务

要构建图片上传服务,你将需要安装两个新的 npm 包:

  • asw-sdk:暴露一个 JavaScript API,允许你和 AWS 服务交互。
  • cuid:一个用于生成唯一 id 的工具。你将用它来生成随机的文件名。
npm i aws-sdk cuid

图片上传服务将放在一个新的实用程序文件中。在 app/utils 中创建一个名为 s3.server.ts 的文件。

为了处理上传,你将利用 Remix 的 unstable_parseMultipartFormData 函数,来处理请求对象中的 multipart/form-data 值。

提示:multipart/form-data 是在表单中发布整个文件时的数据类型。

unstable_parseMultipartFormData 将接收两个参数:

  1. 从表单提交中获取的 request 对象。
  2. uploadHandler 函数,流式传输文件数据并处理上传。

提示:unstable_parseMultipartFormData 函数的用法和我们之前使用过的 Remix request.formData 函数相似。

在新创建的文件中添加以下函数和导入项:

// app/utils/s3.server.ts
import {
  unstable_parseMultipartFormData,
  UploadHandler,
} from "@remix-run/node";
import S3 from "aws-sdk/clients/s3";
import cuid from "cuid";

// 1
const s3 = new S3({
  region: process.env.KUDOS_BUCKET_REGION,
  accessKeyId: process.env.KUDOS_ACCESS_KEY_ID,
  secretAccessKey: process.env.KUDOS_SECRET_ACCESS_KEY,
});

const uploadHandler: UploadHandler = async ({ name, filename, stream }) => {
  // 2
  if (name !== "profile-pic") {
    stream.resume();
    return;
  }

  // 3
  const { Location } = await s3
    .upload({
      Bucket: process.env.KUDOS_BUCKET_NAME || "",
      Key: `${cuid()}.${filename.split(".").slice(-1)}`,
      Body: stream,
    })
    .promise();

  // 4
  return Location;
};

这些代码设置好了 S3 API,所以你可以和储存桶进行交互。它还添加了 uploadHandler 函数。该函数:

  1. 使用你在设置 AWS 用户和 S3 存储桶时保存的环境变量来设置 S3 SDK。
  2. 只要数据键的名称是 ‘profile-pic’,就从 request 中流式传输文件数据。
  3. 上传文件到 S3。
  4. 返回 S3 下发的 Location 数据,该数据包含了新文件在 S3 上的 URL 地址。

现在,uploadHandler 完成了,添加另外一个函数,该函数实际接受 request 对象并将其与 uploadHandler 一起传递给 unstable_parseMultipartFormData 函数。

// app/utils/s3.server.ts
// ...
export async function uploadAvatar(request: Request) {
  const formData = await unstable_parseMultipartFormData(
    request,
    uploadHandler
  );

  const file = formData.get("profile-pic")?.toString() || "";

  return file;
}

该函数被传递了一个 request 对象,该对象将在稍后从 action 函数发送过来。

文件数据通过 uploadHandler 函数传递,该函数处理上传到 S3,formData 将新文件的地址返回到表单数据对象内。随后该函数把 ‘profile-pic’ URL 被从表单中提取并返回。

使用组件和服务

现在,实现个人头像图片上传所需的两部分工作已经完成,将它们结合起来。

通过在 app/routes 中用以下 action 函数创建一个名为 avatar.ts 的新文件,来添加一个资源路由:

// app/routes/avatar.tsx
import { ActionFunction, json } from "@remix-run/node";
import { requireUserId } from "~/utils/auth.server";
import { uploadAvatar } from "~/utils/s3.server";
import { prisma } from "~/utils/prisma.server";

export const action: ActionFunction = async ({ request }) => {
  // 1
  const userId = await requireUserId(request);
  // 2
  const imageUrl = await uploadAvatar(request);

  // 3
  await prisma.user.update({
    data: {
      profile: {
        update: {
          profilePicture: imageUrl,
        },
      },
    },
    where: {
      id: userId,
    },
  });

  // 4
  return json({ imageUrl });
};

以上函数执行了这些步骤以处理上传表单:

  1. 提取请求中的用户 id。
  2. 上传在请求中传过去的文件。
  3. 使用新的 profilePicture URL 来更新当前请求用户的个人资料数据。
  4. 使用 imageUrl 变量响应 POST 请求。

现在你可以用 ImageUploader 组件来处理文件上传并发送文件数据到这个新的 /avatar 路由。

app/routes/home/profile.tsx 中,导入 ImageUploader 组件并把它添加到表单中输入框的左边。

还要添加一个新的函数来处理由 Image Uploader 组件发出的 onChange 事件,并在 formData 变量中添加一个新字段来保存个人头像数据。

// app/routes/home/profile.tsx
// ...
+ import { ImageUploader } from '~/components/image-uploader'
// ...
export default function ProfileSettings() {
   // ...
   const [formData, setFormData] = useState({
      firstName: user?.profile?.firstName,
      lastName: user?.profile?.lastName,
      department: (user?.profile?.department || 'MARKETING'),
+      profilePicture: user?.profile?.profilePicture || ''
   })

+   const handleFileUpload = async (file: File) => {
+      let inputFormData = new FormData()
+      inputFormData.append('profile-pic', file)
+
+      const response = await fetch('/avatar', {
+         method: 'POST',
+         body: inputFormData
+      })
+      const { imageUrl } = await response.json()
+
+      setFormData({
+         ...formData,
+         profilePicture: imageUrl
+      })
+    }

   // ...
   
   return (
      <Modal>
         <div className="p-3">
            <h2 className="text-4xl font-semibold text-blue-600 text-center mb-4">Your Profile</h2>
            <div className="flex">
+               <div className="w-1/3">
+                  <ImageUploader onChange={handleFileUpload} imageUrl={formData.profilePicture || ''}/>
+               </div>
               {/* ... */}
            </div>
         </div>
      </Modal>
   )
}

现在,如果你打开这个表单并尝试上传一个文件,数据应该被正确保存在 S3、数据库及你的表单状态中。

Upload

AnimatedImage.gif

Image Preview

upload-result-page.png

Database

upload-result-database.png

AWS S3

upload-result-s3.png

显示个人头像

很好!图片上传顺利,现在,你只需要在网站上用户圆框出现的任何地方显示这些图片即可。

打开 app/components/user-circle.tsx 中的 UserCircle 组件,并进行这些修改来设置圆框的背景图片为个人头像,如果有的话:

// 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}
+            style={{
+                backgroundSize: "cover",
+                ...(profile.profilePicture ? { backgroundImage: `url(${profile.profilePicture})` } : {}),
+            }}
        >
-        <h2>{profile.firstName.charAt(0).toUpperCase()}{profile.lastName.charAt(0).toUpperCase()}</h2>
+        {
+           !profile.profilePicture && (
+              <h2>{profile.firstName.charAt(0).toUpperCase()}{profile.lastName.charAt(0).toUpperCase()}</h2>
+           )
+        }
      </div>
   )
}

如果你现在给一些用户设置头像,你应该在整个网站上看到它们被显示出来了!

user-images.png

添加账户删除函数

个人资料设置模态里所需的最后一个功能是删除账号。

删除数据,尤其是在无模式数据库中,有可能会创建“孤岛文件”,或曾经与父文档关联过但其父文档在某个时候被删除的文档。

你将在本节中针对这种情况采取保护措施。

添加删除按钮

你将以类似于处理登录和注册表单的方法处理此表单。这个表单将发送一个 _action 键,让 action 函数知道它接收到的是什么样的请求。

app/routes/home/profile.tsx 中,对 ProfileSettings 函数返回的 form 执行以下变更。

// app/routes/home/profile.tsx
{/* ... */}
- <form method="post">
+ <form method="post" onSubmit={e => !confirm('Are you sure?') ? e.preventDefault() : true}>

    {/* ... form fields*/}
+    <button name="_action" value="delete" className="rounded-xl w-full bg-red-300 font-semibold text-white mt-4 px-16 py-2 transition duration-300 ease-in-out hover:bg-red-400 hover:-translate-y-1">
+        Delete Account
+    </button>
    <div className="w-full text-right mt-4">
        <button className="rounded-xl bg-yellow-300 font-semibold text-blue-600 px-16 py-2 transition duration-300 ease-in-out hover:bg-yellow-400 hover:-translate-y-1"
+         name="_action" 
+         value="save" 
         >
            Save
        </button>
    </div>
</form>
{/* ... */}

现在,根据点击的按钮,你可以在 action 函数中处理不同的 _action

更新 action 函数,使用 switch 语句来执行不同的操作:

// app/routes/home/profile.tsx
// ...
export const action: ActionFunction = async ({ request }) => {
    const userId = await requireUserId(request);
    const form = await request.formData();

    let firstName = form.get('firstName')
    let lastName = form.get('lastName')
    let department = form.get('department')
+    const action = form.get('_action')


+    switch (action) {
+        case 'save':
            if (
               typeof firstName !== 'string'
               || typeof lastName !== 'string'
               || typeof department !== 'string'
            ) {
               return json({ error: `Invalid Form Data` }, { status: 400 });
            }

            const errors = {
               firstName: validateName(firstName),
               lastName: validateName(lastName),
               department: validateName(department)
            }

            if (Object.values(errors).some(Boolean))
               return json({ errors, fields: { department, firstName, lastName } }, { status: 400 });

            await updateUser(userId, {
               firstName,
               lastName,
               department: department as Department
            })
            return redirect('/home')
+        case 'delete':
+            // Perform delete function
+            break;
+        default:
+            return json({ error: `Invalid Form Data` }, { status: 400 });
+    }
}
// ...

现在,如果用户保存表单,’save’ 条件将被命中并执行相关功能代码。然而,’delete’ 条件当前没有任何作用。

app/utils/user.server.ts 中添加一个新函数,它接受一个 id 并删除与之相关联的用户:

// app/utils/user.server.ts
// ...
export const deleteUser = async (id: string) => {
  await prisma.user.delete({ where: { id } });
};

现在你可以在个人资料页上填写 ‘delete’ 条件的其余部分了。

// app/routes/home/profile.tsx
// ...
// 👇 Added the deleteUser function
import { updateUser, deleteUser } from "~/utils/user.server";
// 👇 Added the logout function
import { getUser, requireUserId, logout } from "~/utils/auth.server";

// ...
export const action: ActionFunction = async ({ request }) => {
   // ...
   switch (action) {
      case 'save':
         // ...
      case 'delete':
         await deleteUser(userId)
         return logout(request)
      default: 
         // ...
   }
}

用户现在可以删除他们的账号了!

AnimatedImage.gif

更新数据模型以添加参照完整性

删除用户功能的唯一问题是当一个用户被删除,他们撰写的所有 kudo 将会成为孤岛。

当 kudo 的作者被删除时,你可以用参照动作来触发 kudo 的删除。

// prisma/schema.prisma
model Kudo {
  id          String     @id @default(auto()) @map("_id") @db.ObjectId
  message     String
  createdAt   DateTime   @default(now())
  style       KudoStyle?
-  author      User       @relation(references: [id], fields: [authorId], "AuthoredKudos")
+  author      User       @relation(references: [id], fields: [authorId], onDelete: Cascade, "AuthoredKudos")
  authorId    String     @db.ObjectId
  recipient   User       @relation(references: [id], fields: [recipientId], "RecievedKudos")
  recipientId String     @db.ObjectId
}

运行 npx prisma db push 来同步这些变更并生成 Prisma Client。

现在,如果你删除一个账号,该账号撰写的任何 kudo 都将和它一起被删除。

referrential-delete.gif

添加表单验证

你已经接近尾声了!最后一块内容是在个人资料设置表单中连接错误消息处理。

你的 action 函数已经返回所有准确的错误信息了;它们只需要被处理即可。

app/routes/home/profile.tsx 中进行以下修改来处理这些错误:

// app/routes/home/profile.tsx
import { 
  useState, 
+  useRef, 
+  useEffect 
} from "react";
// 👇 Added the useActionData hook
import { 
  useLoaderData, 
+  useActionData 
} from "@remix-run/react"
// ...
export default function ProfileSettings() {
    const { user } = useLoaderData()
    
    // 1
+    const actionData = useActionData()
+    const [formError, setFormError] = useState(actionData?.error || '')
+    const firstLoad = useRef(true)

    const [formData, setFormData] = useState({
-        firstName: user?.profile?.firstName,
-        lastName: user?.profile?.lastName,
-        department: (user?.profile?.department || 'MARKETING'),
-        profilePicture: user?.profile?.profilePicture || ''
+        firstName: actionData?.fields?.firstName || user?.profile?.firstName,
+        lastName: actionData?.fields?.lastName || user?.profile?.lastName,
+        department: actionData?.fields?.department || (user?.profile?.department || 'MARKETING'),
+        profilePicture: user?.profile?.profilePicture || ''
    })


+    useEffect(() => {
+        if (!firstLoad.current) {
+            setFormError('')
+        }
+    }, [formData])


+    useEffect(() => {
+        firstLoad.current = false
+    }, [])


    // ...


    return (
        <Modal isOpen={true} className="w-1/3">
            <div className="p-3">
                <h2 className="text-4xl font-semibold text-blue-600 text-center mb-4">Your Profile</h2>
                {/* 2 */}
+                <div className="text-xs font-semibold text-center tracking-wide text-red-500 w-full mb-2">
+                    {formError}
+                </div>
                <div className="flex">
                    <div className="w-1/3">
                        <ImageUploader onChange={handleFileUpload} imageUrl={formData.profilePicture || ''} />
                    </div>
                    <div className="flex-1">
                        {/* 3 */}
                        <form method="post" onSubmit={e => !confirm('Are you sure?') ? e.preventDefault() : true}>
                            <FormField
                                htmlFor="firstName"
                                label="First Name"
                                value={formData.firstName}
                                onChange={e => handleInputChange(e, 'firstName')}
+                                error={actionData?.errors?.firstName}
                            />
                            <FormField
                                htmlFor="lastName"
                                label="Last Name"
                                value={formData.lastName}
                                onChange={e => handleInputChange(e, 'lastName')}
+                                error={actionData?.errors?.lastName}
                            />
                            {/* ... */}
                        </form>
                    </div>
                </div>
            </div>
        </Modal >
    )
}

以下是上述代码片段所进行的修改:

  1. useActionData 钩子被用来接收错误信息。这些信息被保存在状态变量中,当用户提交错误表单后返回到模态时,就用它们来填充表单。
  2. 添加了错误输出以展示任何表单级别的错误。
  3. 错误数据被传给 FormField 组件,允许它们在需要的时候展示字段级别的错误。

在进行上述修改之后,你将在表单里看到错误验证信息。

form-error.png

总结 & 下一步

通过本文所做的变化,你成功完成了 Kudo 应用程序!

该网站的所有部分都可以正常运行并准好发送给你的用户。

在这一节中你学到了:

  • Remix 嵌套路由
  • AWS S3
  • Prisma 和 MongoDB 的参照动作和完整性

在本系列的下一节中,你将通过构建应用并将其部署到 Vercel 上来完成所有工作!

原文标题:Build A Fullstack App with Remix, Prisma & MongoDB: Referential Integrity & Image Uploads

原文作者:Sabin Adams

发布时间:2022年4月28日

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