Compare commits

..
42 Commits
Author SHA1 Message Date
Pete Matsyburka 3db7d47746 fix option remove 2024-03-17 19:40:30 +02:00
Pete Matsyburka 95899336b8 align number field 2024-03-17 19:06:00 +02:00
Pete Matsyburka f6691c110e adjust field settings 2024-03-17 17:04:39 +02:00
Pete Matsyburka 749722a9df add rate limit 2024-03-16 23:22:11 +02:00
Pete Matsyburka 265b668cb4 show bulk send message 2024-03-16 22:21:23 +02:00
Pete Matsyburka 0527e6a5c1 fix form font size 2024-03-16 01:59:00 +02:00
Pete Matsyburka d989b61d19 fix font size 2024-03-15 23:06:16 +02:00
Pete Matsyburka c1f3e6ff13 reduce image size 2024-03-15 21:16:30 +02:00
Pete Matsyburka 3c0fff2d82 skip on missing font character error 2024-03-15 20:24:12 +02:00
Pete Matsyburka bd599c264f rescue Encoding::CompatibilityError 2024-03-15 13:44:16 +02:00
Pete Matsyburka 0274093c89 save multiple document attachments 2024-03-15 13:03:10 +02:00
Pete Matsyburka 5845b5bdfd process malformed PDF 2024-03-15 12:17:52 +02:00
Pete Matsyburka 3dd0ba6b0b fix pdf font width 2024-03-14 23:10:21 +02:00
Pete Matsyburka e79306f8a3 optimize build annotations 2024-03-14 21:30:57 +02:00
Pete Matsyburka 0171b0c2dd allow to disable stamp logo 2024-03-14 14:09:43 +02:00
Pete Matsyburka a963ef53d3 adjust submitter role assign 2024-03-14 01:21:42 +02:00
Pete Matsyburka 344f302c76 refactor field validation 2024-03-13 20:25:41 +02:00
Pete Matsyburka 17ded282b2 add symbols font 2024-03-13 14:58:32 +02:00
Pete Matsyburka 2ce57df485 sanitize href 2024-03-13 11:54:56 +02:00
Pete Matsyburka 849289287e add html escape replace variable option 2024-03-12 23:47:58 +02:00
Pete Matsyburka 704fbc2393 try get log params 2024-03-11 21:34:59 +02:00
Pete Matsyburka 168fc8bd03 fix testing acocunt 2024-03-11 14:55:50 +02:00
Pete Matsyburka c5598d9efe fix replace email vars 2024-03-11 10:05:43 +02:00
Pete Matsyburka f02315726d error on too small signature 2024-03-10 19:55:45 +02:00
Pete Matsyburka e56dcb59c0 add cors headers on API error 2024-03-10 15:54:44 +02:00
Pete Matsyburka c74f350ea2 fix typo 2024-03-10 12:03:46 +02:00
Pete Matsyburka af9d015cf2 refactor mailer custom content 2024-03-10 00:41:18 +02:00
Pete Matsyburka 633e5ac5e4 allow to disable confetti 2024-03-10 00:13:35 +02:00
Pete Matsyburka 2d0f323854 adjust sender email var 2024-03-09 22:55:55 +02:00
Pete Matsyburka cfe5610ec9 add field description and title 2024-03-09 22:55:54 +02:00
Pete Matsyburka 55616ac321 sort field areas 2024-03-08 00:34:16 +02:00
Pete Matsyburka 8f2d1deb38 do not show console link on testing 2024-03-07 21:36:41 +02:00
Pete Matsyburka e2763ceb80 add builder selectors 2024-03-07 19:01:34 +02:00
Pete Matsyburka 10462f3140 fix existing submitter uuids 2024-03-07 16:28:14 +02:00
Pete Matsyburka 3aef92486b silence active storage error 2024-03-06 23:14:07 +02:00
Pete Matsyburka c1a9ee0bbb return error on file download 400+ resp 2024-03-06 22:36:15 +02:00
Pete Matsyburka 398fae82cb optimize api pagination 2024-03-06 18:58:27 +02:00
Pete Matsyburka a16443f961 add completed at submitter filter 2024-03-06 18:44:13 +02:00
Pete Matsyburka ad4e0e1d27 adjust email variables 2024-03-06 00:27:16 +02:00
Pete Matsyburka 493a2108e9 fix safari builder scaling 2024-03-05 20:53:54 +02:00
Pete Matsyburka c2c9bb64b1 fix audit log original sha 2024-03-05 17:16:18 +02:00
Pete Matsyburka bb1426c0af log generation error 2024-03-05 13:56:47 +02:00
67 changed files with 1059 additions and 284 deletions

No files matched your search

