Merge branch 'master' into Added-blank-page

pull/150/merge^2
iozeey 2 years ago
commit 63a6d5751b

@ -4,6 +4,7 @@ class AccountsController < ApplicationController
LOCALE_OPTIONS = {
'en-US' => 'English (United States)',
'en-GB' => 'English (United Kingdom)',
'fr-FR' => 'French (France)',
'es-ES' => 'Spanish (Spain)',
'pt-PT' => 'Portuguese (Portugal)',
'de-DE' => 'German (Germany)'

@ -3,7 +3,7 @@
module Api
class SubmissionsController < ApiBaseController
load_and_authorize_resource :template, only: :create
load_and_authorize_resource :submission, only: %i[show index]
load_and_authorize_resource :submission, only: %i[show index destroy]
before_action only: :create do
authorize!(:create, Submission)

@ -57,7 +57,7 @@ module Api
schema: [%i[attachment_uuid name]],
submitters: [%i[name uuid]],
fields: [[:uuid, :submitter_uuid, :name, :type, :required, :readonly, :default_value,
{ options: [], areas: [%i[x y w h cell_w attachment_uuid page]] }]]
{ options: [%i[value uuid]], areas: [%i[x y w h cell_w attachment_uuid option_uuid page]] }]]
)
end
end

@ -23,7 +23,7 @@ class PreviewDocumentPageController < ActionController::API
io = Templates::ProcessDocument.generate_pdf_preview_from_file(attachment, file_path, params[:id].to_i)
render plain: io.tap(&:rewind).read
render plain: io.tap(&:rewind).read, content_type: 'image/jpeg'
end
def find_or_create_document_tempfile_path(attachment)

@ -0,0 +1,29 @@
# frozen_string_literal: true
class SubmissionsPreviewController < ApplicationController
skip_before_action :authenticate_user!
skip_authorization_check
PRELOAD_ALL_PAGES_AMOUNT = 200
def show
@submission = Submission.find_by!(slug: params[:slug])
ActiveRecord::Associations::Preloader.new(
records: [@submission],
associations: [:template, { template_schema_documents: :blob }]
).call
total_pages =
@submission.template_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.template_schema_documents,
associations: [:blob, { preview_images_attachments: :blob }]
).call
end
render 'submissions/show', layout: 'plain'
end
end

@ -35,7 +35,7 @@ class TemplatesUploadsController < ApplicationController
def create_file_params_from_url
tempfile = Tempfile.new
tempfile.binmode
tempfile.write(conn.get(params[:url]).body)
tempfile.write(conn.get(Addressable::URI.parse(params[:url]).display_uri.to_s).body)
tempfile.rewind
file = ActionDispatch::Http::UploadedFile.new(

@ -1,7 +1,21 @@
export default class extends HTMLElement {
connectedCallback () {
if (this.dataset.inputId) {
document.getElementById(this.dataset.inputId).value = Intl.DateTimeFormat().resolvedOptions().timeZone
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone
if (this.dataset.params === 'true') {
const params = new URLSearchParams(this.input.value)
params.set('timezone', timezone)
this.input.value = params.toString()
} else {
this.input.value = timezone
}
}
}
get input () {
return document.getElementById(this.dataset.inputId)
}
}

@ -39,12 +39,12 @@
<div
v-else
class="flex absolute text-[1.5vw] lg:text-base"
class="flex absolute lg:text-base"
:style="computedStyle"
:class="{ 'cursor-default': !submittable, 'bg-red-100 border cursor-pointer ': submittable, 'border-red-100': !isActive && submittable, 'bg-opacity-70': !isActive && !isValueSet && submittable, 'border-red-500 border-dashed z-10': isActive && submittable, 'bg-opacity-30': (isActive || isValueSet) && submittable }"
:class="{ 'text-[1.5vw] lg:text-base': !textOverflowChars, 'text-[1.0vw] lg:text-xs': textOverflowChars, 'cursor-default': !submittable, 'bg-red-100 border cursor-pointer ': submittable, 'border-red-100': !isActive && submittable, 'bg-opacity-70': !isActive && !isValueSet && submittable, 'border-red-500 border-dashed z-10': isActive && submittable, 'bg-opacity-30': (isActive || isValueSet) && submittable }"
>
<div
v-if="!isActive && !isValueSet && field.type !== 'checkbox' && submittable"
v-if="!isActive && !isValueSet && field.type !== 'checkbox' && submittable && !area.option_uuid"
class="absolute top-0 bottom-0 right-0 left-0 items-center justify-center h-full w-full"
>
<span
@ -60,7 +60,7 @@
</span>
</div>
<div
v-if="isActive && withLabel"
v-if="isActive && withLabel && !area.option_uuid"
class="absolute -top-7 rounded bg-base-content text-base-100 px-2 text-sm whitespace-nowrap"
>
{{ field.name || fieldNames[field.type] }}
@ -126,6 +126,44 @@
:class="{ '!w-auto !h-full': area.w > area.h, '!w-full !h-auto': area.w <= area.h }"
/>
</div>
<div
v-else-if="field.type === 'radio' && area.option_uuid"
class="w-full p-[0.2vw] flex items-center justify-center"
>
<input
v-if="submittable"
type="radio"
:value="false"
class="aspect-square base-radio"
:class="{ '!w-auto !h-full': area.w > area.h, '!w-full !h-auto': area.w <= area.h }"
:checked="!!modelValue && modelValue === field.options.find((o) => o.uuid === area.option_uuid)?.value"
@click="$emit('update:model-value', field.options.find((o) => o.uuid === area.option_uuid)?.value)"
>
<IconCheck
v-else-if="!!modelValue && modelValue === field.options.find((o) => o.uuid === area.option_uuid)?.value"
class="aspect-square"
:class="{ '!w-auto !h-full': area.w > area.h, '!w-full !h-auto': area.w <= area.h }"
/>
</div>
<div
v-else-if="field.type === 'multiple' && area.option_uuid"
class="w-full p-[0.2vw] flex items-center justify-center"
>
<input
v-if="submittable"
type="checkbox"
:value="false"
class="aspect-square base-checkbox"
:class="{ '!w-auto !h-full': area.w > area.h, '!w-full !h-auto': area.w <= area.h }"
:checked="!!modelValue && modelValue.includes(field.options.find((o) => o.uuid === area.option_uuid)?.value)"
@change="updateMultipleSelectValue(field.options.find((o) => o.uuid === area.option_uuid)?.value)"
>
<IconCheck
v-else-if="!!modelValue && modelValue.includes(field.options.find((o) => o.uuid === area.option_uuid)?.value)"
class="aspect-square"
:class="{ '!w-auto !h-full': area.w > area.h, '!w-full !h-auto': area.w <= area.h }"
/>
</div>
<div
v-else-if="field.type === 'cells'"
class="w-full flex items-center"
@ -141,6 +179,7 @@
</div>
<div
v-else
ref="textContainer"
class="flex items-center px-0.5"
>
<span v-if="Array.isArray(modelValue)">
@ -212,6 +251,11 @@ export default {
}
},
emits: ['update:model-value'],
data () {
return {
textOverflowChars: 0
}
},
computed: {
fieldNames () {
return {
@ -292,6 +336,37 @@ export default {
height: h * 100 + '%'
}
}
},
watch: {
modelValue () {
if (this.field.type === 'text' && this.$refs.textContainer && (this.textOverflowChars === 0 || (this.textOverflowChars - 4) > this.modelValue.length)) {
this.textOverflowChars = this.$refs.textContainer.scrollHeight > this.$refs.textContainer.clientHeight ? this.modelValue.length : 0
}
}
},
mounted () {
if (this.field.type === 'text' && this.$refs.textContainer) {
this.$nextTick(() => {
this.textOverflowChars = this.$refs.textContainer.scrollHeight > this.$refs.textContainer.clientHeight ? this.modelValue.length : 0
})
}
},
methods: {
updateMultipleSelectValue (value) {
if (this.modelValue?.includes(value)) {
const newValue = [...this.modelValue]
newValue.splice(newValue.indexOf(value), 1)
this.$emit('update:model-value', newValue)
} else {
const newValue = this.modelValue ? [...this.modelValue] : []
newValue.push(value)
this.$emit('update:model-value', newValue)
}
}
}
}
</script>

