End to End React with Prisma 2
Start at the beginning and work your way through this project. The code for each step as well as the finished project can be found in the Github repository.
32. Tidying it all up
Objective: Tidy up your app to maximize performance and make it easier to use.
Welcome to this bonus episode where we do some cleanup tasks that will make Newsprism a little snappier and easier to use. Nothing that you see here is required so if you've already hit your limit feel free to skip it.
Step 1: Create a compound index for saved articles
Previously we created saved articles with an id that was a random uuid just like with all of our other models. This works fine but has a slight issue that we would really like to create a unique constraint where somebody can only save an article once. Since this depends on two different things, an article and a user id we can't put the unique constraint on either of them because we need to allow for multiple users to like a particular saved article.
The way around this issue is to create a new index on the SavedArticle model:
prisma/schema.prisma
model SavedArticle {id String @idcontent Jsonfeed Feed? @relation(fields: [feedId], references: [id])feedId String?author User? @relation(fields: [authorId], references: [id])authorId String?url String@@index([authorId, url])}
Then create a package.json task called prisma:generate
:
package.json
json
"scripts": {"dev": "next","build": "next build","start": "next start","prisma:init": "prisma init","prisma:migrate": "prisma migrate dev --preview-feature","prisma:generate": "prisma generate"},
Run npm run prisma:migrate
followed by npm run prisma:generate
. This should apply the migration. Next, you should clear out the existing saved articles so we have a clean slate. The new ids will have the form of userId-articleUrl
so we need to update OneArticle
component to update the id that we generate to this new form.
components/oneArticle.tsx
tsx
// remove this line// import { v4 as uuidv4 } from 'uuid';const newSavedArticle = {data: {// remove the id line hereurl: article.link,content: article,feed: {connect: {id: feed.id,},},},};// more lines of codeoptimisticResponse: () => {const user = _.get(meData, 'me');return {__typename: 'Mutation',['createSavedArticle']: {// change this id to user-urlid: `${user.id}-${newSavedArticle.data.url}`,...newSavedArticle.data,user,feed,__typename: 'SavedArticle',},};},
Now we have to go to the resolvers and update things on the backend. We can now update our savedArticle
mutation to use the findUnique
since there can only be one item of the form authorId-url
utils/api/resolvers.ts
ts
// lines of codesavedArticle: (parent,{ data: { url } },{ prisma, user: { id: authorId } }: Context,) =>prisma.savedArticle.findUnique({where: { id: `${authorId}-${url}` },}),// let's tidy up the createFeed and createBundle mutations while we are at itcreateFeed: (parent, { data }, { prisma, user }: Context) =>prisma.feed.create({data: { ...data, author: { connect: { id: user.id } } },}),createBundle: (parent, { data }, { prisma, user }: Context) =>prisma.bundle.create({data: { ...data, author: { connect: { id: user.id } } },}),// more lines of codecreateSavedArticle: async (parent, { data }, { prisma, user }: Context) =>prisma.savedArticle.create({data: {...data,id: `${user.id}-${data.url}`,author: { connect: { id: user.id } },},}),// more lines of code
Since we are now setting the id on the backend rather than asking the user for it, we need to also update our input type to remove the id. Note that id
has been removed here:
utils/api/typeDefs.ts
ts
input SavedArticleCreateInput {feed: NestedFeedCreateInputcontent: JSONurl: String}
Now create a saved article and confirm that everything works. We should see that the id is now being stored the way we expect with the user and url. Sweet!
Step 2: Typechecking with the context
A nice thing about the prisma library that we generate is that we can get autocomplete with all of the types in prisma library. Unfortunately, we didn't create an interface to expose all of that to the resolvers, so let's create that now. Add the following type here:
utils/api/context.ts
ts
export interface Context {prisma: PrismaClientuser: User}
Then import that interface in the resolvers- there are a bunch of times you'll need add it, one for each resolver.
utils/api/resolvers.ts
ts
import { Context } from './context'// Then we should use Context everywhere we define `{ prisma }` or `{ prisma, user }`const createFieldResolver = (modelName, parName) => ({[parName]: async ({ id }, args, { prisma }: Context) => {const modelResponse = await prisma[modelName].findUnique({where: { id },include: { [parName]: true },})return modelResponse[parName]},// more codehello: (parent, args, context: Context) => 'hi!',// 20 more references to `Context`, see the newsprism repository for more details})
Step 3: Optimize the field resolver
We just touched on the createFieldResolver
function. It turns out there is a way to optimize the function. If we switch the include
with the select
we can ensure we only fetch the data from modelResponse[parName]
and not all of the other scalars within modelResponse
.
utils/api/resolvers.ts
ts
const createFieldResolver = (modelName, parName) => ({[parName]: async ({ id }, args, { prisma }: Context) => {const modelResponse = await prisma[modelName].findUnique({where: { id },include: { [parName]: true },})return modelResponse[parName]},})
Step 4: Enable permissions
We disabled the permissions way back when we were making the backend so now let's enable it. First tweak the isAuthenticated
function to remove the async
from the function. Next, add isAuthenticated
rules for all of the mutations and the savedArticle
and savedArticles
queries.
utils/api/permissions.ts
ts
import { rule, shield } from 'graphql-shield'import * as _ from 'lodash'const rules = {isAuthenticated: rule()((_parent, _args, context) => {return _.isEmpty(context.user) ? false : true}),}export const permissions = shield({Query: {savedArticle: rules.isAuthenticated,savedArticles: rules.isAuthenticated,},Mutation: {createFeed: rules.isAuthenticated,createBundle: rules.isAuthenticated,likeFeed: rules.isAuthenticated,updateFeed: rules.isAuthenticated,updateBundle: rules.isAuthenticated,createSavedArticle: rules.isAuthenticated,deleteBundle: rules.isAuthenticated,deleteFeed: rules.isAuthenticated,deleteSavedArticle: rules.isAuthenticated,},})
Next let's re-enable the permissions
pages/api/graphql.ts
ts
const schema = applyMiddleware(makeExecutableSchema({ typeDefs, resolvers }),log,permissions)
Play around with the app and make sure that all of these actions still work.
Step 5: Improve the tag searching
Next, we will improve the tag selection process. The first thing we want to do is make the searching case insensitive. This will allow us to search for javascript
and match with a tag of Javascript
. Then, we want to clear out the search field after adding a tag. Finally, we will allow the user to press enter to add the first tag on the list.
First, let's enable case insensitive matches in the resolvers:
utils/api/resolvers.ts
ts
findFeedTags: (parent, { data }, { prisma }: Context) =>prisma.feedTag.findMany({where: { name: { contains: data.search, mode: 'insensitive' } },}),findBundleTags: (parent, { data }, { prisma }: Context) =>prisma.bundleTag.findMany({where: { name: { contains: data.search, mode: 'insensitive' } },}),findFeeds: (parent, { data }, { prisma }: Context) =>prisma.feed.findMany({where: { name: { contains: data.search, mode: 'insensitive' } },}),
That's all we need for the case insensitive search. Next, let's clear out the search field. Let's pass the setSearch
into BadgeList
component so we can then pass it into the OneBadge
component.
components/searchItems.tsx
tsx
<BadgeListfieldName={fieldName}action={ActionType.ADD}setItem={setItem}item={dummyNewItem}setSearch={setSearch}/>
components/badgeList.tsx
tsx
import { Dispatch, SetStateAction } from 'react'import {ActionType,BadgeFieldName,BundleObject,FeedObject,} from '../utils/types'import { OneBadge } from './oneBadge'export const BadgeList = ({fieldName,action,setItem,item,setSearch,}: {fieldName: BadgeFieldNameaction: ActionTypeitem: FeedObject | BundleObjectsetItem?: Dispatch<SetStateAction<FeedObject | BundleObject>>setSearch?: Dispatch<SetStateAction<String>>}) => {return item[fieldName] && item[fieldName].length > 0 ? (<>{item[fieldName].map(oneBadge => (<OneBadgekey={`${item['id']}-${oneBadge.name}}`}fieldName={fieldName}item={oneBadge}action={action}setItem={setItem}currentItem={item}setSearch={setSearch}/>))}</>) : (<p className="text-gray-400">None found</p>)}
components/oneBadge.tsx
tsx
export const OneBadge = ({item,action,currentItem,fieldName,setItem,setSearch,}: {item: FeedTag | BundleTag | FeedObject;action: ActionType;currentItem?: FeedObject | BundleObject;fieldName: BadgeFieldName;setItem?: Dispatch<SetStateAction<FeedObject | BundleObject>>;setSearch?: Dispatch<SetStateAction<String>>;}) => {//more of the componentonClick={() => {setItem((currState) => ({...currState,[fieldName]: [...currState[fieldName], { ...item }],}));setSearch('');}}
This will allow the field to clear one we add the tag to the list. The last thing to do is to allow adding a badge when the enter button is pressed.
components/searchItems.tsx
tsx
<inputclassName="border-4 rounded w-full py-2 px-3"value={search}onKeyDown={e => {if (e.key === 'Enter') {e.preventDefault()setItem(currState => ({...currState,[fieldName]: [...currState[fieldName], { ...dummyNewItem.tags[0] }],}))setSearch(() => '')}}}onChange={e => {e.persist()if (e.target.value !== search) {setSearch(() => e.target.value)findItemsQuery({variables: { data: { search: e.target.value } },})}}}/>
We previously had an onChange
handler. In order to check for an enter key, we need to use the onKeyDown
handler. We check to see if the person pressed the enter key and if they do, we will use the setItem
in the same way that we used it in the OneBadge
item. We then can clear the field using setSearch
just like we added above. Now test out the functionality- we should be able to manually add tags like we did before but also add them using the enter key. It should in either case, clear the search field. Awesome!
Step 6: Fix the feed and bundle page layouts
The last step will be a quick one- if you go to a feed or bundle page you will see that the layout isn't ideal- the articles are all squished over to the right. This is due to a small issue with where the GenerateArticleList
component is. Move it outside the div
like so:
pages/feed/[id].tsx
ts
<div className="grid grid-cols-3 gap-4">{feed.bundles.length > 0 ? (feed.bundles.map((item: FeedObject) => (<OneListItem item={item} type={ItemType.BundleType} key={item.id} />))) : (<p>None are present. Why not add one?</p>)}</div><GenerateArticleList feeds={[feed]} />
pages/bundle/[id].tsx
ts
<div className="grid grid-cols-3 gap-4">{bundle.feeds.length > 0 ? (bundle.feeds.map((item: FeedObject) => (<OneListItem item={item} type={ItemType.FeedType} key={item.id} />))) : (<p>None are present. Why not add one?</p>)}</div><GenerateArticleList feeds={bundle.feeds} />
That should be it! Thanks for sticking around, I hope you build some really amazing apps with the techniques that you learned here. Don't be a stranger- hit me up at @codemochi or @drstephenjensen to say hi, ask a question or show me something cool you've done. Cheers!