| @ -0,0 +1,35 @@ | ||||
| # frozen_string_literal: true | ||||
| 
 | ||||
| class TemplatesCloneAndReplaceController < ApplicationController | ||||
|   load_and_authorize_resource :template | ||||
| 
 | ||||
|   def create | ||||
|     return head :unprocessable_entity if params[:files].blank? | ||||
| 
 | ||||
|     ActiveRecord::Associations::Preloader.new( | ||||
|       records: [@template], | ||||
|       associations: [schema_documents: :preview_images_attachments] | ||||
|     ).call | ||||
| 
 | ||||
|     cloned_template = Templates::Clone.call(@template, author: current_user) | ||||
|     cloned_template.name = File.basename(params[:files].first.original_filename, '.*') | ||||
|     cloned_template.save! | ||||
| 
 | ||||
|     documents = Templates::ReplaceAttachments.call(cloned_template, params, extract_fields: true) | ||||
| 
 | ||||
|     cloned_template.save! | ||||
| 
 | ||||
|     Templates::CloneAttachments.call(template: cloned_template, original_template: @template, | ||||
|                                      excluded_attachment_uuids: documents.map(&:uuid)) | ||||
| 
 | ||||
|     respond_to do |f| | ||||
|       f.html { redirect_to edit_template_path(cloned_template) } | ||||
|       f.json { render json: { id: cloned_template.id } } | ||||
|     end | ||||
|   rescue Templates::CreateAttachments::PdfEncrypted | ||||
|     respond_to do |f| | ||||
|       f.html { render turbo_stream: turbo_stream.append(params[:form_id], html: helpers.tag.prompt_password) } | ||||
|       f.json { render json: { error: 'PDF encrypted', status: 'pdf_encrypted' }, status: :unprocessable_entity } | ||||
|     end | ||||
|   end | ||||
| end | ||||
| @ -0,0 +1,206 @@ | ||||
| import { target, targets, targetable } from '@github/catalyst/lib/targetable' | ||||
| 
 | ||||
| const loadingIconHtml = `<svg xmlns="http://www.w3.org/2000/svg" class="animate-spin" width="44" height="44" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
 | ||||
|   <path stroke="none" d="M0 0h24v24H0z" fill="none" /> | ||||
|   <path d="M12 3a9 9 0 1 0 9 9" /> | ||||
| </svg>` | ||||
| 
 | ||||