@ -99,12 +99,12 @@
{{ t('select_your_option') }}
</option>
<option
v-for="(option, index) in currentField.options"
:key="index"
:selected="values[currentField.uuid] == option"
:value="option"
v-for="option in currentField.options"
:key="option.uuid"
:selected="values[currentField.uuid] == option.value"
:value="option.value"
>
{{ option }}
{{ option.value }}
</option>
</select>
</div>
@ -119,24 +119,24 @@
<div class="flex w-full">
<div class="space-y-3.5 mx-auto">
<div
v-for="(option, index) in currentField.options"
:key="index"
v-for="option in currentField.options"
:key="option.uuid"
>
<label
:for="currentField.uuid + option"
:for="option.uuid"
class="flex items-center space-x-3"
>
<input
:id="currentField.uuid + option"
:id="option.uuid"
v-model="values[currentField.uuid]"
type="radio"
class="base-radio !h-7 !w-7"
:name="`values[${currentField.uuid}]`"
:value="option"
:value="option.value"
:required="currentField.required"
>
<span class="text-xl">
{{ option }}
{{ option.value }}
</span>
</label>
</div>
@ -652,12 +652,20 @@ export default {
})
},
saveStep (formData) {
const currentFieldUuid = this.currentField.uuid
if (this.isCompleted) {
return Promise.resolve({})
} else {
return fetch(this.baseUrl + this.submitPath, {
method: 'POST',
body: formData || new FormData(this.$refs.form)
}).then((response) => {
if (response.status === 200) {
this.submittedValues[currentFieldUuid] = this.values[currentFieldUuid]
}
return response
})
}
},
@ -689,8 +697,6 @@ export default {
return Promise.reject(new Error(data.error))
}
this.submittedValues[this.currentField.uuid] = this.values[this.currentField.uuid]
if (isLastStep) {
this.isSecondWalkthrough = true
}

@ -33,6 +33,11 @@ const en = {
please_fill_all_required_fields: 'Please fill all required fields',
set_today: 'Set Today',
toggle_multiline_text: 'Toggle Multiline Text',
draw_signature: 'Draw signature',
type_initial: 'Type initials',
draw: 'Draw',
type: 'Type',
type_text: 'Type text',
date: 'Date',
email_has_been_sent: 'Email has been sent'
}
@ -73,6 +78,11 @@ const es = {
set_today: 'Establecer Hoy',
date: 'Fecha',
toggle_multiline_text: 'Alternar Texto Multilínea',
draw_signature: 'Dibujar firma',
type_initial: 'Escribir iniciales',
draw: 'Dibujar',
type: 'Escribir',
type_text: 'Escribir texto',
email_has_been_sent: 'El correo electrónico ha sido enviado'
}
@ -111,6 +121,11 @@ const it = {
please_fill_all_required_fields: 'Si prega di compilare tutti i campi obbligatori',
set_today: 'Imposta Oggi',
date: 'Data',
draw_signature: 'Disegna firma',
type_initial: 'Inserisci iniziali',
draw: 'Disegna',
type: 'Inserisci',
type_text: 'Inserisci testo',
toggle_multiline_text: 'Attiva Testo Multilinea',
email_has_been_sent: "L'email è stata inviata"
}
@ -150,6 +165,11 @@ const de = {
please_fill_all_required_fields: 'Bitte füllen Sie alle erforderlichen Felder aus',
set_today: 'Heute einstellen',
date: 'Datum',
draw_signature: 'Unterschrift zeichnen',
type_initial: 'Initialen eingeben',
draw: 'Zeichnen',
type: 'Eingeben',
type_text: 'Text eingeben',
toggle_multiline_text: 'Mehrzeiligen Text umschalten',
email_has_been_sent: 'Die E-Mail wurde gesendet'
}
@ -189,6 +209,11 @@ const fr = {
please_fill_all_required_fields: 'Veuillez remplir tous les champs obligatoires',
set_today: "Définir Aujourd'hui",
date: 'Date',
draw_signature: 'Dessiner une signature',
type_initial: 'Saisir les initiales',
draw: 'Dessiner',
type: 'Saisir',
type_text: 'Saisir du texte',
toggle_multiline_text: 'Basculer le Texte Multiligne',
email_has_been_sent: "L'email a été envoyé"
}
@ -228,6 +253,11 @@ const pl = {
please_fill_all_required_fields: 'Proszę wypełnić wszystkie wymagane pola',
set_today: 'Ustaw Dziś',
date: 'Data',
draw_signature: 'Rysuj podpis',
type_initial: 'Wprowadź inicjały',
draw: 'Rysuj',
type: 'Wprowadź',
type_text: 'Wprowadź tekst',
toggle_multiline_text: 'Przełącz Tekst Wielolinijkowy',
email_has_been_sent: 'E-mail został wysłany'
}
@ -267,6 +297,11 @@ const uk = {
please_fill_all_required_fields: "Будь ласка, заповніть всі обов'язкові поля",
set_today: 'Задати Сьогодні',
date: 'Дата',
draw_signature: 'Намалюйте підпис',
type_initial: 'Введіть ініціали',
draw: 'Підпис',
type: 'Текст',
type_text: 'Введіть текст',
toggle_multiline_text: 'Перемкнути Багаторядковий Текст',
email_has_been_sent: 'Електронний лист був відправлений'
}
@ -306,6 +341,11 @@ const cs = {
please_fill_all_required_fields: 'Prosím vyplňte všechny povinné položky',
set_today: 'Nastavit Dnes',
date: 'Datum',
draw_signature: 'Nakreslit podpis',
type_initial: 'Zadat iniciály',
draw: 'Kreslit',
type: 'Zadat',
type_text: 'Zadat text',
toggle_multiline_text: 'Přepnout Víceřádkový Text',
email_has_been_sent: 'E-mail byl odeslán'
}
@ -345,6 +385,11 @@ const pt = {
please_fill_all_required_fields: 'Por favor, preencha todos os campos obrigatórios',
set_today: 'Definir Hoje',
date: 'Data',
draw_signature: 'Desenhar assinatura',
type_initial: 'Inserir iniciais',
draw: 'Desenhar',
type: 'Inserir',
type_text: 'Inserir texto',
toggle_multiline_text: 'Alternar Texto Multilinha',
email_has_been_sent: 'Email enviado'
}

