"use strict";(self.webpackChunkdocs=self.webpackChunkdocs||[]).push([[97551],{79348:(e,t,n)=>{n.d(t,{A:()=>r});n(96540);var a=n(44148),s=n(74848);function r(e){let{path:t}=e;const[n]=(0,a.Dv)("docusaurus.tab.js-ts"),r=t.lastIndexOf("{"),i=t.slice(r+1,t.length-1),[o,l]=i.split(","),d=t.slice(0,r);return(0,s.jsx)("code",{children:d+("js"===n?o:l)})}},48619:(e,t,n)=>{n.r(t),n.d(t,{assets:()=>l,contentTitle:()=>o,default:()=>h,frontMatter:()=>i,metadata:()=>a,toc:()=>d});const a=JSON.parse('{"id":"how-to/file-uploads","title":"File Uploads","description":"As you\'ve probably heard, Redwood thinks the future is serverless. This concept introduces some interesting problems you might not have had to worry about in the past. For example, where do files go when you upload them? There\'s no server! Like many tasks you may have done yourself in the past, this is another job that we can farm out to a third-party service.","source":"@site/versioned_docs/version-3.x/how-to/file-uploads.md","sourceDirName":"how-to","slug":"/how-to/file-uploads","permalink":"/docs/3.x/how-to/file-uploads","draft":false,"unlisted":false,"editUrl":"https://github.com/redwoodjs/redwood/blob/main/docs/docs/how-to/file-uploads.md","tags":[],"version":"3.x","frontMatter":{},"sidebar":"main","previous":{"title":"Disable API/Database","permalink":"/docs/3.x/how-to/disable-api-database"},"next":{"title":"GoTrue Auth","permalink":"/docs/3.x/how-to/gotrue-auth"}}');var s=n(74848),r=n(28453);n(79348);const i={},o="File Uploads",l={},d=[{value:"The Service",id:"the-service",level:2},{value:"The App",id:"the-app",level:2},{value:"The Database",id:"the-database",level:3},{value:"The Uploader",id:"the-uploader",level:2},{value:"The Data",id:"the-data",level:2},{value:"The Transform",id:"the-transform",level:2},{value:"The Improvements",id:"the-improvements",level:2},{value:"The Delete",id:"the-delete",level:2},{value:"The Wrap-up",id:"the-wrap-up",level:2}];function c(e){const t={a:"a",blockquote:"blockquote",code:"code",h1:"h1",h2:"h2",h3:"h3",header:"header",img:"img",p:"p",pre:"pre",strong:"strong",...(0,r.R)(),...e.components};return(0,s.jsxs)(s.Fragment,{children:[(0,s.jsx)(t.header,{children:(0,s.jsx)(t.h1,{id:"file-uploads",children:"File Uploads"})}),"\n",(0,s.jsxs)(t.p,{children:["As you've probably heard, Redwood thinks the future is serverless. This concept introduces some interesting problems you might not have had to worry about in the past. For example, where do files go when you upload them? There's no server! Like many tasks you may have done ",(0,s.jsx)(t.a,{href:"/docs/3.x/tutorial/chapter4/authentication",children:"yourself"})," in the past, this is another job that we can farm out to a third-party service."]}),"\n",(0,s.jsx)(t.h2,{id:"the-service",children:"The Service"}),"\n",(0,s.jsxs)(t.p,{children:["There are many services out there that handle uploading files and serving them from a CDN. Two of the big ones are ",(0,s.jsx)(t.a,{href:"https://cloudinary.com",children:"Cloudinary"})," and ",(0,s.jsx)(t.a,{href:"https://filestack.com",children:"Filestack"}),". We're going to demo a Filestack integration here because we've found it easy to integrate. In addition to storing your uploads and making them available via a CDN, they also offer on-the-fly image transformations so that even if someone uploads a Retina-ready 5000px wide headshot, you can shrink it down and only serve a 100px version for their avatar in the upper right corner of your site. You save bandwidth and transfer costs."]}),"\n",(0,s.jsx)(t.p,{children:"We're going to sign up for a free plan which gives us 100 uploads a month, 1000 transformations (like resizing an image), 1GB of bandwidth, and 0.5GB of storage. That's more than enough for this demo. (And maybe even a low-traffic production site!)"}),"\n",(0,s.jsxs)(t.p,{children:["Head over to ",(0,s.jsx)(t.a,{href:"https://dev.filestack.com/signup/free/",children:"https://dev.filestack.com/signup/free/"})," and sign up. Be sure to use a real email address because they're going to send you a confirmation email before they let you log in. Once you verify your email, you'll be dropped on your dashboard where your API key will be shown in the upper right:"]}),"\n",(0,s.jsx)(t.p,{children:(0,s.jsx)(t.img,{src:"https://user-images.githubusercontent.com/300/82616735-ec41a400-9b82-11ea-9566-f96089e35e52.png",alt:"New image scaffold"})}),"\n",(0,s.jsx)(t.p,{children:"Copy that (or at least keep the tab open) because we're going to need it in a minute. (I already changed that key so don't bother trying to steal it!)"}),"\n",(0,s.jsx)(t.p,{children:"That's it on the Filestack side; on to the application."}),"\n",(0,s.jsx)(t.h2,{id:"the-app",children:"The App"}),"\n",(0,s.jsx)(t.p,{children:"Let's create a very simple DAM (Digital Asset Manager) that lets users upload and catalogue images. They'll be able to click the thumbnail to open a full-size version."}),"\n",(0,s.jsx)(t.p,{children:"Create a new Redwood app:"}),"\n",(0,s.jsx)(t.pre,{children:(0,s.jsx)(t.code,{className:"language-bash",children:"yarn create redwood-app uploader\ncd uploader\n"})}),"\n",(0,s.jsxs)(t.p,{children:["The first thing we'll do is create an environment variable to hold our Filestack API key. This is a best practice so that the key isn't living in our repository for prying eyes to see. Add the key to the ",(0,s.jsx)(t.code,{children:".env"})," file in the root of our app:"]}),"\n",(0,s.jsx)(t.pre,{children:(0,s.jsx)(t.code,{className:"language-bash",children:"REDWOOD_ENV_FILESTACK_API_KEY=AM18i8xV4QpoiGwetoTWd\n"})}),"\n",(0,s.jsxs)(t.blockquote,{children:["\n",(0,s.jsxs)(t.p,{children:["We're prefixing with ",(0,s.jsx)(t.code,{children:"REDWOOD_ENV_"})," here to tell webpack that we want it to replace this variables with its actual value as it's processing pages and statically generating them. Otherwise our generated pages would still contain something like ",(0,s.jsx)(t.code,{children:"process.env.FILESTACK_API_KEY"}),", which wouldn't exist when the pages are static and being served from a CDN."]}),"\n"]}),"\n",(0,s.jsx)(t.p,{children:"Now we can start our development server:"}),"\n",(0,s.jsx)(t.pre,{children:(0,s.jsx)(t.code,{className:"language-bash",children:"yarn rw dev\n"})}),"\n",(0,s.jsx)(t.h3,{id:"the-database",children:"The Database"}),"\n",(0,s.jsx)(t.p,{children:"We'll create a single model to store our image data:"}),"\n",(0,s.jsx)(t.pre,{children:(0,s.jsx)(t.code,{className:"language-javascript",metastring:'title="api/db/schema.prisma"',children:"model Image {\n  id    Int    @id @default(autoincrement())\n  title String\n  url   String\n}\n"})}),"\n",(0,s.jsxs)(t.p,{children:[(0,s.jsx)(t.code,{children:"title"})," will be the user-supplied name for this asset and ",(0,s.jsx)(t.code,{children:"url"})," will contain the public URL that Filestack creates after an upload."]}),"\n",(0,s.jsx)(t.p,{children:'Create a migration to update the database; when prompted, name it "add image":'}),"\n",(0,s.jsx)(t.pre,{children:(0,s.jsx)(t.code,{className:"language-bash",children:"yarn rw prisma migrate dev\n"})}),"\n",(0,s.jsx)(t.p,{children:"To make our lives easier, let's scaffold the screens necessary to create/update/delete an image, then we'll worry about adding the uploader:"}),"\n",(0,s.jsx)(t.pre,{children:(0,s.jsx)(t.code,{className:"language-bash",children:"yarn rw generate scaffold image\n"})}),"\n",(0,s.jsxs)(t.p,{children:["Now head to ",(0,s.jsx)(t.a,{href:"http://localhost:8910/images/new",children:"http://localhost:8910/images/new"})," and let's figure this out!"]}),"\n",(0,s.jsx)(t.p,{children:(0,s.jsx)(t.img,{src:"https://user-images.githubusercontent.com/300/82694608-653f0b00-9c18-11ea-8003-4dc4aeac7b86.png",alt:"New image scaffold"})}),"\n",(0,s.jsx)(t.h2,{id:"the-uploader",children:"The Uploader"}),"\n",(0,s.jsxs)(t.p,{children:["Filestack has a couple of ",(0,s.jsx)(t.a,{href:"https://github.com/filestack/filestack-react",children:"React components"})," that handle all the uploading for us. Let's add the package:"]}),"\n",(0,s.jsx)(t.pre,{children:(0,s.jsx)(t.code,{className:"language-bash",children:"yarn workspace web add filestack-react\n"})}),"\n",(0,s.jsxs)(t.p,{children:["We want the uploader on our scaffolded form, so let's head over to ",(0,s.jsx)(t.code,{children:"ImageForm"}),", import Filestack's inline picker, and try replacing the ",(0,s.jsx)(t.strong,{children:"Url"})," input with it:"]}),"\n",(0,s.jsx)(t.pre,{children:(0,s.jsx)(t.code,{className:"language-jsx",metastring:'{9,49} title="web/src/components/ImageForm/ImageForm.js"',children:'import {\n  Form,\n  FormError,\n  FieldError,\n  Label,\n  TextField,\n  Submit,\n} from \'@redwoodjs/forms\'\nimport { PickerInline } from \'filestack-react\'\n\nconst formatDatetime = (value) => {\n  if (value) {\n    return value.replace(/:\\d{2}\\.\\d{3}\\w/, \'\')\n  }\n}\n\nconst ImageForm = (props) => {\n  const onSubmit = (data) => {\n    props.onSave(data, props?.image?.id)\n  }\n\n  return (\n    <div className="rw-form-wrapper">\n      <Form onSubmit={onSubmit} error={props.error}>\n        <FormError\n          error={props.error}\n          wrapperClassName="rw-form-error-wrapper"\n          titleClassName="rw-form-error-title"\n          listClassName="rw-form-error-list"\n        />\n\n        <Label\n          name="title"\n          className="rw-label"\n          errorClassName="rw-label rw-label-error"\n        >\n          Title\n        </Label>\n        <TextField\n          name="title"\n          defaultValue={props.image?.title}\n          className="rw-input"\n          errorClassName="rw-input rw-input-error"\n          validation={{ required: true }}\n        />\n\n        <FieldError name="title" className="rw-field-error" />\n\n        <PickerInline apikey={process.env.REDWOOD_ENV_FILESTACK_API_KEY} />\n\n        <div className="rw-button-group">\n          <Submit disabled={props.loading} className="rw-button rw-button-blue">\n            Save\n          </Submit>\n        </div>\n      </Form>\n    </div>\n  )\n}\n\nexport default ImageForm\n'})}),"\n",(0,s.jsx)(t.p,{children:"We now have a picker with all kinds of options, like picking a local file, providing a URL, and even grabbing a file from Facebook, Instagram, or Google Drive. Not bad!"}),"\n",(0,s.jsx)(t.p,{children:(0,s.jsx)(t.img,{src:"https://user-images.githubusercontent.com/32992335/133859676-4086a4b9-8112-4a19-a4fe-5663388aafc0.png",alt:"Filestack picker"})}),"\n",(0,s.jsx)(t.p,{children:"You can even try uploading an image to make sure it works:"}),"\n",(0,s.jsx)(t.p,{children:(0,s.jsx)(t.img,{src:"https://user-images.githubusercontent.com/300/82618035-bb636e00-9b86-11ea-9401-61b8c989f43c.png",alt:"Upload"})}),"\n",(0,s.jsxs)(t.blockquote,{children:["\n",(0,s.jsxs)(t.p,{children:["Make sure you click the ",(0,s.jsx)(t.strong,{children:"Upload"})," button that appears after picking your file."]}),"\n"]}),"\n",(0,s.jsx)(t.p,{children:"If you go over to the Filestack dashboard, you'll see that we've uploaded an image:"}),"\n",(0,s.jsx)(t.p,{children:(0,s.jsx)(t.img,{src:"https://user-images.githubusercontent.com/300/82618057-ccac7a80-9b86-11ea-9cd8-7a9e80a5a20f.png",alt:"Filestack dashboard"})}),"\n",(0,s.jsx)(t.p,{children:"But that doesn't help us attach anything to our database record. Let's do that."}),"\n",(0,s.jsx)(t.h2,{id:"the-data",children:"The Data"}),"\n",(0,s.jsxs)(t.p,{children:["Let's see what's going on when an upload completes. The Filestack picker takes an ",(0,s.jsx)(t.code,{children:"onSuccess"})," prop with a function to call when complete:"]}),"\n",(0,s.jsx)(t.pre,{children:(0,s.jsx)(t.code,{className:"language-jsx",metastring:'{8-10,16} title="web/src/components/ImageForm/ImageForm.js"',children:"// imports and stuff...\n\nconst ImageForm = (props) => {\n  const onSubmit = (data) => {\n    props.onSave(data, props?.image?.id)\n  }\n\n  const onFileUpload = (response) => {\n    console.info(response)\n  }\n\n  // form stuff...\n\n  <PickerInline\n    apikey={process.env.REDWOOD_ENV_FILESTACK_API_KEY}\n    onSuccess={onFileUpload}\n  />\n"})}),"\n",(0,s.jsx)(t.p,{children:"Well lookie here:"}),"\n",(0,s.jsx)(t.p,{children:(0,s.jsx)(t.img,{src:"https://user-images.githubusercontent.com/300/82618071-ddf58700-9b86-11ea-9626-e093b4c8d853.png",alt:"Uploader response"})}),"\n",(0,s.jsxs)(t.p,{children:[(0,s.jsx)(t.code,{children:"filesUploaded[0].url"})," seems to be exactly what we need\u2014the public URL to the image that was just uploaded. Excellent! How about we use a little state to track that for us so it's available when we submit our form:"]}),"\n",(0,s.jsx)(t.pre,{children:(0,s.jsx)(t.code,{className:"language-jsx",metastring:'{10,19,26} title="web/src/components/ImageForm/ImageForm.js"',children:"import {\n  Form,\n  FormError,\n  FieldError,\n  Label,\n  TextField,\n  Submit,\n} from '@redwoodjs/forms'\nimport { PickerInline } from 'filestack-react'\nimport { useState } from 'react'\n\nconst formatDatetime = (value) => {\n  if (value) {\n    return value.replace(/:\\d{2}\\.\\d{3}\\w/, '')\n  }\n}\n\nconst ImageForm = (props) => {\n  const [url, setUrl] = useState(props?.image?.url)\n\n  const onSubmit = (data) => {\n    props.onSave(data, props?.image?.id)\n  }\n\n  const onFileUpload = (response) => {\n    setUrl(response.filesUploaded[0].url)\n  }\n\n  return (\n    // component stuff...\n"})}),"\n",(0,s.jsxs)(t.p,{children:["So we'll use ",(0,s.jsx)(t.code,{children:"setState"})," to store the URL for the image. We default it to the existing ",(0,s.jsx)(t.code,{children:"url"})," value, if it exists\u2014remember that scaffolds use this same form for editing of existing records, where we'll already have a value for ",(0,s.jsx)(t.code,{children:"url"}),". If we didn't store that url value somewhere then it would be overridden with ",(0,s.jsx)(t.code,{children:"null"})," if we started editing an existing record!"]}),"\n",(0,s.jsxs)(t.p,{children:["The last thing we need to do is set the value of ",(0,s.jsx)(t.code,{children:"url"})," in the ",(0,s.jsx)(t.code,{children:"data"})," object before it gets passed to the ",(0,s.jsx)(t.code,{children:"onSave"})," handler:"]}),"\n",(0,s.jsx)(t.pre,{children:(0,s.jsx)(t.code,{className:"language-jsx",metastring:'{2,3} title="web/src/components/ImageForm/ImageForm.js"',children:"const onSubmit = (data) => {\n  const dataWithUrl = Object.assign(data, { url })\n  props.onSave(dataWithUrl, props?.image?.id)\n}\n"})}),"\n",(0,s.jsx)(t.p,{children:"Now try uploading a file and saving the form:"}),"\n",(0,s.jsx)(t.p,{children:(0,s.jsx)(t.img,{src:"https://user-images.githubusercontent.com/300/82702493-f5844c80-9c26-11ea-8fc4-0273b92034e4.png",alt:"Upload done"})}),"\n",(0,s.jsx)(t.p,{children:"It worked! Next let's update the display here to actually show the image as a thumbnail and make it clickable to see the full version:"}),"\n",(0,s.jsx)(t.pre,{children:(0,s.jsx)(t.code,{className:"language-jsx",metastring:'{76-78} title="web/src/components/Images/Images.js"',children:"import { useMutation } from '@redwoodjs/web'\nimport { toast } from '@redwoodjs/web/toast'\nimport { Link, routes } from '@redwoodjs/router'\n\nimport { QUERY } from 'src/components/Image/ImagesCell'\n\nconst DELETE_IMAGE_MUTATION = gql`\n  mutation DeleteImageMutation($id: Int!) {\n    deleteImage(id: $id) {\n      id\n    }\n  }\n`\n\nconst MAX_STRING_LENGTH = 150\n\nconst truncate = (text) => {\n  let output = text\n  if (text && text.length > MAX_STRING_LENGTH) {\n    output = output.substring(0, MAX_STRING_LENGTH) + '...'\n  }\n  return output\n}\n\nconst jsonTruncate = (obj) => {\n  return truncate(JSON.stringify(obj, null, 2))\n}\n\nconst timeTag = (datetime) => {\n  return (\n    <time dateTime={datetime} title={datetime}>\n      {new Date(datetime).toUTCString()}\n    </time>\n  )\n}\n\nconst checkboxInputTag = (checked) => {\n  return <input type=\"checkbox\" checked={checked} disabled />\n}\n\nconst ImagesList = ({ images }) => {\n  const [deleteImage] = useMutation(DELETE_IMAGE_MUTATION, {\n    onCompleted: () => {\n      toast.success('Image deleted')\n    },\n    // This refetches the query on the list page. Read more about other ways to\n    // update the cache over here:\n    // https://www.apollographql.com/docs/react/data/mutations/#making-all-other-cache-updates\n    refetchQueries: [{ query: QUERY }],\n    awaitRefetchQueries: true,\n  })\n\n  const onDeleteClick = (id) => {\n    if (confirm('Are you sure you want to delete image ' + id + '?')) {\n      deleteImage({ variables: { id } })\n    }\n  }\n\n  return (\n    <div className=\"rw-segment rw-table-wrapper-responsive\">\n      <table className=\"rw-table\">\n        <thead>\n          <tr>\n            <th>Id</th>\n            <th>Title</th>\n            <th>Url</th>\n            <th>&nbsp;</th>\n          </tr>\n        </thead>\n        <tbody>\n          {images.map((image) => (\n            <tr key={image.id}>\n              <td>{truncate(image.id)}</td>\n              <td>{truncate(image.title)}</td>\n              <td>\n                <a href={image.url} target=\"_blank\">\n                  <img src={image.url} style={{ maxWidth: '50px' }} />\n                </a>\n              </td>\n              <td>\n                <nav className=\"rw-table-actions\">\n                  <Link\n                    to={routes.image({ id: image.id })}\n                    title={'Show image ' + image.id + ' detail'}\n                    className=\"rw-button rw-button-small\"\n                  >\n                    Show\n                  </Link>\n                  <Link\n                    to={routes.editImage({ id: image.id })}\n                    title={'Edit image ' + image.id}\n                    className=\"rw-button rw-button-small rw-button-blue\"\n                  >\n                    Edit\n                  </Link>\n                  <button\n                    type=\"button\"\n                    title={'Delete image ' + image.id}\n                    className=\"rw-button rw-button-small rw-button-red\"\n                    onClick={() => onDeleteClick(image.id)}\n                  >\n                    Delete\n                  </button>\n                </nav>\n              </td>\n            </tr>\n          ))}\n        </tbody>\n      </table>\n    </div>\n  )\n}\n\nexport default ImagesList\n"})}),"\n",(0,s.jsx)(t.p,{children:(0,s.jsx)(t.img,{src:"https://user-images.githubusercontent.com/300/82702575-1fd60a00-9c27-11ea-8d2f-047bcf4e9cae.png",alt:"Image"})}),"\n",(0,s.jsx)(t.h2,{id:"the-transform",children:"The Transform"}),"\n",(0,s.jsx)(t.p,{children:"Remember when we mentioned that Filestack can save you bandwidth by transforming images on the fly? This page is a perfect example\u2014the image is never bigger than 50px, why pull down the full resolution just for that tiny display? Here's how we can tell Filestack that whenever we grab this instance of the image, it only needs to be 100px."}),"\n",(0,s.jsx)(t.p,{children:"Why 100px? Most phones and many laptops and desktop displays are now 4k or larger. Images are actually displayed at at least double resolution on these displays, so even though it's \"50px\", it's really 100px when shown on these displays. So you'll usually want to bring down all images at twice their intended display resolution."}),"\n",(0,s.jsx)(t.p,{children:"We need to add a special indicator to the URL itself to trigger the transform so let's add a function that does that for a given image URL (this can go either inside or outside of the component definition):"}),"\n",(0,s.jsx)(t.pre,{children:(0,s.jsx)(t.code,{className:"language-jsx",metastring:'title="web/src/components/Images/Images.js"',children:"const thumbnail = (url) => {\n  const parts = url.split('/')\n  parts.splice(3, 0, 'resize=width:100')\n  return parts.join('/')\n}\n"})}),"\n",(0,s.jsx)(t.p,{children:"What this does is turn a URL like"}),"\n",(0,s.jsx)(t.pre,{children:(0,s.jsx)(t.code,{children:"https://cdn.filestackcontent.com/81m7qIrURxSp7WHcft9a\n"})}),"\n",(0,s.jsx)(t.p,{children:"into"}),"\n",(0,s.jsx)(t.pre,{children:(0,s.jsx)(t.code,{children:"https://cdn.filestackcontent.com/resize=width:100/81m7qIrURxSp7WHcft9a\n"})}),"\n",(0,s.jsxs)(t.p,{children:["Now we'll use the result of that function in the ",(0,s.jsx)(t.code,{children:"<img>"})," tag:"]}),"\n",(0,s.jsx)(t.pre,{children:(0,s.jsx)(t.code,{className:"language-jsx",metastring:'title="web/src/components/Images/Images.js"',children:"<img src={thumbnail(image.url)} style={{ maxWidth: '50px' }} />\n"})}),"\n",(0,s.jsx)(t.p,{children:"Starting with an uploaded image of 157kB, the 100px thumbnail clocks in at only 6.5kB! Optimizing image delivery is almost always worth the extra effort!"}),"\n",(0,s.jsxs)(t.p,{children:["You can read more about the available transforms at ",(0,s.jsx)(t.a,{href:"https://www.filestack.com/docs/api/processing/",children:"Filestack's API reference"}),"."]}),"\n",(0,s.jsx)(t.h2,{id:"the-improvements",children:"The Improvements"}),"\n",(0,s.jsx)(t.p,{children:"It'd be nice if, after uploading, you could see the image you uploaded. Likewise, when editing an image, it'd be helpful to see what's already attached. Let's make those improvements now."}),"\n",(0,s.jsx)(t.p,{children:"We're already storing the attached image URL in state, so let's use the existence of that state to show the attached image. In fact, let's also hide the uploader and assume you're done (you'll be able to show it again if needed):"}),"\n",(0,s.jsx)(t.pre,{children:(0,s.jsx)(t.code,{className:"language-jsx",metastring:'{5,8} title="web/src/components/ImageForm/ImageForm.js"',children:"<PickerInline\n  apikey={process.env.REDWOOD_ENV_FILESTACK_API_KEY}\n  onSuccess={onFileUpload}\n>\n  <div style={{ display: url ? 'none' : 'block', height: '500px' }}></div>\n</PickerInline>\n\n{url && <img src={url} style={{ marginTop: '2rem' }} />}\n"})}),"\n",(0,s.jsx)(t.p,{children:"Now if you create a new image record, you'll see the picker, and as soon as the upload is complete, the uploaded image will pop into place. If you go to edit an image, you'll see the file that's already attached."}),"\n",(0,s.jsxs)(t.blockquote,{children:["\n",(0,s.jsx)(t.p,{children:"You should probably use the same resize-URL trick here to make sure it doesn't try to display a 10MB image immediately after uploading it. A max width of 500px may be good..."}),"\n"]}),"\n",(0,s.jsx)(t.p,{children:"Now let's add the ability to bring back the uploader if you decide you want to change the image. We can do that by clearing the image that's in state:"}),"\n",(0,s.jsx)(t.pre,{children:(0,s.jsx)(t.code,{className:"language-jsx",metastring:'{8-18} title="web/src/components/ImageForm/ImageForm.js"',children:"<PickerInline\n  apikey={process.env.REDWOOD_ENV_FILESTACK_API_KEY}\n  onSuccess={onFileUpload}\n>\n  <div style={{ display: url ? 'none' : 'block', height: '500px' }}></div>\n</PickerInline>\n\n{url && (\n  <div>\n    <img src={url} style={{ display: 'block', margin: '2rem 0' }} />\n    <button\n      onClick={() => setUrl(null)}\n      className=\"rw-button rw-button-blue\"\n    >\n      Replace Image\n    </button>\n  </div>\n)}\n"})}),"\n",(0,s.jsx)(t.p,{children:(0,s.jsx)(t.img,{src:"https://user-images.githubusercontent.com/300/82719274-e7055780-9c5d-11ea-9a8a-8c1c72185983.png",alt:"Replace image button"})}),"\n",(0,s.jsx)(t.p,{children:"We're borrowing the styles from the submit button and making sure that the image has both a top and bottom margin so it doesn't crash into the new button."}),"\n",(0,s.jsx)(t.h2,{id:"the-delete",children:"The Delete"}),"\n",(0,s.jsx)(t.p,{children:"Having a free plan is great, but if you just load images and never actually remove the unnecessary ones, you'll be in trouble."}),"\n",(0,s.jsxs)(t.p,{children:["To avoid this, we'd better implement the ",(0,s.jsx)(t.code,{children:"deleteImage"})," mutation. It will enable you to make a call to the Filestack API to remove your resources and, on success, remove the row in the ",(0,s.jsx)(t.code,{children:"Image"})," model."]}),"\n",(0,s.jsxs)(t.p,{children:["You are going to need a new ENV var called ",(0,s.jsx)(t.code,{children:"REDWOOD_ENV_FILESTACK_SECRET"}),", which you can find in ",(0,s.jsx)(t.strong,{children:"Filestack > Security > Policy & Signature:"})," App Secret. Put this into your ",(0,s.jsx)(t.code,{children:".env"})," file (don't use this one of course, paste your own in there):"]}),"\n",(0,s.jsx)(t.pre,{children:(0,s.jsx)(t.code,{className:"language-dotenv",metastring:'title=".env"',children:"REDWOOD_ENV_FILESTACK_SECRET= PWRWGEKFZ2HJMXWSBP3YYI5ERZ\n"})}),"\n",(0,s.jsxs)(t.p,{children:["Filestack's library will provide a ",(0,s.jsx)(t.code,{children:"getSecurity"})," method that will allow us to delete a resource, but only if executed on a ",(0,s.jsx)(t.strong,{children:"nodejs"})," environment. Hence, we need to execute the ",(0,s.jsx)(t.code,{children:"delete"})," operation on the ",(0,s.jsx)(t.code,{children:"api"})," side."]}),"\n",(0,s.jsx)(t.p,{children:"Let's add the proper package:"}),"\n",(0,s.jsx)(t.pre,{children:(0,s.jsx)(t.code,{className:"language-shell",children:"yarn workspace api add filestack-js\n"})}),"\n",(0,s.jsx)(t.p,{children:"Great. Now we can modify our service accordingly:"}),"\n",(0,s.jsx)(t.pre,{children:(0,s.jsx)(t.code,{className:"language-js",metastring:'{4-23} title="api/src/services/image/image.ts"',children:"import * as Filestack from 'filestack-js'\n\nexport const deleteImage = async({ id }) => {\n  const client = Filestack.init(process.env.REDWOOD_ENV_FILESTACK_API_KEY)\n\n  const image = await db.image.findUnique({ where: { id } })\n\n  // The `security.handle` is the unique part of the Filestack file's url.\n  const handle = image.url.split('/').pop()\n  \n  const security = Filestack.getSecurity(\n    {\n      // We set `expiry` at `now() + 5 minutes`.\n      expiry: new Date().getTime() + 5 * 60 * 1000,\n      handle,\n      call: ['remove'],\n    },\n    process.env.REDWOOD_ENV_FILESTACK_SECRET\n  )\n\n  await client.remove(handle, security)\n  \n  return db.image.delete({ where: { id } } )\n}\n"})}),"\n",(0,s.jsxs)(t.p,{children:["Great! Now when you click the button in the frontend, the service will make a call to Filestack to remove the image from the service first. We set ",(0,s.jsx)(t.code,{children:"expiry"})," to 20 seconds so that our policy expires 20 seconds after its generation, this is more than enough to protect your access while executing such operation."]}),"\n",(0,s.jsxs)(t.p,{children:["Assuming the request to ",(0,s.jsx)(t.code,{children:"remove()"})," the image succeeded, we then delete it locally. If you wanted to be extra safe you could surround the ",(0,s.jsx)(t.code,{children:"remove()"})," call with a try/catch block and then throw your own error if Filestack ends up throwing an error."]}),"\n",(0,s.jsx)(t.h2,{id:"the-wrap-up",children:"The Wrap-up"}),"\n",(0,s.jsx)(t.p,{children:"Files uploaded!"}),"\n",(0,s.jsxs)(t.p,{children:["There's plenty of ways to integrate a file picker. This is just one, but we think it's simple, yet flexible. We use the same technique on the ",(0,s.jsx)(t.a,{href:"https://github.com/redwoodjs/example-blog",children:"example-blog"}),"."]}),"\n",(0,s.jsx)(t.p,{children:"Have fun and get uploading!"})]})}function h(e={}){const{wrapper:t}={...(0,r.R)(),...e.components};return t?(0,s.jsx)(t,{...e,children:(0,s.jsx)(c,{...e})}):c(e)}},28453:(e,t,n)=>{n.d(t,{R:()=>i,x:()=>o});var a=n(96540);const s={},r=a.createContext(s);function i(e){const t=a.useContext(r);return a.useMemo((function(){return"function"==typeof e?e(t):{...t,...e}}),[t,e])}function o(e){let t;return t=e.disableParentContext?"function"==typeof e.components?e.components(s):e.components||s:i(e.components),a.createElement(r.Provider,{value:t},e.children)}}}]);