| export default targetable(class extends HTMLElement { | ||||
|   static [targets.static] = [ | ||||
|     'hiddenOnDrag', | ||||
|     'folderCards', | ||||
|     'templateCards' | ||||
|   ] | ||||
| 
 | ||||
|   static [target.static] = [ | ||||
|     'form', | ||||
|     'fileDropzone', | ||||
|     'fileDropzoneLoading' | ||||
|   ] | ||||
| 
 | ||||
|   connectedCallback () { | ||||
|     document.addEventListener('drop', this.onWindowDragdrop) | ||||
|     document.addEventListener('dragover', this.onWindowDropover) | ||||
| 
 | ||||
|     window.addEventListener('dragleave', this.onWindowDragleave) | ||||
| 
 | ||||
|     this.fileDropzone?.addEventListener('drop', this.onDropFile) | ||||
| 
 | ||||
|     this.folderCards.forEach((el) => el.addEventListener('drop', (e) => this.onDropFolder(e, el))) | ||||
|     this.templateCards.forEach((el) => el.addEventListener('drop', this.onDropTemplate)) | ||||
|     this.templateCards.forEach((el) => el.addEventListener('dragstart', this.onTemplateDragStart)) | ||||
| 
 | ||||
|     return [this.fileDropzone, ...this.folderCards, ...this.templateCards].forEach((el) => { | ||||
|       el?.addEventListener('dragover', this.onDragover) | ||||
|       el?.addEventListener('dragleave', this.onDragleave) | ||||
|     }) | ||||
|   } | ||||
| 
 | ||||
|   disconnectedCallback () { | ||||
|     document.removeEventListener('drop', this.onWindowDragdrop) | ||||
|     document.removeEventListener('dragover', this.onWindowDropover) | ||||
| 
 | ||||
|     window.removeEventListener('dragleave', this.onWindowDragleave) | ||||
|   } | ||||
| 
 | ||||
|   onTemplateDragStart = (e) => { | ||||
|     const id = e.target.href.split('/').pop() | ||||
| 
 | ||||
|     e.dataTransfer.effectAllowed = 'move' | ||||
| 
 | ||||
|     if (id) { | ||||
|       e.dataTransfer.setData('template_id', id) | ||||
| 
 | ||||
|       const dragPreview = e.target.cloneNode(true) | ||||
|       const rect = e.target.getBoundingClientRect() | ||||
| 
 | ||||
|       const height = e.target.children[0].getBoundingClientRect().height + 50 | ||||
| 
 | ||||
|       dragPreview.children[1].remove() | ||||
|       dragPreview.style.width = `${rect.width}px` | ||||
|       dragPreview.style.height = `${height}px` | ||||
|       dragPreview.style.position = 'absolute' | ||||
|       dragPreview.style.top = '-1000px' | ||||
|       dragPreview.style.pointerEvents = 'none' | ||||
|       dragPreview.style.opacity = '0.9' | ||||
| 
 | ||||
|       document.body.appendChild(dragPreview) | ||||
| 
 | ||||
|       e.dataTransfer.setDragImage(dragPreview, rect.width / 2, height / 2) | ||||
| 
 | ||||
|       setTimeout(() => document.body.removeChild(dragPreview), 0) | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   onDropFile = (e) => { | ||||
|     e.preventDefault() | ||||
| 
 | ||||
|     this.fileDropzoneLoading.classList.remove('hidden') | ||||
|     this.fileDropzoneLoading.previousElementSibling.classList.add('hidden') | ||||
|     this.fileDropzoneLoading.classList.add('opacity-50') | ||||
| 
 | ||||
|     this.uploadFiles(e.dataTransfer.files, '/templates_upload') | ||||
|   } | ||||
| 
 | ||||
|   onDropFolder = (e, el) => { | ||||
|     e.preventDefault() | ||||
| 
 | ||||
|     const templateId = e.dataTransfer.getData('template_id') | ||||
| 
 | ||||
|     if (e.dataTransfer.files.length || templateId) { | ||||
|       const loading = document.createElement('div') | ||||
|       const svg = el.querySelector('svg') | ||||
| 
 | ||||
|       loading.innerHTML = loadingIconHtml | ||||
|       loading.children[0].classList.add(...svg.classList) | ||||
| 
 | ||||
|       el.replaceChild(loading.children[0], svg) | ||||
|       el.classList.add('opacity-50') | ||||
| 
 | ||||
|       if (e.dataTransfer.files.length) { | ||||
|         const params = new URLSearchParams({ folder_name: el.innerText }).toString() | ||||
| 
 | ||||
|         this.uploadFiles(e.dataTransfer.files, `/templates_upload?${params}`) | ||||
|       } else { | ||||
|         const formData = new FormData() | ||||
| 
 | ||||
|         formData.append('name', el.innerText) | ||||
| 
 | ||||
|         fetch(`/templates/${templateId}/folder`, { | ||||
|           method: 'PUT', | ||||
|           redirect: 'manual', | ||||
|           body: formData, | ||||
|           headers: { | ||||
|             'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').content | ||||
|           } | ||||
|         }).finally(() => { | ||||
|           window.Turbo.cache.clear() | ||||
|           window.Turbo.visit(location.href) | ||||
|         }) | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   onDropTemplate = (e) => { | ||||
|     e.preventDefault() | ||||
| 
 | ||||
|     if (e.dataTransfer.files.length) { | ||||
|       const loading = document.createElement('div') | ||||
|       loading.classList.add('bottom-5', 'left-0', 'flex', 'justify-center', 'w-full', 'absolute') | ||||
|       loading.innerHTML = loadingIconHtml | ||||
| 
 | ||||
|       e.target.appendChild(loading) | ||||
|       e.target.classList.add('opacity-50') | ||||
| 
 | ||||
|       const id = e.target.href.split('/').pop() | ||||
| 
 | ||||
|       this.uploadFiles(e.dataTransfer.files, `/templates/${id}/clone_and_replace`) | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   onWindowDragdrop = (e) => { | ||||
|     e.preventDefault() | ||||
| 
 | ||||
|     if (!this.isLoading) this.hideDraghover() | ||||
|   } | ||||
| 
 | ||||
|   uploadFiles (files, url) { | ||||
|     this.isLoading = true | ||||
| 
 | ||||
|     this.form.action = url | ||||
| 
 | ||||
|     this.form.querySelector('[type="file"]').files = files | ||||
| 
 | ||||
|     this.form.querySelector('[type="submit"]').click() | ||||
|   } | ||||
| 
 | ||||
|   onWindowDropover = (e) => { | ||||
|     e.preventDefault() | ||||
| 
 | ||||
|     if (e.dataTransfer?.types?.includes('Files')) { | ||||
|       this.showDraghover() | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   onDragover (e) { | ||||
|     if (e.dataTransfer?.types?.includes('Files') || this.dataset.targets !== 'dashboard-dropzone.templateCards') { | ||||
|       this.style.backgroundColor = '#F7F3F0' | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   onDragleave () { | ||||
|     this.style.backgroundColor = null | ||||
|   } | ||||
| 
 | ||||
|   onWindowDragleave = (e) => { | ||||
|     if (e.clientX <= 0 || e.clientY <= 0 || e.clientX >= window.innerWidth || e.clientY >= window.innerHeight) { | ||||
|       this.hideDraghover() | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   showDraghover = () => { | ||||
|     if (this.isDrag) return | ||||
| 
 | ||||
|     this.isDrag = true | ||||
| 
 | ||||
|     this.fileDropzone?.classList?.remove('hidden') | ||||
| 
 | ||||
|     this.hiddenOnDrag.forEach((el) => { el.style.display = 'none' }) | ||||
| 
 | ||||
|     return [...this.folderCards, ...this.templateCards].forEach((el) => { | ||||
|       el.classList.remove('bg-base-200', 'before:hidden') | ||||
|     }) | ||||
|   } | ||||
| 
 | ||||
|   hideDraghover = () => { | ||||
|     this.isDrag = false | ||||
| 
 | ||||
|     this.fileDropzone?.classList?.add('hidden') | ||||
| 
 | ||||
|     this.hiddenOnDrag.forEach((el) => { el.style.display = null }) | ||||
| 
 | ||||
|     return [...this.folderCards, ...this.templateCards].forEach((el) => { | ||||
|       el.classList.add('bg-base-200', 'before:hidden') | ||||
|     }) | ||||
|   } | ||||
| }) | ||||
| @ -0,0 +1,89 @@ | ||||
| <template> | ||||
|   <div | ||||
|     v-if="isDragging || isLoading" | ||||
|     class="modal modal-open" | ||||
|   > | ||||
|     <div class="flex flex-col gap-2 p-4 items-center bg-base-100 h-full max-h-[85vh] max-w-6xl rounded-2xl w-full"> | ||||
|       <Dropzone | ||||
|         class="flex-1 h-full" | ||||
|         hover-class="bg-base-200/50" | ||||
|         icon="IconFilePlus" | ||||
|         :template-id="templateId" | ||||
|         :accept-file-types="acceptFileTypes" | ||||
|         :with-description="false" | ||||
|         :title="t('upload_a_new_document')" | ||||
|         type="add_files" | ||||
|         @loading="isLoading = $event" | ||||
|         @success="$emit('add', $event)" | ||||
|         @error="$emit('error', $event)" | ||||
|       /> | ||||
|       <div class="flex-1 flex gap-2 w-full"> | ||||
|         <Dropzone | ||||
|           class="flex-1 h-full" | ||||
|           hover-class="bg-base-200/50" | ||||
|           icon="IconFileSymlink" | ||||
|           :template-id="templateId" | ||||
|           :accept-file-types="acceptFileTypes" | ||||
|           :with-description="false" | ||||
|           :title="t('replace_existing_document')" | ||||
|           @loading="isLoading = $event" | ||||
|           @success="$emit('replace', $event)" | ||||
|           @error="$emit('error', $event)" | ||||
|         /> | ||||
|         <Dropzone | ||||
|           v-if="withReplaceAndClone" | ||||
|           class="flex-1 h-full" | ||||
|           hover-class="bg-base-200/50" | ||||
|           icon="IconFiles" | ||||
|           :template-id="templateId" | ||||
|           :accept-file-types="acceptFileTypes" | ||||
|           :with-description="false" | ||||
|           :clone-template-on-upload="true" | ||||
|           :title="t('clone_and_replace_documents')" | ||||
|           @loading="isLoading = $event" | ||||
|           @success="$emit('replace-and-clone', $event)" | ||||
|           @error="$emit('error', $event)" | ||||
|         /> | ||||
|       </div> | ||||
|     </div> | ||||
|   </div> | ||||
| </template> | ||||
| 
 | ||||
| <script> | ||||
| import Dropzone from './dropzone' | ||||
| 
 | ||||
| export default { | ||||
|   name: 'HoverDropzone', | ||||
|   components: { | ||||
|     Dropzone | ||||
|   }, | ||||
|   inject: ['t'], | ||||
|   props: { | ||||
|     isDragging: { | ||||
|       type: Boolean, | ||||
|       required: true, | ||||
|       default: false | ||||
|     }, | ||||
|     templateId: { | ||||
|       type: [Number, String], | ||||
|       required: true | ||||
|     }, | ||||
|     withReplaceAndClone: { | ||||
|       type: Boolean, | ||||
|       required: false, | ||||
|       default: true | ||||
|     }, | ||||
|     acceptFileTypes: { | ||||
|       type: String, | ||||
|       required: false, | ||||
|       default: 'image/*, application/pdf' | ||||
|     } | ||||
|   }, | ||||
|   emits: ['add', 'replace', 'replace-and-clone', 'error'], | ||||
|   data () { | ||||
|     return { | ||||
|       isLoading: false | ||||
|     } | ||||
|   } | ||||
| } | ||||
| </script> | ||||
| Before Width: | Height: | Size: 357 B After Width: | Height: | Size: 362 B | 
| After Width: | Height: | Size: 400 B | 
| After Width: | Height: | Size: 462 B | 
| After Width: | Height: | Size: 485 B | 
| Before Width: | Height: | Size: 619 B After Width: | Height: | Size: 559 B | 
| @ -0,0 +1,20 @@ | ||||
| <div class="absolute bottom-0 w-full cursor-pointer rounded-xl bg-base-100 border-2 border-base-300 border-dashed hidden z-50" data-target="dashboard-dropzone.fileDropzone" style="<%= local_assigns[:style] %>"> | ||||
|   <div class="absolute top-0 right-0 left-0 bottom-0 flex justify-center p-2 items-center pointer-events-none"> | ||||
|     <div class="flex flex-col items-center text-center" data-target="dashboard-dropzone.toggleLoading"> | ||||
|       <span class="flex flex-col items-center"> | ||||
|         <span> | ||||
|           <%= svg_icon('cloud_upload', class: 'w-9 h-9') %> | ||||
|         </span> | ||||
|         <div class="font-medium mb-1"> | ||||
|           <%= t('upload_new_document') %> | ||||
|         </div> | ||||
|       </span> | ||||
|       <span class="flex flex-col items-center hidden" data-target="dashboard-dropzone.fileDropzoneLoading"> | ||||
|         <%= svg_icon('loader', class: 'w-9 h-9 animate-spin') %> | ||||
|         <div class="font-medium mb-1"> | ||||
|           <%= t('uploading') %>... | ||||
|         </div> | ||||
|       </span> | ||||
|     </div> | ||||
|   </div> | ||||
| </div> | ||||
| @ -0,0 +1,65 @@ | ||||
| # frozen_string_literal: true | ||||
| 
 | ||||
| module Templates | ||||
|   module ReplaceAttachments | ||||
|     module_function | ||||
| 
 | ||||
|     # rubocop:disable Metrics | ||||
|     def call(template, params = {}, extract_fields: false) | ||||
|       documents = Templates::CreateAttachments.call(template, params, extract_fields:) | ||||
|       submitter = template.submitters.first | ||||
| 
 | ||||
|       documents.each_with_index do |document, index| | ||||
|         replaced_document_schema = template.schema[index] | ||||
| 
 | ||||
|         template.schema[index] = { attachment_uuid: document.uuid, name: document.filename.base } | ||||
| 
 | ||||
|         if replaced_document_schema | ||||
|           template.fields.each do |field| | ||||
|             next if field['areas'].blank? | ||||
| 
 | ||||
|             field['areas'].each do |area| | ||||
|               if area['attachment_uuid'] == replaced_document_schema['attachment_uuid'] | ||||
|                 area['attachment_uuid'] = document.uuid | ||||
|               end | ||||
|             end | ||||
|           end | ||||
|         end | ||||
| 
 | ||||
|         next if template.fields.any? { |f| f['areas']&.any? { |a| a['attachment_uuid'] == document.uuid } } | ||||
|         next if submitter.blank? || document.metadata.dig('pdf', 'fields').blank? | ||||
| 
 | ||||
|         pdf_fields = document.metadata['pdf'].delete('fields').to_a | ||||
|         pdf_fields.each { |f| f['submitter_uuid'] = submitter['uuid'] } | ||||
| 
 | ||||
|         if index.positive? && pdf_fields.present? | ||||
|           preview_document = template.schema[index - 1] | ||||
|           preview_document_last_field = template.fields.reverse.find do |f| | ||||
|             f['areas']&.any? do |a| | ||||
|               a['attachment_uuid'] == preview_document[:attachment_uuid] | ||||
|             end | ||||
|           end | ||||
| 
 | ||||
|           if preview_document_last_field | ||||
|             last_preview_document_field_index = template.fields.find_index do |f| | ||||
|               f['uuid'] == preview_document_last_field['uuid'] | ||||
|             end | ||||
|           end | ||||
| 
 | ||||
|           if last_preview_document_field_index | ||||
|             template.fields.insert(index, *pdf_fields) | ||||
|           else | ||||
|             template.fields += pdf_fields | ||||
|           end | ||||
|         elsif pdf_fields.present? | ||||
|           template.fields += pdf_fields | ||||
| 
 | ||||
|           template.schema[index]['pending_fields'] = true | ||||
|         end | ||||
|       end | ||||
| 
 | ||||
|       documents | ||||
|     end | ||||
|     # rubocop:enable Metrics | ||||
|   end | ||||
| end | ||||