Merge from docusealco/wip

pull/440/head 1.8.8
Alex Turchyn 9 months ago committed by GitHub
commit 2da059d440
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -450,8 +450,18 @@ export default {
} }
}, },
formatNumber (number, format) { formatNumber (number, format) {
if (!number && number !== 0) {
return ''
}
if (format === 'comma') { if (format === 'comma') {
return new Intl.NumberFormat('en-US').format(number) return new Intl.NumberFormat('en-US').format(number)
} else if (format === 'usd') {
return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(number)
} else if (format === 'gbp') {
return new Intl.NumberFormat('en-GB', { style: 'currency', currency: 'GBP', minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(number)
} else if (format === 'eur') {
return new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR', minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(number)
} else if (format === 'dot') { } else if (format === 'dot') {
return new Intl.NumberFormat('de-DE').format(number) return new Intl.NumberFormat('de-DE').format(number)
} else if (format === 'space') { } else if (format === 'space') {

@ -157,13 +157,15 @@ export default {
const formContainer = root.getElementById('form_container') const formContainer = root.getElementById('form_container')
const container = root.body || root.querySelector('div') const container = root.body || root.querySelector('div')
const padding = 64 const isAndroid = /android/i.test(navigator.userAgent)
const padding = isAndroid ? 128 : 64
const scrollboxTop = isAndroid ? scrollbox.getBoundingClientRect().top : 0
const boxRect = scrollbox.children[0].getBoundingClientRect() const boxRect = scrollbox.children[0].getBoundingClientRect()
const targetRect = target.getBoundingClientRect() const targetRect = target.getBoundingClientRect()
const targetTopRelativeToBox = targetRect.top - boxRect.top const targetTopRelativeToBox = targetRect.top - boxRect.top
scrollbox.scrollTo({ top: targetTopRelativeToBox - container.offsetHeight + formContainer.offsetHeight + target.offsetHeight + padding, behavior: 'smooth' }) scrollbox.scrollTo({ top: targetTopRelativeToBox + scrollboxTop - container.offsetHeight + formContainer.offsetHeight + target.offsetHeight + padding, behavior: 'smooth' })
}, },
setAreaRef (el) { setAreaRef (el) {
if (el) { if (el) {

@ -1113,9 +1113,9 @@ export default {
this.minimizeForm() this.minimizeForm()
} }
const isMobileSafariIos = 'ontouchstart' in window && navigator.maxTouchPoints > 0 && /AppleWebKit/i.test(navigator.userAgent) const isMobile = 'ontouchstart' in window && navigator.maxTouchPoints > 0 && /AppleWebKit|android/i.test(navigator.userAgent)
if (isMobileSafariIos || /iPhone|iPad|iPod/i.test(navigator.userAgent)) { if (isMobile || /iPhone|iPad|iPod/i.test(navigator.userAgent)) {
this.$nextTick(() => { this.$nextTick(() => {
const root = this.$root.$el.parentNode.getRootNode() const root = this.$root.$el.parentNode.getRootNode()
const scrollbox = root.getElementById('scrollbox') const scrollbox = root.getElementById('scrollbox')

@ -297,7 +297,7 @@ export default {
const data = await resp.json() const data = await resp.json()
if (resp.status === 422) { if (resp.status === 422) {
alert(this.t('number_phone_is_invalid').replace('{number}', this.fullInternationalPhoneValue)) alert(data.error || this.t('number_phone_is_invalid').replace('{number}', this.fullInternationalPhoneValue))
} else if (resp.status === 429) { } else if (resp.status === 429) {
alert(data.error) alert(data.error)
} }

@ -479,7 +479,7 @@ export default {
methods: { methods: {
buildDefaultName: Field.methods.buildDefaultName, buildDefaultName: Field.methods.buildDefaultName,
closeDropdown () { closeDropdown () {
document.activeElement.blur() this.$el.getRootNode().activeElement.blur()
}, },
maybeToggleDefaultValue () { maybeToggleDefaultValue () {
if (['text', 'number'].includes(this.field.type)) { if (['text', 'number'].includes(this.field.type)) {
@ -521,6 +521,12 @@ export default {
formatNumber (number, format) { formatNumber (number, format) {
if (format === 'comma') { if (format === 'comma') {
return new Intl.NumberFormat('en-US').format(number) return new Intl.NumberFormat('en-US').format(number)
} else if (format === 'usd') {
return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(number)
} else if (format === 'gbp') {
return new Intl.NumberFormat('en-GB', { style: 'currency', currency: 'GBP', minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(number)
} else if (format === 'eur') {
return new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR', minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(number)
} else if (format === 'dot') { } else if (format === 'dot') {
return new Intl.NumberFormat('de-DE').format(number) return new Intl.NumberFormat('de-DE').format(number)
} else if (format === 'space') { } else if (format === 'space') {

@ -222,8 +222,8 @@
</div> </div>
<div <div
id="pages_container" id="pages_container"
class="w-full overflow-y-hidden overflow-x-hidden mt-0.5 pt-0.5" class="w-full overflow-x-hidden mt-0.5 pt-0.5"
:class="isMobile ? 'overflow-y-auto' : 'md:overflow-y-auto'" :class="isMobile ? 'overflow-y-auto' : 'overflow-y-hidden md:overflow-y-auto'"
> >
<div <div
ref="documents" ref="documents"
@ -1320,7 +1320,11 @@ export default {
if (!this.fieldsDragFieldRef.value) { if (!this.fieldsDragFieldRef.value) {
if (['select', 'multiple', 'radio'].includes(field.type)) { if (['select', 'multiple', 'radio'].includes(field.type)) {
field.options = [{ value: '', uuid: v4() }] if (this.dragField?.options?.length) {
field.options = this.dragField.options.map(option => ({ value: option, uuid: v4() }))
} else {
field.options = [{ value: '', uuid: v4() }]
}
} }
if (['stamp', 'heading'].includes(field.type)) { if (['stamp', 'heading'].includes(field.type)) {
@ -1510,29 +1514,29 @@ export default {
onDocumentRemove (item) { onDocumentRemove (item) {
if (window.confirm(this.t('are_you_sure_'))) { if (window.confirm(this.t('are_you_sure_'))) {
this.template.schema.splice(this.template.schema.indexOf(item), 1) this.template.schema.splice(this.template.schema.indexOf(item), 1)
}
const removedFieldUuids = [] const removedFieldUuids = []
this.template.fields.forEach((field) => { this.template.fields.forEach((field) => {
[...(field.areas || [])].forEach((area) => { [...(field.areas || [])].forEach((area) => {
if (area.attachment_uuid === item.attachment_uuid) { if (area.attachment_uuid === item.attachment_uuid) {
field.areas.splice(field.areas.indexOf(area), 1) field.areas.splice(field.areas.indexOf(area), 1)
removedFieldUuids.push(field.uuid) removedFieldUuids.push(field.uuid)
} }
})
}) })
})
this.template.fields = this.template.fields =
this.template.fields.filter((f) => !removedFieldUuids.includes(f.uuid) || f.areas?.length) this.template.fields.filter((f) => !removedFieldUuids.includes(f.uuid) || f.areas?.length)
this.save() this.save()
}
}, },
onDocumentReplace (data) { onDocumentReplace (data) {
const { replaceSchemaItem, schema, documents } = data const { replaceSchemaItem, schema, documents } = data
this.template.schema.splice(this.template.schema.indexOf(replaceSchemaItem), 1, schema[0]) this.template.schema.splice(this.template.schema.indexOf(replaceSchemaItem), 1, { ...replaceSchemaItem, ...schema[0] })
this.template.documents.push(...documents) this.template.documents.push(...documents)
if (data.fields) { if (data.fields) {

@ -192,14 +192,14 @@
class="w-full input input-primary input-xs text-sm bg-transparent" class="w-full input input-primary input-xs text-sm bg-transparent"
:placeholder="`${t('option')} ${index + 1}`" :placeholder="`${t('option')} ${index + 1}`"
type="text" type="text"
:readonly="!editable" :readonly="!editable || defaultField"
required required
dir="auto" dir="auto"
@focus="maybeFocusOnOptionArea(option)" @focus="maybeFocusOnOptionArea(option)"
@blur="save" @blur="save"
> >
<button <button
v-if="editable" v-if="editable && !defaultField"
class="text-sm w-3.5" class="text-sm w-3.5"
tabindex="-1" tabindex="-1"
@click="removeOption(option)" @click="removeOption(option)"
@ -208,11 +208,11 @@
</button> </button>
</div> </div>
<div <div
v-if="field.options && !editable" v-if="field.options && (!editable || defaultField)"
class="pb-1" class="pb-1"
/> />
<button <button
v-else-if="field.options && editable" v-else-if="field.options && editable && !defaultField"
class="text-center text-sm w-full pb-1" class="text-center text-sm w-full pb-1"
@click="addOption" @click="addOption"
> >
@ -392,7 +392,7 @@ export default {
return this.sortedAreas[0] && this.$emit('scroll-to', this.sortedAreas[0]) return this.sortedAreas[0] && this.$emit('scroll-to', this.sortedAreas[0])
}, },
closeDropdown () { closeDropdown () {
document.activeElement.blur() this.$el.getRootNode().activeElement.blur()
}, },
addOption () { addOption () {
this.field.options.push({ value: '', uuid: v4() }) this.field.options.push({ value: '', uuid: v4() })

@ -26,6 +26,33 @@
{{ t('format') }} {{ t('format') }}
</label> </label>
</div> </div>
<div
v-if="field.type === 'verification'"
class="py-1.5 px-1 relative"
@click.stop
>
<select
:placeholder="t('method')"
class="select select-bordered select-xs font-normal w-full max-w-xs !h-7 !outline-0 bg-transparent"
@change="[field.preferences ||= {}, field.preferences.method = $event.target.value, save()]"
>
<option
v-for="method in ['QeS', 'AeS']"
:key="method"
:value="method.toLowerCase()"
:selected="method.toLowerCase() === field.preferences?.method || (method === 'QeS' && !field.preferences?.method)"
>
{{ method }}
</option>
</select>
<label
:style="{ backgroundColor }"
class="absolute -top-1 left-2.5 px-1 h-4"
style="font-size: 8px"
>
{{ t('method') }}
</label>
</div>
<div <div
v-if="['number', 'cells'].includes(field.type)" v-if="['number', 'cells'].includes(field.type)"
class="py-1.5 px-1 relative" class="py-1.5 px-1 relative"
@ -229,7 +256,7 @@
</label> </label>
</div> </div>
<li <li
v-if="withRequired && field.type != 'phone' && field.type != 'stamp'" v-if="withRequired && field.type !== 'phone' && field.type !== 'stamp' && field.type !== 'verification'"
@click.stop @click.stop
> >
<label class="cursor-pointer py-1.5"> <label class="cursor-pointer py-1.5">
@ -461,6 +488,9 @@ export default {
numberFormats () { numberFormats () {
return [ return [
'none', 'none',
'usd',
'eur',
'gbp',
'comma', 'comma',
'dot', 'dot',
'space' 'space'
@ -541,6 +571,12 @@ export default {
formatNumber (number, format) { formatNumber (number, format) {
if (format === 'comma') { if (format === 'comma') {
return new Intl.NumberFormat('en-US').format(number) return new Intl.NumberFormat('en-US').format(number)
} else if (format === 'usd') {
return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(number)
} else if (format === 'gbp') {
return new Intl.NumberFormat('en-GB', { style: 'currency', currency: 'GBP', minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(number)
} else if (format === 'eur') {
return new Intl.NumberFormat('fr-FR', { style: 'currency', currency: 'EUR', minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(number)
} else if (format === 'dot') { } else if (format === 'dot') {
return new Intl.NumberFormat('de-DE').format(number) return new Intl.NumberFormat('de-DE').format(number)
} else if (format === 'space') { } else if (format === 'space') {

@ -362,7 +362,7 @@ export default {
this.$emit('new-submitter', newSubmitter) this.$emit('new-submitter', newSubmitter)
}, },
closeDropdown () { closeDropdown () {
document.activeElement.blur() this.$el.getRootNode().activeElement.blur()
} }
} }
} }

@ -170,7 +170,7 @@ export default {
}, },
methods: { methods: {
closeDropdown () { closeDropdown () {
document.activeElement.blur() this.$el.getRootNode().activeElement.blur()
} }
} }
} }

@ -1,4 +1,5 @@
const en = { const en = {
method: 'Method',
reorder_fields: 'Reorder fields', reorder_fields: 'Reorder fields',
verify_id: 'Verify ID', verify_id: 'Verify ID',
obtain_qualified_electronic_signature_with_the_trusted_provider_click_to_learn_more: 'Obtain qualified electronic signature (QeS) with the trusted provider. Click to learn more.', obtain_qualified_electronic_signature_with_the_trusted_provider_click_to_learn_more: 'Obtain qualified electronic signature (QeS) with the trusted provider. Click to learn more.',
@ -159,6 +160,7 @@ const en = {
} }
const es = { const es = {
method: 'Método',
reorder_fields: 'Reordenar campos', reorder_fields: 'Reordenar campos',
verify_id: 'Verificar ID', verify_id: 'Verificar ID',
obtain_qualified_electronic_signature_with_the_trusted_provider_click_to_learn_more: 'Obtenga una firma electrónica cualificada (QeS) con el proveedor de confianza. Haga clic para obtener más información.', obtain_qualified_electronic_signature_with_the_trusted_provider_click_to_learn_more: 'Obtenga una firma electrónica cualificada (QeS) con el proveedor de confianza. Haga clic para obtener más información.',
@ -319,6 +321,7 @@ const es = {
} }
const it = { const it = {
method: 'Metodo',
reorder_fields: 'Riordina i campi', reorder_fields: 'Riordina i campi',
verify_id: 'Verifica ID', verify_id: 'Verifica ID',
obtain_qualified_electronic_signature_with_the_trusted_provider_click_to_learn_more: 'Ottieni una firma elettronica qualificata (QeS) con il fornitore di fiducia. Clicca per saperne di più.', obtain_qualified_electronic_signature_with_the_trusted_provider_click_to_learn_more: 'Ottieni una firma elettronica qualificata (QeS) con il fornitore di fiducia. Clicca per saperne di più.',
@ -479,6 +482,7 @@ const it = {
} }
const pt = { const pt = {
method: 'Método',
reorder_fields: 'Reorganizar campos', reorder_fields: 'Reorganizar campos',
verify_id: 'Verificar ID', verify_id: 'Verificar ID',
obtain_qualified_electronic_signature_with_the_trusted_provider_click_to_learn_more: 'Obtenha a assinatura eletrônica qualificada (QeS) com o provedor confiável. Clique para saber mais.', obtain_qualified_electronic_signature_with_the_trusted_provider_click_to_learn_more: 'Obtenha a assinatura eletrônica qualificada (QeS) com o provedor confiável. Clique para saber mais.',
@ -639,6 +643,7 @@ const pt = {
} }
const fr = { const fr = {
method: 'Méthode',
reorder_fields: 'Réorganiser les champs', reorder_fields: 'Réorganiser les champs',
verify_id: "Vérifier l'ID", verify_id: "Vérifier l'ID",
obtain_qualified_electronic_signature_with_the_trusted_provider_click_to_learn_more: 'Obtenez une signature électronique qualifiée (QeS) avec le fournisseur de confiance. Cliquez pour en savoir plus.', obtain_qualified_electronic_signature_with_the_trusted_provider_click_to_learn_more: 'Obtenez une signature électronique qualifiée (QeS) avec le fournisseur de confiance. Cliquez pour en savoir plus.',
@ -799,6 +804,7 @@ const fr = {
} }
const de = { const de = {
method: 'Verfahren',
reorder_fields: 'Felder neu anordnen', reorder_fields: 'Felder neu anordnen',
verify_id: 'ID überprüfen', verify_id: 'ID überprüfen',
obtain_qualified_electronic_signature_with_the_trusted_provider_click_to_learn_more: 'Erhalten Sie eine qualifizierte elektronische Signatur (QeS) beim vertrauenswürdigen Anbieter. Klicken Sie hier, um mehr zu erfahren.', obtain_qualified_electronic_signature_with_the_trusted_provider_click_to_learn_more: 'Erhalten Sie eine qualifizierte elektronische Signatur (QeS) beim vertrauenswürdigen Anbieter. Klicken Sie hier, um mehr zu erfahren.',

@ -153,6 +153,7 @@
<% end %> <% end %>
</div> </div>
<% end %> <% end %>
<%= render 'compliances' %>
<%= render 'integrations' %> <%= render 'integrations' %>
<% if can?(:manage, current_account) && Docuseal.multitenant? && true_user == current_user %> <% if can?(:manage, current_account) && Docuseal.multitenant? && true_user == current_user %>
<div class="px-1 mt-8"> <div class="px-1 mt-8">

@ -173,7 +173,7 @@
<%= t('document_download_filename_format') %> <%= t('document_download_filename_format') %>
</span> </span>
<div class="mt-3"> <div class="mt-3">
<%= f.select :value, [["#{I18n.t('document_name')}.pdf", '{document.name}'], ["#{I18n.t('document_name')} - name@domain.com.pdf", '{document.name} - {submission.submitters}'], ["#{I18n.t('document_name')} - name@domain.com - #{I18n.l(Time.current.beginning_of_year.in_time_zone(current_account.timezone), format: :short)}.pdf", '{document.name} - {submission.submitters} - {submission.completed_at}']], {}, class: 'base-select', onchange: 'this.form.requestSubmit()' %> <%= f.select :value, [["#{I18n.t('document_name')}.pdf", '{document.name}'], ["#{I18n.t('document_name')} - #{I18n.t(:signed)}.pdf", '{document.name} - {submission.status}'], ["#{I18n.t('document_name')} - name@domain.com.pdf", '{document.name} - {submission.submitters}'], ["#{I18n.t('document_name')} - name@domain.com - #{I18n.l(Time.current.beginning_of_year.in_time_zone(current_account.timezone), format: :short)}.pdf", '{document.name} - {submission.submitters} - {submission.completed_at}']], {}, class: 'base-select', onchange: 'this.form.requestSubmit()' %>
</div> </div>
</div> </div>
<% end %> <% end %>

@ -222,6 +222,8 @@
</div> </div>
<% elsif field['type'] == 'checkbox' %> <% elsif field['type'] == 'checkbox' %>
<%= svg_icon('check', class: 'w-6 h-6') %> <%= svg_icon('check', class: 'w-6 h-6') %>
<% elsif field['type'] == 'number' %>
<%= NumberUtils.format_number(value, field.dig('preferences', 'format')) %>
<% elsif field['type'] == 'date' %> <% elsif field['type'] == 'date' %>
<%= TimeUtils.format_date_string(value, field.dig('preferences', 'format'), @submission.account.locale) %> <%= TimeUtils.format_date_string(value, field.dig('preferences', 'format'), @submission.account.locale) %>
<% else %> <% else %>

@ -24,6 +24,7 @@ en: &en
pending_by_me: Pending by me pending_by_me: Pending by me
partially_completed: Partially completed partially_completed: Partially completed
unarchive: Unarchive unarchive: Unarchive
signed: Signed
first_party: 'First Party' first_party: 'First Party'
remove_filter: Remove filter remove_filter: Remove filter
add: Add add: Add
@ -725,6 +726,7 @@ en: &en
read: Read your data read: Read your data
es: &es es: &es
signed: Firmado
reply_to: Responder a reply_to: Responder a
partially_completed: Parcialmente completado partially_completed: Parcialmente completado
pending_by_me: Pendiente por mi pending_by_me: Pendiente por mi
@ -1432,6 +1434,7 @@ es: &es
read: Leer tus datos read: Leer tus datos
it: &it it: &it
signed: Firmato
reply_to: Rispondi a reply_to: Rispondi a
pending_by_me: In sospeso da me pending_by_me: In sospeso da me
add: Aggiungi add: Aggiungi
@ -2138,6 +2141,7 @@ it: &it
read: Leggi i tuoi dati read: Leggi i tuoi dati
fr: &fr fr: &fr
signed: Signé
reply_to: Répondre à reply_to: Répondre à
partially_completed: Partiellement complété partially_completed: Partiellement complété
pending_by_me: En attente par moi pending_by_me: En attente par moi
@ -2846,6 +2850,7 @@ fr: &fr
read: Lire vos données read: Lire vos données
pt: &pt pt: &pt
signed: Assinado
reply_to: Responder a reply_to: Responder a
partially_completed: Parcialmente concluído partially_completed: Parcialmente concluído
pending_by_me: Pendente por mim pending_by_me: Pendente por mim
@ -3553,6 +3558,7 @@ pt: &pt
read: Ler seus dados read: Ler seus dados
de: &de de: &de
signed: Unterschrieben
reply_to: Antworten auf reply_to: Antworten auf
partially_completed: Teilweise abgeschlossen partially_completed: Teilweise abgeschlossen
pending_by_me: Ausstehend von mir pending_by_me: Ausstehend von mir

@ -4,7 +4,16 @@ module NumberUtils
FORMAT_LOCALES = { FORMAT_LOCALES = {
'dot' => 'de', 'dot' => 'de',
'space' => 'fr', 'space' => 'fr',
'comma' => 'en' 'comma' => 'en',
'usd' => 'en',
'eur' => 'fr',
'gbp' => 'en'
}.freeze
CURRENCY_SYMBOLS = {
'usd' => '$',
'eur' => '€',
'gbp' => '£'
}.freeze }.freeze
module_function module_function
@ -12,7 +21,9 @@ module NumberUtils
def format_number(number, format) def format_number(number, format)
locale = FORMAT_LOCALES[format] locale = FORMAT_LOCALES[format]
if locale if CURRENCY_SYMBOLS[format]
ApplicationController.helpers.number_to_currency(number, locale:, precision: 2, unit: CURRENCY_SYMBOLS[format])
elsif locale
ApplicationController.helpers.number_with_delimiter(number, locale:) ApplicationController.helpers.number_with_delimiter(number, locale:)
else else
number number

@ -175,7 +175,7 @@ module Submissions
padding: [15, 0, 8, 0], padding: [15, 0, 8, 0],
position: :float) position: :float)
unless submission.source.in?(%w[embed api]) if show_verify?(submission)
column.formatted_text([{ link: verify_url, text: I18n.t('verify'), style: :link }], column.formatted_text([{ link: verify_url, text: I18n.t('verify'), style: :link }],
font_size: 9, padding: [15, 0, 10, 0], position: :float, text_align: :right) font_size: 9, padding: [15, 0, 10, 0], position: :float, text_align: :right)
end end
@ -425,6 +425,10 @@ module Submissions
def maybe_add_background(_canvas, _submission, _page_size); end def maybe_add_background(_canvas, _submission, _page_size); end
def show_verify?(submission)
!submission.source.in?(%w[embed api])
end
def add_logo(column, _submission = nil) def add_logo(column, _submission = nil)
column.image(PdfIcons.logo_io, width: 40, height: 40, position: :float) column.image(PdfIcons.logo_io, width: 40, height: 40, position: :float)

@ -123,10 +123,22 @@ module Submitters
filename = ReplaceEmailVariables.call(filename_format, submitter:) filename = ReplaceEmailVariables.call(filename_format, submitter:)
filename = filename.gsub('{document.name}', blob.filename.base) filename = filename.gsub('{document.name}', blob.filename.base)
filename = filename.gsub(' - {submission.status}') do
if submitter.submission.submitters.all?(&:completed_at?)
status =
if submitter.submission.template_fields.any? { |f| f['type'] == 'signature' }
I18n.t(:signed)
else
I18n.t(:completed)
end
" - #{status}"
end
end
filename = filename.gsub( filename = filename.gsub(
'{submission.completed_at}', '{submission.completed_at}',
I18n.l(submitter.completed_at.beginning_of_year.in_time_zone(submitter.account.timezone), format: :short) I18n.l(submitter.completed_at.in_time_zone(submitter.account.timezone), format: :short)
) )
"#{filename}.#{blob.filename.extension}" "#{filename}.#{blob.filename.extension}"

Loading…
Cancel
Save