Creating articles
With our database schema and security in place, letโs move on to creating articles.
The articles route
Section titled โThe articles routeโIf you arenโt already, log in to the app as a user, or create a dummy user following the steps we covered previously.
Once logged in, access your userโs site profile via the user actions dropdown in the top right corner of the screen:
This is the user profile page:
Once on your userโs profile page, letโs quickly add a button link to the โArticlesโ page.
Add an articles page link
Section titled โAdd an articles page linkโ-
Open the file at
app/routes/users+/$username.tsx
. -
Find the
ProfileRoute
component (around line 31), and add the following code to it, looking carefully at where it should be placed (just before the โMy Notesโ button).Feel free to expand the collapsed code sections to see the full component:
app/routes/users+/$username.tsx export default function ProfileRoute() {41 collapsed linesconst data = useLoaderData<typeof loader>()const user = data.userconst userDisplayName = user.name ?? user.usernameconst loggedInUser = useOptionalUser()const isLoggedInUser = data.user.id === loggedInUser?.idreturn (<div className="container mb-48 mt-36 flex flex-col items-center justify-center"><Spacer size="4xs" /><div className="container flex flex-col items-center rounded-3xl bg-muted p-12"><div className="relative w-52"><div className="absolute -top-40"><div className="relative"><imgsrc={getUserImgSrc(data.user.image?.id)}alt={userDisplayName}className="h-52 w-52 rounded-full object-cover"/></div></div></div><Spacer size="sm" /><div className="flex flex-col items-center"><div className="flex flex-wrap items-center justify-center gap-4"><h1 className="text-center text-h2">{userDisplayName}</h1></div><p className="mt-2 text-center text-muted-foreground">Joined {data.userJoinedDisplay}</p>{isLoggedInUser ? (<Form action="/logout" method="POST" className="mt-3"><Button type="submit" variant="link" size="pill"><Icon name="exit" className="scale-125 max-md:scale-150">Logout</Icon></Button></Form>) : null}<div className="mt-10 flex gap-4">{isLoggedInUser ? (<><Button asChild><Link to="articles" prefetch="intent">My articles</Link></Button><Button asChild><Link to="notes" prefetch="intent">My notes</Link></Button><Button asChild><Link to="/settings/profile" prefetch="intent">Edit profile</Link></Button>13 collapsed lines</>) : (<Button asChild><Link to="notes" prefetch="intent">{userDisplayName}'s notes</Link></Button>)}</div></div></div></div>)} -
Save your changes, and head back to the browser. You should see the new button link to the โArticlesโ page:
Creating a new article
Section titled โCreating a new articleโClick on this new button to navigate to the articles page.
Once here, click the + New Article
button and fill in the form to create a new article:
Click the โSubmitโ button when finished, and your article will be created.
Add an article category
Section titled โAdd an article categoryโThis is great, but we can make it even better by adding a category to our next article.
To do this, we will need to make some updates to the code.
Loading categories from the database
Section titled โLoading categories from the databaseโIf we are to edit an existing article, we will need to load the existing category
value for the said article from the database.
This is defined in app/routes/users+/$username_+/articles.$articleId.tsx
, where the loader
function makes a call to the database to retrieve the article data.
-
Open
app/routes/users+/$username_+/articles.$articleId.tsx
. -
At the moment, the
article
being loaded does not contain anycategory
data. Letโs update this.Adding the code below will start to make the code editor show red lines, indicating TypeScript errors.
Donโt worry about this for now. We will fix it later.
app/routes/users+/$username_+/articles.$articleId.tsx 30 collapsed linesimport { getFormProps, useForm } from '@conform-to/react'import { parseWithZod } from '@conform-to/zod'import { invariantResponse } from '@epic-web/invariant'import {json,type LoaderFunctionArgs,type ActionFunctionArgs,} from '@remix-run/node'import {Form,Link,useActionData,useLoaderData,type MetaFunction,} from '@remix-run/react'import { formatDistanceToNow } from 'date-fns'import { z } from 'zod'import { GeneralErrorBoundary } from '~/components/ErrorBoundary.js'import { floatingToolbarClassName } from '~/components/floating-toolbar.tsx'import { ErrorList } from '~/components/forms.tsx'import { Button } from '~/components/ui/button.tsx'import { Icon } from '~/components/ui/icon.tsx'import { StatusButton } from '~/components/ui/status-button.tsx'import { requireUserId } from '~/utils/auth.server.ts'import { prisma } from '~/utils/db.server.ts'import { getArticleImgSrc, useIsPending } from '~/utils/misc.tsx'import { requireUserWithPermission } from '~/utils/permissions.server.ts'import { redirectWithToast } from '~/utils/toast.server.ts'import { userHasPermission, useOptionalUser } from '~/utils/user.ts'import { type loader as articlesLoader } from './articles.tsx'export async function loader({ params }: LoaderFunctionArgs) {const article = await prisma.article.findUnique({where: { id: params.articleId },select: {id: true,title: true,content: true,ownerId: true,updatedAt: true,category: {select: {id: true,name: true,},},images: {select: {id: true,altText: true,},},},})9 collapsed linesinvariantResponse(article, 'Not found', { status: 404 })const date = new Date(article.updatedAt)const timeAgo = formatDistanceToNow(date)return json({article,timeAgo,})}
Display the category
name
Section titled โDisplay the category nameโWith this in place, we can now display the category
name in the browser.
-
Navigate down to the
ArticleRoute
component in the same file (around line 120), and add the JSX highlighted in green just below theh2
element:app/routes/users+/$username_+/articles.$articleId.tsx export default function ArticleRoute() {const data = useLoaderData<typeof loader>()const user = useOptionalUser()const isOwner = user?.id === data.article.ownerIdconst canDelete = userHasPermission(user,isOwner ? `delete:article:own` : `delete:article:any`,)const displayBar = canDelete || isOwnerreturn (<div className="absolute inset-0 flex flex-col px-10"><h2 className="mb-2 pt-12 text-h2 lg:mb-6">{data.article.title}</h2><div className="mb-4"><p className="w-fit rounded-lg bg-card px-4 py-2 text-sm text-card-foreground">{data.article.category?.name ?? 'General News'}</p></div><div className={`${displayBar ? 'pb-24' : 'pb-12'} overflow-y-auto`}>// ... Code omitted for brevity ... -
Save your changes
-
Check your browser, and you should now see the
category
name displayed on the article page:
The article editor
Section titled โThe article editorโNow that we can see the category
name on the article page, letโs add a category
field to the article editor.
If a user clicks on an existing article they are the owner of (or have permissions to edit), they should be presented with an โEditโ button.
Clicking the โEditโ button opens the article editor form:
Modifying the article editor
Section titled โModifying the article editorโLoad categories
Section titled โLoad categoriesโTo edit the article, we need to find a different route file.
-
Open the file at
app/routes/users+/$username_+/articles.$articleId_.edit.tsx
. -
Add the code shown below.
This will produce red lines under some code, indicating TypeScript errors.
Donโt worry about this for now. We will fix it later.
app/routes/users+/$username_+/articles.$articleId_.edit.tsx 9 collapsed linesimport { invariantResponse } from '@epic-web/invariant'import { json, type LoaderFunctionArgs } from '@remix-run/node'import { useLoaderData } from '@remix-run/react'import { GeneralErrorBoundary } from '#app/components/ErrorBoundary.js'import { requireUserId } from '#app/utils/auth.server.ts'import { prisma } from '#app/utils/db.server.ts'import { ArticleEditor } from './__article-editor.tsx'export { action } from './__article-editor.server.tsx'export async function loader({ params, request }: LoaderFunctionArgs) {const userId = await requireUserId(request)const categories = await prisma.articleCategory.findMany({select: {id: true,name: true,},})const article = await prisma.article.findFirst({select: {id: true,title: true,content: true,category: {select: {id: true,name: true,},},images: {select: {id: true,altText: true,},},},where: {id: params.articleId,ownerId: userId,},})invariantResponse(article, 'Not found', { status: 404 })return json({ article: article, categories })}export default function ArticleEdit() {const data = useLoaderData<typeof loader>()return <ArticleEditor categories={data.categories} article={data.article} />}export function ErrorBoundary() {return (<GeneralErrorBoundarystatusHandlers={{404: ({ params }) => (<p>No article with the id "{params.articleId}" exists</p>),}}/>)}
We will also now need to load categories
when creating a new article.
-
Open the file
app/routes/users+/$username_+/articles.new.tsx
-
Add the code below:
app/routes/users+/$username_+/articles.new.tsx import { json, type LoaderFunctionArgs } from '@remix-run/node'import { requireUserId } from '~/utils/auth.server.ts'import { ArticleEditor } from './__article-editor.tsx'import { prisma } from '~/utils/db.server.ts'import { useLoaderData } from '@remix-run/react'export { action } from './__article-editor.server.tsx'export async function loader({ request }: LoaderFunctionArgs) {await requireUserId(request)const categories = await prisma.articleCategory.findMany({select: {id: true,name: true,},})return json({})return json({ categories })}export default ArticleEditorexport default function ArticleNew() {const data = useLoaderData<typeof loader>()return <ArticleEditor categories={data.categories} />}
With this in place, letโs now update the editor form to include the new information being passed to it.
Update imports
Section titled โUpdate importsโ-
Open the file at
app/routes/users+/$username_+/__article-editor.tsx
.This file handles the actual article editor form.
-
Start by updating the imports at the top:
app/routes/users+/$username_+/__article-editor.tsx 9 collapsed linesimport {FormProvider,getFieldsetProps,getFormProps,getInputProps,getTextareaProps,useForm,type FieldMetadata,} from '@conform-to/react'import { getZodConstraint, parseWithZod } from '@conform-to/zod'import {ArticleCategory,type Article,type ArticleImage,} from '@prisma/client'import { type SerializeFrom } from '@remix-run/node'11 collapsed linesimport { Form, useActionData } from '@remix-run/react'import { useState } from 'react'import { z } from 'zod'import { GeneralErrorBoundary } from '~/components/ErrorBoundary.js'import { floatingToolbarClassName } from '~/components/floating-toolbar.tsx'import { ErrorList, Field, TextareaField } from '~/components/forms.tsx'import { Button } from '~/components/ui/button.tsx'import { Icon } from '~/components/ui/icon.tsx'import { Label } from '~/components/ui/label.tsx'import { StatusButton } from '~/components/ui/status-button.tsx'import { Textarea } from '~/components/ui/textarea.tsx'import { cn, getArticleImgSrc, useIsPending } from '~/utils/misc.tsx'import { type action } from './__article-editor.server'import SelectorGroup from '~/components/molecules/SelectorGroup.js' -
Update the
Section titled โUpdate the ArticleEditorSchemaโArticleEditorSchema
Just beneath the imports at the top of the screen is a list of
const
variables declaring the min and max lengths for the articletitle
andcontent
. Lets some new values here for ourcategory
field:app/routes/users+/$username_+/__article-editor.tsx 26 collapsed linesimport {FormProvider,getFieldsetProps,getFormProps,getInputProps,getTextareaProps,useForm,type FieldMetadata,} from '@conform-to/react'import { getZodConstraint, parseWithZod } from '@conform-to/zod'import {ArticleCategory,type Article,type ArticleImage,} from '@prisma/client'import { type SerializeFrom } from '@remix-run/node'import { Form, useActionData } from '@remix-run/react'import { useState } from 'react'import { z } from 'zod'import { GeneralErrorBoundary } from '~/components/ErrorBoundary.js'import { floatingToolbarClassName } from '~/components/floating-toolbar.tsx'import { ErrorList, Field, TextareaField } from '~/components/forms.tsx'import { Button } from '~/components/ui/button.tsx'import { Icon } from '~/components/ui/icon.tsx'import { Label } from '~/components/ui/label.tsx'import { StatusButton } from '~/components/ui/status-button.tsx'import { Textarea } from '~/components/ui/textarea.tsx'import { cn, getArticleImgSrc, useIsPending } from '~/utils/misc.tsx'import { type action } from './__article-editor.server'import SelectorGroup from '#app/components/molecules/SelectorGroup.js'const titleMinLength = 1const titleMaxLength = 100const contentMinLength = 1const contentMaxLength = 10000const categoryMinLength = 1const categoryMaxLength = 30// ... Code omitted for brevity ... -
Next, add these new values to the
ArticleEditorSchema
object, defined around line 54 to include the newcategory
field:app/routes/users+/$username_+/__article-editor.tsx export const ArticleEditorSchema = z.object({id: z.string().optional(),title: z.string().min(titleMinLength).max(titleMaxLength),categoryId: z.string().min(categoryMinLength).max(categoryMaxLength).optional(),content: z.string().min(contentMinLength).max(contentMaxLength),images: z.array(ImageFieldsetSchema).max(5).optional(),})
We are adding a new field to the schema called categoryId
, which will hold the id
of the selected category. This field is optional, as the user might decide not to place an article in any particular category.
categoryId: z .string() .min(categoryMinLength) .max(categoryMaxLength) .optional()
The min
and max
methods are used to define the minimum and maximum lengths of the categoryId
field. In this case, the categoryId
field must be between 1 and 30 characters long.
Update ArticleEditor
Section titled โUpdate ArticleEditorโNext, we need to update the props coming into the ArticleEditor
component (still inside app/routes/users+/$username_+/__article-editor.tsx
).
We need to include a new category
field to the article
, and a new categories
prop definition.
These lines of code need adding, starting from around line 64:
export function ArticleEditor({ article, categories,}: { article?: SerializeFrom< Pick<Article, 'id' | 'title' | 'content'> & { category: Pick<ArticleCategory, 'id' | 'name'> | null } & { images: Array<Pick<ArticleImage, 'id' | 'altText'>> } > categories: Array<Pick<ArticleCategory, 'id' | 'name'>>}) { const actionData = useActionData<typeof action>() const isPending = useIsPending()
const [form, fields] = useForm({ id: 'article-editor', constraint: getZodConstraint(ArticleEditorSchema), lastResult: actionData?.result, onValidate({ formData }) { return parseWithZod(formData, { schema: ArticleEditorSchema }) }, defaultValue: { ...article, categoryId: article?.category?.id ?? '', images: article?.images ?? [{}], }, shouldRevalidate: 'onBlur', }) // ... Code omitted for brevity ...}
Here, we are telling TypeScript what the new article
prop should look like, now that the category
field is included. We are also defining the categories
prop, which will be used to populate a new Categories
field in the form.
Nothing has changed on the frontend yet, so letโs fix that now.
Display โCategoriesโ
Section titled โDisplay โCategoriesโโScroll down the ArticleEditor
component further, and you will find the TextAreaField
for the content
field, around line 125.
Letโs place our choices for the category
field just below this:
export function ArticleEditor({11 collapsed lines
article, categories,}: { article?: SerializeFrom< Pick<Article, 'id' | 'title' | 'content'> & { category: Pick<ArticleCategory, 'id' | 'name'> | null } & { images: Array<Pick<ArticleImage, 'id' | 'altText'>> } > categories: Array<Pick<ArticleCategory, 'id' | 'name'>>}) {18 collapsed lines
const actionData = useActionData<typeof action>() const isPending = useIsPending()
const [form, fields] = useForm({ id: 'article-editor', constraint: getZodConstraint(ArticleEditorSchema), lastResult: actionData?.result, onValidate({ formData }) { return parseWithZod(formData, { schema: ArticleEditorSchema }) }, defaultValue: { ...article, category: article?.category?.name ?? '', images: article?.images ?? [{}], }, shouldRevalidate: 'onBlur', }) const imageList = fields.images.getFieldList()
return ( <div className="absolute inset-0"> <FormProvider context={form.context}> <Form method="POST" className="flex h-full flex-col gap-y-4 overflow-y-auto overflow-x-hidden px-10 pb-28 pt-12" {...getFormProps(form)} encType="multipart/form-data" >18 collapsed lines
{/* This hidden submit button is here to ensure that when the user hits "enter" on an input field, the primary form function is submitted rather than the first button in the form (which is delete/add image). */} <button type="submit" className="hidden" /> {article ? ( <input type="hidden" name="id" value={article.id} /> ) : null} <div className="flex flex-col gap-1"> <Field labelProps={{ children: 'Title' }} inputProps={{ autoFocus: true, ...getInputProps(fields.title, { type: 'text' }), }} errors={fields.title.errors} /> <TextareaField labelProps={{ children: 'Content' }} textareaProps={{ ...getTextareaProps(fields.content), }} errors={fields.content.errors} /> <div className="pb-8"> <Label>Category</Label> <SelectorGroup options={categories.map(category => ({ value: category.id, label: category.name, }))} /> </div>41 collapsed lines
<div> <Label>Images</Label> <ul className="flex flex-col gap-4"> {imageList.map((image, index) => { console.log('image.key', image.key) return ( <li key={image.key} className="relative border-b-2 border-muted-foreground" > <button className="absolute right-0 top-0 text-foreground-destructive" {...form.remove.getButtonProps({ name: fields.images.name, index, })} > <span aria-hidden> <Icon name="cross-1" /> </span>{' '} <span className="sr-only"> Remove image {index + 1} </span> </button> <ImageChooser meta={image} /> </li> ) })} </ul> </div> <Button className="mt-3" {...form.insert.getButtonProps({ name: fields.images.name })} > <span aria-hidden> <Icon name="plus">Image</Icon> </span>{' '} <span className="sr-only">Add image</span> </Button> </div> <ErrorList id={form.errorId} errors={form.errors} /> </Form>13 collapsed lines
<div className={floatingToolbarClassName}> <Button variant="destructive" {...form.reset.getButtonProps()}> Reset </Button> <StatusButton form={form.id} type="submit" disabled={isPending} status={isPending ? 'pending' : 'idle'} > Submit </StatusButton> </div> </FormProvider> </div> )}
Save your changes, and you should now see the categories available to choose from:
Modify SelectorGroup
Section titled โModify SelectorGroupโThe SelectorGroup
component appears to work fine on the frontend, but we need to modify its code to make sure it sends the correct information with the rest of the form data.
-
Open the component file at
app/components/molecules/SelectorGroup.tsx
and take a look at the code. -
Add the code shown below:
app/components/molecules/SelectorGroup.tsx import * as RadioGroup from '@radix-ui/react-radio-group'import { useState } from 'react'interface SelectorGroupProps {options: { value: string; label: string }[]name: stringinitialValue?: string}export default function SelectorGroup({options,name,initialValue,}: SelectorGroupProps) {let [selectedValue, setSelectedValue] = useState(initialValue ?? '')return (<RadioGroup.Root className="space-y-4">{options.map(option => (<RadioGroup.ItemclassName={`flex w-full rounded-md border p-4 ${option.value === selectedValue? 'border-sky-500 ring-1 ring-inset ring-sky-500': 'border-gray-500'}`}key={option.value}type="button"onClick={() => setSelectedValue(option.value)}value={selectedValue}><span className="font-semibold">{option.label}</span></RadioGroup.Item>))}<input type="hidden" name={name} value={selectedValue} /></RadioGroup.Root>)}
With these new changes to SelectorGroup
, we now need to pass in the new props weโve defined.
-
Go back to the code in
app/routes/users+/$username_+/__article-editor.tsx
-
Add the following props:
app/routes/users+/$username_+/__article-editor.tsx <div className="pb-8"><Label>Category</Label><SelectorGroupname="categoryId"initialValue={article?.category?.id ?? ''}options={categories.map(category => ({value: category.id,label: category.name,}))}/></div> -
Save your changes, and we can move on to the next step of handling the data once it is sent.
Update ArticleEditor.server.tsx
Section titled โUpdate ArticleEditor.server.tsxโWhen data is submitted from a Remix Form
component, it is sent to the nearest server-side action
function which handles the form submission.
The logic for our form submission is handled in the app/routes/users+/$username_+/__article-editor.server.tsx
file.
Open this now and take a look inside.
There is a lot going on!
Luckily, we only need to make three minor changes towards the end of the file.
These will ensure we capture the new categoryId
field from the form data:
29 collapsed lines
import { parseWithZod } from '@conform-to/zod'import { createId as cuid } from '@paralleldrive/cuid2'import { unstable_createMemoryUploadHandler as createMemoryUploadHandler, json, unstable_parseMultipartFormData as parseMultipartFormData, redirect, type ActionFunctionArgs,} from '@remix-run/node'import { z } from 'zod'import { requireUserId } from '#app/utils/auth.server.ts'import { prisma } from '#app/utils/db.server.ts'import { MAX_UPLOAD_SIZE, ArticleEditorSchema, type ImageFieldset,} from './__article-editor'
function imageHasFile( image: ImageFieldset,): image is ImageFieldset & { file: NonNullable<ImageFieldset['file']> } { return Boolean(image.file?.size && image.file?.size > 0)}
function imageHasId( image: ImageFieldset,): image is ImageFieldset & { id: NonNullable<ImageFieldset['id']> } { return image.id != null}
export async function action({ request }: ActionFunctionArgs) { const userId = await requireUserId(request)
63 collapsed lines
const formData = await parseMultipartFormData( request, createMemoryUploadHandler({ maxPartSize: MAX_UPLOAD_SIZE }), )
const submission = await parseWithZod(formData, { schema: ArticleEditorSchema.superRefine(async (data, ctx) => { if (!data.id) return
const article = await prisma.article.findUnique({ select: { id: true }, where: { id: data.id, ownerId: userId }, })
if (!article) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Article not found', }) } }).transform(async ({ images = [], ...data }) => { return { ...data, imageUpdates: await Promise.all( images.filter(imageHasId).map(async i => { if (imageHasFile(i)) { return { id: i.id, altText: i.altText, contentType: i.file.type, blob: Buffer.from(await i.file.arrayBuffer()), } } else { return { id: i.id, altText: i.altText, } } }), ), newImages: await Promise.all( images .filter(imageHasFile) .filter(i => !i.id) .map(async image => { return { altText: image.altText, contentType: image.file.type, blob: Buffer.from(await image.file.arrayBuffer()), } }), ), } }), async: true, })
if (submission.status !== 'success') { return json( { result: submission.reply() }, { status: submission.status === 'error' ? 400 : 200 }, ) }
const { id: articleId, title, content, categoryId, imageUpdates = [], newImages = [], } = submission.value
const updatedArticle = await prisma.article.upsert({ select: { id: true, owner: { select: { username: true } } }, where: { id: articleId ?? '__new_article__' }, create: { ownerId: userId, title, content, categoryId, images: { create: newImages }, }, update: { title, content, categoryId, images: { deleteMany: { id: { notIn: imageUpdates.map(i => i.id) } }, updateMany: imageUpdates.map(updates => ({ where: { id: updates.id }, data: { ...updates, id: updates.blob ? cuid() : updates.id }, })), create: newImages, }, }, })
return redirect( `/users/${updatedArticle.owner.username}/articles/${updatedArticle.id}`, )}
Summary
Section titled โSummaryโIn this tutorial, we have:
- Added a new
category
field to the article editor form - Loaded
categories
from the database - Displayed the
category
name on the article page - Updated the
ArticleEditorSchema
to include the newcategory
field - Updated the
ArticleEditor
component to include the newcategory
field - Updated the
SelectorGroup
component to include a hidden input field - Updated the
ArticleEditor.server.tsx
file to capture the newcategoryId
field - Generated new articles with a
category
field
Whatโs next?
Section titled โWhatโs next?โIn the next tutorial, we will add a content into the news
page of the app, and display the articles in a grid layout.