+1 -2
View File
@@ -8,8 +8,7 @@
}, },
"rules": { "rules": {
"vue/no-deprecated-html-element-is": 0, "vue/no-deprecated-html-element-is": 0,
"vue/no-mutating-props": 0, "vue/no-mutating-props": 0
"vue/no-v-html": 0
}, },
"parserOptions": { "parserOptions": {
"ecmaVersion": 2022, "ecmaVersion": 2022,
+6 -3
View File
@@ -2,7 +2,9 @@ FROM ruby:3.2.2-alpine3.18 as fonts
WORKDIR /fonts WORKDIR /fonts
RUN apk --no-cache add wget && wget https://github.com/satbyy/go-noto-universal/releases/download/v7.0/GoNotoKurrent-Regular.ttf && wget https://github.com/satbyy/go-noto-universal/releases/download/v7.0/GoNotoKurrent-Bold.ttf && wget https://github.com/impallari/DancingScript/raw/master/fonts/DancingScript-Regular.otf && wget https://github.com/impallari/DancingScript/raw/master/OFL.txt RUN apk --no-cache add fontforge wget && wget https://github.com/satbyy/go-noto-universal/releases/download/v7.0/GoNotoKurrent-Regular.ttf && wget https://github.com/satbyy/go-noto-universal/releases/download/v7.0/GoNotoKurrent-Bold.ttf && wget https://github.com/impallari/DancingScript/raw/master/fonts/DancingScript-Regular.otf && wget https://cdn.jsdelivr.net/gh/notofonts/notofonts.github.io/fonts/NotoSansSymbols2/hinted/ttf/NotoSansSymbols2-Regular.ttf && wget https://github.com/Maxattax97/gnu-freefont/raw/master/ttf/FreeSans.ttf && wget https://github.com/impallari/DancingScript/raw/master/OFL.txt
RUN fontforge -lang=py -c 'font1 = fontforge.open("FreeSans.ttf"); font2 = fontforge.open("NotoSansSymbols2-Regular.ttf"); font1.mergeFonts(font2); font1.generate("FreeSans.ttf")'
FROM ruby:3.2.2-alpine3.18 as webpack FROM ruby:3.2.2-alpine3.18 as webpack
@@ -38,11 +40,11 @@ ENV PIDFILE=/dev/null
WORKDIR /app WORKDIR /app
RUN apk add --no-cache build-base sqlite-dev libpq-dev mariadb-dev vips-dev vips-poppler poppler-utils vips-heif libc6-compat ttf-freefont && mkdir /fonts RUN apk add --no-cache sqlite-dev libpq-dev mariadb-dev vips-dev vips-poppler poppler-utils vips-heif libc6-compat ttf-freefont && mkdir /fonts && rm /usr/share/fonts/freefont/FreeSans.otf
COPY ./Gemfile ./Gemfile.lock ./ COPY ./Gemfile ./Gemfile.lock ./
RUN bundle update --bundler && bundle install && rm -rf ~/.bundle /usr/local/bundle/cache && ruby -e "puts Dir['/usr/local/bundle/**/{spec,rdoc,resources/shared,resources/collation,resources/locales}']" | xargs rm -rf RUN apk add --no-cache build-base && bundle update --bundler && bundle install && apk del build-base && rm -rf ~/.bundle /usr/local/bundle/cache && ruby -e "puts Dir['/usr/local/bundle/**/{spec,rdoc,resources/shared,resources/collation,resources/locales}']" | xargs rm -rf
COPY ./bin ./bin COPY ./bin ./bin
COPY ./app ./app COPY ./app ./app
@@ -55,6 +57,7 @@ COPY ./tmp ./tmp
COPY LICENSE README.md Rakefile config.ru .version ./ COPY LICENSE README.md Rakefile config.ru .version ./
COPY --from=fonts /fonts/GoNotoKurrent-Regular.ttf /fonts/GoNotoKurrent-Bold.ttf /fonts/DancingScript-Regular.otf /fonts/OFL.txt /fonts COPY --from=fonts /fonts/GoNotoKurrent-Regular.ttf /fonts/GoNotoKurrent-Bold.ttf /fonts/DancingScript-Regular.otf /fonts/OFL.txt /fonts
COPY --from=fonts /fonts/FreeSans.ttf /usr/share/fonts/freefont
COPY --from=webpack /app/public/packs ./public/packs COPY --from=webpack /app/public/packs ./public/packs
RUN ln -s /fonts /app/public/fonts RUN ln -s /fonts /app/public/fonts
@@ -8,7 +8,8 @@ class AccountConfigsController < ApplicationController
AccountConfig::ALLOW_TYPED_SIGNATURE, AccountConfig::ALLOW_TYPED_SIGNATURE,
AccountConfig::FORCE_MFA, AccountConfig::FORCE_MFA,
AccountConfig::ALLOW_TO_RESUBMIT, AccountConfig::ALLOW_TO_RESUBMIT,
AccountConfig::ESIGNING_PREFERENCE_KEY AccountConfig::ESIGNING_PREFERENCE_KEY,
AccountConfig::FORM_WITH_CONFETTI_KEY
].freeze ].freeze
InvalidKey = Class.new(StandardError) InvalidKey = Class.new(StandardError)
+8 -2
View File
@@ -19,6 +19,12 @@ module Api
render json: { error: e.message }, status: :unprocessable_entity render json: { error: e.message }, status: :unprocessable_entity
end end
rescue_from RateLimit::LimitApproached do |e|
Rollbar.error(e) if defined?(Rollbar)
render json: { error: 'Too many requests' }, status: :too_many_requests
end
if Rails.env.production? if Rails.env.production?
rescue_from CanCan::AccessDenied do |e| rescue_from CanCan::AccessDenied do |e|
Rollbar.warning(e) if defined?(Rollbar) Rollbar.warning(e) if defined?(Rollbar)
@@ -39,8 +45,8 @@ module Api
result = relation.order(id: :desc) result = relation.order(id: :desc)
.limit([params.fetch(:limit, DEFAULT_LIMIT).to_i, MAX_LIMIT].min) .limit([params.fetch(:limit, DEFAULT_LIMIT).to_i, MAX_LIMIT].min)
result = result.where(relation.arel_table[:id].lt(params[:after])) if params[:after].present? result = result.where(id: ...params[:after].to_i) if params[:after].present?
result = result.where(relation.arel_table[:id].gt(params[:before])) if params[:before].present? result = result.where(id: (params[:before].to_i + 1)...) if params[:before].present?
result result
end end
@@ -10,6 +10,7 @@ module Api
submitters = submitters.where(external_id: params[:application_key]) if params[:application_key].present? submitters = submitters.where(external_id: params[:application_key]) if params[:application_key].present?
submitters = submitters.where(external_id: params[:external_id]) if params[:external_id].present? submitters = submitters.where(external_id: params[:external_id]) if params[:external_id].present?
submitters = submitters.where(submission_id: params[:submission_id]) if params[:submission_id].present? submitters = submitters.where(submission_id: params[:submission_id]) if params[:submission_id].present?
submitters = maybe_filder_by_completed_at(submitters, params)
submitters = paginate( submitters = paginate(
submitters.preload(:template, :submission, :submission_events, submitters.preload(:template, :submission, :submission_events,
@@ -81,6 +82,18 @@ module Api
private private
def maybe_filder_by_completed_at(submitters, params)
if params[:completed_after].present?
submitters = submitters.where(completed_at: Time.zone.parse(params[:completed_after])..)
end
if params[:completed_before].present?
submitters = submitters.where(completed_at: ..Time.zone.parse(params[:completed_before]))
end
submitters
end
def assign_submitter_attrs(submitter, attrs) def assign_submitter_attrs(submitter, attrs)
submitter.email = Submissions.normalize_email(attrs[:email]) if attrs.key?(:email) submitter.email = Submissions.normalize_email(attrs[:email]) if attrs.key?(:email)
submitter.phone = attrs[:phone].to_s.gsub(/[^0-9+]/, '') if attrs.key?(:phone) submitter.phone = attrs[:phone].to_s.gsub(/[^0-9+]/, '') if attrs.key?(:phone)
+4 -1
View File
@@ -87,10 +87,13 @@ module Api
:name, :name,
{ schema: [%i[attachment_uuid name]], { schema: [%i[attachment_uuid name]],
submitters: [%i[name uuid]], submitters: [%i[name uuid]],
fields: [[:uuid, :submitter_uuid, :name, :type, :required, :readonly, :default_value, fields: [[:uuid, :submitter_uuid, :name, :type,
:required, :readonly, :default_value,
:title, :description,
{ preferences: {}, { preferences: {},
conditions: [%i[field_uuid value action]], conditions: [%i[field_uuid value action]],
options: [%i[value uuid]], options: [%i[value uuid]],
validation: %i[message pattern],
areas: [%i[x y w h cell_w attachment_uuid option_uuid page]] }]] } areas: [%i[x y w h cell_w attachment_uuid option_uuid page]] }]] }
] ]
@@ -22,6 +22,12 @@ class ApplicationController < ActionController::Base
redirect_to request.path redirect_to request.path
end end
rescue_from RateLimit::LimitApproached do |e|
Rollbar.error(e) if defined?(Rollbar)
redirect_to request.referer, alert: 'Too many requests', status: :too_many_requests
end
if Rails.env.production? if Rails.env.production?
rescue_from CanCan::AccessDenied do |e| rescue_from CanCan::AccessDenied do |e|
Rollbar.warning(e) if defined?(Rollbar) Rollbar.warning(e) if defined?(Rollbar)
@@ -5,6 +5,10 @@ class ConsoleRedirectController < ApplicationController
skip_authorization_check skip_authorization_check
def index def index
if request.path == '/upgrade'
params[:redir] = Docuseal.multitenant? ? "#{Docuseal::CONSOLE_URL}/plans" : "#{Docuseal::CONSOLE_URL}/on_premise"
end
return redirect_to(new_user_session_path({ redir: params[:redir] }.compact)) if true_user.blank? return redirect_to(new_user_session_path({ redir: params[:redir] }.compact)) if true_user.blank?
auth = JsonWebToken.encode(uuid: true_user.uuid, auth = JsonWebToken.encode(uuid: true_user.uuid,
+10
View File
@@ -22,6 +22,8 @@ class ErrorsController < ActionController::Base
respond_to do |f| respond_to do |f|
f.json do f.json do
set_cors_headers
render json: { status: error_status_code }, status: error_status_code render json: { status: error_status_code }, status: error_status_code
end end
@@ -31,6 +33,14 @@ class ErrorsController < ActionController::Base
private private
def set_cors_headers
headers['Access-Control-Allow-Origin'] = '*'
headers['Access-Control-Allow-Methods'] = 'POST, GET, PUT, PATCH, DELETE, OPTIONS'
headers['Access-Control-Allow-Headers'] = '*'
headers['Access-Control-Max-Age'] = '1728000'
headers['Access-Control-Allow-Credentials'] = true
end
def error_status_code def error_status_code
@error_status_code ||= @error_status_code ||=
ActionDispatch::ExceptionWrapper.new(request.env, ActionDispatch::ExceptionWrapper.new(request.env,
@@ -17,7 +17,11 @@ class PersonalizationSettingsController < ApplicationController
end end
def create def create
@account_config.save! if @account_config.value != false && @account_config.value.blank?
@account_config.destroy!
else
@account_config.save!
end
redirect_back(fallback_location: settings_personalization_path, notice: 'Settings have been saved.') redirect_back(fallback_location: settings_personalization_path, notice: 'Settings have been saved.')
end end
@@ -40,6 +44,8 @@ class PersonalizationSettingsController < ApplicationController
def account_config_params def account_config_params
attrs = params.require(:account_config).permit! attrs = params.require(:account_config).permit!
return attrs if attrs[:value].is_a?(String)
attrs[:value]&.transform_values! do |value| attrs[:value]&.transform_values! do |value|
if value.in?(%w[true false]) if value.in?(%w[true false])
value == 'true' value == 'true'
@@ -21,6 +21,8 @@ class SendSubmissionEmailController < ApplicationController
Submitter.find_by!(slug: params[:submitter_slug]) Submitter.find_by!(slug: params[:submitter_slug])
end end
RateLimit.call("send-email-#{@submitter.id}", limit: 2, ttl: 5.minutes)
SubmitterMailer.documents_copy_email(@submitter, sig: true).deliver_later! SubmitterMailer.documents_copy_email(@submitter, sig: true).deliver_later!
respond_to do |f| respond_to do |f|
+2
View File
@@ -20,6 +20,7 @@ import FolderAutocomplete from './elements/folder_autocomplete'
import SignatureForm from './elements/signature_form' import SignatureForm from './elements/signature_form'
import SubmitForm from './elements/submit_form' import SubmitForm from './elements/submit_form'
import PromptPassword from './elements/prompt_password' import PromptPassword from './elements/prompt_password'
import EmailsTextarea from './elements/emails_textarea'
import * as TurboInstantClick from './lib/turbo_instant_click' import * as TurboInstantClick from './lib/turbo_instant_click'
@@ -53,6 +54,7 @@ window.customElements.define('folder-autocomplete', FolderAutocomplete)
window.customElements.define('signature-form', SignatureForm) window.customElements.define('signature-form', SignatureForm)
window.customElements.define('submit-form', SubmitForm) window.customElements.define('submit-form', SubmitForm)
window.customElements.define('prompt-password', PromptPassword) window.customElements.define('prompt-password', PromptPassword)
window.customElements.define('emails-textarea', EmailsTextarea)
document.addEventListener('turbo:before-fetch-request', encodeMethodIntoRequestBody) document.addEventListener('turbo:before-fetch-request', encodeMethodIntoRequestBody)
document.addEventListener('turbo:submit-end', async (event) => { document.addEventListener('turbo:submit-end', async (event) => {
@@ -0,0 +1,56 @@
const emailRegexp = /([^@;,<>\s]+@[^@;,<>\s]+)/g
export default class extends HTMLElement {
connectedCallback () {
if (this.dataset.limit) {
this.textarea.addEventListener('input', () => {
const emails = this.textarea.value.match(emailRegexp) || []
this.updateCounter(emails.length)
})
}
}
updateCounter (count) {
let counter = document.getElementById('emails_counter')
let bulkMessage = document.getElementById('bulk_message')
if (count < 2) {
counter?.remove()
return
}
if ((count + 10) > this.dataset.limit) {
if (!counter) {
counter = document.createElement('span')
counter.id = 'emails_counter'
counter.classList.add('text-xs', 'right-0', 'absolute')
counter.style.bottom = '-15px'
this.textarea.parentNode.append(counter)
}
counter.innerText = `${count} / ${this.dataset.limit}`
}
if (this.dataset.bulkEnabled !== 'true') {
if (!bulkMessage) {
bulkMessage = document.createElement('span')
bulkMessage.id = 'bulk_message'
bulkMessage.classList.add('text-xs', 'left-0', 'absolute')
bulkMessage.style.bottom = '-15px'
this.textarea.parentNode.append(bulkMessage)
}
bulkMessage.innerHTML = '<a class="link" data-turbo="false" href="/upgrade">Upgrade</a> to bulk send multiple recipients'
}
}
get textarea () {
return this.querySelector('textarea')
}
}
@@ -28,6 +28,8 @@ export default class extends HTMLElement {
if (textarea && item[field]) { if (textarea && item[field]) {
textarea.value = textarea.value.replace(/[^;,\s]+$/, item[field] + ' ') textarea.value = textarea.value.replace(/[^;,\s]+$/, item[field] + ' ')
textarea.dispatchEvent(new Event('input', { bubbles: true }))
} }
}) })
} }
+19 -8
View File
@@ -157,6 +157,7 @@
ref="textContainer" ref="textContainer"
dir="auto" dir="auto"
class="flex items-center px-0.5 w-full" class="flex items-center px-0.5 w-full"
:class="alignClasses[field.preferences?.align]"
> >
<span v-if="Array.isArray(modelValue)"> <span v-if="Array.isArray(modelValue)">
{{ modelValue.join(', ') }} {{ modelValue.join(', ') }}
@@ -167,6 +168,7 @@
<span <span
v-else v-else
class="whitespace-pre-wrap" class="whitespace-pre-wrap"
:class="{ 'w-full': field.preferences?.align }"
>{{ modelValue }}</span> >{{ modelValue }}</span>
</div> </div>
</div> </div>
@@ -253,6 +255,13 @@ export default {
phone: this.t('phone') phone: this.t('phone')
} }
}, },
alignClasses () {
return {
center: 'text-center',
left: 'text-left',
right: 'text-right'
}
},
option () { option () {
return this.field.options.find((o) => o.uuid === this.area.option_uuid) return this.field.options.find((o) => o.uuid === this.area.option_uuid)
}, },
@@ -338,17 +347,19 @@ export default {
}, },
watch: { watch: {
modelValue () { modelValue () {
if (this.field.type === 'text' && this.$refs.textContainer && (this.textOverflowChars === 0 || (this.textOverflowChars - 4) > this.modelValue.length)) { this.$nextTick(() => {
this.textOverflowChars = this.$refs.textContainer.scrollHeight > this.$refs.textContainer.clientHeight ? this.modelValue.length : 0 if (['date', 'text', 'number'].includes(this.field.type) && 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 () { mounted () {
if (this.field.type === 'text' && this.$refs.textContainer) { this.$nextTick(() => {
this.$nextTick(() => { if (['date', 'text', 'number'].includes(this.field.type) && this.$refs.textContainer) {
this.textOverflowChars = this.$refs.textContainer.scrollHeight > this.$refs.textContainer.clientHeight ? this.modelValue.length : 0 this.textOverflowChars = this.$refs.textContainer.scrollHeight > this.$refs.textContainer.clientHeight ? `${this.modelValue}`.length : 0
}) }
} })
}, },
methods: { methods: {
optionValue (option) { optionValue (option) {
@@ -41,6 +41,13 @@
:name="`values[${field.uuid}][]`" :name="`values[${field.uuid}][]`"
> >
</template> </template>
<div
v-if="field.description && !modelValue.length"
dir="auto"
class="mb-3 px-1"
>
<MarkdownContent :string="field.description" />
</div>
<FileDropzone <FileDropzone
:message="`${t('upload')} ${field.name || t('files')}${field.required ? '' : ` (${t('optional')})`}`" :message="`${t('upload')} ${field.name || t('files')}${field.required ? '' : ` (${t('optional')})`}`"
:submitter-slug="submitterSlug" :submitter-slug="submitterSlug"
@@ -53,12 +60,14 @@
<script> <script>
import FileDropzone from './dropzone' import FileDropzone from './dropzone'
import MarkdownContent from './markdown_content'
import { IconPaperclip, IconTrashX } from '@tabler/icons-vue' import { IconPaperclip, IconTrashX } from '@tabler/icons-vue'
export default { export default {
name: 'AttachmentStep', name: 'AttachmentStep',
components: { components: {
FileDropzone, FileDropzone,
MarkdownContent,
IconPaperclip, IconPaperclip,
IconTrashX IconTrashX
}, },
+7 -1
View File
@@ -16,7 +16,8 @@
<div class="space-y-3 mt-5"> <div class="space-y-3 mt-5">
<a <a
v-if="completedButton.url" v-if="completedButton.url"
:href="completedButton.url" :href="sanitizeHref(completedButton.url)"
rel="noopener noreferrer nofollow"
class="white-button flex items-center w-full" class="white-button flex items-center w-full"
> >
<span> <span>
@@ -215,6 +216,11 @@ export default {
this.isDownloading = false this.isDownloading = false
}, },
sanitizeHref (href) {
if (href && href.trim().match(/^((?:https?:\/\/)|\/)/)) {
return href.replace(/javascript:/g, '')
}
},
downloadSafariIos (urls) { downloadSafariIos (urls) {
const fileRequests = urls.map((url) => { const fileRequests = urls.map((url) => {
return fetch(url).then(async (resp) => { return fetch(url).then(async (resp) => {
@@ -1,4 +1,4 @@
function cropCanvasAndExportToPNG (canvas) { function cropCanvasAndExportToPNG (canvas, { errorOnTooSmall } = { errorOnTooSmall: false }) {
const ctx = canvas.getContext('2d') const ctx = canvas.getContext('2d')
const width = canvas.width const width = canvas.width
@@ -33,6 +33,10 @@ function cropCanvasAndExportToPNG (canvas) {
croppedCanvas.height = croppedHeight croppedCanvas.height = croppedHeight
const croppedCtx = croppedCanvas.getContext('2d') const croppedCtx = croppedCanvas.getContext('2d')
if (errorOnTooSmall && (croppedWidth < 20 || croppedHeight < 20)) {
return Promise.reject(new Error('Image too small'))
}
croppedCtx.drawImage(canvas, leftmost, topmost, croppedWidth, croppedHeight, 0, 0, croppedWidth, croppedHeight) croppedCtx.drawImage(canvas, leftmost, topmost, croppedWidth, croppedHeight, 0, 0, croppedWidth, croppedHeight)
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
+22 -3
View File
@@ -1,11 +1,21 @@
<template> <template>
<div dir="auto"> <div dir="auto">
<div class="flex justify-between items-center w-full mb-2"> <div
class="flex justify-between items-center w-full"
:class="{ 'mb-2': !field.description }"
>
<label <label
:for="field.uuid" :for="field.uuid"
class="label text-2xl" class="label text-2xl"
>{{ field.name && showFieldNames ? field.name : t('date') }} >
<template v-if="!field.required">({{ t('optional') }})</template> <MarkdownContent
v-if="field.title"
:string="field.title"
/>
<template v-else>
{{ field.name && showFieldNames ? field.name : t('date') }}
<template v-if="!field.required">({{ t('optional') }})</template>
</template>
</label> </label>
<button <button
class="btn btn-outline btn-sm !normal-case font-normal" class="btn btn-outline btn-sm !normal-case font-normal"
@@ -15,6 +25,13 @@
{{ t('set_today') }} {{ t('set_today') }}
</button> </button>
</div> </div>
<div
v-if="field.description"
class="mb-3 px-1"
dir="auto"
>
<MarkdownContent :string="field.description" />
</div>
<AppearsOn :field="field" /> <AppearsOn :field="field" />
<div class="text-center"> <div class="text-center">
<input <input
@@ -33,11 +50,13 @@
<script> <script>
import { IconCalendarCheck } from '@tabler/icons-vue' import { IconCalendarCheck } from '@tabler/icons-vue'
import AppearsOn from './appears_on' import AppearsOn from './appears_on'
import MarkdownContent from './markdown_content'
export default { export default {
name: 'DateStep', name: 'DateStep',
components: { components: {
IconCalendarCheck, IconCalendarCheck,
MarkdownContent,
AppearsOn AppearsOn
}, },
inject: ['t'], inject: ['t'],
+112 -65
View File
@@ -101,17 +101,32 @@
/> />
<div v-else-if="currentField.type === 'select'"> <div v-else-if="currentField.type === 'select'">
<label <label
v-if="showFieldNames && currentField.name" v-if="showFieldNames && (currentField.name || currentField.title)"
:for="currentField.uuid" :for="currentField.uuid"
dir="auto" dir="auto"
class="label text-2xl mb-2" class="label text-2xl"
>{{ currentField.name }} :class="{ 'mb-2': !currentField.description }"
<template v-if="!currentField.required">({{ t('optional') }})</template> >
<MarkdownContent
v-if="currentField.title"
:string="currentField.title"
/>
<template v-else>
{{ currentField.name }}
<template v-if="!currentField.required">({{ t('optional') }})</template>
</template>
</label> </label>
<div <div
v-else v-else
class="py-1" class="py-1"
/> />
<div
v-if="currentField.description"
dir="auto"
class="mb-3 px-1"
>
<MarkdownContent :string="currentField.description" />
</div>
<AppearsOn :field="currentField" /> <AppearsOn :field="currentField" />
<select <select
:id="currentField.uuid" :id="currentField.uuid"
@@ -140,13 +155,28 @@
</div> </div>
<div v-else-if="currentField.type === 'radio'"> <div v-else-if="currentField.type === 'radio'">
<label <label
v-if="showFieldNames && currentField.name" v-if="showFieldNames && (currentField.name || currentField.title)"
:for="currentField.uuid" :for="currentField.uuid"
dir="auto" dir="auto"
class="label text-2xl mb-2" class="label text-2xl"
>{{ currentField.name }} :class="{ 'mb-2': !currentField.description }"
<template v-if="!currentField.required">({{ t('optional') }})</template> >
<MarkdownContent
v-if="currentField.title"
:string="currentField.title"
/>
<template v-else>
{{ currentField.name }}
<template v-if="!currentField.required">({{ t('optional') }})</template>
</template>
</label> </label>
<div
v-if="currentField.description"
dir="auto"
class="mb-3 px-1"
>
<MarkdownContent :string="currentField.description" />
</div>
<div class="flex w-full max-h-44 overflow-y-auto"> <div class="flex w-full max-h-44 overflow-y-auto">
<div <div
v-if="!showFieldNames || (currentField.options.every((e) => !e.value) && currentField.options.length > 4)" v-if="!showFieldNames || (currentField.options.every((e) => !e.value) && currentField.options.length > 4)"
@@ -197,66 +227,77 @@
/> />
<div <div
v-else-if="currentField.type === 'checkbox'" v-else-if="currentField.type === 'checkbox'"
class="flex w-full max-h-44 overflow-y-auto"
> >
<input
type="hidden"
name="cast_boolean"
value="true"
>
<div <div
class="space-y-3.5 mx-auto" v-if="currentField.description"
dir="auto"
class="mb-3 px-1"
> >
<template v-if="isAnonymousChecboxes || !showFieldNames"> <MarkdownContent :string="currentField.description" />
<span class="text-xl"> </div>
{{ t('complete_hightlighted_checkboxes_and_click') }} <span class="font-semibold">{{ stepFields.length === currentStep + 1 ? t('submit') : t('next') }}</span>. <div
</span> class="flex w-full max-h-44 overflow-y-auto"
<input >
v-for="field in currentStepFields" <input
:key="field.uuid" type="hidden"
type="hidden" name="cast_boolean"
:name="`values[${field.uuid}]`" value="true"
:value="!!values[field.uuid]" >
> <div
</template> class="space-y-3.5 mx-auto"
<template v-else> >
<div <template v-if="isAnonymousChecboxes || !showFieldNames">
v-for="(field, index) in currentStepFields" <span class="text-xl">
:key="field.uuid" {{ t('complete_hightlighted_checkboxes_and_click') }} <span class="font-semibold">{{ stepFields.length === currentStep + 1 ? t('submit') : t('next') }}</span>.
> </span>
<label <input
:for="field.uuid" v-for="field in currentStepFields"
class="flex items-center space-x-3" :key="field.uuid"
type="hidden"
:name="`values[${field.uuid}]`"
:value="!!values[field.uuid]"
> >
<input </template>
type="hidden" <template v-else>
:name="`values[${field.uuid}]`" <div
:value="!!values[field.uuid]" v-for="(field, index) in currentStepFields"
:key="field.uuid"
>
<label
:for="field.uuid"
class="flex items-center space-x-3"
> >
<input <input
:id="field.uuid" type="hidden"
type="checkbox" :name="`values[${field.uuid}]`"
class="base-checkbox !h-7 !w-7" :value="!!values[field.uuid]"
:oninvalid="`this.setCustomValidity('${t('please_check_the_box_to_continue')}')`" >
:onchange="`this.setCustomValidity(validity.valueMissing ? '${t('please_check_the_box_to_continue')}' : '');`" <input
:required="field.required" :id="field.uuid"
:checked="!!values[field.uuid]" type="checkbox"
@click="[scrollIntoField(field), values[field.uuid] = !values[field.uuid]]" class="base-checkbox !h-7 !w-7"
> :oninvalid="`this.setCustomValidity('${t('please_check_the_box_to_continue')}')`"
<span :onchange="`this.setCustomValidity(validity.valueMissing ? '${t('please_check_the_box_to_continue')}' : '');`"
v-if="field.title" :required="field.required"
class="text-xl" :checked="!!values[field.uuid]"
v-html="field.title" @click="[scrollIntoField(field), values[field.uuid] = !values[field.uuid]]"
/> >
<span <span
v-else v-if="field.title"
class="text-xl" class="text-xl"
> >
{{ field.name || field.type + ' ' + (index + 1) }} <MarkdownContent :string="field.title" />
</span> </span>
</label> <span
</div> v-else
</template> class="text-xl"
>
{{ field.name || field.type + ' ' + (index + 1) }}
</span>
</label>
</div>
</template>
</div>
</div> </div>
</div> </div>
<ImageStep <ImageStep
@@ -418,6 +459,7 @@ import PaymentStep from './payment_step'
import TextStep from './text_step' import TextStep from './text_step'
import NumberStep from './number_step' import NumberStep from './number_step'
import DateStep from './date_step' import DateStep from './date_step'
import MarkdownContent from './markdown_content'
import FormCompleted from './completed' import FormCompleted from './completed'
import { IconInnerShadowTop, IconArrowsDiagonal, IconWritingSign, IconArrowsDiagonalMinimize2 } from '@tabler/icons-vue' import { IconInnerShadowTop, IconArrowsDiagonal, IconWritingSign, IconArrowsDiagonalMinimize2 } from '@tabler/icons-vue'
import AppearsOn from './appears_on' import AppearsOn from './appears_on'
@@ -459,6 +501,7 @@ export default {
NumberStep, NumberStep,
FormulaFieldAreas, FormulaFieldAreas,
PhoneStep, PhoneStep,
MarkdownContent,
PaymentStep, PaymentStep,
IconArrowsDiagonalMinimize2, IconArrowsDiagonalMinimize2,
FormCompleted FormCompleted
@@ -654,7 +697,7 @@ export default {
const prevStep = acc[acc.length - 1] const prevStep = acc[acc.length - 1]
if (this.checkFieldConditions(f)) { if (this.checkFieldConditions(f)) {
if (f.type === 'checkbox' && Array.isArray(prevStep) && prevStep[0].type === 'checkbox') { if (f.type === 'checkbox' && Array.isArray(prevStep) && prevStep[0].type === 'checkbox' && !f.description) {
prevStep.push(f) prevStep.push(f)
} else { } else {
acc.push([f]) acc.push([f])
@@ -957,7 +1000,11 @@ export default {
this.isSubmitting = false this.isSubmitting = false
}) })
}).catch(error => { }).catch(error => {
console.log(error) if (error?.message === 'Image too small') {
alert('Signature is too small - please redraw.')
} else {
console.log(error)
}
}).finally(() => { }).finally(() => {
this.isSubmitting = false this.isSubmitting = false
}) })
+13 -3
View File
@@ -24,9 +24,17 @@
:name="`values[${field.uuid}]`" :name="`values[${field.uuid}]`"
> >
</div> </div>
<div> <div
v-if="!modelValue"
>
<div
v-if="field.description"
dir="auto"
class="mb-3 px-1"
>
<MarkdownContent :string="field.description" />
</div>
<FileDropzone <FileDropzone
v-if="!modelValue"
:message="`${t('upload')} ${field.name || t('image')}${field.required ? '' : ` (${t('optional')})`}`" :message="`${t('upload')} ${field.name || t('image')}${field.required ? '' : ` (${t('optional')})`}`"
:submitter-slug="submitterSlug" :submitter-slug="submitterSlug"
:accept="'image/*'" :accept="'image/*'"
@@ -39,12 +47,14 @@
<script> <script>
import FileDropzone from './dropzone' import FileDropzone from './dropzone'
import { IconReload } from '@tabler/icons-vue' import { IconReload } from '@tabler/icons-vue'
import MarkdownContent from './markdown_content'
export default { export default {
name: 'ImageStep', name: 'ImageStep',
components: { components: {
FileDropzone, FileDropzone,
IconReload IconReload,
MarkdownContent
}, },
inject: ['t'], inject: ['t'],
props: { props: {
@@ -1,9 +1,20 @@
<template> <template>
<div dir="auto"> <div dir="auto">
<div class="flex justify-between items-center w-full mb-2"> <div
class="flex justify-between items-center w-full"
:class="{ 'mb-2': !field.description }"
>
<label <label
class="label text-2xl" class="label text-2xl"
>{{ showFieldNames && field.name ? field.name : t('initials') }}</label> >
<MarkdownContent
v-if="field.title"
:string="field.title"
/>
<template v-else>
{{ showFieldNames && field.name ? field.name : t('initials') }}
</template>
</label>
<div class="space-x-2 flex"> <div class="space-x-2 flex">
<span <span
v-if="isDrawInitials" v-if="isDrawInitials"
@@ -70,6 +81,13 @@
</a> </a>
</div> </div>
</div> </div>
<div
v-if="field.description"
dir="auto"
class="mb-3 px-1"
>
<MarkdownContent :string="field.description" />
</div>
<AppearsOn :field="field" /> <AppearsOn :field="field" />
<input <input
:value="modelValue || computedPreviousValue" :value="modelValue || computedPreviousValue"
@@ -105,6 +123,7 @@ import { cropCanvasAndExportToPNG } from './crop_canvas'
import { IconReload, IconTextSize, IconSignature, IconArrowsDiagonalMinimize2 } from '@tabler/icons-vue' import { IconReload, IconTextSize, IconSignature, IconArrowsDiagonalMinimize2 } from '@tabler/icons-vue'
import SignaturePad from 'signature_pad' import SignaturePad from 'signature_pad'
import AppearsOn from './appears_on' import AppearsOn from './appears_on'
import MarkdownContent from './markdown_content'
const scale = 3 const scale = 3
@@ -115,6 +134,7 @@ export default {
IconReload, IconReload,
IconTextSize, IconTextSize,
IconSignature, IconSignature,
MarkdownContent,
IconArrowsDiagonalMinimize2 IconArrowsDiagonalMinimize2
}, },
inject: ['baseUrl', 't'], inject: ['baseUrl', 't'],
@@ -0,0 +1,75 @@
<template>
<span>
<template
v-for="(item, index) in items"
:key="index"
>
<a
v-if="item.startsWith('<a') && item.endsWith('</a>')"
:href="sanitizeHref(extractAttr(item, 'href'))"
rel="noopener noreferrer nofollow"
:class="extractAttr(item, 'class') || 'link'"
target="_blank"
>
{{ extractText(item) }}
</a>
<b
v-else-if="item.startsWith('<b>') || item.startsWith('<strong>')"
>
{{ extractText(item) }}
</b>
<i
v-else-if="item.startsWith('<i>') || item.startsWith('<em>')"
>
{{ extractText(item) }}
</i>
<br
v-else-if="item === '<br>' || item === '\n'"
>
<template
v-else
>
{{ item }}
</template>
</template>
</span>
</template>
<script>
import snarkdown from 'snarkdown'
const htmlSplitRegexp = /(<a.+?<\/a>|<i>.+?<\/i>|<b>.+?<\/b>|<em>.+?<\/em>|<strong>.+?<\/strong>|<br>)/
export default {
name: 'MarkdownContent',
props: {
string: {
type: String,
required: false,
default: ''
}
},
computed: {
items () {
return snarkdown(this.string.replace(/\n/g, '<br>')).split(htmlSplitRegexp)
}
},
methods: {
sanitizeHref (href) {
if (href && href.trim().match(/^((?:https?:\/\/)|\/)/)) {
return href.replace(/javascript:/g, '')
}
},
extractAttr (text, attr) {
if (text.includes(attr)) {
return text.split(attr).pop().split('"')[1]
}
},
extractText (text) {
if (text) {
return text.match(/>(.+?)</)?.[1]
}
}
}
}
</script>
@@ -1,10 +1,23 @@
<template> <template>
<label <label
v-if="showFieldNames && field.name" v-if="showFieldNames && (field.name || field.title)"
:for="field.uuid" :for="field.uuid"
dir="auto" dir="auto"
class="label text-2xl mb-2" class="label text-2xl"
>{{ field.name }}</label> :class="{ 'mb-2': !field.description }"
><MarkdownContent
v-if="field.title"
:string="field.title"
/>
<template v-else>{{ field.name }}</template>
</label>
<div
v-if="field.description"
dir="auto"
class="mb-3 px-1"
>
<MarkdownContent :string="field.description" />
</div>
<div class="flex w-full max-h-44 overflow-y-auto"> <div class="flex w-full max-h-44 overflow-y-auto">
<input <input
v-if="modelValue.length === 0" v-if="modelValue.length === 0"
@@ -53,8 +66,13 @@
</template> </template>
<script> <script>
import MarkdownContent from './markdown_content'
export default { export default {
name: 'MultiSelectStep', name: 'MultiSelectStep',
components: {
MarkdownContent
},
inject: ['t', 'scrollIntoField'], inject: ['t', 'scrollIntoField'],
props: { props: {
field: { field: {
+13 -6
View File
@@ -1,11 +1,14 @@
<template> <template>
<label <label
v-if="showFieldNames && field.name" v-if="showFieldNames && (field.name || field.title)"
:for="field.uuid" :for="field.uuid"
dir="auto" dir="auto"
class="label text-2xl" class="label text-2xl"
:class="{ 'mb-2': !field.description }" :class="{ 'mb-2': !field.description }"
><template v-if="field.title"><span v-html="field.title" /></template> ><MarkdownContent
v-if="field.title"
:string="field.title"
/>
<template v-else>{{ field.name }}</template> <template v-else>{{ field.name }}</template>
<template v-if="!field.required">({{ t('optional') }})</template> <template v-if="!field.required">({{ t('optional') }})</template>
</label> </label>
@@ -15,9 +18,11 @@
/> />
<div <div
v-if="field.description" v-if="field.description"
class="mb-3 px-1 text-lg" dir="auto"
v-html="field.description" class="mb-3 px-1"
/> >
<MarkdownContent :string="field.description" />
</div>
<AppearsOn :field="field" /> <AppearsOn :field="field" />
<div class="items-center flex"> <div class="items-center flex">
<input <input
@@ -41,11 +46,13 @@
<script> <script>
import AppearsOn from './appears_on' import AppearsOn from './appears_on'
import MarkdownContent from './markdown_content'
export default { export default {
name: 'TextStep', name: 'TextStep',
components: { components: {
AppearsOn AppearsOn,
MarkdownContent
}, },
inject: ['t'], inject: ['t'],
props: { props: {
+25 -6
View File
@@ -2,10 +2,25 @@
<div> <div>
<label <label
:for="isCodeSent ? 'one_time_code' : field.uuid" :for="isCodeSent ? 'one_time_code' : field.uuid"
class="label text-2xl mb-2" class="label text-2xl"
>{{ showFieldNames && field.name ? field.name : t('verified_phone_number') }} :class="{ 'mb-2': !field.description }"
<template v-if="!field.required">({{ t('optional') }})</template> >
<MarkdownContent
v-if="field.title"
:string="field.title"
/>
<template v-else>
{{ showFieldNames && field.name ? field.name : t('verified_phone_number') }}
<template v-if="!field.required">({{ t('optional') }})</template>
</template>
</label> </label>
<div
v-if="field.description"
dir="auto"
class="mb-3 px-1"
>
<MarkdownContent :string="field.description" />
</div>
<div> <div>
<input <input
type="hidden" type="hidden"
@@ -53,14 +68,13 @@
class="base-input !text-2xl w-full" class="base-input !text-2xl w-full"
autocomplete="tel" autocomplete="tel"
pattern="^\+[0-9\s\-]+$" pattern="^\+[0-9\s\-]+$"
:oninvalid="`this.value ? this.setCustomValidity('${t('use_international_format')}...') : ''`"
oninput="this.setCustomValidity('')"
type="tel" type="tel"
inputmode="tel" inputmode="tel"
:required="field.required" :required="field.required"
placeholder="+1 234 567-8900" placeholder="+1 234 567-8900"
:name="`values[${field.uuid}]`" :name="`values[${field.uuid}]`"
@input="$emit('update:model-value', $event.target.value)" @invalid="$event.target.value ? $event.target.setCustomValidity(`${t('use_international_format')}...`) : ''"
@input="[$event.target.setCustomValidity(''), $emit('update:model-value', $event.target.value)]"
@focus="$emit('focus')" @focus="$emit('focus')"
> >
</div> </div>
@@ -68,6 +82,8 @@
</template> </template>
<script> <script>
import MarkdownContent from './markdown_content'
function throttle (func, delay) { function throttle (func, delay) {
let lastCallTime = 0 let lastCallTime = 0
@@ -83,6 +99,9 @@ function throttle (func, delay) {
export default { export default {
name: 'PhoneStep', name: 'PhoneStep',
components: {
MarkdownContent
},
inject: ['t', 'baseUrl'], inject: ['t', 'baseUrl'],
props: { props: {
field: { field: {
@@ -1,9 +1,20 @@
<template> <template>
<div dir="auto"> <div dir="auto">
<div class="flex justify-between items-center w-full mb-2"> <div
class="flex justify-between items-center w-full"
:class="{ 'mb-2': !field.description }"
>
<label <label
class="label text-2xl" class="label text-2xl"
>{{ showFieldNames && field.name ? field.name : t('signature') }}</label> >
<MarkdownContent
v-if="field.title"
:string="field.title"
/>
<template v-else>
{{ showFieldNames && field.name ? field.name : t('signature') }}
</template>
</label>
<div class="space-x-2 flex"> <div class="space-x-2 flex">
<span <span
v-if="isTextSignature && field.preferences?.format !== 'typed'" v-if="isTextSignature && field.preferences?.format !== 'typed'"
@@ -30,7 +41,7 @@
<a <a
id="type_text_button" id="type_text_button"
href="#" href="#"
class="btn btn-outline btn-sm font-medium" class="btn btn-outline btn-sm font-medium inline-flex flex-nowrap"
@click.prevent="toggleTextInput" @click.prevent="toggleTextInput"
> >
<IconTextSize :width="16" /> <IconTextSize :width="16" />
@@ -45,7 +56,7 @@
:data-tip="t('take_photo')" :data-tip="t('take_photo')"
> >
<label <label
class="btn btn-outline btn-sm font-medium" class="btn btn-outline btn-sm font-medium inline-flex flex-nowrap"
> >
<IconCamera :width="16" /> <IconCamera :width="16" />
<input <input
@@ -91,6 +102,13 @@
</a> </a>
</div> </div>
</div> </div>
<div
v-if="field.description"
dir="auto"
class="mb-3 px-1"
>
<MarkdownContent :string="field.description" />
</div>
<AppearsOn :field="field" /> <AppearsOn :field="field" />
<input <input
:value="modelValue || computedPreviousValue" :value="modelValue || computedPreviousValue"
@@ -126,6 +144,7 @@ import { IconReload, IconCamera, IconSignature, IconTextSize, IconArrowsDiagonal
import { cropCanvasAndExportToPNG } from './crop_canvas' import { cropCanvasAndExportToPNG } from './crop_canvas'
import SignaturePad from 'signature_pad' import SignaturePad from 'signature_pad'
import AppearsOn from './appears_on' import AppearsOn from './appears_on'
import MarkdownContent from './markdown_content'
let isFontLoaded = false let isFontLoaded = false
@@ -137,6 +156,7 @@ export default {
AppearsOn, AppearsOn,
IconReload, IconReload,
IconCamera, IconCamera,
MarkdownContent,
IconTextSize, IconTextSize,
IconSignature, IconSignature,
IconArrowsDiagonalMinimize2 IconArrowsDiagonalMinimize2
@@ -366,8 +386,8 @@ export default {
return Promise.resolve({}) return Promise.resolve({})
} }
return new Promise((resolve) => { return new Promise((resolve, reject) => {
cropCanvasAndExportToPNG(this.$refs.canvas).then(async (blob) => { cropCanvasAndExportToPNG(this.$refs.canvas, { errorOnTooSmall: true }).then(async (blob) => {
const file = new File([blob], 'signature.png', { type: 'image/png' }) const file = new File([blob], 'signature.png', { type: 'image/png' })
if (this.isDirectUpload) { if (this.isDirectUpload) {
@@ -409,6 +429,8 @@ export default {
return resolve(attachment) return resolve(attachment)
}) })
} }
}).catch((error) => {
return reject(error)
}) })
}) })
} }
+14 -7
View File
@@ -1,11 +1,14 @@
<template> <template>
<label <label
v-if="showFieldNames && field.name" v-if="showFieldNames && (field.name || field.title)"
:for="field.uuid" :for="field.uuid"
dir="auto" dir="auto"
class="label text-2xl" class="label text-2xl"
:class="{ 'mb-2': !field.description }" :class="{ 'mb-2': !field.description }"
><template v-if="field.title"><span v-html="field.title" /></template> ><MarkdownContent
v-if="field.title"
:string="field.title"
/>
<template v-else>{{ field.name }}</template> <template v-else>{{ field.name }}</template>
<template v-if="!field.required">({{ t('optional') }})</template> <template v-if="!field.required">({{ t('optional') }})</template>
</label> </label>
@@ -15,9 +18,11 @@
/> />
<div <div
v-if="field.description" v-if="field.description"
class="mb-3 px-1 text-lg" dir="auto"
v-html="field.description" class="mb-3 px-1"
/> >
<MarkdownContent :string="field.description" />
</div>
<AppearsOn :field="field" /> <AppearsOn :field="field" />
<div class="items-center flex"> <div class="items-center flex">
<input <input
@@ -30,11 +35,11 @@
:class="{ '!pr-11 -mr-10': !field.validation?.pattern }" :class="{ '!pr-11 -mr-10': !field.validation?.pattern }"
:required="field.required" :required="field.required"
:pattern="field.validation?.pattern" :pattern="field.validation?.pattern"
:oninvalid="field.validation?.message ? `this.setCustomValidity(${JSON.stringify(field.validation.message)})` : ''"
:oninput="field.validation?.message ? `this.setCustomValidity('')` : ''"
:placeholder="`${t('type_here_')}${field.required ? '' : ` (${t('optional')})`}`" :placeholder="`${t('type_here_')}${field.required ? '' : ` (${t('optional')})`}`"
type="text" type="text"
:name="`values[${field.uuid}]`" :name="`values[${field.uuid}]`"
@invalid="field.validation?.message ? $event.target.setCustomValidity(field.validation.message) : ''"
@input="field.validation?.message ? $event.target.setCustomValidity('') : ''"
@focus="$emit('focus')" @focus="$emit('focus')"
> >
<textarea <textarea
@@ -69,11 +74,13 @@
<script> <script>
import { IconAlignBoxLeftTop } from '@tabler/icons-vue' import { IconAlignBoxLeftTop } from '@tabler/icons-vue'
import AppearsOn from './appears_on' import AppearsOn from './appears_on'
import MarkdownContent from './markdown_content'
export default { export default {
name: 'TextStep', name: 'TextStep',
components: { components: {
IconAlignBoxLeftTop, IconAlignBoxLeftTop,
MarkdownContent,
AppearsOn AppearsOn
}, },
inject: ['t'], inject: ['t'],
+23 -14
View File
@@ -99,7 +99,7 @@
<div <div
class="flex items-center h-full w-full" class="flex items-center h-full w-full"
dir="auto" dir="auto"
:class="[bgColors[submitterIndex], field?.default_value ? '' : 'justify-center']" :class="[bgColors[submitterIndex], field?.default_value ? (alignClasses[field.preferences?.align] || '') : 'justify-center']"
> >
<span <span
v-if="field" v-if="field"
@@ -211,6 +211,13 @@ export default {
defaultName () { defaultName () {
return this.buildDefaultName(this.field, this.template.fields) return this.buildDefaultName(this.field, this.template.fields)
}, },
alignClasses () {
return {
center: 'justify-center',
left: 'justify-start',
right: 'justify-end'
}
},
optionIndexText () { optionIndexText () {
if (this.area.option_uuid && this.field.options) { if (this.area.option_uuid && this.field.options) {
return `${this.field.options.findIndex((o) => o.uuid === this.area.option_uuid) + 1}.` return `${this.field.options.findIndex((o) => o.uuid === this.area.option_uuid) + 1}.`
@@ -301,17 +308,19 @@ export default {
}, },
watch: { watch: {
'field.default_value' () { '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.$nextTick(() => {
this.textOverflowChars = this.$el.clientHeight < this.$refs.textContainer.clientHeight ? this.field.default_value.length : 0 if (['date', 'text', 'number'].includes(this.field.type) && 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 () { 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.$nextTick(() => { if (['date', 'text', 'number'].includes(this.field.type) && 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 this.textOverflowChars = this.$el.clientHeight < this.$refs.textContainer.clientHeight ? `${this.field.default_value}`.length : 0
}) }
} })
}, },
methods: { methods: {
buildDefaultName: Field.methods.buildDefaultName, buildDefaultName: Field.methods.buildDefaultName,
@@ -343,7 +352,7 @@ export default {
}, },
onResizeCell (e) { onResizeCell (e) {
if (e.target.id === 'mask') { if (e.target.id === 'mask') {
const positionX = e.layerX / (e.target.clientWidth - 1) const positionX = e.offsetX / (e.target.clientWidth - 1)
if (positionX > this.area.x) { if (positionX > this.area.x) {
this.area.cell_w = positionX - this.area.x this.area.cell_w = positionX - this.area.x
@@ -389,16 +398,16 @@ export default {
}, },
resize (e) { resize (e) {
if (e.target.id === 'mask') { if (e.target.id === 'mask') {
this.area.w = e.layerX / e.target.clientWidth - this.area.x this.area.w = e.offsetX / e.target.clientWidth - this.area.x
this.area.h = e.layerY / e.target.clientHeight - this.area.y this.area.h = e.offsetY / e.target.clientHeight - this.area.y
} }
}, },
drag (e) { drag (e) {
if (e.target.id === 'mask') { if (e.target.id === 'mask') {
this.isDragged = true this.isDragged = true
this.area.x = (e.layerX - this.dragFrom.x) / e.target.clientWidth this.area.x = (e.offsetX - this.dragFrom.x) / e.target.clientWidth
this.area.y = (e.layerY - this.dragFrom.y) / e.target.clientHeight this.area.y = (e.offsetY - this.dragFrom.y) / e.target.clientHeight
} }
}, },
startDrag (e) { startDrag (e) {
+8 -2
View File
@@ -94,11 +94,13 @@
</div> </div>
</div> </div>
<div <div
id="main_container"
class="flex" class="flex"
:class="$slots.buttons || withTitle ? 'md:max-h-[calc(100%_-_60px)]' : 'md:max-h-[100%]'" :class="$slots.buttons || withTitle ? 'md:max-h-[calc(100%_-_60px)]' : 'md:max-h-[100%]'"
> >
<div <div
v-if="withDocumentsList" v-if="withDocumentsList"
id="documents_container"
ref="previews" ref="previews"
:style="{ 'display': isBreakpointLg ? 'none' : 'initial' }" :style="{ 'display': isBreakpointLg ? 'none' : 'initial' }"
class="overflow-y-auto overflow-x-hidden w-52 flex-none pr-3 mt-0.5 pt-0.5 hidden lg:block" class="overflow-y-auto overflow-x-hidden w-52 flex-none pr-3 mt-0.5 pt-0.5 hidden lg:block"
@@ -134,7 +136,10 @@
/> />
</div> </div>
</div> </div>
<div class="w-full overflow-y-hidden md:overflow-y-auto overflow-x-hidden mt-0.5 pt-0.5"> <div
id="pages_container"
class="w-full overflow-y-hidden md:overflow-y-auto overflow-x-hidden mt-0.5 pt-0.5"
>
<div <div
ref="documents" ref="documents"
class="pr-3.5 pl-0.5" class="pr-3.5 pl-0.5"
@@ -200,6 +205,7 @@
</div> </div>
<div <div
v-if="withFieldsList" v-if="withFieldsList"
id="fields_list_container"
class="relative w-80 flex-none mt-1 pr-4 pl-0.5 hidden md:block" class="relative w-80 flex-none mt-1 pr-4 pl-0.5 hidden md:block"
:class="drawField ? 'overflow-hidden' : 'overflow-y-auto overflow-x-hidden'" :class="drawField ? 'overflow-hidden' : 'overflow-y-auto overflow-x-hidden'"
> >
@@ -523,7 +529,7 @@ export default {
submitter.name = name submitter.name = name
if (existingSubmittersUuids.filter(Boolean).length) { if (existingSubmittersUuids.filter(Boolean).length) {
submitter.uuid = existingSubmittersUuids[index] || v4() submitter.uuid = existingSubmittersUuids[index] || submitter.uuid || v4()
} else { } else {
submitter.uuid ||= v4() submitter.uuid ||= v4()
} }
@@ -0,0 +1,107 @@
<template>
<div
class="modal modal-open items-start !animate-none overflow-y-auto"
>
<div
class="absolute top-0 bottom-0 right-0 left-0"
@click.prevent="$emit('close')"
/>
<div class="modal-box pt-4 pb-6 px-6 mt-20 max-h-none w-full max-w-xl">
<div class="flex justify-between items-center border-b pb-2 mb-2 font-medium">
<span>
{{ field.name || buildDefaultName(field, template.fields) }}
</span>
<a
href="#"
class="text-xl"
@click.prevent="$emit('close')"
>&times;</a>
</div>
<div>
<form @submit.prevent="saveAndClose">
<div class="space-y-1 mb-1">
<div>
<label
dir="auto"
class="label text-sm"
for="description_field"
>
{{ t('description') }}
</label>
<textarea
id="description_field"
ref="textarea"
v-model="description"
dir="auto"
class="base-textarea !text-base w-full"
@input="resizeTextarea"
/>
</div>
<div>
<label
dir="auto"
class="label text-sm"
for="title_field"
>
{{ t('display_title') }} ({{ t('optional') }})
</label>
<input
id="title_field"
v-model="title"
dir="auto"
class="base-input !text-base w-full"
>
</div>
</div>
<button
class="base-button w-full mt-4"
>
{{ t('save') }}
</button>
</form>
</div>
</div>
</div>
</template>
<script>
export default {
name: 'DescriptionModal',
inject: ['t', 'save', 'template'],
props: {
field: {
type: Object,
required: true
},
buildDefaultName: {
type: Function,
required: true
}
},
emits: ['close'],
data () {
return {
description: this.field.description,
title: this.field.title
}
},
mounted () {
this.resizeTextarea()
},
methods: {
saveAndClose () {
this.field.description = this.description
this.field.title = this.title
this.save()
this.$emit('close')
},
resizeTextarea () {
const textarea = this.$refs.textarea
textarea.style.height = 'auto'
textarea.style.height = textarea.scrollHeight + 'px'
}
}
}
</script>
+102 -11
View File
@@ -1,6 +1,6 @@
<template> <template>
<div <div
class="group pb-2" class="list-field group mb-2"
> >
<div <div
class="border border-base-300 rounded rounded-tr-none relative group" class="border border-base-300 rounded rounded-tr-none relative group"
@@ -116,15 +116,41 @@
@click="closeDropdown" @click="closeDropdown"
> >
<div <div
v-if="field.type === 'text' && !defaultField" v-if="['number'].includes(field.type)"
class="py-1.5 px-1 relative"
@click.stop
>
<select
class="select select-bordered select-xs w-full max-w-xs h-7 !outline-0 font-normal"
@change="[field.preferences ||= {}, field.preferences.align = $event.target.value, save()]"
>
<option
v-for="value in ['left', 'right', 'center']"
:key="value"
:selected="field.preferences?.align ? value === field.preferences.align : value === 'left'"
:value="value"
>
{{ t(value) }}
</option>
</select>
<label
:style="{ backgroundColor: backgroundColor }"
class="absolute -top-1 left-2.5 px-1 h-4"
style="font-size: 8px"
>
{{ t('align') }}
</label>
</div>
<div
v-if="['text', 'number'].includes(field.type) && !defaultField"
class="py-1.5 px-1 relative" class="py-1.5 px-1 relative"
@click.stop @click.stop
> >
<input <input
v-model="field.default_value" v-model="field.default_value"
type="text"
:placeholder="t('default_value')" :placeholder="t('default_value')"
dir="auto" dir="auto"
:type="field.type"
class="input input-bordered input-xs w-full max-w-xs h-7 !outline-0" class="input input-bordered input-xs w-full max-w-xs h-7 !outline-0"
@blur="save" @blur="save"
> >
@@ -202,7 +228,7 @@
</label> </label>
</div> </div>
<li <li
v-if="field.type != 'phone'" v-if="field.type != 'phone' && field.type != 'stamp'"
@click.stop @click.stop
> >
<label class="cursor-pointer py-1.5"> <label class="cursor-pointer py-1.5">
@@ -215,6 +241,20 @@
<span class="label-text">{{ t('required') }}</span> <span class="label-text">{{ t('required') }}</span>
</label> </label>
</li> </li>
<li
v-if="field.type == 'stamp'"
@click.stop
>
<label class="cursor-pointer py-1.5">
<input
:checked="field.preferences?.with_logo != false"
type="checkbox"
class="toggle toggle-xs"
@change="[field.preferences ||= {}, field.preferences.with_logo = field.preferences.with_logo == false, save()]"
>
<span class="label-text">{{ t('with_logo') }}</span>
</label>
</li>
<li <li
v-if="field.type == 'checkbox'" v-if="field.type == 'checkbox'"
@click.stop @click.stop
@@ -244,7 +284,7 @@
</label> </label>
</li> </li>
<li <li
v-if="field.type === 'text' && !defaultField" v-if="['text', 'number'].includes(field.type) && !defaultField"
@click.stop @click.stop
> >
<label class="cursor-pointer py-1.5"> <label class="cursor-pointer py-1.5">
@@ -257,8 +297,28 @@
<span class="label-text">{{ t('read_only') }}</span> <span class="label-text">{{ t('read_only') }}</span>
</label> </label>
</li> </li>
<hr class="pb-0.5 mt-0.5"> <hr
<li> v-if="field.type != 'stamp'"
class="pb-0.5 mt-0.5"
>
<li
v-if="field.type != 'stamp'"
>
<label
class="label-text cursor-pointer text-center w-full flex items-center"
@click="isShowDescriptionModal = !isShowDescriptionModal"
>
<IconInfoCircle
width="18"
/>
<span class="text-sm">
{{ t('description') }}
</span>
</label>
</li>
<li
v-if="field.type != 'stamp'"
>
<label <label
class="label-text cursor-pointer text-center w-full flex items-center" class="label-text cursor-pointer text-center w-full flex items-center"
@click="isShowConditionsModal = !isShowConditionsModal" @click="isShowConditionsModal = !isShowConditionsModal"
@@ -286,7 +346,7 @@
</li> </li>
<hr class="pb-0.5 mt-0.5"> <hr class="pb-0.5 mt-0.5">
<li <li
v-for="(area, index) in field.areas || []" v-for="(area, index) in sortedAreas"
:key="index" :key="index"
> >
<a <a
@@ -435,6 +495,16 @@
@close="isShowConditionsModal = false" @close="isShowConditionsModal = false"
/> />
</Teleport> </Teleport>
<Teleport
v-if="isShowDescriptionModal"
:to="modalContainerEl"
>
<DescriptionModal
:field="field"
:build-default-name="buildDefaultName"
@close="isShowDescriptionModal = false"
/>
</Teleport>
</div> </div>
</template> </template>
@@ -444,7 +514,8 @@ import FieldType from './field_type'
import PaymentSettings from './payment_settings' import PaymentSettings from './payment_settings'
import FormulaModal from './formula_modal' import FormulaModal from './formula_modal'
import ConditionsModal from './conditions_modal' import ConditionsModal from './conditions_modal'
import { IconRouteAltLeft, IconMathFunction, IconShape, IconNewSection, IconTrashX, IconCopy, IconSettings } from '@tabler/icons-vue' import DescriptionModal from './description_modal'
import { IconInfoCircle, IconRouteAltLeft, IconMathFunction, IconShape, IconNewSection, IconTrashX, IconCopy, IconSettings } from '@tabler/icons-vue'
import { v4 } from 'uuid' import { v4 } from 'uuid'
export default { export default {
@@ -455,7 +526,9 @@ export default {
IconShape, IconShape,
PaymentSettings, PaymentSettings,
IconNewSection, IconNewSection,
IconInfoCircle,
FormulaModal, FormulaModal,
DescriptionModal,
ConditionsModal, ConditionsModal,
IconRouteAltLeft, IconRouteAltLeft,
IconTrashX, IconTrashX,
@@ -487,11 +560,24 @@ export default {
showPaymentModal: false, showPaymentModal: false,
isShowFormulaModal: false, isShowFormulaModal: false,
isShowConditionsModal: false, isShowConditionsModal: false,
isShowDescriptionModal: false,
renderDropdown: false renderDropdown: false
} }
}, },
computed: { computed: {
fieldNames: FieldType.computed.fieldNames, fieldNames: FieldType.computed.fieldNames,
schemaAttachmentsIndexes () {
return (this.template.schema || []).reduce((acc, item, index) => {
acc[item.attachment_uuid] = index
return acc
}, {})
},
sortedAreas () {
return (this.field.areas || []).sort((a, b) => {
return this.schemaAttachmentsIndexes[a.attachment_uuid] - this.schemaAttachmentsIndexes[b.attachment_uuid]
})
},
modalContainerEl () { modalContainerEl () {
return this.$el.getRootNode().querySelector('#docuseal_modal_container') return this.$el.getRootNode().querySelector('#docuseal_modal_container')
}, },
@@ -607,7 +693,7 @@ export default {
} }
}, },
scrollToFirstArea () { scrollToFirstArea () {
return this.field.areas?.[0] && this.$emit('scroll-to', this.field.areas[0]) return this.sortedAreas[0] && this.$emit('scroll-to', this.sortedAreas[0])
}, },
closeDropdown () { closeDropdown () {
document.activeElement.blur() document.activeElement.blur()
@@ -625,7 +711,12 @@ export default {
}, },
removeOption (option) { removeOption (option) {
this.field.options.splice(this.field.options.indexOf(option), 1) 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)
const optionIndex = this.field.areas.findIndex((a) => a.option_uuid === option.uuid)
if (optionIndex !== -1) {
this.field.areas.splice(this.field.areas.findIndex((a) => a.option_uuid === option.uuid), 1)
}
this.save() this.save()
}, },
+4 -4
View File
@@ -2,7 +2,7 @@
<div :class="withStickySubmitters ? 'sticky top-0 z-10' : ''"> <div :class="withStickySubmitters ? 'sticky top-0 z-10' : ''">
<FieldSubmitter <FieldSubmitter
:model-value="selectedSubmitter.uuid" :model-value="selectedSubmitter.uuid"
class="w-full rounded-lg" class="roles-dropdown w-full rounded-lg"
:class="{ 'bg-base-100': withStickySubmitters }" :class="{ 'bg-base-100': withStickySubmitters }"
:submitters="submitters" :submitters="submitters"
:editable="editable && !defaultSubmitters.length" :editable="editable && !defaultSubmitters.length"
@@ -14,7 +14,7 @@
</div> </div>
<div <div
ref="fields" ref="fields"
class="mb-1 mt-2" class="fields mb-1 mt-2"
@dragover.prevent="onFieldDragover" @dragover.prevent="onFieldDragover"
@drop="reorderFields" @drop="reorderFields"
> >
@@ -43,7 +43,7 @@
<div <div
:style="{ backgroundColor: backgroundColor }" :style="{ backgroundColor: backgroundColor }"
draggable="true" draggable="true"
class="border border-base-300 rounded rounded-tr-none relative group mb-2" class="default-field border border-base-300 rounded rounded-tr-none relative group mb-2"
@dragstart="onDragstart({ type: 'text', ...field })" @dragstart="onDragstart({ type: 'text', ...field })"
@dragend="$emit('drag-end')" @dragend="$emit('drag-end')"
> >
@@ -74,7 +74,7 @@
<button <button
v-if="(fieldTypes.length === 0 || fieldTypes.includes(type)) && (withPhone || type != 'phone') && (withPayment || type != 'payment')" v-if="(fieldTypes.length === 0 || fieldTypes.includes(type)) && (withPhone || type != 'phone') && (withPayment || type != 'payment')"
draggable="true" draggable="true"
class="group flex items-center justify-center border border-dashed border-base-300 hover:border-base-content/20 w-full rounded relative" class="field-type-button group flex items-center justify-center border border-dashed border-base-300 hover:border-base-content/20 w-full rounded relative"
:style="{ backgroundColor: backgroundColor }" :style="{ backgroundColor: backgroundColor }"
@dragstart="onDragstart({ type: type })" @dragstart="onDragstart({ type: type })"
@dragend="$emit('drag-end')" @dragend="$emit('drag-end')"
+7
View File
@@ -1,4 +1,11 @@
const en = { const en = {
align: 'Align',
left: 'Left',
right: 'Right',
center: 'Center',
description: 'Description',
display_title: 'Display title',
with_logo: 'With logo',
unchecked: 'Unchecked', unchecked: 'Unchecked',
equal: 'Equal', equal: 'Equal',
not_equal: 'Not equal', not_equal: 'Not equal',
+10 -10
View File
@@ -162,8 +162,8 @@ export default {
}, },
onDrop (e) { onDrop (e) {
this.$emit('drop-field', { this.$emit('drop-field', {
x: e.layerX, x: e.offsetX,
y: e.layerY, y: e.offsetY,
maskW: this.$refs.mask.clientWidth, maskW: this.$refs.mask.clientWidth,
maskH: this.$refs.mask.clientHeight, maskH: this.$refs.mask.clientHeight,
page: this.number page: this.number
@@ -186,10 +186,10 @@ export default {
this.$nextTick(() => { this.$nextTick(() => {
this.newArea = { this.newArea = {
initialX: e.layerX / this.$refs.mask.clientWidth, initialX: e.offsetX / this.$refs.mask.clientWidth,
initialY: e.layerY / this.$refs.mask.clientHeight, initialY: e.offsetY / this.$refs.mask.clientHeight,
x: e.layerX / this.$refs.mask.clientWidth, x: e.offsetX / this.$refs.mask.clientWidth,
y: e.layerY / this.$refs.mask.clientHeight, y: e.offsetY / this.$refs.mask.clientHeight,
w: 0, w: 0,
h: 0 h: 0
} }
@@ -197,19 +197,19 @@ export default {
}, },
onPointermove (e) { onPointermove (e) {
if (this.newArea) { if (this.newArea) {
const dx = e.layerX / this.$refs.mask.clientWidth - this.newArea.initialX const dx = e.offsetX / this.$refs.mask.clientWidth - this.newArea.initialX
const dy = e.layerY / this.$refs.mask.clientHeight - this.newArea.initialY const dy = e.offsetY / this.$refs.mask.clientHeight - this.newArea.initialY
if (dx > 0) { if (dx > 0) {
this.newArea.x = this.newArea.initialX this.newArea.x = this.newArea.initialX
} else { } else {
this.newArea.x = e.layerX / this.$refs.mask.clientWidth this.newArea.x = e.offsetX / this.$refs.mask.clientWidth
} }
if (dy > 0) { if (dy > 0) {
this.newArea.y = this.newArea.initialY this.newArea.y = this.newArea.initialY
} else { } else {
this.newArea.y = e.layerY / this.$refs.mask.clientHeight this.newArea.y = e.offsetY / this.$refs.mask.clientHeight
} }
if (this.drawField?.type === 'cells') { if (this.drawField?.type === 'cells') {
+2 -3
View File
@@ -3,10 +3,9 @@
<head> <head>
<title></title> <title></title>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8"> <meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<style> <style></style>
</style>
</head> </head>
<body dir="<%= yield&.match?(TextUtils::RTL_REGEXP) ? 'rtl' : 'auto' %>"> <body dir="<%= TextUtils.rtl?(yield) ? 'rtl' : 'auto' %>">
<%= yield %> <%= yield %>
<%= render partial: 'shared/mailer_attribution' %> <%= render partial: 'shared/mailer_attribution' %>
</body> </body>
@@ -0,0 +1 @@
<%= render 'form_toggle_options' %>
@@ -0,0 +1,14 @@
<% account_config = AccountConfig.where(account: current_account, key: AccountConfig::FORM_WITH_CONFETTI_KEY).first_or_initialize(value: true) %>
<% if can?(:manage, account_config) %>
<div class="px-1 mt-2">
<%= form_for account_config, url: account_configs_path, method: :post do |f| %>
<%= f.hidden_field :key %>
<div class="flex items-center justify-between py-2.5">
<span>
Show confetti on successful completion
</span>
<%= f.check_box :value, { class: 'toggle', checked: account_config.value != false, onchange: 'this.form.requestSubmit()' }, '1', '0' %>
</div>
<% end %>
</div>
<% end %>
@@ -9,8 +9,9 @@
</div> </div>
<p class="text-4xl font-bold mb-4 mt-8">Company Logo</p> <p class="text-4xl font-bold mb-4 mt-8">Company Logo</p>
<%= render 'logo_form' %> <%= render 'logo_form' %>
<p class="text-4xl font-bold mb-4 mt-8">Submitter Form</p> <p class="text-4xl font-bold mb-4 mt-8">Submission Form</p>
<%= render 'form_completed_button_form' %> <%= render 'form_completed_button_form' %>
<%= render 'form_customization_settings' %>
</div> </div>
<div class="w-0 md:w-52"></div> <div class="w-0 md:w-52"></div>
</div> </div>
+34 -32
View File
@@ -31,40 +31,42 @@
</div> </div>
<% end %> <% end %>
<a href="<%= edit_user_signature_path %>" data-turbo-frame="modal" class="base-button w-full">Update Signature</a> <a href="<%= edit_user_signature_path %>" data-turbo-frame="modal" class="base-button w-full">Update Signature</a>
<p class="text-2xl font-bold mt-8 mb-4">Change Password</p> <% if true_user == current_user && !current_account.testing? %>
<%= form_for current_user, url: update_password_settings_profile_index_path, method: :patch, html: { autocomplete: 'off', class: 'space-y-4' } do |f| %> <p class="text-2xl font-bold mt-8 mb-4">Change Password</p>
<div class="form-control"> <%= form_for current_user, url: update_password_settings_profile_index_path, method: :patch, html: { autocomplete: 'off', class: 'space-y-4' } do |f| %>
<%= f.label :password, 'New password', class: 'label' %> <div class="form-control">
<%= f.password_field :password, autocomplete: 'off', class: 'base-input' %> <%= f.label :password, 'New password', class: 'label' %>
</div> <%= f.password_field :password, autocomplete: 'off', class: 'base-input' %>
<div class="form-control"> </div>
<%= f.label :password_confirmation, 'Confirm new password', class: 'label' %> <div class="form-control">
<%= f.password_field :password_confirmation, autocomplete: 'off', class: 'base-input' %> <%= f.label :password_confirmation, 'Confirm new password', class: 'label' %>
</div> <%= f.password_field :password_confirmation, autocomplete: 'off', class: 'base-input' %>
<div class="form-control pt-2"> </div>
<%= f.button button_title(title: 'Update', disabled_with: 'Updating'), class: 'base-button' %> <div class="form-control pt-2">
<%= f.button button_title(title: 'Update', disabled_with: 'Updating'), class: 'base-button' %>
</div>
<% end %>
<p class="text-2xl font-bold mt-8 mb-4">Two-Factor Authentication</p>
<div class="space-y-4">
<% if current_user.otp_required_for_login %>
<p class="flex items-center space-x-1">
<%= svg_icon('circle_check', class: 'stroke-success inline flex-none w-5 h-5') %>
<span>
2FA has been configured.
</span>
</p>
<a href="<%= edit_mfa_setup_path %>" data-turbo-frame="modal" class="white-button w-full !px-8">🔓 Remove 2FA</a>
<% else %>
<p class="flex items-center space-x-1">
<%= svg_icon('info_circle', class: 'stroke-warning inline flex-none w-5 h-5') %>
<span>
2FA is not configured.
</span>
</p>
<a href="<%= new_mfa_setup_path %>" data-turbo-frame="modal" class="base-button w-full !px-8">🔒 Set up 2FA</a>
<% end %>
</div> </div>
<% end %> <% end %>
<p class="text-2xl font-bold mt-8 mb-4">Two-Factor Authentication</p>
<div class="space-y-4">
<% if current_user.otp_required_for_login %>
<p class="flex items-center space-x-1">
<%= svg_icon('circle_check', class: 'stroke-success inline flex-none w-5 h-5') %>
<span>
2FA has been configured.
</span>
</p>
<a href="<%= edit_mfa_setup_path %>" data-turbo-frame="modal" class="white-button w-full !px-8">🔓 Remove 2FA</a>
<% else %>
<p class="flex items-center space-x-1">
<%= svg_icon('info_circle', class: 'stroke-warning inline flex-none w-5 h-5') %>
<span>
2FA is not configured.
</span>
</p>
<a href="<%= new_mfa_setup_path %>" data-turbo-frame="modal" class="base-button w-full !px-8">🔒 Set up 2FA</a>
<% end %>
</div>
</div> </div>
<div class="w-0 md:w-52"></div> <div class="w-0 md:w-52"></div>
</div> </div>
+2 -2
View File
@@ -67,7 +67,7 @@
</li> </li>
<% end %> <% end %>
<%= render 'shared/settings_nav_extra' %> <%= render 'shared/settings_nav_extra' %>
<% if !Docuseal.demo? && can?(:manage, EncryptedConfig) %> <% if !Docuseal.demo? && can?(:manage, EncryptedConfig) && (current_user != true_user || !current_account.testing?) %>
<li> <li>
<%= link_to Docuseal.multitenant? ? console_redirect_index_path(redir: "#{Docuseal::CONSOLE_URL}/plans") : "#{Docuseal::CLOUD_URL}/sign_up?#{{ redir: "#{Docuseal::CONSOLE_URL}/on_premise" }.to_query}", class: 'text-base hover:bg-base-300', data: { prefetch: false } do %> <%= link_to Docuseal.multitenant? ? console_redirect_index_path(redir: "#{Docuseal::CONSOLE_URL}/plans") : "#{Docuseal::CLOUD_URL}/sign_up?#{{ redir: "#{Docuseal::CONSOLE_URL}/on_premise" }.to_query}", class: 'text-base hover:bg-base-300', data: { prefetch: false } do %>
Plans Plans
@@ -75,7 +75,7 @@
<% end %> <% end %>
</li> </li>
<% end %> <% end %>
<% if !Docuseal.demo? && can?(:manage, EncryptedConfig) %> <% if !Docuseal.demo? && can?(:manage, EncryptedConfig) && (current_user != true_user || !current_account.testing?) %>
<li> <li>
<%= 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 %> <%= 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 %> <% if Docuseal.multitenant? %> API <% else %> Console <% end %>
+7 -5
View File
@@ -1,11 +1,13 @@
<%= form_for '', url: template_submissions_path(template), html: { class: 'space-y-4', autocomplete: 'off' }, data: { turbo_frame: :_top } do |f| %> <%= form_for '', url: template_submissions_path(template), html: { class: 'space-y-4', autocomplete: 'off' }, data: { turbo_frame: :_top } do |f| %>
<% if template.submitters.size == 1 %> <% if template.submitters.size == 1 %>
<submitter-item class="form-control"> <submitter-item class="form-control">
<submitters-autocomplete data-field="email"> <emails-textarea data-bulk-enabled="<%= Docuseal.demo? || !Docuseal.multitenant? || can?(:manage, :bulk_send) %>" data-limit="<%= Docuseal.multitenant? ? (can?(:manage, :bulk_send) ? 50 : 1) : nil %>">
<autoresize-textarea> <submitters-autocomplete data-field="email" class="block relative">
<%= f.text_area :emails, required: true, class: 'base-textarea w-full', placeholder: 'Type emails here...' %> <autoresize-textarea>
</autoresize-textarea> <%= f.text_area :emails, required: true, class: 'base-textarea w-full', placeholder: 'Type emails here...' %>
</submitters-autocomplete> </autoresize-textarea>
</submitters-autocomplete>
</emails-textarea>
</submitter-item> </submitter-item>
<% else %> <% else %>
<dynamic-list class="space-y-4"> <dynamic-list class="space-y-4">
+2 -1
View File
@@ -1,4 +1,5 @@
<field-value dir="auto" 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 %>%"> <% align = field.dig('preferences', 'align') %>
<field-value dir="auto" class="flex absolute text-[1.5vw] lg:text-base <%= align == 'right' ? 'justify-end' : (align == 'center' ? 'justify-center' : '') %>" style="width: <%= area['w'] * 100 %>%; height: <%= area['h'] * 100 %>%; left: <%= area['x'] * 100 %>%; top: <%= area['y'] * 100 %>%">
<% if field['type'].in?(['signature', 'image', 'initials', 'stamp']) %> <% if field['type'].in?(['signature', 'image', 'initials', 'stamp']) %>
<img class="object-contain mx-auto" src="<%= attachments_index[value].url %>" loading="lazy"> <img class="object-contain mx-auto" src="<%= attachments_index[value].url %>" loading="lazy">
<% elsif field['type'].in?(['file', 'payment']) %> <% elsif field['type'].in?(['file', 'payment']) %>
@@ -0,0 +1 @@
<%= auto_link(simple_format(h(ReplaceEmailVariables.call(local_assigns[:content], submitter: local_assigns[:submitter], sig: local_assigns[:sig])))) %>
@@ -1,5 +1,5 @@
<% if @email_config %> <% if @email_config %>
<%= auto_link(simple_format(h(ReplaceEmailVariables.call(@email_config.value['body'], submitter: @submitter)))) %> <%= render 'custom_content', content: @email_config.value['body'], submitter: @submitter %>
<% else %> <% else %>
<p>Hi there,</p> <p>Hi there,</p>
<p>"<%= @submitter.submission.template.name %>" form has been completed by <%= @submitter.submission.submitters.order(:completed_at).map { |e| e.name || e.email || e.phone }.join(', ') %>.</p> <p>"<%= @submitter.submission.template.name %>" form has been completed by <%= @submitter.submission.submitters.order(:completed_at).map { |e| e.name || e.email || e.phone }.join(', ') %>.</p>
@@ -1,5 +1,5 @@
<% if @email_config %> <% if @email_config %>
<%= auto_link(simple_format(h(ReplaceEmailVariables.call(@email_config.value['body'], submitter: @submitter, sig: @sig)))) %> <%= render 'custom_content', content: @email_config.value['body'], submitter: @submitter, sig: @sig %>
<% else %> <% else %>
<p>Hi there,</p> <p>Hi there,</p>
<p>Please check the copy of your "<%= @submitter.submission.template.name %>" submission in the email attachments.</p> <p>Please check the copy of your "<%= @submitter.submission.template.name %>" submission in the email attachments.</p>
@@ -1,6 +1,6 @@
<% if @email_config || @body.present? %> <% if @email_config || @body.present? %>
<% body = (@body.presence || @email_config.value['body']).to_s %> <% body = (@body.presence || @email_config.value['body']).to_s %>
<%= auto_link(simple_format(h(ReplaceEmailVariables.call(body, submitter: @submitter)))) %> <%= render 'custom_content', content: body, submitter: @submitter %>
<% if !body.include?(ReplaceEmailVariables::SUBMITTER_LINK) && !body.include?(ReplaceEmailVariables::SUBMITTER_ID) && !body.include?(ReplaceEmailVariables::SUBMISSION_LINK) && !body.include?(ReplaceEmailVariables::TEMPLATE_ID) && !@submitter.submission.source.in?(%w[api embed]) %> <% if !body.include?(ReplaceEmailVariables::SUBMITTER_LINK) && !body.include?(ReplaceEmailVariables::SUBMITTER_ID) && !body.include?(ReplaceEmailVariables::SUBMISSION_LINK) && !body.include?(ReplaceEmailVariables::TEMPLATE_ID) && !@submitter.submission.source.in?(%w[api embed]) %>
<p><%= link_to nil, submit_form_url(slug: @submitter.slug, t: SubmissionEvents.build_tracking_param(@submitter, 'click_email')) %></p> <p><%= link_to nil, submit_form_url(slug: @submitter.slug, t: SubmissionEvents.build_tracking_param(@submitter, 'click_email')) %></p>
<% end %> <% end %>
+1 -1
View File
@@ -128,7 +128,7 @@ Rails.application.configure do
config.lograge.formatter = ->(data) { data.except(:path, :location).to_json } config.lograge.formatter = ->(data) { data.except(:path, :location).to_json }
config.lograge.custom_payload do |controller| config.lograge.custom_payload do |controller|
params = controller.request.params || {} params = controller.request.try(:params) || {}
{ {
fwd: controller.request.ip, fwd: controller.request.ip,
+1 -1
View File
@@ -69,7 +69,7 @@ Rails.configuration.to_prepare do
LoadActiveStorageConfigs.call LoadActiveStorageConfigs.call
rescue StandardError => e rescue StandardError => e
Rails.logger.error(e) Rails.logger.error(e) unless Rails.env.production?
nil nil
end end
+30 -1
View File
@@ -1,8 +1,8 @@
# frozen_string_literal: true # frozen_string_literal: true
# fix NoMethodError: undefined method `field_value' for #<HexaPDF::Type::AcroForm::Field
module HexaPDF module HexaPDF
module Type module Type
# fix NoMethodError: undefined method `field_value' for #<HexaPDF::Type::AcroForm::Field
module AcroForm module AcroForm
class Field class Field
def field_value def field_value
@@ -10,5 +10,34 @@ module HexaPDF
end end
end end
end end
# comparison of Integer with HexaPDF::PDFArray failed
class CIDFont < Font
private
def widths
cache(:widths) do
result = {}
index = 0
array = self[:W] || []
while index < array.size
entry = array[index]
value = array[index + 1]
if value.is_a?(Array) || value.is_a?(HexaPDF::PDFArray)
value.each_with_index { |width, i| result[entry + i] = width }
index += 2
else
width = array[index + 2]
entry.upto(value) { |cid| result[cid] = width }
index += 3
end
end
result
end
end
end
end end
end end
+1
View File
@@ -55,6 +55,7 @@ Rails.application.routes.draw do
resource :user_signature, only: %i[edit update destroy] resource :user_signature, only: %i[edit update destroy]
resources :submissions, only: %i[show destroy] resources :submissions, only: %i[show destroy]
resources :console_redirect, only: %i[index] resources :console_redirect, only: %i[index]
resources :upgrade, only: %i[index], controller: 'console_redirect'
resource :testing_account, only: %i[show destroy] resource :testing_account, only: %i[show destroy]
resources :testing_api_settings, only: %i[index] resources :testing_api_settings, only: %i[index]
resource :templates_upload, only: %i[create] resource :templates_upload, only: %i[create]
+1 -1
View File
@@ -32,7 +32,7 @@ module Accounts
end end
def find_or_create_testing_user(account) def find_or_create_testing_user(account)
user = User.find_by(account: account.testing_accounts) user = User.where(role: :admin).order(:id).find_by(account: account.testing_accounts)
return user if user return user if user
+19
View File
@@ -0,0 +1,19 @@
# frozen_string_literal: true
module RateLimit
LimitApproached = Class.new(StandardError)
STORE = ActiveSupport::Cache::MemoryStore.new
module_function
def call(key, limit:, ttl:, enabled: Docuseal.multitenant?)
return true unless enabled
value = STORE.increment(key, 1, expires_in: ttl)
raise LimitApproached if value > limit
true
end
end
+29 -21
View File
@@ -5,6 +5,8 @@ module ReplaceEmailVariables
TEMPLATE_ID = '{{template.id}}' TEMPLATE_ID = '{{template.id}}'
SUBMITTER_LINK = '{{submitter.link}}' SUBMITTER_LINK = '{{submitter.link}}'
ACCOUNT_NAME = '{{account.name}}' ACCOUNT_NAME = '{{account.name}}'
SENDER_NAME = '{{sender.name}}'
SENDER_EMAIL = '{{sender.email}}'
SUBMITTER_EMAIL = '{{submitter.email}}' SUBMITTER_EMAIL = '{{submitter.email}}'
SUBMITTER_NAME = '{{submitter.name}}' SUBMITTER_NAME = '{{submitter.name}}'
SUBMITTER_ID = '{{submitter.id}}' SUBMITTER_ID = '{{submitter.id}}'
@@ -18,29 +20,25 @@ module ReplaceEmailVariables
module_function module_function
# rubocop:disable Metrics # rubocop:disable Metrics
def call(text, submitter:, tracking_event_type: 'click_email', sig: nil) def call(text, submitter:, tracking_event_type: 'click_email', html_escape: false, sig: nil)
submitter_link = build_submitter_link(submitter, tracking_event_type) text = replace(text, TEMPLATE_NAME, html_escape:) { submitter.template.name }
text = replace(text, TEMPLATE_ID, html_escape:) { submitter.template.id }
submission_link = build_submission_link(submitter.submission) if submitter.submission text = replace(text, SUBMITTER_ID, html_escape:) { submitter.id }
text = replace(text, SUBMITTER_SLUG, html_escape:) { submitter.slug }
text = text.gsub(TEMPLATE_NAME, submitter.template.name) if submitter.template text = replace(text, SUBMISSION_ID, html_escape:) { submitter.submission.id }
text = text.gsub(TEMPLATE_ID, submitter.template.id.to_s) if submitter.template text = replace(text, SUBMITTER_EMAIL, html_escape:) { submitter.email }
text = text.gsub(SUBMITTER_ID, submitter.id.to_s) text = replace(text, SUBMITTER_NAME, html_escape:) { submitter.name || submitter.email || submitter.phone }
text = text.gsub(SUBMITTER_SLUG, submitter.slug.to_s) text = replace(text, SUBMITTER_LINK, html_escape:) { build_submitter_link(submitter, tracking_event_type) }
text = text.gsub(SUBMISSION_ID, submitter.submission.id.to_s) if submitter.submission text = replace(text, SUBMISSION_LINK, html_escape:) do
text = text.gsub(SUBMITTER_EMAIL, submitter.email) if submitter.email submitter.submission ? build_submission_link(submitter.submission) : ''
text = text.gsub(SUBMITTER_NAME, submitter.name || submitter.email || submitter.phone)
text = text.gsub(SUBMITTER_LINK, submitter_link)
text = text.gsub(SUBMISSION_LINK, submission_link) if submission_link
if text.include?(SUBMISSION_SUBMITTERS)
text = text.gsub(SUBMISSION_SUBMITTERS, build_submission_submitters(submitter.submission))
end end
text = text.gsub(DOCUMENTS_LINKS, build_documents_links_text(submitter, sig)) text = replace(text, SUBMISSION_SUBMITTERS, html_escape:) { build_submission_submitters(submitter.submission) }
text = text.gsub(DOCUMENTS_LINK, build_documents_links_text(submitter, sig)) text = replace(text, DOCUMENTS_LINKS, html_escape:) { build_documents_links_text(submitter, sig) }
text = replace(text, DOCUMENTS_LINK, html_escape:) { build_documents_links_text(submitter, sig) }
text = replace(text, ACCOUNT_NAME, html_escape:) { submitter.submission.account.name }
text = replace(text, SENDER_NAME, html_escape:) { submitter.submission.created_by_user&.full_name }
text = text.gsub(ACCOUNT_NAME, submitter.account.name) if submitter.account replace(text, SENDER_EMAIL, html_escape:) { submitter.submission.created_by_user&.email.to_s.sub(/\+\w+@/, '@') }
text
end end
# rubocop:enable Metrics # rubocop:enable Metrics
@@ -50,6 +48,16 @@ module ReplaceEmailVariables
) )
end end
def replace(text, var, html_escape: false)
text.gsub(var) do
if html_escape
ERB::Util.html_escape(yield)
else
yield
end
end
end
def build_submitter_link(submitter, tracking_event_type) def build_submitter_link(submitter, tracking_event_type)
if tracking_event_type == 'click_email' if tracking_event_type == 'click_email'
Rails.application.routes.url_helpers.submit_form_url( Rails.application.routes.url_helpers.submit_form_url(
+8 -3
View File
@@ -125,9 +125,14 @@ module Submissions
end end
def find_submitter_uuid(template, attrs, index) def find_submitter_uuid(template, attrs, index)
attrs[:uuid].presence || uuid = attrs[:uuid].presence
template.submitters.find { |e| e['name'] == attrs[:role] }&.dig('uuid') || uuid ||= template.submitters.find { |e| e['name'].to_s.casecmp(attrs[:role].to_s).zero? }&.dig('uuid')
template.submitters[index]&.dig('uuid')
if attrs[:role].present? && uuid.blank? && defined?(Rollbar)
Rollbar.error("Role doesn't existng: #{attrs[:role]}, #{template.id}")
end
uuid || template.submitters[index]&.dig('uuid')
end end
def build_submitter(submission:, attrs:, uuid:, is_order_sent:, mark_as_sent:, user:, preferences:) def build_submitter(submission:, attrs:, uuid:, is_order_sent:, mark_as_sent:, user:, preferences:)
+4 -1
View File
@@ -33,7 +33,10 @@ module Submissions
sleep WAIT_FOR_RETRY sleep WAIT_FOR_RETRY
retry retry
rescue StandardError rescue StandardError => e
Rollbar.error(e) if defined?(Rollbar)
Rails.logger.error(e)
submitter.document_generation_events.create!(event_name: :fail) submitter.document_generation_events.create!(event_name: :fail)
raise raise
+6 -2
View File
@@ -41,6 +41,8 @@ module Submissions
composer = HexaPDF::Composer.new(skip_page_creation: true) composer = HexaPDF::Composer.new(skip_page_creation: true)
composer.document.fonts.add(FONT_BOLD_NAME, variant: :bold) composer.document.fonts.add(FONT_BOLD_NAME, variant: :bold)
composer.document.config['font.on_missing_glyph'] =
Submissions::GenerateResultAttachments.method(:on_missing_glyph).to_proc
divider = HexaPDF::Layout::Box.create( divider = HexaPDF::Layout::Box.create(
margin: [0, 0, 15, 0], margin: [0, 0, 15, 0],
@@ -90,7 +92,10 @@ module Submissions
last_submitter = submission.submitters.where.not(completed_at: nil).order(:completed_at).last last_submitter = submission.submitters.where.not(completed_at: nil).order(:completed_at).last
documents_data = Submitters.select_attachments_for_download(last_submitter).map do |document| documents_data = Submitters.select_attachments_for_download(last_submitter).map do |document|
original_documents = submission.template.documents.select { |e| e.uuid == document.uuid }.presence original_documents = submission.template.documents.select do |e|
e.uuid == (document.metadata['original_uuid'] || document.uuid)
end.presence
original_documents ||= submission.template.documents.select do |e| original_documents ||= submission.template.documents.select do |e|
e.image? && submission.template_schema.any? do |item| e.image? && submission.template_schema.any? do |item|
item['attachment_uuid'] == e.uuid item['attachment_uuid'] == e.uuid
@@ -242,7 +247,6 @@ module Submissions
end end
value = value.join(', ') if value.is_a?(Array) value = value.join(', ') if value.is_a?(Array)
value = Submissions::GenerateResultAttachments::REPLACE_EMOJI[value] || value
composer.formatted_text_box([{ text: TextUtils.maybe_rtl_reverse(value.to_s.presence || 'n/a') }], composer.formatted_text_box([{ text: TextUtils.maybe_rtl_reverse(value.to_s.presence || 'n/a') }],
text_align: value.to_s.match?(RTL_REGEXP) ? :right : :left, text_align: value.to_s.match?(RTL_REGEXP) ? :right : :left,
+69 -24
View File
@@ -21,9 +21,18 @@ module Submissions
A4_SIZE = [595, 842].freeze A4_SIZE = [595, 842].freeze
SUPPORTED_IMAGE_TYPES = ['image/png', 'image/jpeg'].freeze SUPPORTED_IMAGE_TYPES = ['image/png', 'image/jpeg'].freeze
REPLACE_EMOJI = { MISSING_GLYPH_REPLACE = {
'▪' => '-',
'✔️' => 'V', '✔️' => 'V',
'✔' => 'V' '✔' => 'V',
'✅' => 'V'
}.freeze
MISSING_GLYPH_REPLACE_TYPE1 = {
'▪' => :bullet,
'✔️' => :V,
'✔' => :V,
'✅' => :V
}.freeze }.freeze
module_function module_function
@@ -62,12 +71,14 @@ module Submissions
width = page.box.width width = page.box.width
height = page.box.height height = page.box.height
font_size = ((page.box.width / A4_SIZE[0].to_f) * FONT_SIZE).to_i font_size = (([page.box.width, page.box.height].min / A4_SIZE[0].to_f) * FONT_SIZE).to_i
value = submitter.values[field['uuid']] value = submitter.values[field['uuid']]
layouter = HexaPDF::Layout::TextLayouter.new(text_valign: :center, text_align = field.dig('preferences', 'align').to_s.to_sym.presence ||
text_align: value.to_s.match?(RTL_REGEXP) ? :right : :left, (value.to_s.match?(RTL_REGEXP) ? :right : :left)
layouter = HexaPDF::Layout::TextLayouter.new(text_valign: :center, text_align:,
font: pdf.fonts.add(FONT_NAME), font_size:) font: pdf.fonts.add(FONT_NAME), font_size:)
next if Array.wrap(value).compact_blank.blank? next if Array.wrap(value).compact_blank.blank?
@@ -181,7 +192,6 @@ module Submissions
end end
value = TextUtils.maybe_rtl_reverse(Array.wrap(value).join(', ')) value = TextUtils.maybe_rtl_reverse(Array.wrap(value).join(', '))
value = REPLACE_EMOJI[value] || value
text = HexaPDF::Layout::TextFragment.create(value, font: pdf.fonts.add(FONT_NAME), text = HexaPDF::Layout::TextFragment.create(value, font: pdf.fonts.add(FONT_NAME),
font_size:) font_size:)
@@ -194,15 +204,23 @@ module Submissions
font: pdf.fonts.add(FONT_NAME), font: pdf.fonts.add(FONT_NAME),
font_size: (font_size / 1.4).to_i) font_size: (font_size / 1.4).to_i)
lines = layouter.fit([text], area['w'] * width, height).lines lines = layouter.fit([text], field['type'].in?(%w[date number]) ? width : area['w'] * width, height).lines
box_height = lines.sum(&:height) box_height = lines.sum(&:height)
end end
height_diff = [0, box_height - (area['h'] * height)].max height_diff = [0, box_height - (area['h'] * height)].max
layouter.fit([text], area['w'] * width, height_diff.positive? ? box_height : area['h'] * height) right_align_x_adjustment =
.draw(canvas, (area['x'] * width) + TEXT_LEFT_MARGIN, if field['type'].in?(%w[date number]) && text_align != :left
(width - (area['w'] * width)) / (text_align == :center ? 2.0 : 1)
else
0
end
layouter.fit([text], field['type'].in?(%w[date number]) ? width : area['w'] * width,
height_diff.positive? ? box_height : area['h'] * height)
.draw(canvas, (area['x'] * width) - right_align_x_adjustment + TEXT_LEFT_MARGIN,
height - (area['y'] * height) + height_diff - TEXT_TOP_MARGIN) height - (area['y'] * height) + height_diff - TEXT_TOP_MARGIN)
end end
end end
@@ -211,28 +229,28 @@ module Submissions
image_pdfs = [] image_pdfs = []
original_documents = template.documents.preload(:blob) original_documents = template.documents.preload(:blob)
results = result_attachments =
submitter.submission.template_schema.map do |item| submitter.submission.template_schema.map do |item|
pdf = pdfs_index[item['attachment_uuid']] pdf = pdfs_index[item['attachment_uuid']]
attachment = save_pdf(pdf:, submitter:, pkcs:, tsa_url:, attachment = build_pdf_attachment(pdf:, submitter:, pkcs:, tsa_url:,
uuid: item['attachment_uuid'], uuid: item['attachment_uuid'],
name: item['name']) name: item['name'])
image_pdfs << pdf if original_documents.find { |a| a.uuid == item['attachment_uuid'] }.image? image_pdfs << pdf if original_documents.find { |a| a.uuid == item['attachment_uuid'] }.image?
attachment attachment
end end
return results if image_pdfs.size < 2 return result_attachments.map { |e| e.tap(&:save!) } if image_pdfs.size < 2
images_pdf = images_pdf =
image_pdfs.each_with_object(HexaPDF::Document.new) do |pdf, doc| image_pdfs.each_with_object(HexaPDF::Document.new) do |pdf, doc|
pdf.pages.each { |page| doc.pages << doc.import(page) } pdf.pages.each { |page| doc.pages << doc.import(page) }
end end
images_pdf_result = images_pdf_attachment =
save_pdf( build_pdf_attachment(
pdf: images_pdf, pdf: images_pdf,
submitter:, submitter:,
tsa_url:, tsa_url:,
@@ -241,11 +259,10 @@ module Submissions
name: template.name name: template.name
) )
results + [images_pdf_result] (result_attachments + [images_pdf_attachment]).map { |e| e.tap(&:save!) }
end end
# rubocop:enable Metrics
def save_pdf(pdf:, submitter:, pkcs:, tsa_url:, uuid:, name:) def build_pdf_attachment(pdf:, submitter:, pkcs:, tsa_url:, uuid:, name:)
io = StringIO.new io = StringIO.new
pdf.trailer.info[:Creator] = info_creator pdf.trailer.info[:Creator] = info_creator
@@ -265,12 +282,25 @@ module Submissions
sign_params[:signature_size] = 10_000 sign_params[:signature_size] = 10_000
end end
pdf.sign(io, write_options: { validate: false }, **sign_params) begin
else pdf.sign(io, write_options: { validate: false }, **sign_params)
pdf.write(io, incremental: true, validate: false) rescue HexaPDF::MalformedPDFError => e
end Rollbar.error(e) if defined?(Rollbar)
ActiveStorage::Attachment.create!( pdf.sign(io, write_options: { validate: false, incremental: false }, **sign_params)
end
else
begin
pdf.write(io, incremental: true, validate: false)
rescue HexaPDF::MalformedPDFError => e
Rollbar.error(e) if defined?(Rollbar)
pdf.write(io, incremental: false, validate: false)
end
end
# rubocop:enable Metrics
ActiveStorage::Attachment.new(
blob: ActiveStorage::Blob.create_and_upload!(io: StringIO.new(io.string), filename: "#{name}.pdf"), blob: ActiveStorage::Blob.create_and_upload!(io: StringIO.new(io.string), filename: "#{name}.pdf"),
metadata: { original_uuid: uuid, metadata: { original_uuid: uuid,
analyzed: true, analyzed: true,
@@ -306,10 +336,25 @@ module Submissions
Rollbar.error(e) if defined?(Rollbar) Rollbar.error(e) if defined?(Rollbar)
end end
pdf.config['font.on_missing_glyph'] = method(:on_missing_glyph).to_proc
[attachment.metadata['original_uuid'] || attachment.uuid, pdf] [attachment.metadata['original_uuid'] || attachment.uuid, pdf]
end end
end end
def on_missing_glyph(character, font_wrapper)
Rollbar.error("Missing glyph: #{character}") if character.present? && defined?(Rollbar)
replace_with =
if font_wrapper.font_type == :Type1
MISSING_GLYPH_REPLACE_TYPE1[character] || :space
else
(MISSING_GLYPH_REPLACE[character] || ' ').bytes.first - 29
end
font_wrapper.custom_glyph(replace_with, character)
end
def find_last_submitter(submitter) def find_last_submitter(submitter)
submitter.submission.submitters submitter.submission.submitters
.select(&:completed_at?) .select(&:completed_at?)
+9 -4
View File
@@ -13,8 +13,8 @@ module Submitters
module_function module_function
def call(submitter) def call(submitter, with_logo: true)
image = generate_stamp_image(submitter) image = generate_stamp_image(submitter, with_logo:)
image_data = image.write_to_buffer('.png') image_data = image.write_to_buffer('.png')
@@ -30,8 +30,13 @@ module Submitters
) )
end end
def generate_stamp_image(submitter) def generate_stamp_image(submitter, with_logo: true)
logo = Vips::Image.new_from_buffer(load_logo(submitter).read, '') logo =
if with_logo
Vips::Image.new_from_buffer(load_logo(submitter).read, '')
else
Vips::Image.new_from_buffer(TRANSPARENT_PIXEL, '').resize(WIDTH)
end
logo = logo.resize([WIDTH / logo.width.to_f, HEIGHT / logo.height.to_f].min) logo = logo.resize([WIDTH / logo.width.to_f, HEIGHT / logo.height.to_f].min)
+13 -6
View File
@@ -2,20 +2,27 @@
module Submitters module Submitters
module FormConfigs module FormConfigs
DEFAULT_KEYS = [AccountConfig::FORM_COMPLETED_BUTTON_KEY,
AccountConfig::FORM_WITH_CONFETTI_KEY,
AccountConfig::ALLOW_TYPED_SIGNATURE].freeze
module_function module_function
def call(submitter) def call(submitter, keys = [])
configs = submitter.submission.template.account.account_configs configs = submitter.submission.template.account.account_configs
.where(key: [AccountConfig::FORM_COMPLETED_BUTTON_KEY, .where(key: DEFAULT_KEYS + keys)
AccountConfig::FORM_WITH_CONFETTI_KEY,
AccountConfig::ALLOW_TYPED_SIGNATURE])
completed_button = configs.find { |e| e.key == AccountConfig::FORM_COMPLETED_BUTTON_KEY }&.value || {} completed_button = configs.find { |e| e.key == AccountConfig::FORM_COMPLETED_BUTTON_KEY }&.value || {}
with_typed_signature = configs.find { |e| e.key == AccountConfig::ALLOW_TYPED_SIGNATURE }&.value != false with_typed_signature = configs.find { |e| e.key == AccountConfig::ALLOW_TYPED_SIGNATURE }&.value != false
with_confetti = configs.find { |e| e.key == AccountConfig::FORM_WITH_CONFETTI_KEY }&.value != false with_confetti = configs.find { |e| e.key == AccountConfig::FORM_WITH_CONFETTI_KEY }&.value != false
{ completed_button:, with_typed_signature:, with_confetti: } attrs = { completed_button:, with_typed_signature:, with_confetti: }
keys.each do |key|
attrs[key.to_sym] = configs.find { |e| e.key == key.to_s }&.value
end
attrs
end end
end end
end end
+7 -1
View File
@@ -9,6 +9,7 @@ module Submitters
UnknownFieldName = Class.new(BaseError) UnknownFieldName = Class.new(BaseError)
InvalidDefaultValue = Class.new(BaseError) InvalidDefaultValue = Class.new(BaseError)
UnknownSubmitterName = Class.new(BaseError) UnknownSubmitterName = Class.new(BaseError)
UnableToDownload = Class.new(BaseError)
module_function module_function
@@ -167,7 +168,12 @@ module Submitters
return blob if blob return blob if blob
data = conn.get(Addressable::URI.parse(url).display_uri.to_s).body uri = Addressable::URI.parse(url)
resp = conn.get(uri.display_uri.to_s)
raise UnableToDownload, "Error loading: #{uri.display_uri}" if resp.status >= 400
data = resp.body
checksum = Digest::MD5.base64digest(data) checksum = Digest::MD5.base64digest(data)
+3 -1
View File
@@ -78,7 +78,9 @@ module Submitters
next if field['submitter_uuid'] != submitter.uuid next if field['submitter_uuid'] != submitter.uuid
if field['type'] == 'stamp' if field['type'] == 'stamp'
acc[field['uuid']] ||= Submitters::CreateStampAttachment.call(submitter).uuid acc[field['uuid']] ||=
Submitters::CreateStampAttachment.call(submitter,
with_logo: field.dig('preferences', 'with_logo') != false).uuid
next next
end end
+6 -8
View File
@@ -5,12 +5,10 @@ module Templates
module_function module_function
def call(data) def call(data)
pdf = PDF::Reader.new(StringIO.new(data)) pdf = HexaPDF::Document.new(io: StringIO.new(data))
pdf.pages.flat_map.with_index do |page, index| pdf.pages.flat_map.with_index do |page, index|
annotations = page.objects.deref!(page.attributes[:Annots]) || [] (page[:Annots] || []).filter_map do |annot|
annotations.filter_map do |annot|
next if annot.nil?
next if annot[:A].blank? || annot[:A][:URI].blank? next if annot[:A].blank? || annot[:A][:URI].blank?
next unless annot[:Subtype] == :Link next unless annot[:Subtype] == :Link
next if !annot[:A][:URI].starts_with?('https://') && !annot[:A][:URI].starts_with?('http://') next if !annot[:A][:URI].starts_with?('https://') && !annot[:A][:URI].starts_with?('http://')
@@ -30,10 +28,10 @@ module Templates
{ {
'type' => 'external_link', 'type' => 'external_link',
'value' => annot[:A][:URI], 'value' => annot[:A][:URI],
'x' => left / page.width.to_f, 'x' => left / page.box.width.to_f,
'y' => (page.height - top) / page.height.to_f, 'y' => (page.box.height - top) / page.box.height.to_f,
'w' => (right - left) / page.width.to_f, 'w' => (right - left) / page.box.width.to_f,
'h' => (top - bottom) / page.height.to_f 'h' => (top - bottom) / page.box.height.to_f
} }
end end
end end
+8
View File
@@ -5,6 +5,14 @@ module TextUtils
module_function module_function
def rtl?(text)
return false if text.blank?
text.match?(TextUtils::RTL_REGEXP)
rescue Encoding::CompatibilityError
false
end
def maybe_rtl_reverse(text) def maybe_rtl_reverse(text)
if text.match?(RTL_REGEXP) if text.match?(RTL_REGEXP)
TwitterCldr::Shared::Bidi TwitterCldr::Shared::Bidi
+1
View File
@@ -30,6 +30,7 @@
"sass-loader": "^13.2.2", "sass-loader": "^13.2.2",
"shakapacker": "7.1.0", "shakapacker": "7.1.0",
"signature_pad": "^4.1.5", "signature_pad": "^4.1.5",
"snarkdown": "^2.0.0",
"tailwindcss": "^3.3.2", "tailwindcss": "^3.3.2",
"terser-webpack-plugin": "5.3.8", "terser-webpack-plugin": "5.3.8",
"uuid": "^9.0.0", "uuid": "^9.0.0",
+1
View File
@@ -8,6 +8,7 @@ module.exports = {
'./app/views/start_form/**/*.erb', './app/views/start_form/**/*.erb',
'./app/views/shared/_button_title.html.erb', './app/views/shared/_button_title.html.erb',
'./app/views/shared/_attribution.html.erb', './app/views/shared/_attribution.html.erb',
'./app/views/scripts/_autosize_field.html.erb',
'./app/views/send_submission_email/**/*.erb' './app/views/send_submission_email/**/*.erb'
] ]
} }
+5
View File
@@ -4950,6 +4950,11 @@ sirv@^1.0.7:
mrmime "^1.0.0" mrmime "^1.0.0"
totalist "^1.0.0" totalist "^1.0.0"
snarkdown@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/snarkdown/-/snarkdown-2.0.0.tgz#b1feb4db91b9f94a8ebbd7a50f3e99aee18b1e03"
integrity sha512-MgL/7k/AZdXCTJiNgrO7chgDqaB9FGM/1Tvlcenenb7div6obaDATzs16JhFyHHBGodHT3B7RzRc5qk8pFhg3A==
sockjs@^0.3.24: sockjs@^0.3.24:
version "0.3.24" version "0.3.24"
resolved "https://registry.yarnpkg.com/sockjs/-/sockjs-0.3.24.tgz#c9bc8995f33a111bea0395ec30aa3206bdb5ccce" resolved "https://registry.yarnpkg.com/sockjs/-/sockjs-0.3.24.tgz#c9bc8995f33a111bea0395ec30aa3206bdb5ccce"