Compare commits

...

18 Commits

Author SHA1 Message Date
Pete Matsyburka 505b185cf2 fix download indicator 2024-03-31 18:31:09 +03:00
Pete Matsyburka 50c32ac6ff replace date value 2024-03-31 18:20:33 +03:00
Pete Matsyburka 878ab25fd4 access account via submission 2024-03-31 17:40:51 +03:00
Pete Matsyburka 1b31772ff6 show default date on the page 2024-03-31 17:34:23 +03:00
Pete Matsyburka d4940234b7 add bulk placeholder 2024-03-30 23:36:40 +02:00
Pete Matsyburka 9bdc5d4863 adjust batch limit 2024-03-30 14:42:42 +02:00
Pete Matsyburka a73fd7929c add smtp timeout 2024-03-30 12:43:15 +02:00
Pete Matsyburka 8269a61751 adjust payment connected 2024-03-29 23:24:51 +02:00
Pete Matsyburka de072d1437 scroll to multiple checkbox field 2024-03-29 21:43:13 +02:00
Pete Matsyburka 5b24793f2b render links with markdown in emails 2024-03-29 21:21:04 +02:00
Pete Matsyburka 660f7ab8f3 password reset neutral on-prem 2024-03-29 14:50:18 +02:00
Pete Matsyburka 6e3fea5fa0 validate email param 2024-03-28 11:25:32 +02:00
Pete Matsyburka 29ecbecbc2 flatten annots 2024-03-27 18:24:41 +02:00
Pete Matsyburka ad3bbe9892 sctoll to radio group 2024-03-27 18:24:35 +02:00
Pete Matsyburka b570ebee69 remember builder selected field type 2024-03-27 14:21:30 +02:00
Pete Matsyburka 207780fa3f clear fields on remove doc 2024-03-27 00:11:10 +02:00
Pete Matsyburka 8aa219433b render blank option checkbox 2024-03-26 19:28:41 +02:00
Pete Matsyburka cb1e9f2be4 do not send completed emails to integration 2024-03-26 18:01:35 +02:00
36 changed files with 639 additions and 125 deletions
+2 -1
View File
@@ -8,7 +8,8 @@
},
"rules": {
"vue/no-deprecated-html-element-is": 0,
"vue/no-mutating-props": 0
"vue/no-mutating-props": 0,
"vue/one-component-per-file": 0
},
"parserOptions": {
"ecmaVersion": 2022,
+1 -1
View File
@@ -44,7 +44,7 @@ Layout/LineLength:
AllowedPatterns: ['\A\s*#']
Metrics/AbcSize:
Max: 40
Max: 45
Metrics/ModuleLength:
Max: 500
+1
View File
@@ -52,6 +52,7 @@ DocuSeal is an open source platform that provides secure and efficient digital d
- Automated reminders
- Invitation and identify verification via SMS
- Conditional fields and formulas
- Bulk send with CSV, XLSX spreadsheet import
- SSO / SAML
- Template creation with HTML API ([Guide](https://www.docuseal.co/guides/create-pdf-document-fillable-form-with-html-api))
- Template creation with PDF or DOCX and field tags API ([Guide](https://www.docuseal.co/guides/use-embedded-text-field-tags-in-the-pdf-to-create-a-fillable-form))
+6
View File
@@ -5,6 +5,12 @@ class PasswordsController < Devise::PasswordsController
attribute :user
end
def create
super do |resource|
resource.errors.clear unless Docuseal.multitenant?
end
end
def update
super do |resource|
Current.user = resource
+22
View File
@@ -3,6 +3,7 @@ import { encodeMethodIntoRequestBody } from '@hotwired/turbo-rails/app/javascrip
import { createApp, reactive } from 'vue'
import TemplateBuilder from './template_builder/builder'
import ImportList from './template_builder/import_list'
import ToggleVisible from './elements/toggle_visible'
import DisableHidden from './elements/disable_hidden'
@@ -93,6 +94,7 @@ window.customElements.define('template-builder', class extends HTMLElement {
editable: this.dataset.editable !== 'false',
authenticityToken: document.querySelector('meta[name="csrf-token"]')?.content,
withPayment: this.dataset.withPayment === 'true',
isPaymentConnected: this.dataset.isPaymentConnected === 'true',
withFormula: this.dataset.withFormula === 'true',
withConditions: this.dataset.withConditions === 'true',
currencies: (this.dataset.currencies || '').split(',').filter(Boolean),
@@ -110,3 +112,23 @@ window.customElements.define('template-builder', class extends HTMLElement {
this.appElem?.remove()
}
})
window.customElements.define('import-list', class extends HTMLElement {
connectedCallback () {
this.appElem = document.createElement('div')
this.app = createApp(ImportList, {
template: JSON.parse(this.dataset.template),
authenticityToken: document.querySelector('meta[name="csrf-token"]')?.content
})
this.app.mount(this.appElem)
this.appendChild(this.appElem)
}
disconnectedCallback () {
this.app?.unmount()
this.appElem?.remove()
}
})
+5
View File
@@ -83,6 +83,11 @@ button[disabled] .enabled {
bottom: auto;
}
.tooltip-pre:before {
white-space: pre;
text-align: left;
}
.tooltip-bottom-end:after {
transform: translateX(-25%);
border-color: transparent transparent var(--tooltip-color) transparent;
+3 -3
View File
@@ -212,9 +212,9 @@ export default {
fileRequests.reduce(
(prevPromise, request) => prevPromise.then(() => request()),
Promise.resolve()
)
this.isDownloading = false
).finally(() => {
this.isDownloading = false
})
},
sanitizeHref (href) {
if (href && href.trim().match(/^((?:https?:\/\/)|\/)/)) {
+1
View File
@@ -208,6 +208,7 @@
:name="`values[${currentField.uuid}]`"
:value="option.value || `${t('option')} ${index + 1}`"
:required="currentField.required"
@click="scrollIntoField(currentField)"
>
<span class="text-xl">
{{ option.value || `${t('option')} ${index + 1}` }}
@@ -45,6 +45,7 @@
<label
:for="option.uuid"
class="flex items-center space-x-3"
@click="scrollIntoField(field)"
>
<input
:id="option.uuid"
+101 -45
View File
@@ -166,9 +166,10 @@
:allow-draw="!onlyDefinedFields"
:default-submitters="defaultSubmitters"
:draw-field="drawField"
:draw-field-type="drawFieldType"
:editable="editable"
:base-url="baseUrl"
@draw="onDraw"
@draw="[onDraw($event), withSelectedFieldType ? '' : drawFieldType = '', showDrawField = false]"
@drop-field="onDropfield"
@remove-area="removeArea"
/>
@@ -210,13 +211,13 @@
:class="drawField ? 'overflow-hidden' : 'overflow-y-auto overflow-x-hidden'"
>
<div
v-if="drawField"
v-if="showDrawField || drawField"
class="sticky inset-0 h-full z-20"
:style="{ backgroundColor }"
>
<div class="bg-base-300 rounded-lg p-5 text-center space-y-4">
<div class="bg-base-200 rounded-lg p-5 text-center space-y-4">
<p>
{{ t('draw_field_on_the_document').replace('{field}', drawField.name) }}
{{ t('draw_field_on_the_document').replace('{field}', drawField?.name || '') }}
</p>
<div>
<button
@@ -226,10 +227,10 @@
{{ t('cancel') }}
</button>
<a
v-if="!drawOption && !drawField.areas.length && !['stamp', 'signature', 'initials'].includes(drawField.type)"
v-if="!drawField && !drawOption && !['stamp', 'signature', 'initials'].includes(drawField?.type || drawFieldType)"
href="#"
class="link block mt-3 text-sm"
@click.prevent="[drawField = null, drawOption = null]"
@click.prevent="[addField(drawFieldType), drawField = null, drawOption = null, withSelectedFieldType ? '' : drawFieldType = '', showDrawField = false]"
>
{{ t('or_add_field_without_drawing') }}
</a>
@@ -244,12 +245,15 @@
:selected-submitter="selectedSubmitter"
:with-help="withHelp"
:default-submitters="defaultSubmitters"
:draw-field-type="drawFieldType"
:default-fields="defaultFields"
:field-types="fieldTypes"
:with-sticky-submitters="withStickySubmitters"
:only-defined-fields="onlyDefinedFields"
:editable="editable"
@add-field="addField"
@set-draw="[drawField = $event.field, drawOption = $event.option]"
@set-draw-type="[drawFieldType = $event, showDrawField = true]"
@set-drag="dragField = $event"
@change-submitter="selectedSubmitter = $event"
@drag-end="dragField = null"
@@ -328,6 +332,7 @@ export default {
backgroundColor: this.backgroundColor,
withPhone: this.withPhone,
withPayment: this.withPayment,
isPaymentConnected: this.isPaymentConnected,
withFormula: this.withFormula,
withConditions: this.withConditions,
defaultDrawFieldType: this.defaultDrawFieldType,
@@ -375,6 +380,11 @@ export default {
required: false,
default: () => []
},
withSelectedFieldType: {
type: Boolean,
required: false,
default: false
},
defaultDrawFieldType: {
type: String,
required: false,
@@ -464,6 +474,11 @@ export default {
required: false,
default: false
},
isPaymentConnected: {
type: Boolean,
required: false,
default: false
},
withFormula: {
type: Boolean,
required: false,
@@ -491,7 +506,9 @@ export default {
isBreakpointLg: false,
isSaving: false,
selectedSubmitter: null,
showDrawField: false,
drawField: null,
drawFieldType: null,
drawOption: null,
dragField: null
}
@@ -574,6 +591,34 @@ export default {
t (key) {
return this.i18n[key] || i18nEn[key] || key
},
addField (type, area = null) {
const field = {
name: '',
uuid: v4(),
required: type !== 'checkbox',
areas: area ? [area] : [],
submitter_uuid: this.selectedSubmitter.uuid,
type
}
if (['select', 'multiple', 'radio'].includes(type)) {
field.options = [{ value: '', uuid: v4() }]
}
if (type === 'stamp') {
field.readonly = true
}
if (type === 'date') {
field.preferences = {
format: Intl.DateTimeFormat().resolvedOptions().locale.endsWith('-US') ? 'MM/DD/YYYY' : 'DD/MM/YYYY'
}
}
this.template.fields.push(field)
this.save()
},
startFieldDraw ({ name, type }) {
const existingField = this.template.fields?.find((f) => f.submitter_uuid === this.selectedSubmitter.uuid && name && name === f.name)
@@ -654,15 +699,13 @@ export default {
ref.$el.scrollIntoView({ behavior: 'smooth', block: 'start' })
},
clearDrawField () {
if (this.drawField && !this.drawOption && this.drawField.areas.length === 0) {
const fieldIndex = this.template.fields.indexOf(this.drawField)
if (fieldIndex !== -1) {
this.template.fields.splice(fieldIndex, 1)
}
}
this.drawField = null
this.drawOption = null
this.showDrawField = false
if (!this.withSelectedFieldType) {
this.drawFieldType = ''
}
},
onKeyUp (e) {
if (e.code === 'Escape') {
@@ -712,6 +755,27 @@ export default {
}
}
},
setDefaultAreaSize (area, type) {
const documentRef = this.documentRefs.find((e) => e.document.uuid === area.attachment_uuid)
const pageMask = documentRef.pageRefs[area.page].$refs.mask
if (type === 'checkbox') {
area.w = pageMask.clientWidth / 30 / pageMask.clientWidth
area.h = (pageMask.clientWidth / 30 / pageMask.clientWidth) * (pageMask.clientWidth / pageMask.clientHeight)
} else if (type === 'image') {
area.w = pageMask.clientWidth / 5 / pageMask.clientWidth
area.h = (pageMask.clientWidth / 5 / pageMask.clientWidth) * (pageMask.clientWidth / pageMask.clientHeight)
} else if (type === 'signature' || type === 'stamp') {
area.w = pageMask.clientWidth / 5 / pageMask.clientWidth
area.h = (pageMask.clientWidth / 5 / pageMask.clientWidth) * (pageMask.clientWidth / pageMask.clientHeight) / 2
} else if (type === 'initials') {
area.w = pageMask.clientWidth / 10 / pageMask.clientWidth
area.h = (pageMask.clientWidth / 35 / pageMask.clientWidth)
} else {
area.w = pageMask.clientWidth / 5 / pageMask.clientWidth
area.h = (pageMask.clientWidth / 35 / pageMask.clientWidth)
}
},
onDraw (area) {
if (this.drawField) {
if (this.drawOption) {
@@ -734,25 +798,7 @@ export default {
area.w = previousArea.w
area.h = previousArea.h
} else {
const documentRef = this.documentRefs.find((e) => e.document.uuid === area.attachment_uuid)
const pageMask = documentRef.pageRefs[area.page].$refs.mask
if (this.drawField.type === 'checkbox' || this.drawOption) {
area.w = pageMask.clientWidth / 30 / pageMask.clientWidth
area.h = (pageMask.clientWidth / 30 / pageMask.clientWidth) * (pageMask.clientWidth / pageMask.clientHeight)
} else if (this.drawField.type === 'image') {
area.w = pageMask.clientWidth / 5 / pageMask.clientWidth
area.h = (pageMask.clientWidth / 5 / pageMask.clientWidth) * (pageMask.clientWidth / pageMask.clientHeight)
} else if (this.drawField.type === 'signature' || this.drawField.type === 'stamp') {
area.w = pageMask.clientWidth / 5 / pageMask.clientWidth
area.h = (pageMask.clientWidth / 5 / pageMask.clientWidth) * (pageMask.clientWidth / pageMask.clientHeight) / 2
} else if (this.drawField.type === 'initials') {
area.w = pageMask.clientWidth / 10 / pageMask.clientWidth
area.h = (pageMask.clientWidth / 35 / pageMask.clientWidth)
} else {
area.w = pageMask.clientWidth / 5 / pageMask.clientWidth
area.h = (pageMask.clientWidth / 35 / pageMask.clientWidth)
}
this.setDefaultAreaSize(area, this.drawOption ? 'checkbox' : this.drawField?.type)
}
area.x -= area.w / 2
@@ -787,13 +833,15 @@ export default {
let type = (pageMask.clientWidth * area.w) < 35 ? 'checkbox' : 'text'
if (this.defaultDrawFieldType && this.defaultDrawFieldType !== 'text') {
if (this.drawFieldType) {
type = this.drawFieldType
} else if (this.defaultDrawFieldType && this.defaultDrawFieldType !== 'text') {
type = this.defaultDrawFieldType
} else if (this.fieldTypes.length !== 0 && !this.fieldTypes.includes(type)) {
type = this.fieldTypes[0]
}
if (type === 'checkbox') {
if (type === 'checkbox' && !this.drawFieldType) {
const previousField = [...this.template.fields].reverse().find((f) => f.type === type)
const previousArea = previousField?.areas?.[previousField.areas.length - 1]
@@ -811,21 +859,22 @@ export default {
}
}
if (area.w) {
const field = {
name: '',
uuid: v4(),
required: type !== 'checkbox',
type,
submitter_uuid: this.selectedSubmitter.uuid,
areas: [area]
if (this.drawFieldType && (area.w === 0 || area.h === 0)) {
if (this.selectedField?.type === this.drawFieldType) {
area.w = this.selectedAreaRef.value.w
area.h = this.selectedAreaRef.value.h
} else {
this.setDefaultAreaSize(area, this.drawFieldType)
}
this.template.fields.push(field)
area.x -= area.w / 2
area.y -= area.h / 2
}
if (area.w) {
this.addField(type, area)
this.selectedAreaRef.value = area
this.save()
}
}
},
@@ -956,14 +1005,21 @@ export default {
this.template.schema.splice(this.template.schema.indexOf(item), 1)
}
const removedFieldUuids = []
this.template.fields.forEach((field) => {
[...(field.areas || [])].forEach((area) => {
if (area.attachment_uuid === item.attachment_uuid) {
field.areas.splice(field.areas.indexOf(area), 1)
removedFieldUuids.push(field.uuid)
}
})
})
this.template.fields =
this.template.fields.filter((f) => !removedFieldUuids.includes(f.uuid) || f.areas?.length)
this.save()
},
onDocumentReplace ({ replaceSchemaItem, schema, documents }) {
@@ -12,6 +12,7 @@
:default-fields="defaultFields"
:default-submitters="defaultSubmitters"
:draw-field="drawField"
:draw-field-type="drawFieldType"
:selected-submitter="selectedSubmitter"
:image="image"
@drop-field="$emit('drop-field', {...$event, attachment_uuid: document.uuid })"
@@ -43,6 +44,11 @@ export default {
required: false,
default: () => []
},
drawFieldType: {
type: String,
required: false,
default: ''
},
defaultSubmitters: {
type: Array,
required: false,
+1 -1
View File
@@ -5,7 +5,7 @@
@drop.prevent="onDropFiles"
>
<label
class="w-full relative bg-base-200 hover:bg-base-200/70 rounded-md border border-base-content border-dashed"
class="w-full relative hover:bg-base-200/30 rounded-md border border-2 border-base-content/10 border-dashed"
:for="inputId"
:class="{ 'opacity-50': isLoading || isProcessing }"
>
+16 -39
View File
@@ -105,14 +105,18 @@
<button
v-if="(fieldTypes.length === 0 || fieldTypes.includes(type)) && (withPhone || type != 'phone') && (withPayment || type != 'payment')"
draggable="true"
class="field-type-button group flex items-center justify-center border border-dashed border-base-300 hover:border-base-content/20 w-full rounded relative"
class="field-type-button group flex items-center justify-center border border-dashed w-full rounded relative"
:style="{ backgroundColor: backgroundColor }"
:class="drawFieldType === type ? 'border-base-content/40' : 'border-base-300 hover:border-base-content/20'"
@dragstart="onDragstart({ type: type })"
@dragend="$emit('drag-end')"
@click="addField(type)"
@click="['file', 'payment'].includes(type) ? $emit('add-field', type) : $emit('set-draw-type', type)"
>
<div class="flex items-console group-hover:bg-base-200/50 transition-all cursor-grab h-full absolute left-0">
<IconDrag class=" my-auto" />
<div
class="flex items-console transition-all cursor-grab h-full absolute left-0"
:class="drawFieldType === type ? 'bg-base-200/50' : 'group-hover:bg-base-200/50'"
>
<IconDrag class="my-auto" />
</div>
<div class="flex items-center flex-col px-2 py-2">
<component :is="icon" />
@@ -170,10 +174,9 @@
<script>
import Field from './field'
import { v4 } from 'uuid'
import FieldType from './field_type'
import FieldSubmitter from './field_submitter'
import { IconLock } from '@tabler/icons-vue'
import { IconLock, IconCirclePlus } from '@tabler/icons-vue'
import IconDrag from './icon_drag'
export default {
@@ -181,6 +184,7 @@ export default {
components: {
Field,
FieldType,
IconCirclePlus,
FieldSubmitter,
IconDrag,
IconLock
@@ -211,6 +215,11 @@ export default {
required: false,
default: true
},
drawFieldType: {
type: String,
required: false,
default: ''
},
defaultSubmitters: {
type: Array,
required: false,
@@ -235,7 +244,7 @@ export default {
required: true
}
},
emits: ['set-draw', 'set-drag', 'drag-end', 'scroll-to-area', 'change-submitter'],
emits: ['add-field', 'set-draw', 'set-draw-type', 'set-drag', 'drag-end', 'scroll-to-area', 'change-submitter'],
data () {
return {
defaultFieldsSearch: ''
@@ -332,38 +341,6 @@ export default {
})
})
this.save()
},
addField (type, area = null) {
const field = {
name: '',
uuid: v4(),
required: type !== 'checkbox',
areas: [],
submitter_uuid: this.selectedSubmitter.uuid,
type
}
if (['select', 'multiple', 'radio'].includes(type)) {
field.options = [{ value: '', uuid: v4() }]
}
if (type === 'stamp') {
field.readonly = true
}
if (type === 'date') {
field.preferences = {
format: Intl.DateTimeFormat().resolvedOptions().locale.endsWith('-US') ? 'MM/DD/YYYY' : 'DD/MM/YYYY'
}
}
this.fields.push(field)
if (!['payment', 'file'].includes(type)) {
this.$emit('set-draw', { field })
}
this.save()
}
}
@@ -0,0 +1,383 @@
<template>
<div>
<div v-if="selectedSheetIndex === null && spreadsheet">
<form @submit.prevent="[selectedSheetIndex = $refs.selectWorksheet.value, buildDefaultMappings()]">
<label class="label">
Select Worksheet
</label>
<select
ref="selectWorksheet"
class="base-select"
>
<option
v-for="(sheet, index) in spreadsheet"
:key="index"
:value="index"
>
{{ sheet[0] || index }}
</option>
</select>
<button class="base-button mt-4 w-full">
Open
</button>
</form>
</div>
<div v-else-if="selectedSheetIndex !== null">
<div
v-for="submitter in submitters"
:key="submitter.uuid"
class="mb-4"
>
<div
v-if="submitters.length > 1"
class="px-3 border-y py-2 border-base-300 text-center w-full"
>
{{ submitter.name }}
</div>
<div class="flex">
<div class="relative w-full py-2 px-2 text-sm">
Recipient field
</div>
<div class="relative w-full py-2 pl-4 text-sm">
Spreadsheet column
</div>
</div>
<div
v-for="mapping in mappings.filter((m) => m.submitter_uuid === submitter.uuid)"
:key="mapping.uuid"
class="mb-2"
>
<div class="flex">
<select
class="base-select !select-sm !h-10"
required
@change="mapping.field_name = $event.target.value"
>
<option
disabled
value=""
:selected="!mapping.field_name"
>
Select Field
</option>
<option
v-for="(field, index) in selectFieldsForSubmitter(submitter)"
:key="index"
:value="field.name"
:selected="mapping.field_name === field.name"
>
{{ field.name }}
</option>
</select>
<div class="flex items-center px-1">
<IconArrowsHorizontal style="width: 19px; height: 19px" />
</div>
<div class="w-full relative">
<select
class="base-select !select-sm !h-10"
required
@change="mapping.column_index = parseInt($event.target.value)"
>
<option
disabled
value=""
:selected="mapping.column_index == null"
>
Select Column
</option>
<template
v-for="(column, index) in columns"
:key="index"
>
<option
v-if="column"
:value="index"
:selected="index === mapping.column_index"
>
{{ column }}
</option>
</template>
</select>
<div
v-if="mapping.column_index != null"
class="absolute top-0 bottom-0 right-1 flex items-center"
>
<span
class="tooltip tooltip-bottom-end pr-1 tooltip-pre"
style="padding-top: 2px"
:data-tip="[0, 1, 2].map((i) => rows[i]?.[mapping.column_index] ?? '---').join('\n')"
>
<button
class="btn btn-xs btn-circle bg-white border-0 border-gray-300"
@click.prevent
>
<IconInfoCircle class="h-4 w-4" />
</button>
</span>
</div>
</div>
<div class="flex items-center pl-1">
<span
class="tooltip tooltip-top"
data-tip="Remove"
>
<button
:disabled="mappings.filter((m) => m.submitter_uuid === submitter.uuid).length < 2"
class="btn btn-xs btn-circle"
@click.prevent="mappings.splice(mappings.indexOf(mapping), 1)"
>
<IconX class="h-3.5 w-3.5" />
</button>
</span>
</div>
</div>
</div>
<div>
<button
class="btn btn-sm btn-primary w-full !normal-case font-medium"
@click.prevent="addMapping(submitter)"
>
<IconPlus class="w-4 h-4" />
New Field Mapping
</button>
</div>
</div>
<div>
<input
name="submissions_json"
hidden
:value="JSON.stringify(submissionsData.slice(0, 1100))"
>
</div>
<div
class="px-3 border-y py-2 border-base-300 text-center w-full text-sm font-semibold"
>
Total entries: {{ submissionsData.length }}
<template v-if="submissionsData.length >= 1000">
/ 1000
</template>
</div>
</div>
<div
v-else
class="flex h-52 w-full"
@dragover.prevent
@drop.prevent="onDropFiles"
>
<label
class="w-full relative hover:bg-base-200/30 rounded-md border border-2 border-base-content/10 border-dashed"
for="import_list_file"
:class="{ 'opacity-50': isLoading }"
>
<div class="absolute top-0 right-0 left-0 bottom-0 flex items-center justify-center">
<div class="flex flex-col items-center">
<IconInnerShadowTop
v-if="isLoading"
class="animate-spin"
:width="40"
:height="40"
/>
<IconCloudUpload
v-else
:width="40"
:height="40"
/>
<div
class="font-medium text-lg mb-1"
>
Upload CSV or XLSX Spreadsheet
</div>
<div class="text-sm">
<span class="font-medium">Click to Upload</span> or drag and drop files.
</div>
</div>
</div>
<form
ref="form"
class="hidden"
>
<input
id="import_list_file"
ref="input"
type="file"
name="file"
accept=".xlsx, .xls, .csv"
@change="onSelectFile"
>
</form>
</label>
</div>
</div>
</template>
<script>
import { IconCloudUpload, IconX, IconPlus, IconArrowsHorizontal, IconInfoCircle, IconInnerShadowTop } from '@tabler/icons-vue'
import { v4 } from 'uuid'
export default {
name: 'FileDropzone',
components: {
IconCloudUpload,
IconX,
IconArrowsHorizontal,
IconPlus,
IconInfoCircle,
IconInnerShadowTop
},
props: {
template: {
type: Object,
required: true
},
authenticityToken: {
type: String,
required: false,
default: ''
}
},
data () {
return {
isLoading: false,
spreadsheet: null,
selectedSheetIndex: null,
mappings: []
}
},
computed: {
table () {
return this.spreadsheet[this.selectedSheetIndex][1]
},
submissionsData () {
const submissions = []
this.rows.forEach((row) => {
const submittersIndex = {}
this.mappings.forEach((mapping) => {
if (mapping.field_name && mapping.column_index != null) {
submittersIndex[mapping.submitter_uuid] ||= { uuid: mapping.submitter_uuid, fields: [] }
if (['name', 'email', 'phone'].includes(mapping.field_name.toLowerCase())) {
submittersIndex[mapping.submitter_uuid][mapping.field_name.toLowerCase()] = row[mapping.column_index]
} else {
submittersIndex[mapping.submitter_uuid].fields.push({
name: mapping.field_name, default_value: row[mapping.column_index], readonly: true
})
}
}
})
if (Object.keys(submittersIndex).length !== 0) {
submissions.push({ submitters: Object.values(submittersIndex) })
}
})
return submissions
},
submitters () {
return this.template.submitters
},
columns () {
return this.table[0]
},
form () {
return this.$el.closest('form')
},
fieldTypes () {
return ['text', 'cells', 'date', 'number', 'radio', 'select', 'checkbox']
},
defaultFields () {
return [
{ name: 'Name' },
{ name: 'Email' },
{ name: 'Phone' }
]
},
rows () {
return this.table.slice(1)
}
},
watch: {
selectedSheetIndex (value) {
if (value !== null) {
document.getElementById('list_form_buttons')?.classList?.remove('hidden')
}
}
},
methods: {
onDropFiles (e) {
this.uploadFile(e.dataTransfer.files[0])
},
onSelectFile (e) {
this.uploadFile(e.target.files[0])
},
addMapping (submitter) {
this.mappings.push({ uuid: v4(), field_name: '', column_index: null, submitter_uuid: submitter.uuid })
},
selectFieldsForSubmitter (submitter) {
const templateFields = this.template.fields.filter((field) => {
return field.submitter_uuid === submitter.uuid &&
field.name &&
this.fieldTypes.includes(field.type) &&
this.defaultFields.every((f) => field.name?.toLowerCase() !== f.name?.toLowerCase())
})
return [...this.defaultFields, ...templateFields]
},
buildDefaultMappings () {
this.submitters.forEach((submitter) => {
const fields = this.selectFieldsForSubmitter(submitter)
fields.forEach((field) => {
const columnIndex = this.columns.findIndex((column, index) => {
return column &&
column.toString().toLowerCase().includes(field.name?.toLowerCase()) &&
this.mappings.every((m) => m.column_index !== index)
})
if (columnIndex !== -1) {
this.mappings.push({ uuid: v4(), field_name: field.name, column_index: columnIndex, submitter_uuid: submitter.uuid })
}
})
if (!this.mappings.some((m) => m.field_name.toLowerCase() === 'name' && m.submitter_uuid === submitter.uuid)) {
this.mappings.unshift({ uuid: v4(), field_name: 'Name', submitter_uuid: submitter.uuid })
}
if (!this.mappings.some((m) => m.field_name.toLowerCase() === 'email' && m.submitter_uuid === submitter.uuid)) {
this.mappings.unshift({ uuid: v4(), field_name: 'Email', submitter_uuid: submitter.uuid })
}
})
},
uploadFile (file) {
this.isLoading = true
const formData = new FormData()
formData.append('file', file)
return fetch('/upload_spreadsheet', {
method: 'POST',
body: formData,
headers: {
'X-CSRF-Token': this.authenticityToken
}
}).then(resp => resp.json()).then((data) => {
if (data.error) {
return alert(data.error)
}
this.spreadsheet = data
if (data.length === 1) {
this.selectedSheetIndex = 0
this.buildDefaultMappings()
}
}).finally(() => {
this.isLoading = false
})
}
}
}
</script>
+8 -1
View File
@@ -77,6 +77,11 @@ export default {
required: false,
default: () => []
},
drawFieldType: {
type: String,
required: false,
default: ''
},
allowDraw: {
type: Boolean,
required: false,
@@ -123,7 +128,9 @@ export default {
},
computed: {
defaultFieldType () {
if (this.defaultDrawFieldType && this.defaultDrawFieldType !== 'text') {
if (this.drawFieldType) {
return this.drawFieldType
} else if (this.defaultDrawFieldType && this.defaultDrawFieldType !== 'text') {
return this.defaultDrawFieldType
} else if (this.fieldTypes.length !== 0 && !this.fieldTypes.includes('text')) {
return this.fieldTypes[0]
@@ -167,7 +167,7 @@ export default {
IconInnerShadowTop,
IconBrandStripe
},
inject: ['backgroundColor', 'save', 'currencies', 't'],
inject: ['backgroundColor', 'save', 'currencies', 't', 'isPaymentConnected'],
props: {
field: {
type: Object,
@@ -221,6 +221,8 @@ export default {
mounted () {
this.field.preferences.currency ||= this.defaultCurrency
isConnected.value ||= this.isPaymentConnected
if (!this.isConnected) {
this.checkStatus()
}
+4 -3
View File
@@ -27,14 +27,15 @@ class ProcessSubmitterCompletionJob < ApplicationJob
user = submission.created_by_user || submitter.template.author
if submitter.account.users.exists?(id: user.id) && submission.preferences['send_email'] != false
if submitter.submission.submitters.map(&:email).exclude?(user.email) &&
user.user_configs.find_by(key: UserConfig::RECEIVE_COMPLETED_EMAIL)&.value != false
if submission.submitters.map(&:email).exclude?(user.email) &&
user.user_configs.find_by(key: UserConfig::RECEIVE_COMPLETED_EMAIL)&.value != false &&
user.role != 'integration'
SubmitterMailer.completed_email(submitter, user).deliver_later!
end
bcc = submission.preferences['bcc_completed'].presence ||
submission.account.account_configs
.find_by(key: AccountConfig::BCC_EMAILS)&.value.presence
.find_by(key: AccountConfig::BCC_EMAILS)&.value
bcc.to_s.scan(User::EMAIL_REGEXP).each do |to|
SubmitterMailer.completed_email(submitter, user, to:).deliver_later!
+3 -3
View File
@@ -7,7 +7,7 @@ class SubmitterMailer < ApplicationMailer
DEFAULT_INVITATION_SUBJECT = 'You are invited to submit a form'
def invitation_email(submitter)
@current_account = submitter.submission.template.account
@current_account = submitter.submission.account
@submitter = submitter
if submitter.preferences['email_message_uuid']
@@ -36,7 +36,7 @@ class SubmitterMailer < ApplicationMailer
end
def completed_email(submitter, user, to: nil)
@current_account = submitter.submission.template.account
@current_account = submitter.submission.account
@submitter = submitter
@submission = submitter.submission
@user = user
@@ -64,7 +64,7 @@ class SubmitterMailer < ApplicationMailer
end
def documents_copy_email(submitter, to: nil, sig: false)
@current_account = submitter.submission.template.account
@current_account = submitter.submission.account
@submitter = submitter
@sig = submitter.signed_id(expires_in: SIGN_TTL, purpose: :download_completed) if sig
@@ -0,0 +1,11 @@
<div class="alert">
<%= svg_icon('info_circle', class: 'w-6 h-6') %>
<div>
<p class="font-bold">Bulk send from Excel XLSX or CSV</p>
<p class="text-gray-700">
Unlock with DocuSeal Pro
<br>
<a class="link font-medium" target="_blank" href="<%= Docuseal.multitenant? ? console_redirect_index_path(redir: "#{Docuseal::CONSOLE_URL}/plans") : "#{Docuseal::CLOUD_URL}/sign_up?#{{ redir: "#{Docuseal::CONSOLE_URL}/on_premise" }.to_query}" %>" data-turbo="false">Learn More</a>
</p>
</div>
</div>
+1 -1
View File
@@ -1,7 +1,7 @@
<%= form_for '', url: template_submissions_path(template), html: { class: 'space-y-4', autocomplete: 'off' }, data: { turbo_frame: :_top } do |f| %>
<% if template.submitters.size == 1 %>
<submitter-item class="form-control">
<emails-textarea data-bulk-enabled="<%= Docuseal.demo? || !Docuseal.multitenant? || can?(:manage, :bulk_send) %>" data-limit="<%= Docuseal.multitenant? ? (can?(:manage, :bulk_send) ? 50 : 1) : nil %>">
<emails-textarea data-bulk-enabled="<%= Docuseal.demo? || !Docuseal.multitenant? || can?(:manage, :bulk_send) %>" data-limit="<%= Docuseal.multitenant? ? (can?(:manage, :bulk_send) ? 40 : 1) : nil %>">
<submitters-autocomplete data-field="email" class="block relative">
<autoresize-textarea>
<%= f.text_area :emails, required: true, class: 'base-textarea w-full', placeholder: 'Type emails here...' %>
@@ -0,0 +1 @@
<%= render 'submissions/bulk_send_placeholder' %>
+4 -2
View File
@@ -18,7 +18,8 @@
</div>
<% elsif field['type'].in?(%w[multiple radio]) && area['option_uuid'] %>
<% option = field['options']&.find { |o| o['uuid'] == area['option_uuid'] } %>
<% if option && Array.wrap(value).include?(option['value']) %>
<% option_name = option['value'].presence || "Option #{field['options'].index(option) + 1}" %>
<% if option && Array.wrap(value).include?(option_name) %>
<div class="w-full flex items-center justify-center">
<%= svg_icon('check', class: "aspect-square #{area['w'] > area['h'] ? '!w-auto !h-full' : '!w-full !h-auto'}") %>
</div>
@@ -32,9 +33,10 @@
<% end %>
<% end %>
</div>
<% elsif field['type'] == 'date' && value != '{{date}}' %>
<% elsif field['type'] == 'date' %>
<autosize-field></autosize-field>
<div class="flex items-center px-0.5">
<% value = Time.current.in_time_zone(local_assigns[:timezone]).to_date.to_s if value == '{{date}}' %>
<%= TimeUtils.format_date_string(value, field.dig('preferences', 'format'), local_assigns[:locale]) %>
</div>
<% else %>
+4 -6
View File
@@ -1,5 +1,5 @@
<%= render 'shared/turbo_modal', title: params[:selfsign] ? 'Add Recipients' : 'Add New Recipients' do %>
<% options = [['via Email', 'email'], ['via Phone', 'phone'], %w[Detailed detailed], (Docuseal.multitenant? && params[:with_link] && @template.submitters.to_a.size < 2 ? ['via Link', 'link'] : nil)].compact %>
<% options = [['via Email', 'email'], ['via Phone', 'phone'], %w[Detailed detailed], ['Upload List', 'list']].compact %>
<toggle-visible data-element-ids="<%= options.map(&:last).to_json %>" class="relative text-center mt-4 block">
<div class="join">
<% options.each_with_index do |(label, value), index| %>
@@ -22,11 +22,9 @@
<div id="detailed" class="hidden">
<%= render 'detailed_form', template: @template %>
</div>
<% if Docuseal.multitenant? && params[:with_link] && @template.submitters.to_a.size < 2 %>
<div id="link" class="hidden">
<%= render 'link_form', template: @template %>
</div>
<% end %>
<div id="list" class="hidden">
<%= render 'list_form', template: @template %>
</div>
</div>
<%= content_for(:modal_extra) %>
<% end %>
+4 -4
View File
@@ -1,6 +1,6 @@
<div style="max-width: 1600px" class="mx-auto pl-4">
<div class="flex justify-between py-1.5 items-center pr-4 sticky top-0 md:relative z-10 bg-base-100">
<a href="<%= signed_in? && @submission.template.account_id == current_account&.id ? template_path(@submission.template) : '/' %>" class="flex items-center space-x-3 py-1">
<a href="<%= signed_in? && @submission.account_id == current_account&.id ? template_path(@submission.template) : '/' %>" class="flex items-center space-x-3 py-1">
<span><%= render 'submissions/logo' %></span>
<span class="text-xl md:text-3xl font-semibold focus:text-clip" style="overflow: hidden; display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2;"><%= @submission.template.name %></span>
</a>
@@ -63,7 +63,7 @@
<% fields_index.dig(document.uuid, index)&.each do |(area, field)| %>
<% value = values[field['uuid']] %>
<% next if value.blank? %>
<%= render 'submissions/value', area:, field:, attachments_index:, value:, locale: @submission.template.account.locale %>
<%= render 'submissions/value', area:, field:, attachments_index:, value:, locale: @submission.account.locale, timezone: @submission.account.timezone %>
<% end %>
</div>
</div>
@@ -112,7 +112,7 @@
<div class="flex items-center space-x-1 mt-1">
<%= svg_icon('writing', class: 'w-5 h-5') %>
<span>
<%= submitter&.completed_at? ? l(submitter.completed_at.in_time_zone(@submission.template.account.timezone), format: :long, locale: @submission.template.account.locale) : 'Not completed yet' %>
<%= submitter&.completed_at? ? l(submitter.completed_at.in_time_zone(@submission.account.timezone), format: :long, locale: @submission.account.locale) : 'Not completed yet' %>
</span>
</div>
<% if signed_in? && submitter && submitter.email && !submitter.completed_at && can?(:update, submitter) %>
@@ -162,7 +162,7 @@
<% elsif field['type'] == 'checkbox' %>
<%= svg_icon('check', class: 'w-6 h-6') %>
<% elsif field['type'] == 'date' %>
<%= TimeUtils.format_date_string(value, field.dig('preferences', 'format'), @submission.template.account.locale) %>
<%= TimeUtils.format_date_string(value, field.dig('preferences', 'format'), @submission.account.locale) %>
<% else %>
<%= Array.wrap(value).join(', ') %>
<% end %>
@@ -1,4 +1,4 @@
<% data_attachments = attachments_index.values.select { |e| e.record_id == submitter.id }.to_json(only: %i[uuid], methods: %i[url filename content_type]) %>
<% data_fields = (submitter.submission.template_fields || submitter.submission.template.fields).select { |f| f['submitter_uuid'] == submitter.uuid }.to_json %>
<% configs = Submitters::FormConfigs.call(submitter) %>
<submission-form data-is-demo="<%= Docuseal.demo? %>" data-with-confetti="<%= configs[:with_confetti] %>" data-completed-redirect-url="<%= submitter.preferences['completed_redirect_url'] %>" data-completed-button="<%= configs[:completed_button].to_json %>" data-go-to-last="<%= submitter.opened_at? %>" data-is-direct-upload="<%= Docuseal.active_storage_public? %>" data-submitter="<%= submitter.to_json(only: %i[uuid slug name phone email]) %>" data-can-send-email="<%= Accounts.can_send_emails?(Struct.new(:id).new(@submitter.submission.template.account_id)) %>" data-attachments="<%= data_attachments %>" data-fields="<%= data_fields %>" data-values="<%= submitter.values.to_json %>" data-with-typed-signature="<%= configs[:with_typed_signature] %>"></submission-form>
<submission-form data-is-demo="<%= Docuseal.demo? %>" data-with-confetti="<%= configs[:with_confetti] %>" data-completed-redirect-url="<%= submitter.preferences['completed_redirect_url'] %>" data-completed-button="<%= configs[:completed_button].to_json %>" data-go-to-last="<%= submitter.opened_at? %>" data-is-direct-upload="<%= Docuseal.active_storage_public? %>" data-submitter="<%= submitter.to_json(only: %i[uuid slug name phone email]) %>" data-can-send-email="<%= Accounts.can_send_emails?(Struct.new(:id).new(@submitter.submission.account_id)) %>" data-attachments="<%= data_attachments %>" data-fields="<%= data_fields %>" data-values="<%= submitter.values.to_json %>" data-with-typed-signature="<%= configs[:with_typed_signature] %>"></submission-form>
+1 -1
View File
@@ -25,7 +25,7 @@
<% next if value.blank? %>
<% next if !field['readonly'] && field['submitter_uuid'] == @submitter.uuid %>
<% next if field.dig('preferences', 'formula') && field['submitter_uuid'] == @submitter.uuid %>
<%= render 'submissions/value', area:, field:, attachments_index:, value:, locale: @submitter.account.locale %>
<%= render 'submissions/value', area:, field:, attachments_index:, value:, locale: @submitter.account.locale, timezone: @submitter.account.timezone %>
<% end %>
</div>
</div>
@@ -1 +1 @@
<%= auto_link(simple_format(h(ReplaceEmailVariables.call(local_assigns[:content], submitter: local_assigns[:submitter], sig: local_assigns[:sig])))) %>
<%= auto_link(simple_format(MarkdownToHtml.call(h(ReplaceEmailVariables.call(local_assigns[:content], submitter: local_assigns[:submitter], sig: local_assigns[:sig]))))) %>
@@ -9,7 +9,6 @@ class UpdateFieldOptions < ActiveRecord::Migration[7.0]
self.table_name = 'submissions'
end
# rubocop:disable Metrics
def up
MigrationTemplate.find_each do |template|
next if template.fields.blank?
@@ -43,7 +42,6 @@ class UpdateFieldOptions < ActiveRecord::Migration[7.0]
submission.update_columns(template_fields: new_fields.to_json) if template_fields != new_fields
end
end
# rubocop:enable Metrics
def down
nil
+5
View File
@@ -1,6 +1,9 @@
# frozen_string_literal: true
module ActionMailerConfigsInterceptor
OPEN_TIMEOUT = 15
READ_TIMEOUT = 25
module_function
def delivering_email(message)
@@ -43,6 +46,8 @@ module ActionMailerConfigsInterceptor
openssl_verify_mode: OpenSSL::SSL::VERIFY_NONE,
authentication: value.fetch('authentication', 'plain'),
enable_starttls_auto: true,
open_timeout: OPEN_TIMEOUT,
read_timeout: READ_TIMEOUT,
ssl: value['security'] == 'ssl',
tls: value['security'] == 'tls' || (value['security'].blank? && value['port'].to_s == '465')
}.compact_blank
+12
View File
@@ -0,0 +1,12 @@
# frozen_string_literal: true
module MarkdownToHtml
LINK_REGEXP = %r{\[([^\]]+)\]\((https?://[^)]+)\)}
LINK_REPLACE = '<a href="\2">\1</a>'
module_function
def call(text)
text.gsub(LINK_REGEXP, LINK_REPLACE)
end
end
@@ -43,6 +43,8 @@ module Params
type(params, :completed_redirect_url, String)
type(params, :bcc_completed, String)
type(params, :reply_to, String)
format(params, :bcc_completed, /@/, message: 'bcc_completed email is invalid')
format(params, :reply_to, /@/, message: 'reply_to email is invalid')
type(params, :message, Hash)
type(params, :submitters, Array)
@@ -73,6 +75,7 @@ module Params
type(submitter_params, :reply_to, String)
type(submitter_params, :email, String)
format(submitter_params, :email, /@/, message: 'email is invalid')
format(submitter_params, :reply_to, /@/, message: 'reply_to email is invalid')
type(submitter_params, :phone, String)
format(submitter_params, :phone, /\A\+\d+\z/,
message: 'phone should start with +<country code> and contain only digits')
@@ -102,6 +105,7 @@ module Params
type(params, :order, String)
type(params, :completed_redirect_url, String)
type(params, :bcc_completed, String)
format(params, :bcc_completed, /@/, message: 'bcc_completed email is invalid')
type(params, :message, Hash)
in_path(params, :message) do |message_params|
+3 -1
View File
@@ -7,7 +7,7 @@ module Submissions
def call(template:, user:, submissions_attrs:, source:, submitters_order:, mark_as_sent: false, params: {})
preferences = Submitters.normalize_preferences(user.account, user, params)
Array.wrap(submissions_attrs).map do |attrs|
Array.wrap(submissions_attrs).filter_map do |attrs|
submission_preferences = Submitters.normalize_preferences(user.account, user, attrs)
submission_preferences = preferences.merge(submission_preferences)
@@ -36,6 +36,8 @@ module Submissions
preferences: preferences.merge(submission_preferences))
end
next if submission.submitters.blank?
submission.tap(&:save!)
end
end
+1 -1
View File
@@ -36,7 +36,7 @@ module Submissions
# rubocop:disable Metrics
def call(submission)
account = submission.template.account
account = submission.account
pkcs = Accounts.load_signing_pkcs(account)
tsa_url = Accounts.load_timeserver_url(account)
verify_url = Rails.application.routes.url_helpers.settings_esign_url(**Docuseal.default_url_options)
+10 -2
View File
@@ -43,7 +43,7 @@ module Submissions
template = submitter.submission.template
account = submitter.submission.template.account
account = submitter.account
pkcs = Accounts.load_signing_pkcs(account)
tsa_url = Accounts.load_timeserver_url(account)
attachments_data_cache = {}
@@ -83,6 +83,12 @@ module Submissions
next if Array.wrap(value).compact_blank.blank?
begin
page.flatten_annotations
rescue StandardError => e
Rollbar.error(e) if defined?(Rollbar)
end
canvas = page.canvas(type: :overlay)
canvas.font(FONT_NAME, size: font_size)
@@ -159,7 +165,9 @@ module Submissions
if field['type'].in?(%w[multiple radio])
option = field['options']&.find { |o| o['uuid'] == area['option_uuid'] }
value = Array.wrap(value).include?(option['value'])
option_name = option['value'].presence || "Option #{field['options'].index(option) + 1}"
value = Array.wrap(value).include?(option_name)
end
next unless value == true
+1 -1
View File
@@ -9,7 +9,7 @@ module Submitters
module_function
def call(submitter, keys = [])
configs = submitter.submission.template.account.account_configs
configs = submitter.submission.account.account_configs
.where(key: DEFAULT_KEYS + keys)
completed_button = configs.find { |e| e.key == AccountConfig::FORM_COMPLETED_BUTTON_KEY }&.value || {}
+8 -4
View File
@@ -38,6 +38,9 @@ module Submitters
submitter.ua = request.user_agent
submitter.values = merge_default_values(submitter)
submitter.values = merge_formula_values(submitter)
submitter.values = submitter.values.transform_values do |v|
v == '{{date}}' ? Time.current.in_time_zone(submitter.account.timezone).to_date.to_s : v
end
end
ApplicationRecord.transaction do
@@ -121,11 +124,11 @@ module Submitters
replace_default_variables(value,
submitter.attributes.merge('role' => role),
submitter.submission.template,
submitter.submission,
with_time:)
end
def replace_default_variables(value, attrs, template, with_time: false)
def replace_default_variables(value, attrs, submission, with_time: false)
return value if value.in?([true, false])
return if value.blank?
@@ -135,13 +138,14 @@ module Submitters
attrs['submission_id']
when 'time'
if with_time
I18n.l(Time.current.in_time_zone(template.account.timezone), format: :long, locale: template.account.locale)
I18n.l(Time.current.in_time_zone(submission.account.timezone),
format: :long, locale: submission.account.locale)
else
e
end
when 'date'
if with_time
I18n.l(Time.current.in_time_zone(template.account.timezone).to_date)
I18n.l(Time.current.in_time_zone(submission.account.timezone).to_date)
else
e
end