3

I'm using TinyMCE in a custom field for the KeystoneJS AdminUI, which is a React app. I'd like to upload images from the React front to the KeystoneJS GraphQL back. I can upload the images using a REST endpoint I added to the Keystone server -- passing TinyMCE an images_upload_handler callback -- but I'd like to take advantage of Keystone's already-built GraphQL endpoint for an Image list/type I've created.

enter image description here

I first tried to use the approach detailed in this article, using axios to upload the image

const getGQL = (theFile) => {
    const query = gql`
  mutation upload($file: Upload!) {
    createImage(file: $file) {
      id
      file {
        path
        filename
      }
    }
  }
`;
// The operation contains the mutation itself as "query"
    // and the variables that are associated with the arguments
    // The file variable is null because we can only pass text
    // in operation variables
    const operation = {
        query,
        variables: {
            file: null
        }
    };
    // This map is used to associate the file saved in the body
    // of the request under "0" with the operation variable "variables.file"
    const map = {
        '0': ['variables.file']
    };

    // This is the body of the request
    // the FormData constructor builds a multipart/form-data request body
    // Here we add the operation, map, and file to upload
    const body = new FormData();
    body.append('operations', JSON.stringify(operation));
    body.append('map', JSON.stringify(map));
    body.append('0', theFile);

    // Create the options of our POST request
    const opts = {
        method: 'post',
        url: 'http://localhost:4545/admin/api',
        body
    };
// @ts-ignore
    return axios(opts);

};

but I'm not sure what to pass as theFile -- TinyMCE's images_upload_handler, from which I need to call the image upload, accepts a blobInfo object which contains functions to give me

enter image description here

The file name doesn't work, neither does the blob -- both give me server errors 500 -- the error message isn't more specific.

I would prefer to use a GraphQL client to upload the image -- another SO article suggests using apollo-upload-client. However, I'm operating within the KeystoneJS environment, and Apollo-upload-client says

Apollo Client can only have 1 “terminating” Apollo Link that sends the GraphQL requests; if one such as apollo-link-http is already setup, remove it.

I believe Keystone has already set up Apollo-link-http (it comes up multiple times on search), so I don't think I can use Apollo-upload-client.

halfer
  • 19,824
  • 17
  • 99
  • 186
Cerulean
  • 5,543
  • 9
  • 59
  • 111

3 Answers3

1

The UploadLink is just a drop-in replacement for HttpLink. There's no reason you shouldn't be able to use it. There's a demo KeystoneJS app here that shows the Apollo Client configuration, including using createUploadLink.

Actual usage of the mutation with the Upload scalar is shown here.

Looking at the source code, you should be able to use a custom image handler and call blob on the provided blobInfo object. Something like this:

tinymce.init({
  images_upload_handler: async function (blobInfo, success, failure) {
    const image = blobInfo.blob()
    try {
      await apolloClient.mutate(
        gql` mutation($image: Upload!) { ... } `,
        {
          variables: { image }
        } 
      )
      success()
    } catch (e) {
      failure(e)
    }
  }
})
Daniel Rearden
  • 80,636
  • 11
  • 185
  • 183
  • I'm a relative newbie with GraphQL -- when you say 'drop-in replacement', what specifically do I do with `UploadLink`? Their docs say to remove any other 'terminating link' that has been set up. Do I in fact need to do this? In the context of Keystone, how? -- Also, the example you linked is very helpful, but as I'm operating within a constrained context where I'm only offered things like the file name, the blob, etc (as per above), how do I use this to arrive at the File object that the example is using (which it gets from the form)? – Cerulean Mar 12 '20 at 15:30
  • 1
    Normally, you use an HttpLink [like this](https://www.apollographql.com/docs/react/v3.0-beta/get-started/#create-a-client). Instead of creating an HttpLink, you create the UploadLink as shown [here](https://github.com/jaydenseric/apollo-upload-client#function-createuploadlink). – Daniel Rearden Mar 12 '20 at 15:54
  • I'll pore through everything. The one question I have is does any of this change when I'm working within the AdminUI? That is, looking at the demo -- https://github.com/keystonejs/keystone/blob/d89b7bf7da85436c7c3a94543370fa5d377f6b33/demo-projects/blog/app/lib/init-apollo.js -- it seems to be setting up the Apollo Client from scratch, but within the AdminUI all that's been done already. Should I do it again, that is, set up my own 'local' Apollo Client? Or am I misunderstanding something? – Cerulean Mar 12 '20 at 17:04
  • 1
    @DanielRearden what about result - blob shouldn't be replaced with new one made with remote url returned from mutation? – xadm Mar 12 '20 at 17:06
  • 1
    I'm not all that familiar with KeystoneJS, but it looks like the admin app [already uses createUploadLink](https://github.com/keystonejs/keystone/blob/5c28c1428545527feee1d2a8924511ae80c30ee0/packages/app-admin-ui/client/apolloClient.js) – Daniel Rearden Mar 12 '20 at 17:23
1

I used to have the same problem and solved it with Apollo upload link. Now when the app got into the production phase I realized that Apollo client took 1/3rd of the gzipped built files and I created minimal graphql client just for keystone use with automatic image upload. The package is available in npm: https://www.npmjs.com/package/@sylchi/keystone-graphql-client

Usage example that will upload github logo to user profile if there is an user with avatar field set as file:

import {  mutate } from  '@sylchi/keystone-graphql-client'

const  getFile  = () =>  fetch('https://github.githubassets.com/images/modules/logos_page/GitHub-Mark.png',
{
    mode:  "cors",
    cache:  "no-cache"
})
.then(response  =>  response.blob())
.then(blob  => {
    return  new  File([blob], "file.png", { type:  "image/png" })
});

getFile().then(file  => {

const  options  = {
    mutation: `
        mutation($id: ID!, $data: UserUpdateInput!){
            updateUser(id: $id, data: $data){
                id
            }
        }
    `,
    variables: {
        id:  "5f5a7f712a64d9db72b30602", //replace with user id
        data: {
            avatar:  file
        }
    }
}

mutate(options).then(result  =>  console.log(result));

});

The whole package is just 50loc with 1 dependency :)

Ken Kauksi
  • 11
  • 2
1

The easies way for me was to use graphql-request. The advantage is that you don't need to set manually any header prop and it uses the variables you need from the images_upload_handler as de docs describe.

I did it this way:

const { request, gql} = require('graphql-request')

const query = gql`
  mutation IMAGE ($file: Upload!) {
    createImage (data:
      file: $file,
    }) {
      id
      file {
        publicUrl
      }
    }
  }
`

images_upload_handler = (blobInfo, success) => {
//                             ^        ^   varibles you get from tinymce
  const variables = {
    file: blobInfo.blob()
  }

  request(GRAPHQL_API_URL, query, variables)
    .then( data => {
      console.log(data)
      success(data.createImage.fileRemote.publicUrl)
    })
}

For Keystone 5 editorConfig would stripe out functions, so I clone the field and set the function in the views/Field.js file.

Good luck ( ^_^)/*

Ralexrdz
  • 797
  • 8
  • 12