@ -6,23 +6,43 @@
>{{ field.name || t('initials') }}</label>
<div class="space-x-2 flex">
<span
v-if="isDrawInitials"
class="tooltip"
:data-tip="t('type_initials')"
>
<a
id="type_text_button"
href="#"
class="btn btn-outline font-medium btn-sm"
@click.prevent="toggleTextInput"
>
<IconTextSize :width="16" />
<span class="hidden sm:inline">
{{ t('type') }}
</span>
</a>
</span>
<span
v-else
class="tooltip"
:data-tip="t('draw_initials')"
>
<a
id="type_text_button"
href="#"
class="btn btn-sm btn-circle"
:class="{ 'btn-neutral': isDrawInitials, 'btn-outline': !isDrawInitials }"
class="btn btn-outline font-medium btn-sm"
@click.prevent="toggleTextInput"
>
<IconSignature :width="16" />
<span class="hidden sm:inline">
{{ t('draw') }}
</span>
</a>
</span>
<a
v-if="modelValue || computedPreviousValue"
href="#"
class="btn btn-outline btn-sm"
class="btn font-medium btn-outline btn-sm"
@click.prevent="remove"
>
<IconReload :width="16" />
@ -31,7 +51,7 @@
<a
v-else
href="#"
class="btn btn-outline btn-sm"
class="btn font-medium btn-outline btn-sm"
@click.prevent="clear"
>
<IconReload :width="16" />
@ -63,7 +83,7 @@
<canvas
v-show="!modelValue && !computedPreviousValue"
ref="canvas"
class="bg-white border border-base-300 rounded"
class="bg-white border border-base-300 rounded-2xl"
/>
<input
v-if="!isDrawInitials && !modelValue && !computedPreviousValue"
@ -81,13 +101,14 @@
<script>
import { cropCanvasAndExportToPNG } from './crop_canvas'
import { IconReload, IconSignature, IconArrowsDiagonalMinimize2 } from '@tabler/icons-vue'
import { IconReload, IconTextSize, IconSignature, IconArrowsDiagonalMinimize2 } from '@tabler/icons-vue'
import SignaturePad from 'signature_pad'
export default {
name: 'InitialsStep',
components: {
IconReload,
IconTextSize,
IconSignature,
IconArrowsDiagonalMinimize2
},

@ -7,25 +7,25 @@
<div class="flex w-full">
<div class="space-y-3.5 mx-auto">
<div
v-for="(option, index) in field.options"
:key="index"
v-for="option in field.options"
:key="option.uuid"
>
<label
:for="field.uuid + option"
:for="option.uuid"
class="flex items-center space-x-3"
>
<input
:id="field.uuid + option"
:id="option.uuid"
:ref="setInputRef"
type="checkbox"
:name="`values[${field.uuid}][]`"
:value="option"
:value="option.value"
class="base-checkbox !h-7 !w-7"
:checked="(modelValue || []).includes(option)"
:checked="(modelValue || []).includes(option.value)"
@change="onChange"
>
<span class="text-xl">
{{ option }}
{{ option.value }}
</span>
</label>
</div>

@ -6,17 +6,37 @@
>{{ field.name || t('signature') }}</label>
<div class="space-x-2 flex">
<span
v-if="isTextSignature"
class="tooltip"
data-tip="Type text"
:data-tip="t('draw_signature')"
>
<a
id="type_text_button"
href="#"
class="btn btn-sm btn-circle"
:class="{ 'btn-neutral': isTextSignature, 'btn-outline': !isTextSignature }"
class="btn btn-outline btn-sm font-medium"
@click.prevent="toggleTextInput"
>
<IconSignature :width="16" />
<span class="hidden sm:inline">
{{ t('draw') }}
</span>
</a>
</span>
<span
v-else
class="tooltip"
:data-tip="t('type_text')"
>
<a
id="type_text_button"
href="#"
class="btn btn-outline btn-sm font-medium"
@click.prevent="toggleTextInput"
>
<IconTextSize :width="16" />
<span class="hidden sm:inline">
{{ t('type') }}
</span>
</a>
</span>
<span
@ -24,7 +44,7 @@
data-tip="Take photo"
>
<label
class="btn btn-outline btn-sm btn-circle"
class="btn btn-outline btn-sm font-medium"
>
<IconCamera :width="16" />
<input
@ -33,12 +53,15 @@
accept="image/*"
@change="drawImage"
>
<span class="hidden sm:inline">
{{ t('upload') }}
</span>
</label>
</span>
<a
v-if="modelValue || computedPreviousValue"
href="#"
class="btn btn-outline btn-sm"
class="btn btn-outline btn-sm font-medium"
@click.prevent="remove"
>
<IconReload :width="16" />
@ -47,7 +70,7 @@
<a
v-else
href="#"
class="btn btn-outline btn-sm"
class="btn btn-outline btn-sm font-medium"
@click.prevent="clear"
>
<IconReload :width="16" />
@ -79,7 +102,8 @@
<canvas
v-show="!modelValue && !computedPreviousValue"
ref="canvas"
class="bg-white border border-base-300 rounded"
style="padding: 1px; 0"
class="bg-white border border-base-300 rounded-2xl"
/>
<input
v-if="isTextSignature"
@ -95,7 +119,7 @@
</template>
<script>
import { IconReload, IconCamera, IconTextSize, IconArrowsDiagonalMinimize2 } from '@tabler/icons-vue'
import { IconReload, IconCamera, IconSignature, IconTextSize, IconArrowsDiagonalMinimize2 } from '@tabler/icons-vue'
import { cropCanvasAndExportToPNG } from './crop_canvas'
import SignaturePad from 'signature_pad'
@ -107,6 +131,7 @@ export default {
IconReload,
IconCamera,
IconTextSize,
IconSignature,
IconArrowsDiagonalMinimize2
},
inject: ['baseUrl', 't'],

@ -15,6 +15,7 @@
v-if="!isTextArea"
:id="field.uuid"
v-model="text"
:maxlength="cellsMaxLegth"
class="base-input !text-2xl w-full !pr-11 -mr-10"
:required="field.required"
:pattern="field.validation?.pattern"
@ -38,7 +39,7 @@
@focus="$emit('focus')"
/>
<div
v-if="!isTextArea"
v-if="!isTextArea && field.type !== 'cells'"
class="tooltip"
:data-tip="t('toggle_multiline_text')"
>
@ -80,6 +81,19 @@ export default {
}
},
computed: {
cellsMaxLegth () {
if (this.field.type === 'cells') {
const area = this.field.areas?.[0]
if (area) {
return parseInt(area.w / area.cell_w) + 1
} else {
return null
}
} else {
return null
}
},
text: {
set (value) {
this.$emit('update:model-value', value)

@ -66,9 +66,9 @@
@keydown.enter.prevent="onNameEnter"
@focus="onNameFocus"
@blur="onNameBlur"
>{{ field.name || defaultName }}</span>
>{{ optionIndexText }} {{ field.name || defaultName }}</span>
<div
v-if="isNameFocus && field.type !== 'checkbox'"
v-if="isNameFocus && !['checkbox', 'phone'].includes(field.type)"
class="flex items-center ml-1.5"
>
<input
@ -121,9 +121,12 @@
>
<div
v-if="field?.default_value"
class="text-[1.5vw] lg:text-base"
:class="{ 'text-[1.5vw] lg:text-base': !textOverflowChars, 'text-[1.0vw] lg:text-xs': textOverflowChars }"
>
<div class="flex items-center px-0.5">
<div
ref="textContainer"
class="flex items-center px-0.5"
>
<span class="whitespace-pre-wrap">{{ field.default_value }}</span>
</div>
</div>
@ -154,6 +157,7 @@ import FieldSubmitter from './field_submitter'
import FieldType from './field_type'
import Field from './field'
import { IconX } from '@tabler/icons-vue'
import { v4 } from 'uuid'
export default {
name: 'FieldArea',
@ -190,6 +194,7 @@ export default {
isResize: false,
isDragged: false,
isNameFocus: false,
textOverflowChars: 0,
dragFrom: { x: 0, y: 0 }
}
},
@ -197,6 +202,13 @@ export default {
defaultName: Field.computed.defaultName,
fieldNames: FieldType.computed.fieldNames,
fieldIcons: FieldType.computed.fieldIcons,
optionIndexText () {
if (this.area.option_uuid && this.field.options) {
return `${this.field.options.findIndex((o) => o.uuid === this.area.option_uuid) + 1}.`
} else {
return ''
}
},
cells () {
const cells = []
@ -258,6 +270,20 @@ export default {
}
}
},
watch: {
'field.default_value' () {
if (this.field.type === 'text' && this.field.default_value && this.$refs.textContainer && (this.textOverflowChars === 0 || (this.textOverflowChars - 4) > this.field.default_value.length)) {
this.textOverflowChars = this.$el.clientHeight < this.$refs.textContainer.clientHeight ? this.field.default_value.length : 0
}
}
},
mounted () {
if (this.field.type === 'text' && this.field.default_value && this.$refs.textContainer && (this.textOverflowChars === 0 || (this.textOverflowChars - 4) > this.field.default_value)) {
this.$nextTick(() => {
this.textOverflowChars = this.$el.clientHeight < this.$refs.textContainer.clientHeight ? this.field.default_value.length : 0
})
}
},
methods: {
onNameFocus (e) {
this.selectedAreaRef.value = this.area
@ -302,7 +328,7 @@ export default {
}
if (['select', 'multiple', 'radio'].includes(this.field.type)) {
this.field.options ||= ['']
this.field.options ||= [{ value: '', uuid: v4() }]
}
(this.field.areas || []).forEach((area) => {

@ -77,6 +77,7 @@
:item="item"
:document="sortedDocuments[index]"
:accept-file-types="acceptFileTypes"
:with-replace-button="withUploadButton"
:editable="editable"
:template="template"
:is-direct-upload="isDirectUpload"
@ -96,7 +97,7 @@
:class="{ 'bg-base-100': withStickySubmitters }"
>
<Upload
v-if="sortedDocuments.length && editable"
v-if="sortedDocuments.length && editable && withUploadButton"
:accept-file-types="acceptFileTypes"
:template-id="template.id"
:is-direct-upload="isDirectUpload"
@ -110,7 +111,7 @@
class="pr-3.5 pl-0.5"
>
<Dropzone
v-if="!sortedDocuments.length"
v-if="!sortedDocuments.length && withUploadButton"
:template-id="template.id"
:accept-file-types="acceptFileTypes"
:is-direct-upload="isDirectUpload"
@ -126,9 +127,10 @@
:areas-index="fieldAreasIndex[document.uuid]"
:selected-submitter="selectedSubmitter"
:document="document"
:is-drag="!!dragFieldType"
:is-drag="!!dragField"
:draw-field="drawField"
:editable="editable"
:base-url="baseUrl"
@draw="onDraw"
@drop-field="onDropfield"
@remove-area="removeArea"
@ -137,6 +139,7 @@
v-if="isBreakpointLg && editable"
:with-arrows="template.schema.length > 1"
:item="template.schema.find((item) => item.attachment_uuid === document.uuid)"
:with-replace-button="withUploadButton"
:document="document"
:template="template"
:is-direct-upload="isDirectUpload"
@ -153,6 +156,7 @@
class="pb-4"
>
<Upload
v-if="withUploadButton"
:template-id="template.id"
:is-direct-upload="isDirectUpload"
@success="updateFromUpload"
@ -168,7 +172,7 @@
:selected-submitter="selectedSubmitter"
class="md:hidden"
:editable="editable"
@cancel="drawField = null"
@cancel="[drawField = null, drawOption = null]"
@change-submitter="[selectedSubmitter = $event, drawField.submitter_uuid = $event.uuid]"
/>
<FieldType
@ -209,7 +213,7 @@
<p>
<button
class="base-button"
@click="drawField = null"
@click="[drawField = null, drawOption = null]"
>
Cancel
</button>
@ -222,12 +226,13 @@
:fields="template.fields"
:submitters="template.submitters"
:selected-submitter="selectedSubmitter"
:default-fields="defaultFields"
:with-sticky-submitters="withStickySubmitters"
:editable="editable"
@set-draw="drawField = $event"
@set-drag="dragFieldType = $event"
@set-draw="[drawField = $event.field, drawOption = $event.option]"
@set-drag="dragField = $event"
@change-submitter="selectedSubmitter = $event"
@drag-end="dragFieldType = null"
@drag-end="dragField = null"
@scroll-to-area="scrollToArea"
/>
</div>
@ -300,6 +305,16 @@ export default {
required: false,
default: true
},
defaultFields: {
type: Array,
required: false,
default: () => []
},
defaultSubmitters: {
type: Array,
required: false,
default: () => []
},
acceptFileTypes: {
type: String,
required: false,
@ -320,6 +335,11 @@ export default {
required: false,
default: true
},
withUploadButton: {
type: Boolean,
required: false,
default: true
},
withPhone: {
type: Boolean,
required: false,
@ -340,7 +360,9 @@ export default {
drawField: null,
dragFieldType: null,
isLoading: false,
isDeleting: false
isDeleting: false,
drawOption: null,
dragField: null
}
},
computed: {
@ -372,6 +394,13 @@ export default {
}
},
created () {
this.defaultSubmitters.forEach((name, index) => {
const submitter = (this.template.submitters[index] ||= {})
submitter.name = name
submitter.uuid ||= v4()
})
this.selectedSubmitter = this.template.submitters[0]
},
mounted () {
@ -408,10 +437,11 @@ export default {
}
if (['select', 'multiple', 'radio'].includes(type)) {
field.options = ['']
field.options = [{ value: '', uuid: v4() }]
}
this.drawField = field
this.drawOption = null
},
undo () {
if (this.undoStack.length > 1) {
@ -460,6 +490,7 @@ export default {
onKeyUp (e) {
if (e.code === 'Escape') {
this.drawField = null
this.drawOption = null
this.selectedAreaRef.value = null
}
@ -506,6 +537,16 @@ export default {
},
onDraw (area) {
if (this.drawField) {
if (this.drawOption) {
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
}
area.option_uuid = this.drawOption.uuid
}
this.drawField.areas ||= []
this.drawField.areas.push(area)
@ -514,6 +555,7 @@ export default {
}
this.drawField = null
this.drawOption = null
this.selectedAreaRef.value = area
@ -563,14 +605,14 @@ export default {
onDropfield (area) {
const field = {
name: '',
type: this.dragFieldType,
uuid: v4(),
submitter_uuid: this.selectedSubmitter.uuid,
required: this.dragFieldType !== 'checkbox'
required: this.dragField.type !== 'checkbox',
...this.dragField
}
if (['select', 'multiple', 'radio'].includes(this.dragFieldType)) {
field.options = ['']
if (['select', 'multiple', 'radio'].includes(field.type)) {
field.options = [{ value: '', uuid: v4() }]
}
const fieldArea = {
@ -584,27 +626,27 @@ export default {
let baseArea
if (this.selectedField?.type === this.dragFieldType) {
if (this.selectedField?.type === field.type) {
baseArea = this.selectedAreaRef.value
} else if (previousField?.areas?.length) {
baseArea = previousField.areas[previousField.areas.length - 1]
} else {
if (['checkbox'].includes(this.dragFieldType)) {
if (['checkbox'].includes(field.type)) {
baseArea = {
w: area.maskW / 30 / area.maskW,
h: area.maskW / 30 / area.maskW * (area.maskW / area.maskH)
}
} else if (this.dragFieldType === 'image') {
} else if (field.type === 'image') {
baseArea = {
w: area.maskW / 5 / area.maskW,
h: (area.maskW / 5 / area.maskW) * (area.maskW / area.maskH)
}
} else if (this.dragFieldType === 'signature') {
} else if (field.type === 'signature') {
baseArea = {
w: area.maskW / 5 / area.maskW,
h: (area.maskW / 5 / area.maskW) * (area.maskW / area.maskH) / 2
}
} else if (this.dragFieldType === 'initials') {
} else if (field.type === 'initials') {
baseArea = {
w: area.maskW / 10 / area.maskW,
h: area.maskW / 35 / area.maskW
@ -621,7 +663,7 @@ export default {
fieldArea.h = baseArea.h
fieldArea.y = fieldArea.y - baseArea.h / 2
if (this.dragFieldType === 'cells') {
if (field.type === 'cells') {
fieldArea.cell_w = baseArea.cell_w || (baseArea.w / 5)
}

@ -7,6 +7,7 @@
@update:model-value="onUpdateName"
/>
<ReplaceButton
v-if="withReplaceButton"
:is-direct-upload="isDirectUpload"
:template-id="template.id"
@click.stop
@ -67,6 +68,11 @@ export default {
required: true,
default: false
},
withReplaceButton: {
type: Boolean,
required: true,
default: true
},
withArrows: {
type: Boolean,
required: false,

@ -49,6 +49,11 @@ export default {
required: false,
default: null
},
baseUrl: {
type: String,
required: false,
default: ''
},
isDrag: {
type: Boolean,
required: false,
@ -62,6 +67,13 @@ export default {
}
},
computed: {
basePreviewUrl () {
if (this.baseUrl) {
return new URL(this.baseUrl).origin
} else {
return ''
}
},
numberOfPages () {
return this.document.metadata?.pdf?.number_of_pages || this.document.preview_images.length
},
@ -72,7 +84,7 @@ export default {
return this.previewImagesIndex[i] || {
metadata: lazyloadMetadata,
id: Math.random().toString(),
url: `/preview/${this.document.uuid}/${i}.jpg`
url: this.basePreviewUrl + `/preview/${this.document.uuid}/${i}.jpg`
}
})
},

@ -34,19 +34,21 @@
v-if="isNameFocus"
class="flex items-center relative"
>
<input
:id="`required-checkbox-${field.uuid}`"
v-model="field.required"
type="checkbox"
class="checkbox checkbox-xs no-animation rounded"
@mousedown.prevent
>
<label
:for="`required-checkbox-${field.uuid}`"
class="label text-xs"
@click.prevent="field.required = !field.required"
@mousedown.prevent
>Required</label>
<template v-if="field.type != 'phone'">
<input
:id="`required-checkbox-${field.uuid}`"
v-model="field.required"
type="checkbox"
class="checkbox checkbox-xs no-animation rounded"
@mousedown.prevent
>
<label
:for="`required-checkbox-${field.uuid}`"
class="label text-xs"
@click.prevent="field.required = !field.required"
@mousedown.prevent
>Required</label>
</template>
</div>
<div
v-else-if="editable"
@ -56,7 +58,7 @@
v-if="field && !field.areas.length"
title="Draw"
class="relative cursor-pointer text-transparent group-hover:text-base-content"
@click="$emit('set-draw', field)"
@click="$emit('set-draw', { field })"
>
<IconNewSection
:width="18"
@ -104,7 +106,10 @@
Default value
</label>
</div>
<li @click.stop>
<li
v-if="field.type != 'phone'"
@click.stop
>
<label class="cursor-pointer py-1.5">
<input
v-model="field.required"
@ -146,11 +151,11 @@
Page {{ area.page + 1 }}
</a>
</li>
<li>
<li v-if="!field.areas?.length || !['radio', 'multiple'].includes(field.type)">
<a
href="#"
class="text-sm py-1 px-2"
@click.prevent="$emit('set-draw', field)"
@click.prevent="$emit('set-draw', { field })"
>
<IconNewSection
:width="20"
@ -188,26 +193,54 @@
</div>
<div
v-if="field.options"
ref="options"
class="border-t border-base-300 mx-2 pt-2 space-y-1.5"
draggable="true"
@dragstart.prevent.stop
>
<div
v-for="(option, index) in field.options"
:key="index"
:key="option.uuid"
class="flex space-x-1.5 items-center"
>
<span class="text-sm w-3.5">
{{ index + 1 }}.
</span>
<div
v-if="['radio', 'multiple'].includes(field.type) && (index > 0 || field.areas.find((a) => a.option_uuid) || !field.areas.length) && !field.areas.find((a) => a.option_uuid === option.uuid)"
class="items-center flex w-full"
>
<input
v-model="option.value"
class="w-full input input-primary input-xs text-sm bg-transparent !pr-7 -mr-6"
type="text"
required
@blur="save"
>
<button
title="Draw"
tabindex="-1"
@click.prevent="$emit('set-draw', { field, option })"
>
<IconNewSection
:width="18"
:stroke-width="1.6"
/>
</button>
</div>
<input
v-model="field.options[index]"
v-else
v-model="option.value"
class="w-full input input-primary input-xs text-sm bg-transparent"
type="text"
required
@focus="maybeFocusOnOptionArea(option)"
@blur="save"
>
<button
class="text-sm w-3.5"
@click="[field.options.splice(index, 1), save()]"
tabindex="-1"
@click="removeOption(option)"
>
&times;
</button>
@ -215,7 +248,7 @@
<button
v-if="field.options"
class="text-center text-sm w-full pb-1"
@click="[field.options.push(''), save()]"
@click="addOption"
>
+ Add option
</button>
@ -228,6 +261,7 @@
import Contenteditable from './contenteditable'
import FieldType from './field_type'
import { IconShape, IconNewSection, IconTrashX, IconCopy, IconSettings } from '@tabler/icons-vue'
import { v4 } from 'uuid'
export default {
name: 'TemplateField',
@ -240,7 +274,7 @@ export default {
IconCopy,
FieldType
},
inject: ['template', 'save', 'backgroundColor'],
inject: ['template', 'save', 'backgroundColor', 'selectedAreaRef'],
props: {
field: {
type: Object,
@ -298,12 +332,36 @@ export default {
}, 1)
}
},
maybeFocusOnOptionArea (option) {
const area = this.field.areas.find((a) => a.option_uuid === option.uuid)
if (area) {
this.selectedAreaRef.value = area
}
},
scrollToFirstArea () {
return this.field.areas?.[0] && this.$emit('scroll-to', this.field.areas[0])
},
closeDropdown () {
document.activeElement.blur()
},
addOption () {
this.field.options.push({ value: '', uuid: v4() })
this.$nextTick(() => {
const inputs = this.$refs.options.querySelectorAll('input')
inputs[inputs.length - 1]?.focus()
})
this.save()
},
removeOption (option) {
this.field.options.splice(this.field.options.indexOf(option), 1)
this.field.areas.splice(this.field.areas.findIndex((a) => a.option_uuid === option.uuid), 1)
this.save()
},
maybeUpdateOptions () {
delete this.field.default_value
@ -312,7 +370,7 @@ export default {
}
if (['radio', 'multiple', 'select'].includes(this.field.type)) {
this.field.options ||= ['']
this.field.options ||= [{ value: '', uuid: v4() }]
}
(this.field.areas || []).forEach((area) => {

@ -32,6 +32,35 @@
@set-draw="$emit('set-draw', $event)"
/>
</div>
<div v-if="submitterDefaultFields.length">
<hr class="mb-2">
<template
v-for="field in submitterDefaultFields"
:key="field.name"
>
<div
:style="{ backgroundColor: backgroundColor }"
draggable="true"
class="border border-base-300 rounded rounded-tr-none relative group mb-2"
@dragstart="onDragstart({ type: 'text', ...field })"
@dragend="$emit('drag-end')"
>
<div class="flex items-center justify-between relative cursor-grab">
<div class="flex items-center p-1 space-x-1">
<IconDrag />
<FieldType
:model-value="field.type || 'text'"
:editable="false"
:button-width="20"
/>
<span class="block pl-0.5">
{{ field.name }}
</span>
</div>
</div>
</div>
</template>
</div>
<div
v-if="editable"
class="grid grid-cols-3 gap-1 pb-2"
@ -45,35 +74,12 @@
draggable="true"
class="flex items-center justify-center border border-dashed border-base-300 w-full rounded relative"
:style="{ backgroundColor: backgroundColor }"
@dragstart="onDragstart(type)"
@dragstart="onDragstart({ type: type })"
@dragend="$emit('drag-end')"
@click="addField(type)"
>
<div class="w-0 absolute left-0">
<svg
xmlns="http://www.w3.org/2000/svg"
class="cursor-grab"
width="18"
height="18"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
>
<path
stroke="none"
d="M0 0h24v24H0z"
fill="none"
/>
<path d="M9 5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
<path d="M9 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
<path d="M9 19m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
<path d="M15 5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
<path d="M15 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
<path d="M15 19m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
</svg>
<IconDrag class="cursor-grab" />
</div>
<div class="flex items-center flex-col px-2 py-2">
<component :is="icon" />
@ -134,12 +140,15 @@ import { v4 } from 'uuid'
import FieldType from './field_type'
import FieldSubmitter from './field_submitter'
import { IconLock } from '@tabler/icons-vue'
import IconDrag from './icon_drag'
export default {
name: 'TemplateFields',
components: {
Field,
FieldType,
FieldSubmitter,
IconDrag,
IconLock
},
inject: ['save', 'backgroundColor', 'withPhone'],
@ -153,6 +162,11 @@ export default {
required: false,
default: true
},
defaultFields: {
type: Array,
required: false,
default: () => []
},
withStickySubmitters: {
type: Boolean,
required: false,
@ -178,11 +192,16 @@ export default {
fieldIcons: FieldType.computed.fieldIcons,
submitterFields () {
return this.fields.filter((f) => f.submitter_uuid === this.selectedSubmitter.uuid)
},
submitterDefaultFields () {
return this.defaultFields.filter((f) => {
return !this.fields.find((field) => field.name === f.name) && (!f.role || f.role === this.selectedSubmitter.name)
})
}
},
methods: {
onDragstart (fieldType) {
this.$emit('set-drag', fieldType)
onDragstart (field) {
this.$emit('set-drag', field)
},
onFieldDragover (e) {
const targetFieldUuid = e.target.closest('[data-uuid]')?.dataset?.uuid
@ -233,7 +252,7 @@ export default {
}
if (['select', 'multiple', 'radio'].includes(type)) {
field.options = ['']
field.options = [{ value: '', uuid: v4() }]
}
this.fields.push(field)

@ -0,0 +1,31 @@
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
>
<path
stroke="none"
d="M0 0h24v24H0z"
fill="none"
/>
<path d="M9 5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
<path d="M9 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
<path d="M9 19m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
<path d="M15 5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
<path d="M15 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
<path d="M15 19m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
</svg>
</template>
<script>
export default {
name: 'DragIcon'
}
</script>

@ -41,7 +41,7 @@
id="mask"
ref="mask"
class="top-0 bottom-0 left-0 right-0 absolute"
:class="{ 'cursor-grab': isDrag || isMove, 'cursor-nwse-resize': drawField, [resizeDirectionClasses[resizeDirection]]: !!resizeDirectionClasses }"
:class="{ 'z-10': !isMobile, 'cursor-grab': isDrag || isMove, 'cursor-nwse-resize': drawField, [resizeDirectionClasses[resizeDirection]]: !!resizeDirectionClasses }"
@pointermove="onPointermove"
@pointerdown="onStartDraw"
@dragover.prevent

@ -33,6 +33,7 @@
</div>
<div class="">
<ReplaceButton
v-if="withReplaceButton"
:is-direct-upload="isDirectUpload"
:template-id="template.id"
:accept-file-types="acceptFileTypes"
@ -162,6 +163,11 @@ export default {
required: false,
default: 'image/*, application/pdf'
},
withReplaceButton: {
type: Boolean,
required: true,
default: true
},
isDirectUpload: {
type: Boolean,
required: true,

@ -8,7 +8,7 @@ class SubmitterMailer < ApplicationMailer
@submitter = submitter
@body = body.presence
@email_config = @current_account.account_configs.find_by(key: AccountConfig::SUBMITTER_INVITATION_EMAIL_KEY)
@email_config = AccountConfigs.find_for_account(@current_account, AccountConfig::SUBMITTER_INVITATION_EMAIL_KEY)
subject =
if @email_config || subject.present?
@ -31,7 +31,7 @@ class SubmitterMailer < ApplicationMailer
Submissions::EnsureResultGenerated.call(submitter)
@email_config = @current_account.account_configs.find_by(key: AccountConfig::SUBMITTER_COMPLETED_EMAIL_KEY)
@email_config = AccountConfigs.find_for_account(@current_account, AccountConfig::SUBMITTER_COMPLETED_EMAIL_KEY)
add_completed_email_attachments!(submitter)
@ -58,7 +58,7 @@ class SubmitterMailer < ApplicationMailer
@documents = add_completed_email_attachments!(submitter)
@email_config = @current_account.account_configs.find_by(key: AccountConfig::SUBMITTER_DOCUMENTS_COPY_EMAIL_KEY)
@email_config = AccountConfigs.find_for_account(@current_account, AccountConfig::SUBMITTER_DOCUMENTS_COPY_EMAIL_KEY)
subject =
if @email_config

@ -49,7 +49,7 @@ class AccountConfig < ApplicationRecord
'body' => "Hi there,\n\n" \
"Please check the copy of your \"{{template.name}}\" submission in the email attachments.\n" \
"Alternatively, you can download your copy using:\n\n" \
"{{documents.links}}\n\n" \
"{{documents.link}}\n\n" \
"Thanks,\n" \
'{{account.name}}'
}

@ -6,6 +6,7 @@
#
# id :bigint not null, primary key
# deleted_at :datetime
# slug :string not null
# source :text not null
# submitters_order :string not null
# template_fields :text
@ -19,6 +20,7 @@
# Indexes
#
# index_submissions_on_created_by_user_id (created_by_user_id)
# index_submissions_on_slug (slug) UNIQUE
# index_submissions_on_template_id (template_id)
#
# Foreign Keys
@ -41,6 +43,8 @@ class Submission < ApplicationRecord
attribute :source, :string, default: 'link'
attribute :submitters_order, :string, default: 'random'
attribute :slug, :string, default: -> { SecureRandom.base58(14) }
has_one_attached :audit_trail
has_many :template_schema_documents,

@ -61,7 +61,7 @@ class Submitter < ApplicationRecord
def friendly_name
if name.present? && email.present? && email.exclude?(',')
"#{name} <#{email}>"
%("#{name.delete('"')}" <#{email}>)
else
email
end

@ -52,6 +52,7 @@ class User < ApplicationRecord
belongs_to :account
has_one :access_token, dependent: :destroy
has_many :templates, dependent: :destroy, foreign_key: :author_id, inverse_of: :author
has_many :template_folders, dependent: :destroy, foreign_key: :author_id, inverse_of: :author
has_many :user_configs, dependent: :destroy
has_many :encrypted_configs, dependent: :destroy, class_name: 'EncryptedUserConfig'
@ -94,7 +95,7 @@ class User < ApplicationRecord
def friendly_name
if full_name.present?
"#{full_name} <#{email}>"
%("#{full_name.delete('"')}" <#{email}>)
else
email
end

@ -16,7 +16,11 @@
</div>
<% end %>
<% if devise_mapping.omniauthable? %>
<%= button_to button_title(title: 'Sign up with Google', icon: svg_icon('brand_google', class: 'w-6 h-6')), omniauth_authorize_path(resource_name, :google_oauth2), class: 'white-button w-full mt-4', data: { turbo: false }, method: :post %>
<%= form_for '', url: omniauth_authorize_path(resource_name, :google_oauth2), data: { turbo: false }, method: :post do |f| %>
<set-timezone data-input-id="state" data-params="true"></set-timezone>
<%= hidden_field_tag :state, { redir: params[:redir].to_s }.compact_blank.to_query %>
<%= f.button button_title(title: 'Sign up with Google', icon: svg_icon('brand_google', class: 'w-6 h-6')), class: 'white-button w-full mt-4' %>
<% end %>
<% end %>
<%= render 'devise/shared/links' %>
</div>

@ -20,7 +20,11 @@
</div>
<% end %>
<% if devise_mapping.omniauthable? %>
<%= button_to button_title(title: 'Sign in with Google', icon: svg_icon('brand_google', class: 'w-6 h-6')), omniauth_authorize_path(resource_name, :google_oauth2), class: 'white-button w-full mt-4', data: { turbo: false }, method: :post %>
<%= form_for '', url: omniauth_authorize_path(resource_name, :google_oauth2), data: { turbo: false }, method: :post do |f| %>
<set-timezone data-input-id="state" data-params="true"></set-timezone>
<%= hidden_field_tag :state, { redir: params[:redir].to_s }.compact_blank.to_query %>
<%= f.button button_title(title: 'Sign in with Google', icon: svg_icon('brand_google', class: 'w-6 h-6')), class: 'white-button w-full mt-4' %>
<% end %>
<% end %>
<%= render 'devise/shared/links' %>
</div>

@ -1,9 +0,0 @@
<script defer data-domain="<%= ENV.fetch('PLAUSIBLE_DOMAIN', nil) %>" src="https://plausible.io/js/script.manual.js"></script>
<script>window.plausible = window.plausible || function() { (window.plausible.q = window.plausible.q || []).push(arguments) }</script>
<script>
document.addEventListener("turbo:load", function (e) {
if (e.detail.url.match(/sign_in|sign_up|password/)) {
plausible('pageview')
}
})
</script>

@ -77,10 +77,17 @@
<% end %>
<% if !Docuseal.demo? && can?(:manage, EncryptedConfig) %>
<li>
<%= link_to Docuseal.multitenant? ? console_redirect_index_path : "#{Docuseal::CONSOLE_URL}/on_premise", class: 'text-base hover:bg-base-300', data: { prefetch: false } do %>
Console
<%= link_to Docuseal.multitenant? ? console_redirect_index_path(redir: "#{Docuseal::CONSOLE_URL}/api") : "#{Docuseal::CONSOLE_URL}/on_premise", class: 'text-base hover:bg-base-300', data: { prefetch: false } do %>
<% if Docuseal.multitenant? %> API <% else %> Console <% end %>
<% end %>
</li>
<% if Docuseal.multitenant? %>
<li>
<%= link_to console_redirect_index_path(redir: "#{Docuseal::CONSOLE_URL}/embedding/form"), class: 'text-base hover:bg-base-300', data: { prefetch: false } do %>
Embedding
<% end %>
</li>
<% end %>
<% end %>
</ul>
</menu-active>

@ -1,4 +1,4 @@
<div class="flex absolute text-[1.5vw] lg:text-base" style="width: <%= area['w'] * 100 %>%; height: <%= area['h'] * 100 %>%; left: <%= area['x'] * 100 %>%; top: <%= area['y'] * 100 %>%">
<div class="flex absolute <%= field['readonly'] ? 'text-[1.5vw] lg:text-xs' : 'text-[1.5vw] lg:text-base' %>" style="width: <%= area['w'] * 100 %>%; height: <%= area['h'] * 100 %>%; left: <%= area['x'] * 100 %>%; top: <%= area['y'] * 100 %>%">
<% if field['type'].in?(['signature', 'image', 'initials']) %>
<img class="object-contain mx-auto" src="<%= attachments_index[value].url %>" loading="lazy">
<% elsif field['type'] == 'file' %>
@ -11,9 +11,16 @@
<% end %>
</div>
<% elsif field['type'] == 'checkbox' %>
<div class="w-full p-[0.2vw] flex items-center justify-center">
<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>
<% 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']) %>
<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>
<% end %>
<% elsif field['type'] == 'cells' %>
<% cell_width = area['cell_w'] / area['w'] * 100 %>
<div class="w-full flex items-center">

@ -61,7 +61,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: current_account.locale %>
<%= render 'submissions/value', area:, field:, attachments_index:, value:, locale: @submission.template.account.locale %>
<% end %>
</div>
</div>
@ -109,18 +109,18 @@
<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(current_account.timezone), format: :long, locale: current_account.locale) : 'Not completed yet' %>
<%= submitter&.completed_at? ? l(submitter.completed_at.in_time_zone(@submission.template.account.timezone), format: :long, locale: @submission.template.account.locale) : 'Not completed yet' %>
</span>
</div>
<% if submitter && submitter.email && !submitter.completed_at && can?(:update, submitter) %>
<% if signed_in? && submitter && submitter.email && !submitter.completed_at && can?(:update, submitter) %>
<div class="mt-2 mb-1">
<%= button_to button_title(title: submitter.sent_at? ? 'Re-send Email' : 'Send Email', disabled_with: 'Sending'), submitter_send_email_index_path(submitter_slug: submitter.slug), class: 'btn btn-sm btn-primary w-full' %>
</div>
<% end %>
<% if submitter && submitter.phone && !submitter.completed_at && can?(:update, submitter) %>
<% if signed_in? && submitter && submitter.phone && !submitter.completed_at && can?(:update, submitter) %>
<%= render 'send_sms_button', submitter: %>
<% end %>
<% if submitter && !submitter.completed_at? && can?(:create, submitter) %>
<% if signed_in? && submitter && !submitter.completed_at? && can?(:create, submitter) %>
<div class="mt-2 mb-1">
<a class="btn btn-sm btn-primary w-full" target="_blank" href="<%= submit_form_path(slug: submitter.slug) %>">
Submit Form
@ -160,7 +160,7 @@
<% elsif field['type'] == 'checkbox' %>
<%= svg_icon('check', class: 'w-6 h-6') %>
<% elsif field['type'] == 'date' %>
<%= l(Date.parse(value), locale: current_account.locale, format: :long) %>
<%= l(Date.parse(value), locale: @submission.template.account.locale, format: :long) %>
<% else %>
<%= Array.wrap(value).join(', ') %>
<% end %>

@ -1 +1,3 @@
<%= render 'docuseal_logo' %>
<div class="flex mt-4">
<%= render 'docuseal_logo' %>
</div>

@ -5,9 +5,8 @@
<div style="max-height: -webkit-fill-available;">
<div id="scrollbox">
<div class="mx-auto block pb-72" style="max-width: 1000px">
<div class="mt-4 flex">
<%= render 'banner' %>
</div>
<%# flex block w-full sticky top-0 z-50 space-x-2 items-center bg-yellow-100 p-2 border-y border-yellow-200 %>
<%= render 'banner' %>
<% (@submitter.submission.template_schema || @submitter.submission.template.schema).each do |item| %>
<% document = @submitter.submission.template_schema_documents.find { |a| a.uuid == item['attachment_uuid'] } %>
<% document_annots_index = document.metadata.dig('pdf', 'annotations')&.group_by { |e| e['page'] } || {} %>

@ -3,14 +3,10 @@
<% else %>
<p>Hi there,</p>
<p>Please check the copy of your "<%= @submitter.submission.template.name %>" submission in the email attachments.</p>
<p>Alternatively, you can download your copy using:</p>
<% @documents.each do |document| %>
<ul>
<li>
<%= link_to document.filename.to_s, rails_blob_url(document) %>
</li>
</ul>
<% end %>
<p>Alternatively, you can review and download your copy using:</p>
<p>
<%= link_to @submitter.template.name, submissions_preview_url(@submitter.submission.slug) %>
</p>
<p>
Thanks,<br><%= @current_account.name %>
</p>

@ -22,7 +22,7 @@ module DocuSeal
config.active_storage.routes_prefix = ''
config.i18n.available_locales = %i[en en-US en-GB es-ES pt-PT de-DE]
config.i18n.available_locales = %i[en en-US en-GB es-ES fr-FR pt-PT de-DE]
config.i18n.fallbacks = [:en]
config.action_view.frozen_string_literal = true

@ -1,2 +1,9 @@
en:
hello: "Hello world"
en-US:
date:
formats:
default: "%m/%d/%Y"
en-GB:
date:
formats:
default: "%d/%m/%Y"

@ -83,6 +83,8 @@ Rails.application.routes.draw do
get :completed
end
resources :submissions_preview, only: %i[show], path: 'e', param: 'slug'
resources :send_submission_email, only: %i[create] do
get :success, on: :collection
end

@ -0,0 +1,51 @@
# frozen_string_literal: true
class UpdateFieldOptions < ActiveRecord::Migration[7.0]
class MigrationTemplate < ApplicationRecord
self.table_name = 'templates'
end
class MigrationSubmission < ApplicationRecord
self.table_name = 'submissions'
end
# rubocop:disable Metrics
def up
MigrationTemplate.find_each do |template|
next if template.fields.blank?
template_fields = JSON.parse(template.fields)
new_fields = template_fields.deep_dup
new_fields.each do |field|
if field['options'].present? && !field['options'].first.is_a?(Hash)
field['options'] = field['options'].map { |o| { value: o || '', uuid: SecureRandom.uuid } }
end
end
template.update_columns(fields: new_fields.to_json) if template_fields != new_fields
end
MigrationSubmission.find_each do |submission|
next if submission.template_fields.blank?
template_fields = JSON.parse(submission.template_fields)
new_fields = template_fields.deep_dup
new_fields.each do |field|
if field['options'].present? && !field['options'].first.is_a?(Hash)
field['options'] = field['options'].map { |o| { value: o || '', uuid: SecureRandom.uuid } }
end
end
submission.update_columns(template_fields: new_fields.to_json) if template_fields != new_fields
end
end
# rubocop:enable Metrics
def down
nil
end
end

@ -0,0 +1,23 @@
# frozen_string_literal: true
class AddSlugToSubmissions < ActiveRecord::Migration[7.0]
class MigrationSubmission < ApplicationRecord
self.table_name = 'submissions'
end
def up
add_column :submissions, :slug, :string
MigrationSubmission.where(slug: nil).find_each do |submission|
submission.update_columns(slug: SecureRandom.base58(14))
end
change_column_null :submissions, :slug, false
add_index :submissions, :slug, unique: true
end
def down
remove_column :submissions, :slug
end
end

@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.0].define(version: 2023_11_02_171817) do
ActiveRecord::Schema[7.0].define(version: 2023_11_19_222105) do
# These are extensions that must be enabled in order to support this database
enable_extension "plpgsql"
@ -124,7 +124,9 @@ ActiveRecord::Schema[7.0].define(version: 2023_11_02_171817) do
t.text "template_submitters"
t.text "source", null: false
t.string "submitters_order", null: false
t.string "slug", null: false
t.index ["created_by_user_id"], name: "index_submissions_on_created_by_user_id"
t.index ["slug"], name: "index_submissions_on_slug", unique: true
t.index ["template_id"], name: "index_submissions_on_template_id"
end

@ -17,7 +17,15 @@ module AccountConfigs
module_function
def find_or_initialize_for_key(account, key)
account.account_configs.find_by(key:) ||
find_for_account(account, key) ||
account.account_configs.new(key:, value: AccountConfig::DEFAULT_VALUES[key])
end
def find_for_account(account, key)
configs = account.account_configs.find_by(key:)
configs ||= Account.order(:id).first.account_configs.find_by(key:) unless Docuseal.multitenant?
configs
end
end

@ -23,7 +23,7 @@ module ActionMailerConfigsInterceptor
if email_configs
message.delivery_method(:smtp, build_smtp_configs_hash(email_configs))
message.from = "#{email_configs.account.name} <#{email_configs.value['from_email']}>"
message.from = %("#{email_configs.account.name.to_s.delete('"')}" <#{email_configs.value['from_email']}>)
else
message.delivery_method(:test)
end

@ -15,7 +15,5 @@ class AuthWithTokenStrategy < Devise::Strategies::Base
else
fail!('Invalid token')
end
rescue JWT::VerificationError
fail!('Invalid token')
end
end

@ -9,6 +9,7 @@ module ReplaceEmailVariables
SUBMISSION_LINK = '{{submission.link}}'
SUBMISSION_SUBMITTERS = '{{submission.submitters}}'
DOCUMENTS_LINKS = '{{documents.links}}'
DOCUMENTS_LINK = '{{documents.link}}'
module_function
@ -26,6 +27,7 @@ module ReplaceEmailVariables
text = text.gsub(SUBMISSION_SUBMITTERS, build_submission_submitters(submitter.submission))
end
text = text.gsub(DOCUMENTS_LINKS, build_documents_links_text(submitter))
text = text.gsub(DOCUMENTS_LINK, build_documents_links_text(submitter))
text = text.gsub(ACCOUNT_NAME, submitter.template.account.name) if submitter.template
@ -33,14 +35,9 @@ module ReplaceEmailVariables
end
def build_documents_links_text(submitter)
Submitters.select_attachments_for_download(submitter).map do |document|
link =
Rails.application.routes.url_helpers.rails_blob_url(
document, **Docuseal.default_url_options
)
"#{link}\n"
end.join
Rails.application.routes.url_helpers.submissions_preview_url(
submitter.submission.slug, **Docuseal.default_url_options
)
end
def build_submitter_link(submitter, tracking_event_type)

@ -28,9 +28,7 @@ module Submissions
end
def create_from_emails(template:, user:, emails:, source:, mark_as_sent: false)
emails = emails.to_s.scan(User::EMAIL_REGEXP) unless emails.is_a?(Array)
emails.uniq.map do |email|
parse_emails(emails).uniq.map do |email|
submission = template.submissions.new(created_by_user: user, source:, template_submitters: template.submitters)
submission.submitters.new(email: normalize_email(email),
uuid: template.submitters.first['uuid'],
@ -40,6 +38,12 @@ module Submissions
end
end
def parse_emails(emails)
emails = emails.to_s.scan(User::EMAIL_REGEXP) unless emails.is_a?(Array)
emails
end
def create_from_submitters(template:, user:, submissions_attrs:, source:, mark_as_sent: false,
submitters_order: DEFAULT_SUBMITTERS_ORDER)
Submissions::CreateFromSubmitters.call(

@ -107,7 +107,7 @@ module Submissions
"\n",
{ text: 'Generated at: ', font: [FONT_BOLD_NAME, { variant: :bold }] },
"#{I18n.l(document.created_at.in_time_zone(account.timezone), format: :long, locale: account.locale)} " \
"#{timezone_abbr(account.timezone, document.created_at)}"
"#{TimeUtils.timezone_abbr(account.timezone, document.created_at)}"
], line_spacing: 1.8
)
]
@ -232,7 +232,7 @@ module Submissions
submitter = submission.submitters.find { |e| e.id == event.submitter_id }
[
"#{I18n.l(event.event_timestamp.in_time_zone(account.timezone), format: :long, locale: account.locale)} " \
"#{timezone_abbr(account.timezone, event.event_timestamp)}",
"#{TimeUtils.timezone_abbr(account.timezone, event.event_timestamp)}",
composer.document.layout.formatted_text_box(
[
{ text: SubmissionEvents::EVENT_NAMES[event.event_type.to_sym],
@ -268,14 +268,6 @@ module Submissions
)
end
def timezone_abbr(timezone, time = Time.current)
tz_info = TZInfo::Timezone.get(
ActiveSupport::TimeZone::MAPPING[timezone] || timezone || 'UTC'
)
tz_info.abbreviation(time)
end
def add_logo(column)
column.image(PdfIcons.logo_io, width: 40, height: 40, position: :float)

@ -126,7 +126,13 @@ module Submissions
layouter.fit(items, area['w'] * width, height_diff.positive? ? box_height : area['h'] * height)
.draw(canvas, (area['x'] * width) + TEXT_LEFT_MARGIN,
height - (area['y'] * height) + height_diff - TEXT_TOP_MARGIN)
when 'checkbox'
when ->(type) { type == 'checkbox' || (type.in?(%w[multiple radio]) && area['option_uuid'].present?) }
if field['type'].in?(%w[multiple radio])
option = field['options']&.find { |o| o['uuid'] == area['option_uuid'] }
value = Array.wrap(value).include?(option['value'])
end
next unless value == true
scale = [(area['w'] * width) / PdfIcons::WIDTH, (area['h'] * height) / PdfIcons::HEIGHT].min
@ -168,6 +174,17 @@ module Submissions
lines = layouter.fit([text], area['w'] * width, height).lines
box_height = lines.sum(&:height)
if box_height > (area['h'] * height) + 1
text = HexaPDF::Layout::TextFragment.create(Array.wrap(value).join(', '),
font: pdf.fonts.add(FONT_NAME),
font_size: (font_size / 1.4).to_i)
lines = layouter.fit([text], area['w'] * width, height).lines
box_height = lines.sum(&:height)
end
height_diff = [0, box_height - (area['h'] * height)].max
layouter.fit([text], area['w'] * width, height_diff.positive? ? box_height : area['h'] * height)

@ -19,7 +19,9 @@ module Submitters
fields.each do |field|
next if field['submitter_uuid'] != submitter.uuid
submitter.values[field['uuid']] ||= get_default_value_for_field(field, user, submitter)
default_value = get_default_value_for_field(field, user, submitter)
submitter.values[field['uuid']] ||= default_value if default_value.present?
end
submitter.save!

@ -71,7 +71,7 @@ module Submitters
return blob if blob
data = conn.get(url).body
data = conn.get(Addressable::URI.parse(url).display_uri.to_s).body
checksum = Digest::MD5.base64digest(data)

@ -88,6 +88,7 @@ module Submitters
def template_default_value_for_submitter(value, submitter, with_time: false)
return if value.blank?
return if submitter.blank?
role = submitter.submission.template_submitters.find { |e| e['uuid'] == submitter.uuid }['name']

@ -17,7 +17,9 @@ module Templates
build_external_link_hash(page, annot).merge('page' => index)
end
end
rescue PDF::Reader::MalformedPDFError, OpenSSL::Cipher::CipherError
rescue StandardError => e
Rollbar.error(e) if defined?(Rollbar)
[]
end

@ -0,0 +1,13 @@
# frozen_string_literal: true
module TimeUtils
module_function
def timezone_abbr(timezone, time = Time.current)
tz_info = TZInfo::Timezone.get(
ActiveSupport::TimeZone::MAPPING[timezone] || timezone || 'UTC'
)
tz_info.abbreviation(time)
end
end

@ -0,0 +1,22 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe 'Submission Preview' do
let(:account) { create(:account) }
let(:user) { create(:user, account:) }
let(:template) { create(:template, account:, author: user) }
context 'when not submitted' do
let(:submission) { create(:submission, template:, created_by_user: user) }
let(:submitters) { template.submitters.map { |s| create(:submitter, submission:, uuid: s['uuid']) } }
before do
visit submissions_preview_path(slug: submission.slug)
end
it 'completes the form' do
expect(page).to have_content('Not completed')
end
end
end
Loading…
Cancel
Save