Frontend Serverless with React and GraphQL
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.
45. Add Picture Uploader
In this final step we will add picture uploading capabilities to our web application. This will allow us to upload a new picture with new recipes as well as update an existing picture for a recipe that already exists.
GraphQL just by itself does not support picture uploading, you have to link it up with a different picture service in order to make it all work. Our strategy will be to upload a file to filestack, create a new Asset
record in GraphCMS that has all the important data like the file's location, size, and file type, and then finally create a one-to-one relationship between the asset and a particular recipe, similar to the Recipe<->UserLike relation we set up before.
We can start by added the new environmental variables to our .env
file:
.env
CDNBASE=https://cdn.filestackcontent.com/APIURL=https://www.filestackapi.com/api/store/S3APIKEY=
We will upload our file at the www
subdomain and fetch it from the faster and distributed cdn
subdomain. In order to get the APIKEY
we will need to go to our GraphCMS API playground and select ManagementApi from the environment dropdown. Run the following query
graphql
{viewer {projects {environments {assetConfig {apiKey}}}}}
You will get back a response that looks like this:
graphql
{"data": {"viewer": {"projects": [{"environments": [{"assetConfig": {"apiKey": "your-api-key-here"}}]}]}}}
Set your APIKEY
environmental variable equal to the key you find at "your-api-key-here" in the response.
Next, let's add those environmental variables to the next.config.js
file. Make sure to restart your server once you save this file.
next.config.js
require('dotenv').config();const {BRANCH,GRAPHCMSURL,GRAPHCMSPROJECTID,domain,clientId,clientSecret,scope,redirectUri,postLogoutRedirectUri,cookieSecret,BACKEND_URL,GRAPHCMS_TOKEN,CDNBASE,APIURL,APIKEY,} = process.env;module.exports = {publicRuntimeConfig: {backend: { BACKEND_URL },graphcms: {GRAPHCMSPROJECTID,BRANCH,CDNBASE,APIURL,APIKEY,},},serverRuntimeConfig: {graphcms: {BRANCH,GRAPHCMSURL,GRAPHCMSPROJECTID,GRAPHCMS_TOKEN,},auth: {domain,clientId,clientSecret,scope,redirectUri,postLogoutRedirectUri,},cookieSecret,},};
You will also want to add those 3 environmental variables to Vercel secrets for when we deploy this later. Next, we have to create our picture uploader component. At its core is the <Upload>
ant design component which wraps around a regular button. When the button is clicked the onChange
button springs into action and it will get fired off as the status of the upload progresses. We can use these state changes to wait until the upload has finished and then get the response back to save off parameters like size
, mimeType
, width
, height
, filename
and handle
which we will use later in a createAsset
mutation.
The action
upload prop dictates the endpoint that we upload the file to and this will be based on the APIURL
that we just defined, a key and a path to our particular project and branch. We send the data in a JSON object where the file is under a fileUpload
key- we can format these particular details using the data
upload prop.
components/PictureUploader.tsx
import { Upload, Button } from 'antd';import { UploadOutlined } from '@ant-design/icons';import getConfig from 'next/config';import { Dispatch, SetStateAction } from 'react';import * as _ from 'lodash';const { publicRuntimeConfig } = getConfig();const {GRAPHCMSPROJECTID,BRANCH,CDNBASE,APIURL,APIKEY,} = publicRuntimeConfig.graphcms;console.log(CDNBASE);export const PictureUploader = ({setRecipeState,handleSubmitImage,}: {setRecipeState: Dispatch<SetStateAction<{isPicUploading: boolean;isQueryLoading: boolean;}>>;handleSubmitImage: (image: any) => void;}) => {const uploadProps = {name: 'file',action: (file) =>`${APIURL}?key=${APIKEY}&path=/${GRAPHCMSPROJECTID}-${BRANCH}/${file.name}`,data: (file) => ({ fileUpload: file }),onChange: async (info) => {if (info.file.status === 'uploading') {setRecipeState((state) => ({ ...state, isPicUploading: true }));}if (info.file.status === 'done') {console.log(info.file.response);const { size, type, filename } = info.file.response;var img = new Image();img.onload = function () {console.log(this);const height = _.get(this, 'naturalHeight');const width = _.get(this, 'naturalWidth');handleSubmitImage({create: {size,mimeType: type,fileName: filename,handle: _.get(info, 'file.response.url').replace(CDNBASE, ''),height,width,},});setRecipeState((state) => ({ ...state, isPicUploading: false }));};img.src = info.file.response.url;} else if (info.file.status === 'error') {setRecipeState((state) => ({ ...state, isPicUploading: false }));}},};return (<Upload {...uploadProps}><Button><UploadOutlined />Click to Upload</Button></Upload>);};
Next, we can turn to the <UpdateRecipe>
component to add picture uploading capability to it. We can add a new upload image form item which calls the <PictureUploader>
component that we just created. We can also use <GraphImg>
to be able to show the image that is currently associated with the recipe.
The initiateUpdateRecipe
function now gets a little more complicated because we have to check now whether a picture got updated when we go to send an update recipe mutation. In the even that the picture was swapped out, we need to delete the previous image that was associated with the picture if it exists. Luckily as we update the recipe we can create the new asset in the same graphQL request, and then link them together without needing to make a second request. This will also be the case for the create recipe mutation that we will tackle shortly- being able to create both the recipe and asset and link them together all in one go is definitely a huge benefit with GraphCMS. This magic all works based on the particular type of request we are sending:
graphql
mutation {createRecipe(input: {image: {create: {// asset properties here}}}){idimage {id}}}
In our case when we are creating a new recipe or updating a new recipe with a new image, we can use the create
option where we can pass in the image properties directly into it. Creating an asset in this way, that is, nested inside the create or update recipe will directly link the two documents together and will allow us to easily access our image information from within our recipe object.
We also will add a new state called isPicUploading
into the UpdateRecipe component which will allow us to disable the submit and delete buttons while an upload is in progress.
components/UpdateRecipe.tsx
import { useQuery, useMutation } from '@apollo/react-hooks';import { recipeGraphQL } from '../graphql/queries/recipe';import { submitForm } from '../utils/submitForm';import { useState } from 'react';import * as _ from 'lodash';import { Form, Row, Col, Button } from 'antd';import { GenerateInput, GenerateTextInput } from './GenerateFields';import { GenerateIngredients } from './GenerateIngredients';import { Loading } from './notify/Loading';import { createUpdateObj } from '../utils/createUpdateObj';import { updateRecipeGraphQL } from '../graphql/mutations/updateRecipe';import { DeleteButton } from './DeleteButton';import { PictureUploader } from './PictureUploader';import GraphImg from 'graphcms-image';import { deleteAssetGraphQL } from '../graphql/mutations/deleteAsset';export const UpdateRecipe = ({ id }) => {const { loading: isQueryLoading, data, error } = useQuery(recipeGraphQL, {variables: { where: { id } },});const [updateRecipeMutation, { loading: updateRecipeLoading }] = useMutation(updateRecipeGraphQL,);const [deleteAssetMutation, { loading: deleteAssetLoading }] = useMutation(deleteAssetGraphQL,);const [recipeState, setRecipeState] = useState({isQueryLoading: true,isPicUploading: false,});const initiateUpdateRecipe = async () => {const queryImageHandle = _.get(data, 'recipe.image.handle');const inputsImageHandle = _.get(inputs, 'image.create.handle');const queryImageId = _.get(data, 'recipe.image.id');if (queryImageHandle !== inputsImageHandle && !_.isNil(inputsImageHandle)) {await deleteAssetMutation({variables: {where: {id: queryImageId,},},});}const updateObj = createUpdateObj(data, inputs);if (!_.isEmpty(updateObj)) {const result = await updateRecipeMutation({refetchQueries: [{ query: recipeGraphQL, variables: { where: { id } } },],variables: {data: {...updateObj,},where: { id },},});const updateRecipe = _.get(result, 'data.updateRecipe');return updateRecipe;} else {const recipe = _.get(data, 'recipe');return recipe;}};const {inputs,handleInputChange,handleAddIngredient,handleDeleteIngredient,handleDropdownChange,handleUpdate,handleSubmitImage,setInputs,} = submitForm({title: '',description: '',content: '',ingredients: [],},initiateUpdateRecipe,);if (!isQueryLoading && recipeState.isQueryLoading) {const { __typename, ...loadedRecipe } = _.get(data, 'recipe');setInputs((state) => ({ ...state, ...loadedRecipe }));setRecipeState((state) => ({ ...state, isQueryLoading }));}if (!data) return <Loading />;const disabled =isQueryLoading ||updateRecipeLoading ||recipeState.isPicUploading ||deleteAssetLoading;return (<Form onFinish={handleUpdate}><GenerateInputname="title"value={inputs.title}handleInputChange={handleInputChange}/><GenerateInputname="description"value={inputs.description}handleInputChange={handleInputChange}/><GenerateTextInputname="content"value={inputs.content}handleInputChange={handleInputChange}/><GenerateIngredientsnames={['amount', 'unit', 'type']}values={inputs.ingredients}handleAddIngredient={handleAddIngredient}handleDeleteIngredient={handleDeleteIngredient}handleInputChange={handleInputChange}handleDropdownChange={handleDropdownChange}/><Row><Col span={12} /><Col span={4}><Form.Item label="Upload Image">{inputs.image ? <GraphImg image={inputs.image} /> : null}<PictureUploadersetRecipeState={setRecipeState}handleSubmitImage={handleSubmitImage}/></Form.Item></Col><Col span={4}><Form.Item label="Action"><Button block disabled={disabled} type="primary" htmlType="submit">Update Recipe</Button><DeleteButtonid={id}disabled={disabled}imageId={_.get(inputs, 'image.id')}/></Form.Item></Col></Row></Form>);};
The next change we need to make is change how the handleUpdate
function in the submitForm hook works. Previously, we would get the response from the initiateUpdateRecipe
and return it so we could access it in the handleUpdate
function using the callback method. We are reworking the initiateUpdateRecipe
slightly so that it always is returning a recipe object rather than only returning an object if there was an actual change in our object. Keeping consistent return types will help us keep a little more sanity so its worth doing.
In addition we will also be creating a handleSubmitImage
method which will handle setting our local state with image information that we get from the <PictureUploader>
function.
utils/submitForm.ts
import { useState } from 'react';import * as _ from 'lodash';export const submitForm = (initialValues, callback) => {const [inputs, setInputs] = useState(initialValues);const handleInputChange = (event) => {event.persist();setInputs((inputs) => {const newInputs = _.cloneDeep(inputs);_.set(newInputs, event.target.name, event.target.value);return newInputs;});};const handleDropdownChange = (event) => {setInputs((inputs) => {const newInputs = _.cloneDeep(inputs);_.set(newInputs, event.item.props.title, event.key);return newInputs;});};const handleAddIngredient = (event) => {event.persist();setInputs((inputs) => {const sortedIngredients = _.sortBy(inputs.ingredients, ['key']);const key =sortedIngredients.length > 0? sortedIngredients[sortedIngredients.length - 1].key + 1: 0;return {...inputs,ingredients: _.concat(inputs.ingredients, [{ key, amount: '', unit: '-', type: '' },]),};});};const handleDeleteIngredient = (event) => {event.persist();const position = parseInt(event.target.name);setInputs((inputs) => ({...inputs,ingredients: _.filter(inputs.ingredients,(_i, index) => index !== position,),}));};const handleUpdate = async () => {const updatedResult = await callback();const {content,description,status,title,ingredients,image,} = updatedResult;setInputs(() => ({content,description,status,title,ingredients,image,}));};const handleSubmit = () => {callback();setInputs(() => ({ ...initialValues }));};const handleSubmitImage = (image) => {setInputs((inputs) => {const newInput = _.cloneDeep(inputs);_.set(newInput, 'image', image);console.log(newInput);return newInput;});};return {inputs,setInputs,handleSubmit,handleSubmitImage,handleUpdate,handleInputChange,handleAddIngredient,handleDeleteIngredient,handleDropdownChange,};};
Now that we have working uploads for an existing recipe, let's modify the <CreateRecipe>
component to add it there too. Luckily, since we were so consistent in how we handle forms across the create and update recipe components, we will have very few changes that we need to make.
The first, is that we will need to make a state and use isPicUploading
just like we did in the <UpdateRecipe>
component. We can use it on the create recipe button so that we can disable pushing it when a picture is uploading, just like we disabled the button being pressed on submit.
The next change is that we will need to call the <PictureUploader>
component and pass recipeState and the handleSubmitImage
that we get from the submitForm
hook.
The final touch for this component is adding a recipesGraphQL
refetchQueries call which will make sure that the new recipe we add will get incorporated into the local cache Apollo has of recipes that it uses to display on our list pages. There's a good chance we might not strictly need this, but it's a good practice to have after a create mutation like this.
components/CreateRecipe.tsx
import { Row, Col, Form, Button } from 'antd';import { submitForm } from '../utils/submitForm';import { GenerateInput, GenerateTextInput } from './GenerateFields';import { GenerateIngredients } from './GenerateIngredients';import { useMutation } from '@apollo/react-hooks';import { createRecipeGraphQL } from '../graphql/mutations/createRecipe';import { useFetchUser } from '../utils/user';import * as _ from 'lodash';import { Loading } from './notify/Loading';import Router from 'next/router';import { recipesGraphQL } from '../graphql/queries/recipes';import { PictureUploader } from './PictureUploader';import { useState } from 'react';export const CreateRecipe = () => {const [recipeState, setRecipeState] = useState({ isPicUploading: false });const [createRecipeMutation, { loading }] = useMutation(createRecipeGraphQL);const { user, loading: isFetchingUser } = useFetchUser();const owner = _.get(user, 'sub');const initiateCreateRecipe = () => {createRecipeMutation({refetchQueries: [{ query: recipesGraphQL },{ query: recipesGraphQL, variables: { where: { owner } } },],variables: {data: {...inputs,owner,},},});Router.replace('/my-recipes');};const {inputs,handleInputChange,handleAddIngredient,handleDeleteIngredient,handleDropdownChange,handleSubmit,handleSubmitImage,} = submitForm({title: '',description: '',content: '',ingredients: [],},initiateCreateRecipe,);if (isFetchingUser) return <Loading />;return (<Form onFinish={handleSubmit}><GenerateInputname="title"value={inputs.title}handleInputChange={handleInputChange}/><GenerateInputname="description"value={inputs.description}handleInputChange={handleInputChange}/><GenerateTextInputname="content"value={inputs.content}handleInputChange={handleInputChange}/><GenerateIngredientsnames={['amount', 'unit', 'type']}values={inputs.ingredients}handleAddIngredient={handleAddIngredient}handleDeleteIngredient={handleDeleteIngredient}handleInputChange={handleInputChange}handleDropdownChange={handleDropdownChange}/><Row><Col span={12} /><Col span={4}><Form.Item label="Upload Image"><PictureUploadersetRecipeState={setRecipeState}handleSubmitImage={handleSubmitImage}/></Form.Item></Col><Col span={4}><Form.Item label="Create Recipe"><Buttondisabled={loading || recipeState.isPicUploading}type="primary"htmlType="submit">Create Recipe</Button></Form.Item></Col></Row></Form>);};
The last react component that we will need to update is the <DeleteButton>
. You might have noticed that in the <UpdateRecipe>
updates we are now passing in a new parameter called imageId
. This allows us to not only delete our recipe but the associated image so we don't accidentally orphan any images. We can call a deleteAsset
mutation right before deleting the recipe if it sees that an image is present. We need to make this check in the case where a recipe might not have an image. In that case we'd just delete the recipe.
components/DeleteButton.tsx
import { Button, Modal } from 'antd';import { useMutation } from '@apollo/react-hooks';import { deleteRecipeGraphQL } from '../graphql/mutations/deleteRecipe';import { useState } from 'react';import { recipesGraphQL } from '../graphql/queries/recipes';import Router from 'next/router';import { deleteAssetGraphQL } from '../graphql/mutations/deleteAsset';export const DeleteButton = ({id,disabled,imageId,}: {id: string;disabled: boolean;imageId: string;}) => {const [deleteRecipeMutation, { loading: deleteRecipeLoading }] = useMutation(deleteRecipeGraphQL,);const [deleteAssetMutation, { loading: deleteAssetLoading }] = useMutation(deleteAssetGraphQL,);const [isModalVisible, setModalVisibility] = useState(false);const handleOk = async () => {if (imageId && !deleteAssetLoading) {await deleteAssetMutation({refetchQueries: [{ query: recipesGraphQL }],variables: {where: { id: imageId },},});}if (!deleteRecipeLoading) {await deleteRecipeMutation({refetchQueries: [{ query: recipesGraphQL }],variables: {where: { id },},});}setModalVisibility(false);Router.replace('/my-recipes');};const handleShow = () => setModalVisibility(true);const handleHide = () => setModalVisibility(false);return (<><Buttonblock// @ts-ignoretype="danger"disabled={disabled || deleteRecipeLoading || deleteAssetLoading}onClick={handleShow}>Delete Recipe</Button><Modaltitle="Confirm Delete"visible={isModalVisible}onOk={handleOk}onCancel={handleHide}><p>Are you sure that you want to delete this recipe?</p></Modal></>);};
Now that the react components have been updated we have 2 small sets of changes left. The first is that we need to update the verifyUserPermissions
function to check for the new deleteAsset
function that we are calling. We want to make sure that when a deleteAsset
mutation is called that we look up the corresponding recipe and ensure that the owner matches the person making the request. Update the mutationsToWatch
array with one more check:
utils/verify.ts
// More lines of code omitted for clarityconst mutationToMatch = [{match: /deleteRecipe/g,queryToCheck: print(recipeGraphQL),vars: variables,path: 'recipe.owner',},{match: /deleteUserLike/g,queryToCheck: print(userLikeGraphQL),vars: variables,path: 'userLike.user',},{match: /updateRecipe/g,queryToCheck: print(recipeGraphQL),vars: variables,path: 'recipe.owner',},{match: /deleteAsset/g,queryToCheck: print(recipesGraphQL),vars: { where: { images: { id: _.get(variables, 'where.id') } } },path: 'recipes[0].owner',},];
Finally, before we deploy to Vercel, there are several harmless lines that will still nonetheless fail in typescript so we will add a // @ts-ignore
to them. The first is in components/DeleteButton.tsx
:
components/DeleteButton.tsx
<Buttonblock// @ts-ignoretype="danger"disabled={disabled || deleteRecipeLoading || deleteAssetLoading}onClick={handleShow}>Delete Recipe</Button>
The next is in the components/GenerateIngredients.tsx
file:
components/GenerateIngredients.tsx
<ButtononClick={handleDeleteIngredient}// @ts-ignoretype="danger"shape="circle"size="small"name={`${index}`}>-</Button>
The ant design typescript definitions are slightly out of date apparently and throw an error when you try to use the "danger" type even though it is perfectly valid.
Now that the entire app has been built, commit the changes and push to Vercel. You should see that it detects a new commit and deploys everything for you automatically. Provided that all of the new environmental variables have been adde as secrets, you should have a fully functional working application! I sincerely hope that you've enjoyed building this application and definitely let me know what you think by tweeting me @codemochi.