Search code examples
ruby-on-railsvue.jsrails-activestorage

Rails: direct upload and create has_one_attached parent


I'm making an image library type thing in Rails and Vue, and I'm using DirectUpload to manage attachments.

# photo.rb

class Photo < ApplicationRecord
  has_one_attached :file
  # ...
end
# photos_controller.rb

class PhotosController < ApplicationController
  load_and_authorize_resource

  before_action :set_photo, only: %i[show update destroy]

  protect_from_forgery unless: -> { request.format.json? }

  def index
    @photo = current_user.photos.new

    render action: 'index'
  end

  def create
    @photo = current_user.photos.create(photo_params)
    render json: PhotoBlueprint.render(@photo, root: :photo)
  end

  # ...

  def photo_params
    params.require(:photo).permit(:id, :file)
  end
end
# photos/index.html.erb

<%= simple_form_for(@photo, url: photos_path(@photo), html: { multipart: true }) do |f| %>
  <%= f.file_field :file, multiple: true, direct_upload: true, style: 'display: none;' %>
<% end %>

<div id='photos-app'></div>
// UserFileLib.vue

<script>
  import { mapState, mapActions } from 'pinia'
  import { usePhotoStore } from '@stores/photo'
  import { DirectUpload } from '@rails/activestorage'

  export default {
    name: 'UserFileLib',

    computed: {
      ...mapState(usePhotoStore, [
        'photos'
      ]),
      url () {
        return document.getElementById('photo_file').dataset.directUploadUrl
      },
      input () {
        return document.getElementById('file-input')
      },
    },

    mounted () {
      this.getPhotos()
    },

    methods: {
      ...mapActions(usePhotoStore, [
        'addPhoto',
        'getPhotos',
      ]),
      activestorageURL (blob) {
        return `/rails/active_storage/blobs/redirect/${blob.signed_id}/${blob.filename}`
      },
      uploadToActiveStorage () {
        const file = this.input.files[0]
        const upload = new DirectUpload(file, this.url)

        upload.create((error, blob) => {
          if (error) {
            console.log(error)
          } else {
            const url = this.activestorageURL(blob)
            console.log(url)

            this.getPhotos()
          }
        })
      },
      openFileBrowser () {
        this.input.click()
      },
      formatSize (bytes) {
        return Math.round(bytes / 1000)
      }
    }
  }
</script>

<template>
  <div
    @click="openFileBrowser"
    class="card p-3">

    Click or drop files here
  </div>

  <input
    type="file"
    :multiple="true"
    @change="uploadToActiveStorage"
    id="file-input" />

  <div class="grid is-inline-grid mt-2">
    <div
      class="image-container"
      v-for="image in photos"
      :key="image.id">

      <img :src="image.url" :alt="image.label" />

      <div class="filename">
        <strong>{{ image.label }}</strong>

        <br />

        {{ formatSize(image.size) }} kb
      </div>

      <div class="close">
        &times;
      </div>
    </div>
  </div>
</template>

Now, the uploads work fine, the blob is stored correctly.

My issue is that a new Photo object is not created to wrap the attachment, meaning the uploads are lost in the system and have no parent record.

What am I doing wrong?


Solution

  • I've solved this for anyone else looking for help. The logic is to create or update the parent record after the upload is done. I missed this in the official documentation.

    upload.create((error, blob) => {
      if (error) {
        // Handle the error
      } else {
        // ** This is the way **
        // Add an appropriately-named hidden input to the form with a
        // value of blob.signed_id so that the blob ids will be
        // transmitted in the normal upload flow 
        // ** End of **
        //
        const hiddenField = document.createElement('input')
        hiddenField.setAttribute("type", "hidden");
        hiddenField.setAttribute("value", blob.signed_id);
        hiddenField.name = input.name
        document.querySelector('form').appendChild(hiddenField)
      }
    })
    

    Since I'm using Vue and Pinia I made a solution in keeping with that logic:

    // UserImageLib.vue
    
    uploadToActiveStorage (input, file) {
      const url = input.dataset.directUploadUrl
      const upload = new DirectUpload(file, url)
    
      upload.create((error, blob) => {
        if (error) {
          console.log(error)
        } else {
          const params = { [input.name]: blob.signed_id }
          this.createPhoto(params)
        }
      })
    },
    
    // stores/photo.js
    
    addPhoto (payload) {
      this.photos.unshift(payload)
    },
    createPhoto (payload) {
      http.post(`/photos`, payload).then(res => {
        const photo = res.data.photo
        this.addPhoto(photo)
      })
    },