Merge from docusealco/wip

pull/555/merge
Alex Turchyn 5 days ago committed by GitHub
commit 997711b34a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -64,7 +64,7 @@ DocuSeal is an open source platform that provides secure and efficient digital d
|Heroku|Railway|
|:--:|:---:|
| [<img alt="Deploy on Heroku" src="https://www.herokucdn.com/deploy/button.svg" height="40">](https://heroku.com/deploy?template=https://github.com/docusealco/docuseal-heroku) | [<img alt="Deploy on Railway" src="https://railway.app/button.svg" height="40">](https://railway.app/template/IGoDnc?referralCode=ruU7JR)|
| [<img alt="Deploy on Heroku" src="https://www.herokucdn.com/deploy/button.svg" height="40">](https://heroku.com/deploy?template=https://github.com/docusealco/docuseal-heroku) | [<img alt="Deploy on Railway" src="https://railway.app/button.svg" height="40">](https://railway.com/deploy/IGoDnc?referralCode=ruU7JR)|
|**DigitalOcean**|**Render**|
| [<img alt="Deploy on DigitalOcean" src="https://www.deploytodo.com/do-btn-blue.svg" height="40">](https://cloud.digitalocean.com/apps/new?repo=https://github.com/docusealco/docuseal-digitalocean/tree/master&refcode=421d50f53990) | [<img alt="Deploy to Render" src="https://render.com/images/deploy-to-render-button.svg" height="40">](https://render.com/deploy?repo=https://github.com/docusealco/docuseal-render)

@ -40,6 +40,10 @@ module Api
return render json: { error: 'Submitter has already completed the submission.' }, status: :unprocessable_content
end
if @submitter.declined_at?
return render json: { error: 'Submitter has already declined the submission.' }, status: :unprocessable_content
end
submission = @submitter.submission
role = submission.template_submitters.find { |e| e['uuid'] == @submitter.uuid }['name']

@ -359,7 +359,9 @@ export default {
},
initTextInitial () {
if (this.submitter.name) {
this.$refs.textInput.value = this.submitter.name.trim().split(/\s+/).filter(Boolean).slice(0, 2).map((part) => part[0]?.toUpperCase() || '').join('')
const parts = this.submitter.name.trim().split(/\s+/)
this.$refs.textInput.value = (parts.length > 1 ? [parts[0], parts[parts.length - 1]] : parts).map((part) => part[0]?.toUpperCase() || '').join('')
}
if (this.$refs.textInput.value) {

@ -364,7 +364,14 @@
:document="dynamicDocuments.find((dynamicDocument) => dynamicDocument.uuid === document.uuid)"
:selected-submitter="selectedSubmitter"
:drag-field="dragField"
:draw-field="drawField"
:draw-field-type="drawFieldType"
:draw-custom-field="drawCustomField"
:draw-option="drawOption"
@update="onDynamicDocumentUpdate"
@draw="clearDrawField"
@add-custom-field="addCustomField"
@set-draw="[drawField = $event.field, drawOption = $event.option]"
/>
<Document
v-else
@ -509,6 +516,8 @@
:show-tour-start-form="showTourStartForm"
@add-field="addField"
@set-draw="[drawField = $event.field, drawOption = $event.option]"
@remove-field="onRemoveField"
@remove-submitter="onRemoveSubmitter"
@select-submitter="selectedSubmitter = $event"
@set-draw-type="[drawFieldType = $event, showDrawField = true]"
@set-draw-custom-field="[drawCustomField = $event, showDrawField = true]"
@ -1155,6 +1164,32 @@ export default {
this.drawCustomField = null
this.showDrawField = false
},
onRemoveField (field) {
if (this.dynamicDocuments.length) {
field.areas?.forEach((area) => {
this.documentRefs.forEach((documentRef) => {
if (documentRef.isDynamic && documentRef.document.uuid === area.attachment_uuid) {
documentRef.removeArea(area)
}
})
})
}
},
onRemoveSubmitter (submitter) {
if (this.dynamicDocuments.length) {
this.template.fields.forEach((field) => {
if (field.submitter_uuid === submitter.uuid) {
field.areas?.forEach((area) => {
this.documentRefs.forEach((documentRef) => {
if (documentRef.isDynamic && documentRef.document.uuid === area.attachment_uuid) {
documentRef.removeArea(area)
}
})
})
}
})
}
},
toggleSelectMode () {
this.isSelectModeRef.value = !this.isSelectModeRef.value
@ -2512,15 +2547,14 @@ export default {
this.save()
},
onDocumentRemove (item) {
if (window.confirm(this.t('are_you_sure_'))) {
this.template.schema.splice(this.template.schema.indexOf(item), 1)
removeAreasByAttachmentUuid (attachmentUuid) {
const removedFieldUuids = []
this.selectedAreasRef.value = this.selectedAreasRef.value.filter((area) => area.attachment_uuid !== attachmentUuid)
this.template.fields.forEach((field) => {
[...(field.areas || [])].forEach((area) => {
if (area.attachment_uuid === item.attachment_uuid) {
if (area.attachment_uuid === attachmentUuid) {
field.areas.splice(field.areas.indexOf(area), 1)
removedFieldUuids.push(field.uuid)
@ -2528,30 +2562,50 @@ export default {
})
})
this.template.fields = this.template.fields.reduce((acc, f) => {
if (removedFieldUuids.includes(f.uuid) && !f.areas?.length) {
this.removeFieldConditions(f)
this.template.fields = this.template.fields.reduce((acc, field) => {
if (removedFieldUuids.includes(field.uuid) && !field.areas?.length) {
this.removeFieldConditions(field)
} else {
acc.push(f)
acc.push(field)
}
return acc
}, [])
},
onDocumentRemove (item) {
if (window.confirm(this.t('are_you_sure_'))) {
this.template.schema.splice(this.template.schema.indexOf(item), 1)
this.removeAreasByAttachmentUuid(item.attachment_uuid)
this.save()
}
},
onDocumentReplace (data) {
const { replaceSchemaItem, schema, documents } = data
const isReplacingDynamicDocument = !!replaceSchemaItem.dynamic
// eslint-disable-next-line camelcase
const { google_drive_file_id, dynamic, ...cleanedReplaceSchemaItem } = replaceSchemaItem
this.template.schema.splice(this.template.schema.indexOf(replaceSchemaItem), 1, { ...cleanedReplaceSchemaItem, ...schema[0] })
this.template.documents.push(...documents)
if (isReplacingDynamicDocument) {
this.removeAreasByAttachmentUuid(replaceSchemaItem.attachment_uuid)
const dynamicDocumentIndex = this.dynamicDocuments.findIndex((doc) => doc.uuid === replaceSchemaItem.attachment_uuid)
if (dynamicDocumentIndex !== -1) {
this.dynamicDocuments.splice(dynamicDocumentIndex, 1)
}
}
if (data.fields) {
this.template.fields = data.fields
if (isReplacingDynamicDocument) {
this.removeAreasByAttachmentUuid(replaceSchemaItem.attachment_uuid)
} else {
const removedFieldUuids = []
this.template.fields.forEach((field) => {
@ -2567,6 +2621,7 @@ export default {
this.template.fields =
this.template.fields.filter((f) => !removedFieldUuids.includes(f.uuid) || f.areas?.length)
}
}
if (data.submitters) {
this.template.submitters = data.submitters
@ -2576,6 +2631,7 @@ export default {
}
}
if (!isReplacingDynamicDocument) {
this.template.fields.forEach((field) => {
(field.areas || []).forEach((area) => {
if (area.attachment_uuid === replaceSchemaItem.attachment_uuid) {
@ -2583,6 +2639,7 @@ export default {
}
})
})
}
if (this.onUpload) {
this.onUpload(this.template)
@ -2694,9 +2751,13 @@ export default {
scrollToArea (area) {
const documentRef = this.documentRefs.find((a) => a.document.uuid === area.attachment_uuid)
documentRef.scrollToArea(area)
if (documentRef.isDynamic) {
this.selectedAreasRef.value = []
} else {
this.selectedAreasRef.value = [area]
}
documentRef.scrollToArea(area)
},
baseFetch (path, options = {}) {
return fetch(this.baseUrl + path, {
@ -2942,15 +3003,15 @@ export default {
}
})
this.reconcileDynamicFields()
this.save()
},
rebuildVariablesSchema ({ disable = true } = {}) {
const parsed = {}
const dynamicDocumentRef = this.documentRefs.find((e) => e.mergeSchemaProperties)
const dynamicDocumentRef = this.documentRefs.find((e) => e.isDynamic)
this.documentRefs.forEach((ref) => {
if (ref.updateVariablesSchema) {
if (ref.isDynamic) {
ref.updateVariablesSchema()
}
})
@ -2964,70 +3025,8 @@ export default {
if (!this.template.variables_schema) {
this.template.variables_schema = parsed
} else {
this.syncVariablesSchema(this.template.variables_schema, parsed, { disable })
}
},
syncVariablesSchema (existing, parsed, { disable = true } = {}) {
for (const key of Object.keys(parsed)) {
if (!existing[key]) {
existing[key] = parsed[key]
}
}
for (const key of Object.keys(existing)) {
if (!parsed[key]) {
if (disable) {
existing[key].disabled = true
} else {
delete existing[key]
}
} else {
delete existing[key].disabled
if (!existing[key].form_type) {
existing[key].type = parsed[key].type
}
if (parsed[key].items) {
if (!existing[key].items) {
existing[key].items = parsed[key].items
} else if (existing[key].items.properties && parsed[key].items.properties) {
this.syncVariablesSchema(existing[key].items.properties, parsed[key].items.properties, { disable })
} else if (!existing[key].items.properties && !parsed[key].items.properties) {
existing[key].items.type = parsed[key].items.type
}
dynamicDocumentRef.syncVariablesSchema(this.template.variables_schema, parsed, { disable })
}
if (existing[key].properties && parsed[key].properties) {
this.syncVariablesSchema(existing[key].properties, parsed[key].properties, { disable })
}
}
}
},
reconcileDynamicFields () {
const dynamicFieldUuids = new Set()
this.dynamicDocuments.forEach((doc) => {
const body = doc.body || ''
const uuidRegex = /uuid="([^"]+)"/g
let match
while ((match = uuidRegex.exec(body)) !== null) {
dynamicFieldUuids.add(match[1])
}
})
const toRemove = this.template.fields.filter((field) => {
if (field.areas && field.areas.length > 0) return false
return field.uuid && !dynamicFieldUuids.has(field.uuid)
})
toRemove.forEach((field) => {
this.template.fields.splice(this.template.fields.indexOf(field), 1)
})
this.save()
}
}
}

@ -5,7 +5,6 @@
:draggable="editable"
:style="[nodeStyle]"
@mousedown="selectArea"
@click.stop
@dragstart="onDragStart"
@contextmenu.prevent.stop="onContextMenu"
>
@ -25,7 +24,14 @@
<span
v-if="field?.default_value"
class="text-xs overflow-hidden text-ellipsis whitespace-nowrap pr-1 font-normal pl-0.5"
>{{ field.default_value }}</span>
>
<template v-if="field.default_value === '{{date}}'">
{{ t('signing_date') }}
</template>
<template v-else>
{{ field.default_value }}
</template>
</span>
<span
v-else-if="field && !iconOnlyField"
class="text-xs overflow-hidden text-ellipsis whitespace-nowrap pr-1 opacity-70 font-normal pl-0.5"
@ -227,6 +233,7 @@ export default {
this.onAreaDragStart()
},
onContextMenu (e) {
this.selectArea()
this.onAreaContextMenu(this.area, e)
},
onResizeStart (e) {

@ -29,18 +29,28 @@
:container="$refs.container"
:editable="editable"
:section="section"
:section-refs="sectionRefs"
:container-width="containerWidth"
:attachments-index="attachmentsIndex"
:selected-submitter="selectedSubmitter"
:drag-field="dragField"
:draw-field="drawField"
:draw-field-type="drawFieldType"
:draw-custom-field="drawCustomField"
:render-html-for-save-ref="renderHtmlForSaveRef"
:draw-option="drawOption"
:attachment-uuid="document.uuid"
@update="onSectionUpdate(section, $event)"
@draw="$emit('draw', $event)"
@add-custom-field="$emit('add-custom-field', $event)"
@set-draw="$emit('set-draw', $event)"
/>
</Teleport>
</div>
</template>
<script>
import { ref } from 'vue'
import DynamicSection from './dynamic_section.vue'
import { dynamicStylesheet, tiptapStylesheet } from './dynamic_editor.js'
import { buildVariablesSchema, mergeSchemaProperties } from './dynamic_variables_schema.js'
@ -70,9 +80,29 @@ export default {
type: Object,
required: false,
default: null
},
drawField: {
type: Object,
required: false,
default: null
},
drawFieldType: {
type: String,
required: false,
default: ''
},
drawCustomField: {
type: Object,
required: false,
default: null
},
drawOption: {
type: Object,
required: false,
default: null
}
},
emits: ['update'],
emits: ['update', 'draw', 'set-draw', 'add-custom-field'],
data () {
return {
containerWidth: 1040,
@ -81,6 +111,10 @@ export default {
}
},
computed: {
renderHtmlForSaveRef: () => ref(false),
isDynamic () {
return true
},
attachmentsIndex () {
return (this.document.attachments || []).reduce((acc, att) => {
acc[att.uuid] = att.url
@ -135,6 +169,15 @@ export default {
},
methods: {
mergeSchemaProperties,
removeArea (area) {
this.sectionRefs.forEach((sectionRef) => {
const pos = sectionRef.findAreaNodePos(area.uuid)
if (pos !== -1) {
sectionRef.editor.chain().focus().deleteRange({ from: pos, to: pos + 1 }).run()
}
})
},
setSectionRefs (ref) {
if (ref) {
this.sectionRefs.push(ref)
@ -150,11 +193,13 @@ export default {
}
},
scrollToArea (area) {
this.sectionRefs.forEach(({ editor }) => editor.commands.setNodeSelection(0))
this.sectionRefs.forEach(({ editor }) => {
const el = editor.view.dom.querySelector(`[data-area-uuid="${area.uuid}"]`)
if (el) {
editor.chain().focus().setNodeSelection(editor.view.posAtDOM(el, 0)).run()
editor.commands.setNodeSelection(editor.view.posAtDOM(el, 0))
el.scrollIntoView({ behavior: 'smooth', block: 'center' })
}
@ -176,7 +221,7 @@ export default {
const target = this.bodyDom.getElementById(section.id)
if (target) {
target.innerHTML = editor.getHTML()
target.innerHTML = this.getHtmlForSave(editor)
}
this.document.body = this.bodyDom.body.innerHTML
@ -200,7 +245,7 @@ export default {
this.sectionRefs.forEach(({ section, editor }) => {
const target = this.bodyDom.getElementById(section.id)
target.innerHTML = editor.getHTML()
target.innerHTML = this.getHtmlForSave(editor)
})
this.document.body = this.bodyDom.body.innerHTML
@ -209,6 +254,15 @@ export default {
this.$emit('update', this.document)
},
getHtmlForSave (editor) {
this.renderHtmlForSaveRef.value = true
const result = editor.getHTML()
this.renderHtmlForSaveRef.value = false
return result
},
saveBody () {
clearTimeout(this.saveTimer)
@ -219,6 +273,43 @@ export default {
body: JSON.stringify({ body: this.bodyDom.body.innerHTML }),
headers: { 'Content-Type': 'application/json' }
})
},
syncVariablesSchema (existing, parsed, { disable = true } = {}) {
for (const key of Object.keys(parsed)) {
if (!existing[key]) {
existing[key] = parsed[key]
}
}
for (const key of Object.keys(existing)) {
if (!parsed[key]) {
if (disable) {
existing[key].disabled = true
} else {
delete existing[key]
}
} else {
delete existing[key].disabled
if (!existing[key].form_type) {
existing[key].type = parsed[key].type
}
if (parsed[key].items) {
if (!existing[key].items) {
existing[key].items = parsed[key].items
} else if (existing[key].items.properties && parsed[key].items.properties) {
this.syncVariablesSchema(existing[key].items.properties, parsed[key].items.properties, { disable })
} else if (!existing[key].items.properties && !parsed[key].items.properties) {
existing[key].items.type = parsed[key].items.type
}
}
if (existing[key].properties && parsed[key].properties) {
this.syncVariablesSchema(existing[key].properties, parsed[key].properties, { disable })
}
}
}
}
}
}

@ -28,7 +28,7 @@ tiptapStylesheet.replaceSync(
white-space: break-spaces;
-webkit-font-variant-ligatures: none;
font-variant-ligatures: none;
font-feature-settings: "liga" 0; /* the above doesn't seem to work in Edge */
font-feature-settings: "liga" 0;
}
.ProseMirror [contenteditable="false"] {
@ -89,7 +89,7 @@ img.ProseMirror-separator {
.ProseMirror-focused .ProseMirror-gapcursor {
display: block;
}
.variable-highlight {
dynamic-variable {
background-color: #fef3c7;
}`)
@ -533,7 +533,7 @@ function buildDecorations (doc) {
const from = pos + match.index
const to = from + match[0].length
decorations.push(Decoration.inline(from, to, { class: 'variable-highlight' }))
decorations.push(Decoration.inline(from, to, { nodeName: 'dynamic-variable' }))
}
})
@ -584,7 +584,7 @@ const VariableHighlight = Extension.create({
}
})
export function buildEditor ({ dynamicAreaProps, attachmentsIndex, onFieldDrop, onFieldDestroy, editorOptions }) {
export function buildEditor ({ dynamicAreaProps, attachmentsIndex, renderHtmlForSaveRef, onFieldDrop, onFieldDestroy, editorOptions }) {
const FieldNode = Node.create({
name: 'fieldNode',
inline: true,
@ -617,11 +617,32 @@ export function buildEditor ({ dynamicAreaProps, attachmentsIndex, onFieldDrop,
}]
},
renderHTML ({ node }) {
return ['dynamic-field', {
const attrs = {
uuid: node.attrs.uuid,
'area-uuid': node.attrs.areaUuid,
style: `width: ${node.attrs.width}; height: ${node.attrs.height}; display: ${node.attrs.display}; vertical-align: ${node.attrs.verticalAlign};`
}]
}
if (!renderHtmlForSaveRef.value) {
const fieldArea = dynamicAreaProps.findFieldArea(node.attrs.areaUuid)
if (fieldArea?.field && fieldArea?.area) {
const field = JSON.parse(JSON.stringify(fieldArea.field))
const area = JSON.parse(JSON.stringify(fieldArea.area))
delete field.areas
delete field.uuid
delete field.submitter_uuid
delete area.uuid
delete area.attachment_uuid
attrs['data-field'] = JSON.stringify(field)
attrs['data-area'] = JSON.stringify(area)
attrs['data-template-id'] = dynamicAreaProps.template.id
}
}
return ['dynamic-field', attrs]
},
addNodeView () {
return ({ node, getPos, editor }) => {

@ -1,11 +1,18 @@
<template>
<div
class="relative bg-white select-none mb-4 before:border before:rounded before:top-0 before:bottom-0 before:left-0 before:right-0 before:absolute"
:class="{ 'cursor-crosshair': isDrawMode && editable }"
>
<div
v-if="isDrawMode && editable && cursorHighlightCoords"
class="absolute pointer-events-none z-10 bg-black"
:style="{ width: '1px', height: cursorHighlightCoords.height + 'px', left: cursorHighlightCoords.x + 'px', top: cursorHighlightCoords.y + 'px' }"
/>
<div :style="{ zoom: containerWidth / sectionWidthPx }">
<section
:id="section.id"
ref="editorElement"
dir="auto"
:class="section.classList.value"
:style="section.style.cssText"
/>
@ -44,7 +51,10 @@
:field="contextMenuField"
:with-copy-to-all-pages="false"
@close="closeContextMenu"
@copy="onContextMenuCopy"
@delete="onContextMenuDelete"
@add-custom-field="$emit('add-custom-field', $event)"
@set-draw="$emit('set-draw', $event)"
@save="save"
/>
</Teleport>
@ -53,6 +63,7 @@
<script>
import { shallowRef } from 'vue'
import { DOMSerializer, Fragment } from '@tiptap/pm/model'
import { v4 } from 'uuid'
import FieldContextMenu from './field_context_menu.vue'
import AreaTitle from './area_title.vue'
@ -72,6 +83,11 @@ export default {
type: Object,
required: true
},
sectionRefs: {
type: Array,
required: false,
default: () => []
},
editable: {
type: Boolean,
required: false,
@ -100,19 +116,45 @@ export default {
required: false,
default: null
},
drawField: {
type: Object,
required: false,
default: null
},
drawFieldType: {
type: String,
required: false,
default: ''
},
drawCustomField: {
type: Object,
required: false,
default: null
},
renderHtmlForSaveRef: {
type: Object,
required: false,
default: null
},
drawOption: {
type: Object,
required: false,
default: null
},
attachmentUuid: {
type: String,
required: false,
default: null
}
},
emits: ['update'],
emits: ['update', 'draw', 'set-draw', 'add-custom-field'],
data () {
return {
isAreaDrag: false,
areaToolbarCoords: null,
dynamicMenuCoords: null,
contextMenu: null
contextMenu: null,
cursorHighlightCoords: null
}
},
computed: {
@ -140,6 +182,7 @@ export default {
verification: { width: '150px', height: '80px' },
image: { width: '200px', height: '100px' },
date: { width: '100px', height: this.defaultHeight },
datenow: { width: '100px', height: this.defaultHeight },
text: { width: '120px', height: this.defaultHeight },
cells: { width: '120px', height: this.defaultHeight },
file: { width: '120px', height: this.defaultHeight },
@ -164,6 +207,9 @@ export default {
isDraggingField () {
return !!(this.fieldsDragFieldRef?.value || this.customDragFieldRef?.value || this.dragField)
},
isDrawMode () {
return !!(this.drawField || this.drawCustomField || this.drawFieldType)
},
selectedArea () {
return this.selectedAreasRef.value[0]
},
@ -192,15 +238,6 @@ export default {
}
},
mounted () {
this.initEditor()
},
beforeUnmount () {
if (this.editor) {
this.editor.destroy()
}
},
methods: {
async initEditor () {
this.editorRef.value = buildEditor({
dynamicAreaProps: {
template: this.template,
@ -216,6 +253,7 @@ export default {
attachmentsIndex: this.attachmentsIndex,
onFieldDrop: this.onFieldDrop,
onFieldDestroy: this.onFieldDestroy,
renderHtmlForSaveRef: this.renderHtmlForSaveRef,
editorOptions: {
element: this.$refs.editorElement,
editable: this.editable,
@ -225,12 +263,96 @@ export default {
onBlur: () => { this.dynamicMenuCoords = null }
}
})
this.editor.view.dom.addEventListener('paste', this.onEditorPaste, true)
this.editor.view.dom.addEventListener('pointerdown', this.onEditorPointerDown, true)
this.editor.view.dom.addEventListener('mousemove', this.onEditorMouseMove)
this.editor.view.dom.addEventListener('mouseleave', this.onEditorMouseLeave)
this.editor.view.dom.addEventListener('keydown', this.onEditorKeyDown)
},
beforeUnmount () {
if (this.editor) {
this.editor.view.dom.removeEventListener('paste', this.onEditorPaste, true)
this.editor.view.dom.removeEventListener('pointerdown', this.onEditorPointerDown, true)
this.editor.view.dom.removeEventListener('mousemove', this.onEditorMouseMove)
this.editor.view.dom.removeEventListener('mouseleave', this.onEditorMouseLeave)
this.editor.view.dom.removeEventListener('keydown', this.onEditorKeyDown)
this.editor.destroy()
}
},
methods: {
findAreaNodePos (areaUuid) {
const el = this.editor.view.dom.querySelector(`[data-area-uuid="${areaUuid}"]`)
return this.editor.view.posAtDOM(el, 0)
},
getFieldInsertIndex (pos) {
const view = this.editor.view
const fields = this.template.fields || []
if (!fields.length) {
return 0
}
let previousField = null
view.state.doc.nodesBetween(0, pos, (node, nodePos) => {
if (node.type.name !== 'fieldNode') {
return
}
if (nodePos + node.nodeSize <= pos) {
previousField = this.fieldAreaIndex[node.attrs.areaUuid]?.field || previousField
}
})
if (!previousField) {
previousField = this.getPreviousSectionField()
}
if (!previousField) {
return 0
}
const previousFieldIndex = fields.indexOf(previousField)
return previousFieldIndex === -1 ? fields.length : previousFieldIndex + 1
},
insertFieldInTemplate (field, index) {
const currentFieldIndex = this.template.fields.indexOf(field)
if (currentFieldIndex !== -1) {
return currentFieldIndex
}
this.template.fields.splice(index, 0, field)
return index
},
getLastFieldInSection (sectionRef) {
let lastField = null
sectionRef.editor.state.doc.descendants((node) => {
if (node.type.name === 'fieldNode') {
lastField = this.fieldAreaIndex[node.attrs.areaUuid]?.field || lastField
}
})
return lastField
},
getPreviousSectionField () {
const sectionIndex = this.sectionRefs.indexOf(this)
for (let index = sectionIndex - 1; index >= 0; index -= 1) {
const previousField = this.getLastFieldInSection(this.sectionRefs[index])
if (previousField) {
return previousField
}
}
return null
},
removeArea (area) {
const { field } = this.fieldAreaIndex[area.uuid]
const areaIndex = field.areas.indexOf(area)
@ -361,6 +483,220 @@ export default {
closeContextMenu () {
this.contextMenu = null
},
onEditorMouseLeave () {
this.cursorHighlightCoords = null
},
onEditorKeyDown (event) {
if (event.key === 'Escape') {
this.editor.chain().setNodeSelection(0).blur().run()
this.deselectArea()
}
},
onEditorMouseMove (event) {
if (!this.isDrawMode || !this.editable) {
this.cursorHighlightCoords = null
return
}
const view = this.editor?.view
if (!view) return
const pos = view.posAtCoords({ left: event.clientX, top: event.clientY })
if (!pos) {
this.cursorHighlightCoords = null
return
}
const coords = view.coordsAtPos(pos.pos)
const outerRect = this.$el.getBoundingClientRect()
const lineHeight = coords.bottom - coords.top
this.cursorHighlightCoords = {
x: coords.left - outerRect.left,
y: coords.top - outerRect.top,
height: lineHeight
}
},
getFieldNode (areaUuid) {
const pos = this.findAreaNodePos(areaUuid)
return this.editor.state.doc.nodeAt(pos)
},
serializeFieldNodeHtml (areaUuid) {
const node = this.getFieldNode(areaUuid)
if (!node) {
return null
}
const serializer = DOMSerializer.fromSchema(this.editor.state.schema)
const container = document.createElement('div')
container.appendChild(serializer.serializeFragment(Fragment.from(node)))
return container.innerHTML
},
async writeHtmlToClipboard ({ html, text }, clipboardData = null) {
if (clipboardData) {
clipboardData.setData('text/html', html)
clipboardData.setData('text/plain', text || html)
return true
}
if (navigator.clipboard?.write && window.ClipboardItem) {
await navigator.clipboard.write([
new ClipboardItem({
'text/html': new Blob([html], { type: 'text/html' }),
'text/plain': new Blob([text || html], { type: 'text/plain' })
})
])
return true
}
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text || html)
return true
}
return false
},
async copyFieldAreaToClipboard (areaUuid, clipboardData = null) {
const html = this.serializeFieldNodeHtml(areaUuid)
if (!html) {
return false
}
try {
await this.writeHtmlToClipboard({ html, text: html }, clipboardData)
return true
} catch (e) {
console.error('Failed to copy dynamic field:', e)
return false
}
},
async onContextMenuCopy () {
await this.copyFieldAreaToClipboard(this.contextMenu.areaUuid)
this.closeContextMenu()
},
buildCopiedField (payload) {
const field = JSON.parse(JSON.stringify(payload.field))
const area = {
...JSON.parse(JSON.stringify(payload.area)),
uuid: v4(),
attachment_uuid: this.attachmentUuid
}
if (payload.templateId !== this.template.id) {
delete field.conditions
if (field.preferences) {
delete field.preferences.formula
}
}
const newField = {
...field,
uuid: v4(),
submitter_uuid: this.selectedSubmitter.uuid,
areas: [area]
}
if (['radio', 'multiple'].includes(field.type) && field.options?.length) {
const oldOptionUuid = area.option_uuid
const optionsMap = {}
newField.options = field.options.map((opt) => {
const newUuid = v4()
optionsMap[opt.uuid] = newUuid
return { ...opt, uuid: newUuid }
})
area.option_uuid = optionsMap[oldOptionUuid] || newField.options[0]?.uuid
}
return { field: newField, area }
},
onEditorPaste (event) {
const clipboardData = event.clipboardData
if (!clipboardData) {
return
}
const html = clipboardData.getData('text/html')
const text = clipboardData.getData('text/plain')
const clipboardHtml = html || (text.includes('<dynamic-field') ? text : '')
if (!clipboardHtml || !clipboardHtml.includes('data-field=')) {
return
}
const container = document.createElement('div')
container.innerHTML = clipboardHtml
const fieldNodes = [...container.querySelectorAll('dynamic-field[data-field][data-area]')]
if (!fieldNodes.length) {
return
}
event.preventDefault()
const { selection } = this.editor.state
const { from, to } = selection
let insertIndex = this.getFieldInsertIndex(selection.node?.type.name === 'fieldNode' ? to : from)
let lastArea = null
fieldNodes.forEach((fieldNode) => {
const fieldValue = fieldNode.dataset.field
const areaValue = fieldNode.dataset.area
const templateId = fieldNode.dataset.templateId
if (!fieldValue || !areaValue) {
return
}
const { field, area } = this.buildCopiedField({
field: JSON.parse(fieldValue),
area: JSON.parse(areaValue),
templateId: Number(templateId)
})
this.insertFieldInTemplate(field, insertIndex)
insertIndex += 1
fieldNode.setAttribute('uuid', field.uuid)
fieldNode.setAttribute('area-uuid', area.uuid)
fieldNode.removeAttribute('data-field')
fieldNode.removeAttribute('data-area')
fieldNode.removeAttribute('data-template-id')
lastArea = area
})
this.editor.chain().focus().insertContentAt({ from, to }, container.innerHTML).run()
if (lastArea) {
this.editor.commands.setNodeSelection(this.findAreaNodePos(lastArea.uuid))
}
this.closeContextMenu()
this.save()
},
onContextMenuDelete () {
const menu = this.contextMenu
const fieldArea = this.fieldAreaIndex[menu.areaUuid]
@ -398,44 +734,100 @@ export default {
if (!pos) return false
const fieldType = draggedField.type || 'text'
const dims = this.defaultSizes[fieldType] || this.defaultSizes.text
const areaUuid = v4()
this.insertFieldAtRange({
sourceField: draggedField,
existingField: this.fieldsDragFieldRef?.value,
from: pos.pos
})
const existingField = this.fieldsDragFieldRef?.value
this.fieldsDragFieldRef.value = null
this.customDragFieldRef.value = null
if (existingField) {
if (!this.template.fields.includes(existingField)) {
this.template.fields.push(existingField)
return true
},
onEditorPointerDown (event) {
if (!this.isDrawMode || !this.editable || this.isDraggingField) {
return
}
existingField.areas = existingField.areas || []
existingField.areas.push({ uuid: areaUuid, attachment_uuid: this.attachmentUuid })
if (event.button === 2) {
return
}
const nodeType = view.state.schema.nodes.fieldNode
const fieldNode = nodeType.create({
uuid: existingField.uuid,
areaUuid,
width: dims.width,
height: dims.height
const view = this.editor?.view
if (!view) {
return
}
const selection = view.state.selection
const isTextRangeSelection = !selection.empty && !selection.node
let from = selection.from
let to = selection.to
if (!isTextRangeSelection) {
const pos = view.posAtCoords({ left: event.clientX, top: event.clientY })
if (!pos) {
return
}
from = pos.pos
to = pos.pos
}
const sourceField = this.drawField || this.drawCustomField || { type: this.drawFieldType }
event.preventDefault()
event.stopPropagation()
if (this.drawOption && this.drawField) {
const areaWithoutOption = this.drawField.areas?.find((a) => !a.option_uuid)
if (areaWithoutOption && !this.drawField.areas.find((a) => a.option_uuid === this.drawField.options[0].uuid)) {
areaWithoutOption.option_uuid = this.drawField.options[0].uuid
}
}
const inserted = this.insertFieldAtRange({
sourceField,
existingField: this.drawField,
optionUuid: this.drawOption?.uuid,
from,
to
})
const tr = view.state.tr.insert(pos.pos, fieldNode)
if (inserted) {
this.$emit('draw', inserted)
}
},
buildFieldArea ({ optionUuid = null } = {}) {
const area = {
uuid: v4(),
attachment_uuid: this.attachmentUuid
}
view.dispatch(tr)
} else {
if (optionUuid) {
area.option_uuid = optionUuid
}
return area
},
buildNewField (sourceField, area) {
const fieldType = sourceField?.type || 'text'
const newField = {
name: draggedField.name || '',
name: sourceField?.name || '',
uuid: v4(),
required: fieldType !== 'checkbox',
submitter_uuid: this.selectedSubmitter.uuid,
type: fieldType,
areas: [{ uuid: areaUuid, attachment_uuid: this.attachmentUuid }]
areas: [area]
}
if (['select', 'multiple', 'radio'].includes(fieldType)) {
if (draggedField.options?.length) {
newField.options = draggedField.options.map((opt) => ({
if (sourceField?.options?.length) {
newField.options = sourceField.options.map((opt) => ({
value: typeof opt === 'string' ? opt : opt.value,
uuid: v4()
}))
@ -458,29 +850,51 @@ export default {
}
}
this.template.fields.push(newField)
return newField
},
insertFieldAtRange ({ sourceField, existingField = null, optionUuid = null, from, to = from }) {
if (!sourceField) {
return null
}
const fieldType = sourceField.type || existingField?.type || 'text'
const dims = this.defaultSizes[fieldType]
const area = this.buildFieldArea({ optionUuid })
const insertIndex = this.getFieldInsertIndex(from)
let field = existingField
const view = this.editor.view
if (field) {
if (!this.template.fields.includes(field)) {
this.insertFieldInTemplate(field, insertIndex)
}
field.areas = field.areas || []
field.areas.push(area)
} else {
field = this.buildNewField(sourceField, area)
this.insertFieldInTemplate(field, insertIndex)
}
const nodeType = view.state.schema.nodes.fieldNode
const fieldNode = nodeType.create({
uuid: newField.uuid,
areaUuid,
uuid: field.uuid,
areaUuid: area.uuid,
width: dims.width,
height: dims.height
})
const tr = view.state.tr.insert(pos.pos, fieldNode)
const tr = from !== to
? view.state.tr.replaceWith(from, to, fieldNode)
: view.state.tr.insert(from, fieldNode)
view.dispatch(tr)
}
this.fieldsDragFieldRef.value = null
this.customDragFieldRef.value = null
this.editor.chain().focus().setNodeSelection(pos.pos).run()
this.editor.chain().focus().setNodeSelection(from).run()
this.save()
return true
return { area, field, pos: from }
}
}
}

@ -22,28 +22,30 @@
class="border-base-300"
>
<label class="peer flex items-center py-1.5 cursor-pointer select-none">
<span class="w-5 flex justify-center items-center">
<input
type="checkbox"
class="hidden peer"
checked
>
<IconChevronDown
class="hidden peer-checked:block"
class="hidden peer-checked:block ml-0.5"
:width="14"
:stroke-width="1.6"
/>
<IconChevronRight
class="block peer-checked:hidden"
class="block peer-checked:hidden ml-0.5"
:width="14"
:stroke-width="1.6"
/>
</span>
<span class="ml-1">{{ key }}</span>
<span
v-if="node.type === 'array'"
class="text-xs bg-base-200 rounded px-1 ml-1"
>{{ t('list') }}</span>
</label>
<div class="hidden peer-has-[:checked]:block pl-3.5">
<div class="hidden peer-has-[:checked]:block pl-5">
<template
v-for="[varNode, varPath] in nestedVariables(node, key)"
:key="varPath"
@ -101,7 +103,7 @@ export default {
},
methods: {
isGroup (node) {
return (node.type === 'object' && node.properties) || (node.type === 'array' && node.items?.properties)
return (node.type === 'object' && node.properties) || (node.type === 'array' && node.items?.type === 'object' && node.items?.properties)
},
nestedVariables (node, groupKey) {
const properties = node.type === 'array' ? node.items?.properties : node.properties

@ -353,7 +353,26 @@ function extractConditionVariables (node, acc = []) {
return acc
}
const IRREGULAR_PLURALS = {
people: 'person',
men: 'man',
women: 'woman',
children: 'child',
teeth: 'tooth',
feet: 'foot',
mice: 'mouse',
geese: 'goose',
oxen: 'ox',
criteria: 'criterion',
phenomena: 'phenomenon',
alumni: 'alumnus',
data: 'datum',
media: 'medium'
}
function singularize (word) {
if (IRREGULAR_PLURALS[word]) return IRREGULAR_PLURALS[word]
if (word.endsWith('ies')) return word.slice(0, -3) + 'y'
if (word.endsWith('ches') || word.endsWith('shes')) return word.slice(0, -2)
if (word.endsWith('ses') || word.endsWith('xes') || word.endsWith('zes')) return word.slice(0, -2)
@ -513,6 +532,12 @@ function processOperators (operators, propertiesHash = {}, parentProperties = {}
assignNestedSchema(propertiesHash, parentProperties, op.variableName, { type: 'array', items: itemProperties })
processOperators(op.children, propertiesHash, { ...parentProperties, [singularKey]: itemProperties })
if (itemProperties.type === 'object' && itemProperties.properties && Object.keys(itemProperties.properties).length === 0) {
delete itemProperties.properties
itemProperties.type = 'string'
}
break
}
}

@ -501,7 +501,7 @@ export default {
default: false
}
},
emits: ['add-field', 'set-draw', 'set-draw-type', 'set-draw-custom-field', 'set-drag', 'drag-end', 'scroll-to-area', 'change-submitter', 'set-drag-placeholder', 'select-submitter', 'rebuild-variables-schema'],
emits: ['add-field', 'set-draw', 'set-draw-type', 'set-draw-custom-field', 'set-drag', 'drag-end', 'scroll-to-area', 'change-submitter', 'set-drag-placeholder', 'select-submitter', 'rebuild-variables-schema', 'remove-field', 'remove-submitter'],
data () {
return {
fieldPagesLoaded: null,
@ -805,6 +805,8 @@ export default {
this.$emit('change-submitter', this.submitters[0])
}
this.$emit('remove-submitter', submitter)
this.save()
},
removeField (field, save = true) {
@ -830,6 +832,8 @@ export default {
this.selectedAreasRef.value.splice(this.selectedAreasRef.value.indexOf(area), 1)
})
this.$emit('remove-field', field)
if (save) {
this.save()
}

@ -274,13 +274,16 @@ export default {
makeDynamic () {
this.isMakeDynamicLoading = true
Promise.all([
this.baseFetch(`/templates/${this.template.id}/dynamic_documents`, {
method: 'POST',
body: JSON.stringify({ uuid: this.document.uuid }),
headers: {
'Content-Type': 'application/json'
}
}).then(async (resp) => {
}),
import(/* webpackChunkName: "dynamic-editor" */ './dynamic_document')
]).then(async ([resp, _]) => {
const dynamicDocument = await resp.json()
this.template.schema.find((item) => item.attachment_uuid === dynamicDocument.uuid).dynamic = true

@ -125,7 +125,7 @@ class Submission < ApplicationRecord
dynamic_count = template_schema&.count { |e| e['dynamic'] }.to_i
if template.variables_schema.blank?
if variables_schema.blank?
if dynamic_count > 0
if dynamic_count == template_schema.size
template_schema_dynamic_document_attachments

@ -131,6 +131,7 @@
</div>
<% end %>
<% end %>
<% if !Docuseal.multitenant? || can?(:manage, :disable_decline) %>
<% account_config = AccountConfig.find_or_initialize_by(account: current_account, key: AccountConfig::ALLOW_TO_DECLINE_KEY) %>
<% if can?(:manage, account_config) %>
<%= form_for account_config, url: account_configs_path, method: :post do |f| %>
@ -148,6 +149,7 @@
</div>
<% end %>
<% end %>
<% end %>
<% account_config = AccountConfig.find_or_initialize_by(account: current_account, key: AccountConfig::FORM_PREFILL_SIGNATURE_KEY) %>
<% if can?(:manage, account_config) %>
<%= form_for account_config, url: account_configs_path, method: :post do |f| %>

@ -8,18 +8,20 @@
<% variables_form = render 'variables_form', schema: @template.variables_schema if @template.variables_schema.present? && @template.variables_schema.any? { |_, v| !v['disabled'] } %>
<%= render 'shared/turbo_modal_large', title: params[:selfsign] ? t('add_recipients') : t('add_new_recipients') do %>
<% options = [only_detailed ? nil : [t('via_email'), 'email'], only_detailed ? nil : [t('via_phone'), 'phone'], [t('detailed'), 'detailed'], with_list ? [t('upload_list'), 'list'] : nil].compact %>
<% if options.size > 1 %>
<toggle-visible data-element-ids="<%= options.map(&:last).to_json %>" class="relative text-center px-2 mt-4 block">
<div class="flex justify-center">
<% options.each_with_index do |(label, value), index| %>
<%= content_tag(value == 'list' ? 'span' : 'toggle-cookies', data: { value:, key: 'add_recipients_tab' }) do %>
<%= radio_button_tag 'option', value, value == (only_detailed ? 'detailed' : default_tab), class: 'peer hidden', data: { action: 'change:toggle-visible#trigger' } %>
<label for="option_<%= value %>" class="block bg-base-200 md:min-w-[112px] text-sm font-semibold whitespace-nowrap py-1.5 px-4 peer-checked:bg-base-300 <%= 'hidden sm:inline-block' if value == 'list' %> <%= 'rounded-l-3xl' if index.zero? %> <%= 'rounded-r-3xl sm:rounded-r-none' if value == 'detailed' %> <%= 'rounded-r-3xl' if index == options.size - 1 %>">
<label for="option_<%= value %>" class="block bg-base-200 md:min-w-[112px] text-sm font-semibold whitespace-nowrap py-1.5 px-4 peer-checked:bg-base-300 <%= 'hidden sm:inline-block' if value == 'list' %> <%= 'rounded-l-3xl' if index.zero? %> <%= 'rounded-r-3xl sm:rounded-r-none' if value == 'detailed' && index != options.size - 1 %> <%= 'rounded-r-3xl' if index == options.size - 1 %>">
<%= label %>
</label>
<% end %>
<% end %>
</div>
</toggle-visible>
<% end %>
<div class="px-5 mb-5 mt-4">
<% unless only_detailed %>
<div id="email" class="<%= 'hidden' if default_tab != 'email' %>">

@ -85,9 +85,9 @@
<div class="mt-6">
<h2 id="log" class="text-3xl font-bold"><%= t('events_log') %></h2>
<div class="tabs border-b mt-4">
<%= link_to t('all'), url_for(params: request.query_parameters.except('status')), style: 'margin-bottom: -1px', class: "tab h-10 text-base #{params[:status].blank? ? 'tab-active tab-bordered' : 'pb-[3px]'}" %>
<%= link_to t('succeeded'), url_for(params: request.query_parameters.merge('status' => 'success')), style: 'margin-bottom: -1px', class: "tab h-10 text-base #{params[:status] == 'success' ? 'tab-active tab-bordered' : 'pb-[3px]'}" %>
<%= link_to t('failed'), url_for(params: request.query_parameters.merge('status' => 'error')), style: 'margin-bottom: -1px', class: "tab h-10 text-base #{params[:status] == 'error' ? 'tab-active tab-bordered' : 'pb-[3px]'}" %>
<%= link_to t('all'), url_for(params: request.query_parameters.except('status', 'page')), style: 'margin-bottom: -1px', class: "tab h-10 text-base #{params[:status].blank? ? 'tab-active tab-bordered' : 'pb-[3px]'}" %>
<%= link_to t('succeeded'), url_for(params: request.query_parameters.except('page').merge('status' => 'success')), style: 'margin-bottom: -1px', class: "tab h-10 text-base #{params[:status] == 'success' ? 'tab-active tab-bordered' : 'pb-[3px]'}" %>
<%= link_to t('failed'), url_for(params: request.query_parameters.except('page').merge('status' => 'error')), style: 'margin-bottom: -1px', class: "tab h-10 text-base #{params[:status] == 'error' ? 'tab-active tab-bordered' : 'pb-[3px]'}" %>
</div>
<% if @webhook_events.present? %>
<div class="divide-y divide-base-300 rounded-lg">

@ -883,6 +883,13 @@ en: &en
email_address_is_awaiting_confirmation_follow_the_link_in_the_email_to_confirm: "%{email} email address is awaiting confirmation. Follow the link in the email to confirm."
please_confirm_your_email_address_using_the_link_below_: 'Please confirm your email address using the link below:'
confirm_email: Confirm email
please_verify_your_email_address_to_continue: Please verify your email address to continue.
verification_code_sent_click_link_or_enter_code: A verification code has been sent to your email. Click the email link or enter the one time code to confirm your email.
use_otp_code_to_verify_email_or_click_link_below_html: 'Use <b>%{code}</b> code to verify your email or click the link below:'
verify_your_email: Verify your email
your_email_has_been_confirmed: Your email has been confirmed.
invalid_or_expired_verification_code: Invalid or expired verification code.
didnt_receive_an_email: Did not receive an email?
unconfirmed: Unconfirmed
you_requested_to_reset_your_password_use_the_link_below_to_continue: You requested to reset your password. Use the link below to continue
if_you_didnt_request_this_you_can_ignore_this_email: "If you didn't request this, please ignore this email."
@ -1900,6 +1907,13 @@ es: &es
email_address_is_awaiting_confirmation_follow_the_link_in_the_email_to_confirm: "%{email} está pendiente de confirmación. Sigue el enlace en el correo para confirmarla."
please_confirm_your_email_address_using_the_link_below_: 'Por favor, confirma tu dirección de correo electrónico utilizando el enlace a continuación:'
confirm_email: Confirmar correo
please_verify_your_email_address_to_continue: Por favor, verifica tu dirección de correo electrónico para continuar.
verification_code_sent_click_link_or_enter_code: Se ha enviado un código de verificación a tu correo. Puedes hacer clic en el enlace del correo o ingresar el código a continuación.
use_otp_code_to_verify_email_or_click_link_below_html: 'Usa el código <b>%{code}</b> para verificar tu correo electrónico o haz clic en el enlace a continuación:'
verify_your_email: Verificar tu correo electrónico
your_email_has_been_confirmed: Tu correo electrónico ha sido confirmado.
invalid_or_expired_verification_code: Código de verificación inválido o expirado.
didnt_receive_an_email: "¿No recibiste un correo electrónico?"
unconfirmed: No confirmado
you_requested_to_reset_your_password_use_the_link_below_to_continue: Solicitaste restablecer tu contraseña. Usa el enlace a continuación para continuar.
if_you_didnt_request_this_you_can_ignore_this_email: "Si no solicitaste esto, puedes ignorar este correo electrónico."
@ -2918,6 +2932,13 @@ it: &it
email_address_is_awaiting_confirmation_follow_the_link_in_the_email_to_confirm: "%{email} è in attesa di conferma. Segui il link nell'email per confermare."
please_confirm_your_email_address_using_the_link_below_: 'Conferma il tuo indirizzo email utilizzando il link qui sotto:'
confirm_email: Conferma email
please_verify_your_email_address_to_continue: Verifica il tuo indirizzo email per continuare.
verification_code_sent_click_link_or_enter_code: "È stato inviato un codice di verifica alla tua email. Puoi cliccare il link nell'email o inserire il codice qui sotto."
use_otp_code_to_verify_email_or_click_link_below_html: 'Usa il codice <b>%{code}</b> per verificare la tua email o clicca il link qui sotto:'
verify_your_email: Verifica la tua email
your_email_has_been_confirmed: La tua email è stata confermata.
invalid_or_expired_verification_code: Codice di verifica non valido o scaduto.
didnt_receive_an_email: Non hai ricevuto un'email?
unconfirmed: Non confermato
you_requested_to_reset_your_password_use_the_link_below_to_continue: Hai richiesto di reimpostare la tua password. Usa il link qui sotto per continuare.
if_you_didnt_request_this_you_can_ignore_this_email: "Se non hai richiesto questo, puoi ignorare questa email."
@ -3932,6 +3953,13 @@ fr: &fr
email_address_is_awaiting_confirmation_follow_the_link_in_the_email_to_confirm: "%{email} est en attente de confirmation. Suivez le lien dans l'e-mail pour la confirmer."
please_confirm_your_email_address_using_the_link_below_: 'Veuillez confirmer votre adresse e-mail en utilisant le lien ci-dessous :'
confirm_email: "Confirmer l'e-mail"
please_verify_your_email_address_to_continue: "Veuillez vérifier votre adresse e-mail pour continuer."
verification_code_sent_click_link_or_enter_code: "Un code de vérification a été envoyé à votre adresse e-mail. Vous pouvez cliquer sur le lien dans l'e-mail ou saisir le code ci-dessous."
use_otp_code_to_verify_email_or_click_link_below_html: "Utilisez le code <b>%{code}</b> pour vérifier votre e-mail ou cliquez sur le lien ci-dessous :"
verify_your_email: "Vérifier votre e-mail"
your_email_has_been_confirmed: "Votre adresse e-mail a été confirmée."
invalid_or_expired_verification_code: "Code de vérification invalide ou expiré."
didnt_receive_an_email: "Vous n'avez pas reçu d'e-mail ?"
unconfirmed: Non confirmé
you_requested_to_reset_your_password_use_the_link_below_to_continue: Vous avez demandé à réinitialiser votre mot de passe. Utilisez le lien ci-dessous pour continuer.
if_you_didnt_request_this_you_can_ignore_this_email: "Si vous n'avez pas fait cette demande, veuillez ignorer cet e-mail."
@ -4949,6 +4977,13 @@ pt: &pt
email_address_is_awaiting_confirmation_follow_the_link_in_the_email_to_confirm: "%{email} está aguardando confirmação. Siga o link enviado para esse endereço de e-mail para confirmar."
please_confirm_your_email_address_using_the_link_below_: 'Por favor, confirme seu endereço de e-mail usando o link abaixo:'
confirm_email: Confirmar e-mail
please_verify_your_email_address_to_continue: Verifique seu endereço de e-mail para continuar.
verification_code_sent_click_link_or_enter_code: Um código de verificação foi enviado para seu e-mail. Você pode clicar no link do e-mail ou inserir o código abaixo.
use_otp_code_to_verify_email_or_click_link_below_html: 'Use o código <b>%{code}</b> para verificar seu e-mail ou clique no link abaixo:'
verify_your_email: Verificar seu e-mail
your_email_has_been_confirmed: Seu e-mail foi confirmado.
invalid_or_expired_verification_code: Código de verificação inválido ou expirado.
didnt_receive_an_email: Não recebeu um e-mail?
unconfirmed: Não confirmado
you_requested_to_reset_your_password_use_the_link_below_to_continue: Você solicitou a redefinição da sua senha. Use o link abaixo para continuar.
if_you_didnt_request_this_you_can_ignore_this_email: "Se você não solicitou isso, pode ignorar este e-mail."
@ -5966,6 +6001,13 @@ de: &de
email_address_is_awaiting_confirmation_follow_the_link_in_the_email_to_confirm: "%{email} wartet auf Bestätigung. Folgen Sie dem Link in der E-Mail, um sie zu bestätigen."
please_confirm_your_email_address_using_the_link_below_: 'Bitte bestätigen Sie Ihre E-Mail-Adresse über den folgenden Link:'
confirm_email: E-Mail bestätigen
please_verify_your_email_address_to_continue: Bitte bestätigen Sie Ihre E-Mail-Adresse, um fortzufahren.
verification_code_sent_click_link_or_enter_code: Ein Verifizierungscode wurde an Ihre E-Mail gesendet. Sie können auf den Link in der E-Mail klicken oder den Code unten eingeben.
use_otp_code_to_verify_email_or_click_link_below_html: 'Verwenden Sie den Code <b>%{code}</b>, um Ihre E-Mail zu bestätigen, oder klicken Sie auf den Link unten:'
verify_your_email: E-Mail bestätigen
your_email_has_been_confirmed: Ihre E-Mail-Adresse wurde bestätigt.
invalid_or_expired_verification_code: Ungültiger oder abgelaufener Verifizierungscode.
didnt_receive_an_email: Keine E-Mail erhalten?
unconfirmed: Unbestätigt
you_requested_to_reset_your_password_use_the_link_below_to_continue: Sie haben angefordert, Ihr Passwort zurückzusetzen. Verwenden Sie den untenstehenden Link, um fortzufahren.
if_you_didnt_request_this_you_can_ignore_this_email: "Wenn Sie dies nicht angefordert haben, können Sie diese E-Mail ignorieren."
@ -7368,6 +7410,13 @@ nl: &nl
email_address_is_awaiting_confirmation_follow_the_link_in_the_email_to_confirm: "%{email} wacht op bevestiging. Volg de link in de e-mail om te bevestigen."
please_confirm_your_email_address_using_the_link_below_: 'Bevestig je e-mailadres via de onderstaande link:'
confirm_email: E-mailadres bevestigen
please_verify_your_email_address_to_continue: Bevestig je e-mailadres om door te gaan.
verification_code_sent_click_link_or_enter_code: Er is een verificatiecode naar je e-mail gestuurd. Je kunt op de link in de e-mail klikken of de code hieronder invoeren.
use_otp_code_to_verify_email_or_click_link_below_html: 'Gebruik code <b>%{code}</b> om je e-mail te verifiëren of klik op de link hieronder:'
verify_your_email: Je e-mail verifiëren
your_email_has_been_confirmed: Je e-mailadres is bevestigd.
invalid_or_expired_verification_code: Ongeldige of verlopen verificatiecode.
didnt_receive_an_email: Geen e-mail ontvangen?
unconfirmed: Onbevestigd
you_requested_to_reset_your_password_use_the_link_below_to_continue: Je hebt gevraagd je wachtwoord te resetten. Gebruik de onderstaande link om verder te gaan.
if_you_didnt_request_this_you_can_ignore_this_email: "Als je dit niet hebt aangevraagd, kun je deze e-mail negeren."

@ -3,8 +3,6 @@
module Submissions
DEFAULT_SUBMITTERS_ORDER = 'random'
PRELOAD_ALL_PAGES_AMOUNT = 200
module_function
def search(current_user, submissions, keyword, search_values: false, search_template: false)
@ -81,19 +79,9 @@ module Submissions
def preload_with_pages(submission)
ActiveRecord::Associations::Preloader.new(
records: submission.schema_documents,
associations: [:blob]
associations: [:blob, { preview_images_attachments: :blob }]
).call
total_pages =
submission.schema_documents.sum { |e| e.metadata.dig('pdf', 'number_of_pages').to_i }
if total_pages < PRELOAD_ALL_PAGES_AMOUNT
ActiveRecord::Associations::Preloader.new(
records: submission.schema_documents,
associations: [{ preview_images_attachments: :blob }]
).call
end
submission
end

@ -37,7 +37,7 @@ module Submissions
bold_italic: FONT_BOLD_NAME
}.freeze
SIGN_REASON = 'Signed by %<name>s with DocuSeal.com'
SIGN_REASON = 'Signed with DocuSeal.com'
RTL_REGEXP = TextUtils::RTL_REGEXP

@ -2,7 +2,6 @@
module Submitters
TRUE_VALUES = ['1', 'true', true].freeze
PRELOAD_ALL_PAGES_AMOUNT = 200
FIELD_NAME_WEIGHTS = {
'email' => 'A',

Loading…
Cancel
Save