add builder dashboard dropzone

pull/480/head
Alex Turchyn 6 months ago committed by Pete Matsyburka
parent e34a763dd9
commit 97b8ac4444

@ -0,0 +1,36 @@
# frozen_string_literal: true
class TemplateReplaceDocumentsController < ApplicationController
load_and_authorize_resource :template
def create
if params[:blobs].blank? && params[:files].blank?
return respond_to do |f|
f.html { redirect_back fallback_location: template_path(@template), alert: I18n.t('file_is_missing') }
f.json { render json: { error: I18n.t('file_is_missing') }, status: :unprocessable_entity }
end
end
ActiveRecord::Associations::Preloader.new(
records: [@template],
associations: [schema_documents: :preview_images_attachments]
).call
cloned_template = Templates::Clone.call(@template, author: current_user)
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
render json: { error: 'PDF encrypted', status: 'pdf_encrypted' }, status: :unprocessable_entity
end
end

@ -33,6 +33,7 @@ import MaskedInput from './elements/masked_input'
import SetDateButton from './elements/set_date_button' import SetDateButton from './elements/set_date_button'
import IndeterminateCheckbox from './elements/indeterminate_checkbox' import IndeterminateCheckbox from './elements/indeterminate_checkbox'
import AppTour from './elements/app_tour' import AppTour from './elements/app_tour'
import DashboardDropzone from './elements/dashboard_dropzone'
import * as TurboInstantClick from './lib/turbo_instant_click' import * as TurboInstantClick from './lib/turbo_instant_click'
@ -101,6 +102,7 @@ safeRegisterElement('masked-input', MaskedInput)
safeRegisterElement('set-date-button', SetDateButton) safeRegisterElement('set-date-button', SetDateButton)
safeRegisterElement('indeterminate-checkbox', IndeterminateCheckbox) safeRegisterElement('indeterminate-checkbox', IndeterminateCheckbox)
safeRegisterElement('app-tour', AppTour) safeRegisterElement('app-tour', AppTour)
safeRegisterElement('dashboard-dropzone', DashboardDropzone)
safeRegisterElement('template-builder', class extends HTMLElement { safeRegisterElement('template-builder', class extends HTMLElement {
connectedCallback () { connectedCallback () {
@ -125,6 +127,7 @@ safeRegisterElement('template-builder', class extends HTMLElement {
withSendButton: this.dataset.withSendButton !== 'false', withSendButton: this.dataset.withSendButton !== 'false',
withSignYourselfButton: this.dataset.withSignYourselfButton !== 'false', withSignYourselfButton: this.dataset.withSignYourselfButton !== 'false',
withConditions: this.dataset.withConditions === 'true', withConditions: this.dataset.withConditions === 'true',
withReplaceAndCloneUpload: this.dataset.withReplaceAndCloneUpload !== 'false',
currencies: (this.dataset.currencies || '').split(',').filter(Boolean), currencies: (this.dataset.currencies || '').split(',').filter(Boolean),
acceptFileTypes: this.dataset.acceptFileTypes, acceptFileTypes: this.dataset.acceptFileTypes,
showTourStartForm: this.dataset.showTourStartForm === 'true' showTourStartForm: this.dataset.showTourStartForm === 'true'

@ -0,0 +1,123 @@
import { actionable } from '@github/catalyst/lib/actionable'
import { target, targets, targetable } from '@github/catalyst/lib/targetable'
export default actionable(targetable(class extends HTMLElement {
static [targets.static] = ['hiddenOnHover']
static [target.static] = [
'loading',
'icon',
'input',
'fileDropzone'
]
connectedCallback () {
this.showOnlyOnWindowHover = this.dataset.showOnlyOnWindowHover === 'true'
document.addEventListener('drop', this.onWindowDragdrop)
document.addEventListener('dragover', this.onWindowDropover)
window.addEventListener('dragleave', this.onWindowDragleave)
this.addEventListener('dragover', this.onDragover)
this.addEventListener('dragleave', this.onDragleave)
this.fileDropzone.addEventListener('drop', this.onDrop)
this.fileDropzone.addEventListener('turbo:submit-start', this.showDraghover)
this.fileDropzone.addEventListener('turbo:submit-end', this.hideDraghover)
}
disconnectedCallback () {
document.removeEventListener('drop', this.onWindowDragdrop)
document.removeEventListener('dragover', this.onWindowDropover)
window.removeEventListener('dragleave', this.onWindowDragleave)
this.removeEventListener('dragover', this.onDragover)
this.removeEventListener('dragleave', this.onDragleave)
this.fileDropzone.removeEventListener('drop', this.onDrop)
this.fileDropzone.removeEventListener('turbo:submit-start', this.showDraghover)
this.fileDropzone.removeEventListener('turbo:submit-end', this.hideDraghover)
}
onDrop = (e) => {
e.preventDefault()
this.input.files = e.dataTransfer.files
this.uploadFiles(e.dataTransfer.files)
}
onWindowDragdrop = () => {
if (!this.hovered) this.hideDraghover()
}
onSelectFiles (e) {
e.preventDefault()
this.uploadFiles(this.input.files)
}
toggleLoading = (e) => {
if (e && e.target && (!e.target.contains(this) || !e.detail?.formSubmission?.formElement?.contains(this))) {
return
}
this.loading?.classList?.toggle('hidden')
this.icon?.classList?.toggle('hidden')
}
uploadFiles () {
this.toggleLoading()
this.fileDropzone.querySelector('button[type="submit"]').click()
}
onWindowDropover = (e) => {
e.preventDefault()
if (e.dataTransfer?.types?.includes('Files')) {
this.showDraghover()
}
}
onWindowDragleave = (e) => {
if (e.clientX <= 0 || e.clientY <= 0 || e.clientX >= window.innerWidth || e.clientY >= window.innerHeight) {
this.hideDraghover()
}
}
onDragover (e) {
e.preventDefault()
this.hovered = true
this.style.backgroundColor = '#F7F3F0'
}
onDragleave (e) {
e.preventDefault()
this.hovered = false
this.style.backgroundColor = null
}
showDraghover = () => {
if (this.showOnlyOnWindowHover) {
this.classList.remove('hidden')
}
this.classList.remove('bg-base-200', 'border-transparent')
this.classList.add('bg-base-100', 'border-base-300', 'border-dashed')
this.fileDropzone.classList.remove('hidden')
this.hiddenOnHover.forEach((el) => { el.style.display = 'none' })
}
hideDraghover = () => {
if (this.showOnlyOnWindowHover) {
this.classList.add('hidden')
}
this.classList.add('bg-base-200', 'border-transparent')
this.classList.remove('bg-base-100', 'border-base-300', 'border-dashed')
this.fileDropzone.classList.add('hidden')
this.hiddenOnHover.forEach((el) => { el.style.display = null })
}
}))

@ -1,10 +1,23 @@
<template> <template>
<HoverDropzone
v-if="sortedDocuments.length && (withUploadButton || withAddPageButton)"
:is-dragging="isDragging"
:template-id="template.id"
:accept-file-types="acceptFileTypes"
:with-replace-and-clone="withReplaceAndCloneUpload"
hover-class="bg-base-200/50"
@add="[updateFromUpload($event), isDragging = false]"
@replace="[onDocumentsReplace($event), isDragging = false]"
@replace-and-clone="[onDocumentsReplaceAndTemplateClone($event), isDragging = false]"
@error="onUploadFailed"
/>
<div <div
ref="dragContainer" ref="dragContainer"
style="max-width: 1600px" style="max-width: 1600px"
class="mx-auto pl-3 h-full" class="mx-auto pl-3 h-full"
:class="isMobile ? 'pl-4' : 'md:pl-4'" :class="isMobile ? 'pl-4' : 'md:pl-4'"
@dragover="onDragover" @dragover="onDragover"
@drop="isDragging = false"
> >
<DragPlaceholder <DragPlaceholder
ref="dragPlaceholder" ref="dragPlaceholder"
@ -433,6 +446,7 @@
<script> <script>
import Upload from './upload' import Upload from './upload'
import Dropzone from './dropzone' import Dropzone from './dropzone'
import HoverDropzone from './hover_dropzone'
import DragPlaceholder from './drag_placeholder' import DragPlaceholder from './drag_placeholder'
import Fields from './fields' import Fields from './fields'
import MobileDrawField from './mobile_draw_field' import MobileDrawField from './mobile_draw_field'
@ -462,6 +476,7 @@ export default {
MobileFields, MobileFields,
Logo, Logo,
Dropzone, Dropzone,
HoverDropzone,
DocumentPreview, DocumentPreview,
DocumentControls, DocumentControls,
IconInnerShadowTop, IconInnerShadowTop,
@ -665,6 +680,11 @@ export default {
required: false, required: false,
default: true default: true
}, },
withReplaceAndCloneUpload: {
type: Boolean,
required: false,
default: true
},
withPhone: { withPhone: {
type: Boolean, type: Boolean,
required: false, required: false,
@ -724,7 +744,8 @@ export default {
copiedArea: null, copiedArea: null,
drawFieldType: null, drawFieldType: null,
drawOption: null, drawOption: null,
dragField: null dragField: null,
isDragging: false
} }
}, },
computed: { computed: {
@ -836,6 +857,7 @@ export default {
window.addEventListener('keydown', this.onKeyDown) window.addEventListener('keydown', this.onKeyDown)
window.addEventListener('resize', this.onWindowResize) window.addEventListener('resize', this.onWindowResize)
window.addEventListener('dragleave', this.onWindowDragLeave)
this.$nextTick(() => { this.$nextTick(() => {
if (document.location.search?.includes('stripe_connect_success')) { if (document.location.search?.includes('stripe_connect_success')) {
@ -854,6 +876,7 @@ export default {
window.removeEventListener('keydown', this.onKeyDown) window.removeEventListener('keydown', this.onKeyDown)
window.removeEventListener('resize', this.onWindowResize) window.removeEventListener('resize', this.onWindowResize)
window.removeEventListener('dragleave', this.onWindowDragLeave)
}, },
beforeUpdate () { beforeUpdate () {
this.documentRefs = [] this.documentRefs = []
@ -868,6 +891,13 @@ export default {
ref.x = e.clientX - ref.offsetX ref.x = e.clientX - ref.offsetX
ref.y = e.clientY - ref.offsetY ref.y = e.clientY - ref.offsetY
} else if (e.dataTransfer?.types?.includes('Files')) {
this.isDragging = true
}
},
onWindowDragLeave (event) {
if (event.clientX <= 0 || event.clientY <= 0 || event.clientX >= window.innerWidth || event.clientY >= window.innerHeight) {
this.isDragging = false
} }
}, },
reorderFields (item) { reorderFields (item) {
@ -1529,6 +1559,11 @@ export default {
}) })
}, 'image/png') }, 'image/png')
}, },
onUploadFailed (error) {
this.isDragging = false
if (error) alert(error)
},
updateFromUpload (data) { updateFromUpload (data) {
this.template.schema.push(...data.schema) this.template.schema.push(...data.schema)
this.template.documents.push(...data.documents) this.template.documents.push(...data.documents)
@ -1649,6 +1684,29 @@ export default {
this.save() this.save()
}, },
onDocumentsReplace (data) {
data.schema.forEach((schemaItem , index) => {
const existingSchemaItem = this.template.schema[index]
if (this.template.schema[index]) {
this.onDocumentReplace({
replaceSchemaItem: existingSchemaItem,
schema: [schemaItem],
documents: [data.documents.find((doc) => doc.uuid === schemaItem.attachment_uuid)]
})
} else {
this.updateFromUpload({
schema: [schemaItem],
documents: [data.documents.find((doc) => doc.uuid === schemaItem.attachment_uuid)],
fields: data.fields,
submitters: data.submitters
})
}
})
},
onDocumentsReplaceAndTemplateClone (template) {
window.Turbo.visit(`/templates/${template.id}/edit`)
},
moveDocument (item, direction) { moveDocument (item, direction) {
const currentIndex = this.template.schema.indexOf(item) const currentIndex = this.template.schema.indexOf(item)

@ -2,15 +2,17 @@
<div <div
class="flex h-60 w-full" class="flex h-60 w-full"
@dragover.prevent @dragover.prevent
@dragenter="isDragEntering = true"
@dragleave="isDragEntering = false"
@drop.prevent="onDropFiles" @drop.prevent="onDropFiles"
> >
<label <label
id="document_dropzone" id="document_dropzone"
class="w-full relative hover:bg-base-200/30 rounded-md border border-2 border-base-content/10 border-dashed" class="w-full relative rounded-md border-2 border-base-content/10 border-dashed"
:for="inputId" :for="inputId"
:class="{ 'opacity-50': isLoading || isProcessing }" :class="[{ 'opacity-50': isLoading || isProcessing, 'hover:bg-base-200': !hoverClass }, isDragEntering && hoverClass ? hoverClass : '']"
> >
<div class="absolute top-0 right-0 left-0 bottom-0 flex items-center justify-center"> <div class="absolute top-0 right-0 left-0 bottom-0 flex items-center justify-center pointer-events-none">
<div class="flex flex-col items-center"> <div class="flex flex-col items-center">
<IconInnerShadowTop <IconInnerShadowTop
v-if="isLoading || isProcessing" v-if="isLoading || isProcessing"
@ -18,7 +20,8 @@
:width="40" :width="40"
:height="40" :height="40"
/> />
<IconCloudUpload <component
:is="icon"
v-else v-else
:width="40" :width="40"
:height="40" :height="40"
@ -29,7 +32,10 @@
> >
{{ message }} {{ message }}
</div> </div>
<div class="text-sm"> <div
v-if="withDescription"
class="text-sm"
>
<span class="font-medium">{{ t('click_to_upload') }}</span> {{ t('or_drag_and_drop_files') }} <span class="font-medium">{{ t('click_to_upload') }}</span> {{ t('or_drag_and_drop_files') }}
</div> </div>
</div> </div>
@ -54,13 +60,16 @@
<script> <script>
import Upload from './upload' import Upload from './upload'
import { IconCloudUpload, IconInnerShadowTop } from '@tabler/icons-vue' import { IconCloudUpload, IconFilePlus, IconFileSymlink, IconFiles, IconInnerShadowTop } from '@tabler/icons-vue'
export default { export default {
name: 'FileDropzone', name: 'FileDropzone',
components: { components: {
IconFilePlus,
IconCloudUpload, IconCloudUpload,
IconInnerShadowTop IconInnerShadowTop,
IconFileSymlink,
IconFiles
}, },
inject: ['baseFetch', 't'], inject: ['baseFetch', 't'],
props: { props: {
@ -68,33 +77,74 @@ export default {
type: [Number, String], type: [Number, String],
required: true required: true
}, },
icon: {
type: String,
required: false,
default: 'IconCloudUpload'
},
hoverClass: {
type: String,
required: false,
default: null
},
cloneTemplateOnUpload: {
type: Boolean,
required: false,
default: false
},
withDescription: {
type: Boolean,
required: false,
default: true
},
header: {
type: Object,
required: false,
default: () => ({})
},
acceptFileTypes: { acceptFileTypes: {
type: String, type: String,
required: false, required: false,
default: 'image/*, application/pdf' default: 'image/*, application/pdf'
} }
}, },
emits: ['success'], emits: ['success', 'error', 'loading', 'processing'],
data () { data () {
return { return {
isLoading: false, isLoading: false,
isProcessing: false isProcessing: false,
isDragEntering: false
} }
}, },
computed: { computed: {
inputId () { inputId () {
return 'el' + Math.random().toString(32).split('.')[1] return 'el' + Math.random().toString(32).split('.')[1]
}, },
uploadUrl () {
if (this.cloneTemplateOnUpload) {
return `/templates/${this.templateId}/replace_documents`
} else {
return `/templates/${this.templateId}/documents`
}
},
message () { message () {
if (this.isLoading) { if (this.isLoading) {
return this.t('uploading') return this.t('uploading')
} else if (this.isProcessing) { } else if (this.isProcessing) {
return this.t('processing_') return this.t('processing_')
} else if (this.acceptFileTypes === 'image/*, application/pdf') { } else if (this.acceptFileTypes === 'image/*, application/pdf') {
return this.t('add_pdf_documents_or_images') return this.header.pdf_documents_or_images || this.header.documents_or_images || this.t('add_pdf_documents_or_images')
} else { } else {
return this.t('add_documents_or_images') return this.header.documents_or_images || this.t('add_documents_or_images')
}
} }
},
watch: {
isLoading (value) {
this.$emit('loading', value)
},
isProcessing (value) {
this.$emit('processing', value)
} }
}, },
methods: { methods: {

@ -0,0 +1,93 @@
<template>
<div
v-if="isDragging || isLoading || isProcessing"
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"
:header="{ documents_or_images: t('upload_new_document') }"
type="add_files"
@loading="isLoading = $event"
@processing="isProcessing = $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"
:header="{ documents_or_images: t('replace_existing_document') }"
@loading="isLoading = $event"
@processing="isProcessing = $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"
:header="{ documents_or_images: t('clone_template_and_replace_documents') }"
@loading="isLoading = $event"
@processing="isProcessing = $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,
isProcessing: false
}
}
}
</script>

@ -63,6 +63,9 @@ const en = {
processing_: 'Processing...', processing_: 'Processing...',
add_pdf_documents_or_images: 'Add PDF documents or images', add_pdf_documents_or_images: 'Add PDF documents or images',
add_documents_or_images: 'Add documents or images', add_documents_or_images: 'Add documents or images',
upload_new_document: 'Upload new document',
replace_existing_document: 'Replace existing document',
clone_template_and_replace_documents: 'Clone template and replace documents',
required: 'Required', required: 'Required',
default_value: 'Default value', default_value: 'Default value',
format: 'Format', format: 'Format',

@ -63,7 +63,7 @@ export default {
default: 'image/*, application/pdf' default: 'image/*, application/pdf'
} }
}, },
emits: ['success'], emits: ['success', 'error'],
data () { data () {
return { return {
isLoading: false, isLoading: false,
@ -73,14 +73,18 @@ export default {
computed: { computed: {
inputId () { inputId () {
return 'el' + Math.random().toString(32).split('.')[1] return 'el' + Math.random().toString(32).split('.')[1]
},
uploadUrl () {
return `/templates/${this.templateId}/documents`
} }
}, },
methods: { methods: {
async upload () { async upload () {
this.isLoading = true this.isLoading = true
this.baseFetch(`/templates/${this.templateId}/documents`, { this.baseFetch(this.uploadUrl, {
method: 'POST', method: 'POST',
headers: { Accept: 'application/json' },
body: new FormData(this.$refs.form) body: new FormData(this.$refs.form)
}).then((resp) => { }).then((resp) => {
if (resp.ok) { if (resp.ok) {
@ -95,7 +99,7 @@ export default {
formData.append('password', prompt(this.t('enter_pdf_password'))) formData.append('password', prompt(this.t('enter_pdf_password')))
this.baseFetch(`/templates/${this.templateId}/documents`, { this.baseFetch(this.uploadUrl, {
method: 'POST', method: 'POST',
body: formData body: formData
}).then(async (resp) => { }).then(async (resp) => {
@ -104,10 +108,18 @@ export default {
this.$refs.input.value = '' this.$refs.input.value = ''
} else { } else {
alert(this.t('wrong_password')) alert(this.t('wrong_password'))
this.$emit('error', await resp.json().error)
} }
}) })
} else {
this.$emit('error', data.error)
} }
}) })
} else {
resp.json().then((data) => {
this.$emit('error', data.error)
})
} }
}).finally(() => { }).finally(() => {
this.isLoading = false this.isLoading = false

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" class="<%= local_assigns[:class] %>" 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="M15 3v4a1 1 0 0 0 1 1h4" />
<path d="M18 17h-7a2 2 0 0 1 -2 -2v-10a2 2 0 0 1 2 -2h4l5 5v7a2 2 0 0 1 -2 2z" />
<path d="M16 17v2a2 2 0 0 1 -2 2h-7a2 2 0 0 1 -2 -2v-10a2 2 0 0 1 2 -2h2" />
</svg>

After

Width:  |  Height:  |  Size: 485 B

@ -1,5 +1,6 @@
<% is_long = folder.name.size > 32 %> <% is_long = folder.name.size > 32 %>
<a href="<%= folder_path(folder) %>" class="flex h-full flex-col justify-between rounded-2xl py-5 px-6 w-full bg-base-200"> <dashboard-dropzone class="relative rounded-2xl bg-base-200 border-2 border-transparent">
<a href="<%= folder_path(folder) %>" class="flex h-full flex-col justify-between py-5 px-6 w-full">
<% if !is_long %> <% if !is_long %>
<%= svg_icon('folder', class: 'w-6 h-6') %> <%= svg_icon('folder', class: 'w-6 h-6') %>
<% end %> <% end %>
@ -10,3 +11,5 @@
<%= folder.name %> <%= folder.name %>
</div> </div>
</a> </a>
<%= render 'templates/dashboard_dropzone_form', url: templates_upload_path(folder_name: folder.name) %>
</dashboard-dropzone>

@ -4,7 +4,10 @@
<span><%= t('home') %></span> <span><%= t('home') %></span>
<% end %> <% end %>
</div> </div>
<div class="flex justify-between items-center w-full mb-4"> <div class="relative flex justify-between items-center w-full mb-4">
<dashboard-dropzone data-show-only-on-window-hover="true" class="absolute bottom-0 h-36 w-full rounded-xl bg-base-200 border-2 border-dashed z-10 hidden">
<%= render 'templates/dashboard_dropzone_form', url: templates_upload_path(folder_name: @template_folder.name), title: t('upload_new_document'), icon: 'cloud_upload' %>
</dashboard-dropzone>
<h1 class="text-4xl font-bold flex flex-grow min-w-0 space-x-2 md:flex <%= 'hidden' if params[:q].present? %>"> <h1 class="text-4xl font-bold flex flex-grow min-w-0 space-x-2 md:flex <%= 'hidden' if params[:q].present? %>">
<%= svg_icon('folder', class: 'w-9 h-9 flex-shrink-0') %> <%= svg_icon('folder', class: 'w-9 h-9 flex-shrink-0') %>
<span class="peer truncate"> <span class="peer truncate">

@ -0,0 +1,29 @@
<%= form_for '', url: local_assigns.fetch(:url, templates_upload_path), id: form_id = SecureRandom.uuid, method: :post, class: 'block hidden', html: { enctype: 'multipart/form-data' }, data: { target: 'dashboard-dropzone.fileDropzone' } do %>
<input type="hidden" name="form_id" value="<%= form_id %>">
<button type="submit" class="hidden"></button>
<label for="dashboard_dropzone_input_<%= form_id %>">
<div class="absolute top-0 right-0 left-0 bottom-0 flex justify-center p-2 items-<%= local_assigns.fetch(:position, 'center') %>">
<div class="flex flex-col items-center text-center">
<% if local_assigns[:icon] %>
<span data-target="dashboard-dropzone.icon" class="flex flex-col items-center">
<span>
<%= svg_icon(local_assigns[:icon], class: 'w-10 h-10') %>
</span>
<% if local_assigns[:title] %>
<div class="font-medium mb-1">
<%= local_assigns[:title] %>
</div>
<% end %>
</span>
<% end %>
<span data-target="dashboard-dropzone.loading" class="flex flex-col items-center hidden">
<%= svg_icon('loader', class: 'w-10 h-10 animate-spin') %>
<div class="font-medium mb-1">
<%= t('uploading') %>...
</div>
</span>
</div>
<input id="dashboard_dropzone_input_<%= form_id %>" name="files[]" class="hidden" data-action="change:dashboard-dropzone#onSelectFiles" data-target="dashboard-dropzone.input" type="file" accept="image/*, application/pdf<%= ', .docx, .doc, .xlsx, .xls, .odt, .rtf' if Docuseal.multitenant? %>" multiple>
</div>
</label>
<% end %>

@ -1,12 +1,12 @@
<div class="h-36 relative group"> <dashboard-dropzone class="block h-36 relative group bg-base-200 border-2 rounded-2xl border-transparent">
<a href="<%= template_path(template) %>" class="flex h-full flex-col justify-between rounded-2xl pt-6 px-7 w-full bg-base-200 peer"> <a href="<%= template_path(template) %>" class="flex h-full flex-col justify-between pt-6 px-7 w-full peer">
<div class="pb-4 text-xl font-semibold" style="overflow: hidden; display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2;"> <div class="pb-4 text-xl font-semibold" style="overflow: hidden; display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2;">
<% if template.template_accesses.present? %> <% if template.template_accesses.present? %>
<%= svg_icon('lock', class: 'w-6 h-6 inline -translate-y-[4px]') %> <%= svg_icon('lock', class: 'w-6 h-6 inline -translate-y-[4px]') %>
<% end %> <% end %>
<% template.name.split(/(_)/).each do |item| %><%= item %><wbr><% end %> <% template.name.split(/(_)/).each do |item| %><%= item %><wbr><% end %>
</div> </div>
<div class="pb-6 pt-1 space-y-1"> <div class="pb-6 pt-1 space-y-1" data-targets="dashboard-dropzone.hiddenOnHover">
<p class="flex items-center space-x-1 text-xs text-base-content/60"> <p class="flex items-center space-x-1 text-xs text-base-content/60">
<%= svg_icon('user', class: 'w-4 h-4') %> <%= svg_icon('user', class: 'w-4 h-4') %>
<span><%= template.author.full_name.presence || template.author.email.to_s.sub(/\+\w+@/, '@') %></span> <span><%= template.author.full_name.presence || template.author.email.to_s.sub(/\+\w+@/, '@') %></span>
@ -28,7 +28,8 @@
</p> </p>
</div> </div>
</a> </a>
<div class="absolute top-0 bottom-0 w-0 flex items-center hidden md:group-hover:flex" style="right: 37px"> <%= render 'templates/dashboard_dropzone_form', url: template_replace_documents_path(template), title: t('upload_new_based_on_template'), icon: 'files', position: 'end' %>
<div class="absolute top-0 bottom-0 w-0 flex items-center hidden md:group-hover:flex" style="right: 37px" data-targets="dashboard-dropzone.hiddenOnHover">
<div class="space-y-1"> <div class="space-y-1">
<% if can?(:update, template) && !template.archived_at? && template.account_id == current_account.id %> <% if can?(:update, template) && !template.archived_at? && template.account_id == current_account.id %>
<span class="tooltip tooltip-left" data-tip="<%= t('move') %>"> <span class="tooltip tooltip-left" data-tip="<%= t('move') %>">
@ -68,4 +69,4 @@
<% end %> <% end %>
</div> </div>
</div> </div>
</div> </dashboard-dropzone>

@ -1,7 +1,12 @@
<% has_archived = current_account.templates.where.not(archived_at: nil).exists? %> <% has_archived = current_account.templates.where.not(archived_at: nil).exists? %>
<% show_dropzone = params[:q].blank? && @pagy.pages == 1 && ((@template_folders.size < 10 && @templates.size.zero?) || (@template_folders.size < 7 && @templates.size < 4) || (@template_folders.size < 4 && @templates.size < 7)) %> <% show_dropzone = params[:q].blank? && @pagy.pages == 1 && ((@template_folders.size < 10 && @templates.size.zero?) || (@template_folders.size < 7 && @templates.size < 4) || (@template_folders.size < 4 && @templates.size < 7)) %>
<% if Docuseal.demo? %><%= render 'shared/demo_alert' %><% end %> <% if Docuseal.demo? %><%= render 'shared/demo_alert' %><% end %>
<div class="flex justify-between items-center w-full mb-4"> <div class="flex justify-between items-center w-full mb-4 relative">
<% unless show_dropzone %>
<dashboard-dropzone data-show-only-on-window-hover="true" class="absolute bottom-0 w-full rounded-xl bg-base-200 border-2 border-dashed z-10 hidden" style="height: 7.5rem;">
<%= render 'templates/dashboard_dropzone_form', title: t('upload_new_document'), icon: 'cloud_upload' %>
</dashboard-dropzone>
<% end %>
<div class="flex items-center flex-grow min-w-0"> <div class="flex items-center flex-grow min-w-0">
<% if has_archived || @pagy.count > 0 || @template_folders.present? %> <% if has_archived || @pagy.count > 0 || @template_folders.present? %>
<div class="mr-2"> <div class="mr-2">

@ -717,6 +717,7 @@ en: &en
name_a_z: Name A-Z name_a_z: Name A-Z
recently_used: Recently used recently_used: Recently used
newest_first: Newest first newest_first: Newest first
upload_new_based_on_template: Upload new based on template
submission_sources: submission_sources:
api: API api: API
bulk: Bulk Send bulk: Bulk Send

@ -97,6 +97,7 @@ Rails.application.routes.draw do
resources :templates, only: %i[new create edit update show destroy] do resources :templates, only: %i[new create edit update show destroy] do
resource :debug, only: %i[show], controller: 'templates_debug' if Rails.env.development? resource :debug, only: %i[show], controller: 'templates_debug' if Rails.env.development?
resources :documents, only: %i[create], controller: 'template_documents' resources :documents, only: %i[create], controller: 'template_documents'
resources :replace_documents, only: %i[create], controller: 'template_replace_documents'
resources :restore, only: %i[create], controller: 'templates_restore' resources :restore, only: %i[create], controller: 'templates_restore'
resources :archived, only: %i[index], controller: 'templates_archived_submissions' resources :archived, only: %i[index], controller: 'templates_archived_submissions'
resources :submissions, only: %i[new create] resources :submissions, only: %i[new create]

@ -4,10 +4,12 @@ module Templates
module CloneAttachments module CloneAttachments
module_function module_function
def call(template:, original_template:, documents: []) def call(template:, original_template:, documents: [], excluded_attachment_uuids: [])
schema_uuids_replacements = {} schema_uuids_replacements = {}
template.schema.each_with_index do |schema_item, index| template.schema.each_with_index do |schema_item, index|
next if excluded_attachment_uuids.include?(schema_item['attachment_uuid'])
new_schema_item_uuid = SecureRandom.uuid new_schema_item_uuid = SecureRandom.uuid
schema_uuids_replacements[schema_item['attachment_uuid']] = new_schema_item_uuid schema_uuids_replacements[schema_item['attachment_uuid']] = new_schema_item_uuid
@ -22,17 +24,22 @@ module Templates
next if field['areas'].blank? next if field['areas'].blank?
field['areas'].each do |area| field['areas'].each do |area|
area['attachment_uuid'] = schema_uuids_replacements[area['attachment_uuid']] new_attachment_uuid = schema_uuids_replacements[area['attachment_uuid']]
area['attachment_uuid'] = new_attachment_uuid if new_attachment_uuid
end end
end end
template.save! template.save!
original_template.schema_documents.map do |document| original_template.schema_documents.filter_map do |document|
new_attachment_uuid = schema_uuids_replacements[document.uuid]
next unless new_attachment_uuid
new_document = new_document =
ApplicationRecord.no_touching do ApplicationRecord.no_touching do
template.documents_attachments.create!( template.documents_attachments.create!(
uuid: schema_uuids_replacements[document.uuid], uuid: new_attachment_uuid,
blob_id: document.blob_id blob_id: document.blob_id
) )
end end

@ -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 unless submitter && document.metadata.dig('pdf', 'fields').present?
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
Loading…
Cancel
Save