Compare commits

..
62 Commits
Author SHA1 Message Date
Alex Turchyn 744d45d2c5 Merge from docusealco/wip 2026-05-04 13:55:19 +03:00
Pete Matsyburka 310b16f54a use mcp source 2026-05-04 09:42:51 +03:00
Pete Matsyburka 6d3ad4a869 update gh stars 2026-05-03 18:37:56 +03:00
Alex Turchyn 1ce2448820 update create_template and send_documents annotations 2026-05-03 17:43:22 +03:00
Pete Matsyburka 07b4ada5be optimize edit json 2026-05-03 16:32:37 +03:00
Pete Matsyburka f70bf530ba hide qr link 2026-05-03 08:19:58 +03:00
Pete Matsyburka a26aa0f411 remove log 2026-05-03 07:27:24 +03:00
Pete Matsyburka 584577c21f fix ed.jp email typo 2026-05-01 13:50:17 +03:00
Pete Matsyburka 1503375eed respond to HEAD file proxy 2026-04-30 17:55:13 +03:00
Pete Matsyburka 2dec099f20 fix signature canvas minimize 2026-04-30 14:51:24 +03:00
Pete Matsyburka bf6eed4ea2 fix canvas error 2026-04-30 14:42:22 +03:00
Pete Matsyburka e0dc33cfb4 fix email domain search 2026-04-30 11:27:17 +03:00
Pete Matsyburka 9c9dc27537 fix new area order 2026-04-30 11:13:35 +03:00
Pete Matsyburka 7305637b8c sort appears on 2026-04-30 10:32:51 +03:00
Pete Matsyburka a6e22adf2e fix preview 2026-04-29 18:00:47 +03:00
Pete Matsyburka 809c6b1aa8 fix modal open 2026-04-29 10:27:02 +03:00
Pete Matsyburka 1536ded2a6 redis error log 2026-04-28 19:30:51 +03:00
Pete Matsyburka 76d54971ce fix test mode clone 2026-04-28 11:58:17 +03:00
Pete Matsyburka 6dec56198c add require email 2fa to submitter update 2026-04-28 11:58:17 +03:00
Pete Matsyburka d96d252df3 use post for test mode 2026-04-28 11:58:17 +03:00
Pete Matsyburka 3fca76a716 change state param 2026-04-28 11:58:17 +03:00
Pete Matsyburka a184fa11f1 move auth links 2026-04-28 11:58:17 +03:00
Pete Matsyburka e9c0b6a703 rate limit 2026-04-28 11:58:17 +03:00
Pete Matsyburka ec7df08496 adjust send submission email 2026-04-28 11:58:17 +03:00
Pete Matsyburka 6627d7eac5 fix erblint 2026-04-27 15:34:57 +03:00
Pete Matsyburka 1f8945d710 fix expired filter 2026-04-27 15:31:29 +03:00
Alex Turchyn daaa289a5c Merge from docusealco/wip 2026-04-27 13:43:34 +03:00
Pete Matsyburka 81a6a48d70 validate color 2026-04-26 15:13:19 +03:00
Pete Matsyburka b93c7cd261 add has_many document_metadata 2026-04-26 10:24:20 +03:00
Pete Matsyburka 570482e12d rename error 2026-04-25 12:39:26 +03:00
Pete Matsyburka 39407557f2 adjust page preview 2026-04-25 09:01:53 +03:00
Pete Matsyburka 3b040d558d adjust compression 2026-04-24 18:39:19 +03:00
Alex Turchyn 56ba3c2a52 add custom classes 2026-04-24 17:01:33 +03:00
Pete Matsyburka c7afe33c72 add time formats 2026-04-24 14:01:28 +03:00
Pete Matsyburka 82500c2d7d update dep 2026-04-24 10:56:19 +03:00
Pete Matsyburka fb1c1f117b remove unused const 2026-04-24 10:56:19 +03:00
Pete Matsyburka 7c6a7513fd add with custom field tab 2026-04-24 09:59:34 +03:00
Pete Matsyburka 6d13119ee0 input mode condition and formula 2026-04-22 10:58:50 +03:00
Pete Matsyburka 1e2c752937 detect existing fields 2026-04-22 10:27:18 +03:00
Pete Matsyburka e82faf8320 queue page field detection 2026-04-22 07:43:45 +03:00
Pete Matsyburka 4adb1001b0 add rotate incremental config 2026-04-21 12:35:00 +03:00
Pete Matsyburka 992a1b26c0 adjust tooltip position 2026-04-20 20:18:07 +03:00
Pete Matsyburka c07bc6687c add metadata param 2026-04-20 15:43:41 +03:00
Alex Turchyn eea44bda34 Merge from docusealco/wip 2026-04-20 13:57:33 +03:00
Pete Matsyburka 7cdf263da1 add screen reader mode 2026-04-19 20:18:52 +03:00
Pete Matsyburka ee65a5693c improve accessibility 2026-04-19 10:08:16 +03:00
Pete Matsyburka 0af6ccf35f pipeline field detection 2026-04-19 09:01:08 +03:00
Pete Matsyburka 888f1ec6df adjust mcp 2026-04-18 08:33:42 +03:00
Pete Matsyburka c95a8616ac deduplicate submitter uuids 2026-04-17 22:01:03 +03:00
Pete Matsyburka 5ea6289b7a fix test mode modal 2026-04-16 09:00:43 +03:00
Pete Matsyburka d4a79ca5db adjust dynamic editor 2026-04-15 10:06:41 +03:00
Pete Matsyburka 70015ce1c4 adjust dynamic editor 2026-04-14 14:14:25 +03:00
Pete Matsyburka 6b85c28944 add error message 2026-04-14 10:51:12 +03:00
Pete Matsyburka 6c289cf273 optimize build 2026-04-14 09:37:27 +03:00
Pete Matsyburka a64bc3c618 change button style 2026-04-14 09:21:39 +03:00
Pete Matsyburka 46cf1e3067 add log 2026-04-14 09:13:33 +03:00
Pete Matsyburka 565e1eb2bc adjust validation 2026-04-13 19:55:07 +03:00
Pete Matsyburka e689687805 fix erblint 2026-04-13 19:21:24 +03:00
Pete Matsyburka 3c3b61fb47 adjust reason field 2026-04-13 19:18:55 +03:00
Pete Matsyburka 1355d350c5 fix xlsx boolean value 2026-04-13 18:13:38 +03:00
Pete Matsyburka fda911e178 add submitter field validation 2026-04-13 16:32:03 +03:00
Pete Matsyburka f995e1864c add shared link qr code 2026-04-13 13:59:00 +03:00
120 changed files with 2890 additions and 590 deletions

No files matched your search

+1 -1
View File
@@ -28,7 +28,7 @@ Lint/MissingSuper:
Enabled: false
Metrics/ParameterLists:
Max: 10
Max: 12
Metrics/MethodLength:
Max: 30
+1 -1
View File
@@ -48,7 +48,7 @@ ENV OPENSSL_CONF=/etc/openssl_legacy.cnf
WORKDIR /app
RUN apk add --no-cache libpq vips redis vips-heif fontconfig onnxruntime
RUN apk add --no-cache libpq vips redis vips-heif onnxruntime
RUN addgroup -g 2000 docuseal && adduser -u 2000 -G docuseal -s /bin/sh -D -h /home/docuseal docuseal
+1 -1
View File
@@ -179,7 +179,7 @@ GEM
dotenv (3.2.0)
drb (2.2.3)
email_typo (0.2.3)
erb (6.0.2)
erb (6.0.4)
erb_lint (0.9.0)
activesupport
better_html (>= 2.0.1)
@@ -33,9 +33,15 @@ module Api
else
http_cache_forever public: true do
response.headers['Accept-Ranges'] = 'bytes'
response.headers['Content-Length'] = blob.byte_size.to_s
send_blob_stream blob, disposition: params[:disposition]
if request.head?
response.headers['Content-Type'] = blob.content_type_for_serving
head :ok
else
send_blob_stream blob, disposition: params[:disposition]
end
response.headers['Content-Length'] = blob.byte_size.to_s
end
end
end
@@ -57,8 +63,6 @@ module Api
return if !require_ttl && !require_auth
end
Rollbar.error('Blob unauthorized') if defined?(Rollbar)
raise CanCan::AccessDenied
end
end
+6 -3
View File
@@ -161,9 +161,7 @@ module Api
submitter.values = Submitters::SubmitValues.maybe_remove_condition_values(submitter)
end
submitter.values = submitter.values.transform_values do |v|
v == '{{date}}' ? Time.current.in_time_zone(submitter.account.timezone).to_date.to_s : v
end
submitter.values = Submitters::SubmitValues.replace_current_date_placeholders(submitter)
end
submitter
@@ -205,10 +203,15 @@ module Api
submitter.preferences['send_sms'] = submitter_preferences['send_sms'] if submitter_preferences.key?('send_sms')
submitter.preferences['reply_to'] = submitter_preferences['reply_to'] if submitter_preferences.key?('reply_to')
if submitter_preferences.key?('require_phone_2fa')
submitter.preferences['require_phone_2fa'] = submitter_preferences['require_phone_2fa']
end
if submitter_preferences.key?('require_email_2fa')
submitter.preferences['require_email_2fa'] = submitter_preferences['require_email_2fa']
end
if submitter_preferences.key?('go_to_last')
submitter.preferences['go_to_last'] = submitter_preferences['go_to_last']
end
@@ -41,7 +41,7 @@ class PreviewDocumentPageController < ActionController::API
end
def find_or_create_document_tempfile_path(attachment)
file_path = "#{Dir.tmpdir}/#{attachment.uuid}"
file_path = "#{Dir.tmpdir}/attachment-#{Digest::SHA1.hexdigest("#{attachment.id}-#{attachment.uuid}")}"
File.open(file_path, File::RDWR | File::CREAT, 0o644) do |f|
f.flock(File::LOCK_EX)
@@ -1,6 +1,14 @@
# frozen_string_literal: true
class RevealAccessTokenController < ApplicationController
rate_limit to: 4, within: 1.minute, only: %i[create], by: -> { current_user.id }, with: lambda {
Rollbar.error('Rate limit api key') if defined?(Rollbar)
render turbo_stream: turbo_stream.replace(:modal, template: 'reveal_access_token/show',
locals: { error_message: I18n.t(:too_many_attempts) }),
status: :unprocessable_content
}
def show
authorize!(:manage, current_user.access_token)
end
@@ -14,7 +14,7 @@ class SendSubmissionEmailController < ApplicationController
template = Template.find_by!(slug: params[:template_slug])
@submitter =
Submitter.completed.where(submission: template.submissions).find_by!(email: params[:email].to_s.downcase)
Submitter.completed.where(submission: template.submissions).find_by(email: params[:email].to_s.downcase)
elsif params[:submission_slug]
submission = Submission.find_by(slug: params[:submission_slug])
@@ -27,9 +27,11 @@ class SendSubmissionEmailController < ApplicationController
@submitter = Submitter.completed.find_by!(slug: params[:submitter_slug])
end
RateLimit.call("send-email-#{@submitter.id}", limit: 2, ttl: 5.minutes)
if @submitter
RateLimit.call("send-email-#{@submitter.id}", limit: 2, ttl: 5.minutes)
SubmitterMailer.documents_copy_email(@submitter, sig: true).deliver_later! if can_send?(@submitter)
SubmitterMailer.documents_copy_email(@submitter, sig: true).deliver_later! if can_send?(@submitter)
end
respond_to do |f|
f.html { render :success }
@@ -79,6 +79,8 @@ class SubmitFormController < ApplicationController
render json: { field_uuid: e.message }, status: :unprocessable_content
rescue Submitters::SubmitValues::ValidationError => e
Rollbar.warning("Validation error #{@submitter.id}: #{e.message}") if defined?(Rollbar)
render json: { error: e.message }, status: :unprocessable_content
end
@@ -0,0 +1,37 @@
# frozen_string_literal: true
class SubmitFormMetadataController < ApplicationController
skip_before_action :authenticate_user!
skip_authorization_check
def index
submitter = Submitter.find_by!(slug: params[:submit_form_slug])
return head :not_found if submitter.declined_at? ||
submitter.completed_at? ||
submitter.submission.archived_at? ||
submitter.submission.expired? ||
submitter.submission.template&.archived_at? ||
submitter.account.archived_at? ||
!Submitters::AuthorizedForForm.call(submitter, current_user, request)
submission = submitter.submission
values = submission.submitters.reduce({}) { |acc, sub| acc.merge(sub.values) }
schema = Submissions.filtered_conditions_schema(submission, values:, include_submitter_uuid: submitter.uuid)
documents = schema.filter_map do |item|
submission.schema_documents.find { |a| a.uuid == item['attachment_uuid'] }
end
ActiveRecord::Associations::Preloader.new(records: documents, associations: %i[blob record]).call
text_runs = documents.to_h do |document|
[
document.uuid,
DocumentMetadatas.find_or_create_for_document(document, account_id: document.record.account_id).text_runs
]
end
render json: { text_runs: }
end
end
+7 -2
View File
@@ -1,6 +1,10 @@
# frozen_string_literal: true
class TemplatesController < ApplicationController
TEMPLATE_FIELDS = %i[id author_id folder_id external_id name slug
schema fields submitters variables_schema preferences
shared_link source archived_at created_at updated_at].freeze
load_and_authorize_resource :template
def show
@@ -33,10 +37,11 @@ class TemplatesController < ApplicationController
).call
@template_data =
@template.as_json.merge(
@template.as_json(only: TEMPLATE_FIELDS).merge(
documents: @template.schema_documents.as_json(
only: %i[id uuid],
methods: %i[metadata signed_key],
include: { preview_images: { methods: %i[url metadata filename] } }
include: { preview_images: { only: %i[id], methods: %i[url metadata filename] } }
)
).to_json
@@ -0,0 +1,22 @@
# frozen_string_literal: true
class TemplatesShareLinkQrController < ApplicationController
load_and_authorize_resource :template
def show
return render :disabled, layout: 'plain' unless @template.shared_link?
shared_link_url = start_form_url(slug: @template.slug, host: form_link_host)
@qr_svg_code = RQRCode::QRCode.new(shared_link_url, level: :m).as_svg(viewbox: true)
@page_size =
if TimeUtils.timezone_abbr(current_account.timezone, Time.current.beginning_of_year).in?(TimeUtils::US_TIMEZONES)
'Letter'
else
'A4'
end
render :show, layout: false
end
end
@@ -3,7 +3,7 @@
class TestingAccountsController < ApplicationController
skip_authorization_check only: :destroy
def show
def create
authorize!(:manage, current_account)
authorize!(:manage, current_user)
@@ -32,9 +32,13 @@ class WebhookSettingsController < ApplicationController
def new; end
def create
@webhook_url.save!
if @webhook_url.url.present?
@webhook_url.save!
redirect_to settings_webhooks_path, notice: I18n.t('webhook_url_has_been_saved')
redirect_to settings_webhooks_path, notice: I18n.t('webhook_url_has_been_saved')
else
redirect_back fallback_location: settings_webhooks_path
end
end
def update
+2
View File
@@ -169,6 +169,7 @@ safeRegisterElement('template-builder', class extends HTMLElement {
withKba: ['true', 'false'].includes(this.dataset.withKba) ? this.dataset.withKba === 'true' : null,
withLogo: this.dataset.withLogo !== 'false',
withFieldsDetection: this.dataset.withFieldsDetection === 'true',
withDetectExistingFields: this.dataset.withDetectExistingFields === 'true',
editable: this.dataset.editable !== 'false',
authenticityToken: document.querySelector('meta[name="csrf-token"]')?.content,
withCustomFields: true,
@@ -180,6 +181,7 @@ safeRegisterElement('template-builder', class extends HTMLElement {
withConditions: this.dataset.withConditions === 'true',
withDynamicDocuments: this.dataset.withDynamicDocuments === 'true',
withGoogleDrive: this.dataset.withGoogleDrive === 'true',
pagePreviewFormat: this.dataset.pagePreviewFormat || '.jpg',
withReplaceAndCloneUpload: true,
withDownload: true,
currencies: (this.dataset.currencies || '').split(',').filter(Boolean),
+2 -1
View File
@@ -11,6 +11,7 @@ window.customElements.define('draw-signature', class extends HTMLElement {
this.resizeObserver = new ResizeObserver(() => {
requestAnimationFrame(() => {
if (!this.canvas) return
if (!this.canvas.parentNode?.clientWidth) return
const { width, height } = this.canvas
@@ -89,7 +90,7 @@ window.customElements.define('draw-signature', class extends HTMLElement {
}
redrawCanvas (oldWidth, oldHeight) {
if (this.pad && !this.pad.isEmpty() && oldWidth > 0 && oldHeight > 0) {
if (this.pad && !this.pad.isEmpty() && oldWidth > 0 && oldHeight > 0 && this.canvas.width > 0 && this.canvas.height > 0) {
const sx = this.canvas.width / oldWidth
const sy = this.canvas.height / oldHeight
@@ -5,6 +5,12 @@ export default targetable(class extends HTMLElement {
connectedCallback () {
this.addEventListener('click', () => this.downloadFiles())
this.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
this.downloadFiles()
}
})
}
toggleState () {
+18
View File
@@ -0,0 +1,18 @@
export default class extends HTMLElement {
connectedCallback () {
const dialog = document.getElementById(this.dataset.target)
this.querySelector('button').addEventListener('click', () => {
if (dialog) {
dialog.inert = false
dialog.showModal()
}
})
if (dialog) {
dialog.addEventListener('close', () => {
dialog.inert = true
})
}
}
}
@@ -47,11 +47,13 @@ export default class extends HTMLElement {
this.classList.remove('hidden', '-translate-y-10', 'opacity-0')
this.classList.add('translate-y-0', 'opacity-100')
this.inert = false
}
hideButtons () {
this.classList.remove('translate-y-0', 'opacity-100')
this.classList.add('-translate-y-10', 'opacity-0')
this.inert = true
setTimeout(() => {
if (this.classList.contains('-translate-y-10')) {
+2 -1
View File
@@ -14,6 +14,7 @@ export default targetable(class extends HTMLElement {
this.resizeObserver = new ResizeObserver(() => {
requestAnimationFrame(() => {
if (!this.canvas) return
if (!this.canvas.parentNode?.clientWidth) return
const { width, height } = this.canvas
@@ -80,7 +81,7 @@ export default targetable(class extends HTMLElement {
}
redrawCanvas (oldWidth, oldHeight) {
if (this.pad && !this.pad.isEmpty() && oldWidth > 0 && oldHeight > 0) {
if (this.pad && !this.pad.isEmpty() && oldWidth > 0 && oldHeight > 0 && this.canvas.width > 0 && this.canvas.height > 0) {
const sx = this.canvas.width / oldWidth
const sy = this.canvas.height / oldHeight
+2
View File
@@ -7,6 +7,7 @@ import FetchForm from './elements/fetch_form'
import ScrollButtons from './elements/scroll_buttons'
import PageContainer from './elements/page_container'
import SubmitForm from './elements/submit_form'
import ModalButton from './elements/modal_button'
const safeRegisterElement = (name, element, options = {}) => !window.customElements.get(name) && window.customElements.define(name, element, options)
@@ -16,6 +17,7 @@ safeRegisterElement('fetch-form', FetchForm)
safeRegisterElement('scroll-buttons', ScrollButtons)
safeRegisterElement('page-container', PageContainer)
safeRegisterElement('submit-form', SubmitForm)
safeRegisterElement('modal-button', ModalButton)
safeRegisterElement('submission-form', class extends HTMLElement {
connectedCallback () {
this.appElem = document.createElement('div')
@@ -0,0 +1,434 @@
<template>
<template
v-for="(pages, docUuid) in textRuns"
:key="docUuid"
>
<template
v-for="(_, pageIndex) in pages"
:key="pageIndex"
>
<template
v-for="(pageElem, i) in [findPageElement(docUuid, pageIndex)]"
:key="i"
>
<Teleport
v-if="pageElem"
:to="pageElem"
>
<template
v-for="(item, index) in sortedItemsForPage(docUuid, pageIndex)"
:key="index"
>
<template v-if="item.type === 'text_group'">
<span
class="absolute overflow-hidden text-transparent select-none pointer-events-none"
:style="{ left: item.x * 100 + '%', top: item.y * 100 + '%', width: item.w * 100 + '%', height: item.h * 100 + '%' }"
>{{ item.text }}</span>
<span
v-for="(run, runIndex) in item.items"
:key="runIndex"
aria-hidden="true"
class="absolute overflow-hidden text-transparent"
:style="{ left: run.x * 100 + '%', top: run.y * 100 + '%', width: run.w * 100 + '%', height: run.h * 100 + '%', fontSize: run.font_size ? (run.font_size / 10) + 'cqmin' : undefined, textAlign: 'justify', textAlignLast: 'justify', textJustify: 'inter-character' }"
>{{ run.text }}</span>
</template>
<FieldArea
v-else-if="item.type === 'field_area'"
:ref="setAreaRef"
v-model="values[item.field.uuid]"
:values="values"
:field="item.field"
:area="item.area"
:submittable="true"
:page-width="1400"
:page-height="(1400.0 / pageElem.offsetWidth) * pageElem.offsetHeight"
:field-index="item.fieldIndex"
:is-inline-size="isInlineSize"
:scroll-padding="scrollPadding"
:submitter="submitter"
:with-field-placeholder="withFieldPlaceholder"
:with-signature-id="withSignatureId"
:is-active="currentStep === item.step"
:with-label="withLabel && !withFieldPlaceholder && item.step.length < 2"
:is-value-set="item.step.some((f) => f.uuid in values)"
:attachments-index="attachmentsIndex"
@click="[$emit('focus-step', item.stepIndex), maybeScrollOnClick(item.field, item.area)]"
/>
<FieldArea
v-else-if="item.type === 'readonly_field_area'"
:model-value="readonlyConditionalFieldValues[item.field.uuid]"
:values="readonlyConditionalFieldValues"
:field="item.field"
:area="item.area"
:submittable="false"
:page-width="1400"
:page-height="(1400.0 / pageElem.offsetWidth) * pageElem.offsetHeight"
:field-index="item.fieldIndex"
:is-inline-size="isInlineSize"
:submitter="submitter"
:attachments-index="attachmentsIndex"
/>
<FieldArea
v-else-if="item.type === 'formula_area' && isMathLoaded"
:model-value="calculateFormula(item.field)"
:is-inline-size="isInlineSize"
:field="item.field"
:area="item.area"
:submittable="false"
:field-index="item.fieldIndex"
/>
</template>
</Teleport>
</template>
</template>
</template>
</template>
<script>
import FieldArea from './area'
import FormulaAreas from './formula_areas'
import FieldAreas from './areas'
export default {
name: 'AccessibilityAreas',
components: {
FieldArea
},
inject: ['baseUrl', 't'],
props: {
submitterSlug: {
type: String,
required: true
},
filledFieldsIndex: {
type: Object,
required: false,
default: null
},
steps: {
type: Array,
required: false,
default: () => []
},
readonlyConditionalFields: {
type: Array,
required: false,
default: () => []
},
readonlyConditionalFieldValues: {
type: Object,
required: false,
default: () => ({})
},
formulaFields: {
type: Array,
required: false,
default: () => []
},
values: {
type: Object,
required: false,
default: () => ({})
},
readonlyValues: {
type: Object,
required: false,
default: () => ({})
},
submitter: {
type: Object,
required: true
},
currentStep: {
type: Array,
required: false,
default: () => []
},
withFieldPlaceholder: {
type: Boolean,
required: false,
default: false
},
withSignatureId: {
type: Boolean,
required: false,
default: false
},
withLabel: {
type: Boolean,
required: false,
default: true
},
scrollPadding: {
type: String,
required: false,
default: '-80px'
},
scrollEl: {
type: Object,
required: false,
default: null
},
attachmentsIndex: {
type: Object,
required: false,
default: () => ({})
},
fetchOptions: {
type: Object,
required: false,
default: () => ({})
}
},
emits: ['focus-step'],
data () {
return {
isMathLoaded: false,
math: null,
textRuns: {},
areaRefs: []
}
},
computed: {
fieldValuesIndex () {
return this.filledFieldsIndex || this.extractStaticValues()
},
isMobileContainer: FieldAreas.computed.isMobileContainer,
isInlineSize: FieldAreas.computed.isInlineSize,
fieldsUuidIndex () {
return this.formulaFields.reduce((acc, field) => {
acc[field.uuid] = field
return acc
}, {})
},
fieldAreasIndex () {
const index = Object.create(null)
this.steps.forEach((step, stepIndex) => {
step.forEach((field, fieldIndex) => {
(field.areas || []).forEach((area) => {
index[area.attachment_uuid] ||= Object.create(null)
index[area.attachment_uuid][area.page] ||= []
index[area.attachment_uuid][area.page].push({
type: 'field_area',
field,
area,
step,
stepIndex,
fieldIndex,
x: area.x,
y: area.y,
w: area.w,
h: area.h
})
})
})
})
return index
},
formulaAreasIndex () {
const index = Object.create(null)
this.formulaFields.forEach((field, fieldIndex) => {
(field.areas || []).forEach((area) => {
index[area.attachment_uuid] ||= Object.create(null)
index[area.attachment_uuid][area.page] ||= []
index[area.attachment_uuid][area.page].push({
type: 'formula_area',
field,
area,
fieldIndex,
x: area.x,
y: area.y,
w: area.w,
h: area.h
})
})
})
return index
},
readonlyFieldAreasIndex () {
const index = Object.create(null)
this.readonlyConditionalFields.forEach((field, fieldIndex) => {
(field.areas || []).forEach((area) => {
index[area.attachment_uuid] ||= Object.create(null)
index[area.attachment_uuid][area.page] ||= []
index[area.attachment_uuid][area.page].push({
type: 'readonly_field_area',
field,
area,
fieldIndex,
x: area.x,
y: area.y,
w: area.w,
h: area.h
})
})
})
return index
}
},
beforeUpdate () {
this.areaRefs = []
},
async mounted () {
const [metadataResult] = await Promise.all([
fetch(this.baseUrl + `/s/${this.submitterSlug}/metadata`, {
...this.fetchOptions
}).then((r) => r.json()).catch(() => ({})),
this.loadMath()
])
this.textRuns = metadataResult.text_runs || {}
},
methods: {
normalizeFormula: FormulaAreas.methods.normalizeFormula,
calculateFormula: FormulaAreas.methods.calculateFormula,
scrollInContainer: FieldAreas.methods.scrollInContainer,
scrollIntoArea: FieldAreas.methods.scrollIntoArea,
scrollIntoField: FieldAreas.methods.scrollIntoField,
maybeScrollOnClick: FieldAreas.methods.maybeScrollOnClick,
setAreaRef (el) {
if (el) {
this.areaRefs.push(el)
}
},
async loadMath () {
if (this.formulaFields.length && !this.isMathLoaded) {
const { Calculator } = await import('./calculator')
this.math = new Calculator()
this.isMathLoaded = true
}
},
extractStaticValues () {
const result = Object.create(null)
const root = this.$root.$el?.parentNode?.getRootNode() || document
const pageContainers = root.querySelectorAll('page-container')
pageContainers.forEach((container) => {
const overlay = container.querySelector('[id^="page-"]')
if (!overlay) return
const parts = overlay.id.split('-')
const pageIndex = parseInt(parts[parts.length - 1])
const docUuid = parts.slice(1, -1).join('-')
const fieldValues = overlay.querySelectorAll('field-value')
if (!fieldValues.length) return
result[docUuid] ||= Object.create(null)
result[docUuid][pageIndex] = []
fieldValues.forEach((el) => {
const style = el.style
const x = parseFloat(style.left) / 100
const y = parseFloat(style.top) / 100
const w = parseFloat(style.width) / 100
const h = parseFloat(style.height) / 100
const text = el.textContent.trim()
if (text) {
result[docUuid][pageIndex].push({ type: 'static_value', text, x, y, w, h })
}
})
})
return result
},
findPageElement (docUuid, pageIndex) {
return (this.$root.$el?.parentNode?.getRootNode() || document).getElementById(`page-${docUuid}-${pageIndex}`)
},
sortedItemsForPage (docUuid, pageIndex) {
const items = []
const pageTextRuns = this.textRuns[docUuid]?.[pageIndex] || []
pageTextRuns.forEach((run) => {
items.push({
type: 'text_run',
text: run.text,
x: run.x,
y: run.y,
w: run.w,
h: run.h,
font_size: run.font_size
})
})
const fieldAreas = this.fieldAreasIndex[docUuid]?.[pageIndex] || []
items.push(...fieldAreas)
const readonlyFieldAreas = this.readonlyFieldAreasIndex[docUuid]?.[pageIndex] || []
items.push(...readonlyFieldAreas)
const formulaAreas = this.formulaAreasIndex[docUuid]?.[pageIndex] || []
items.push(...formulaAreas)
const pageFieldValues = this.fieldValuesIndex[docUuid]?.[pageIndex] || []
items.push(...pageFieldValues)
items.sort((a, b) => {
const aCenterY = a.y + a.h / 2
const bCenterY = b.y + b.h / 2
const lineThreshold = Math.min(a.h, b.h) / 2
if (Math.abs(aCenterY - bCenterY) < lineThreshold) {
return a.x - b.x
}
return aCenterY - bCenterY
})
const grouped = []
let currentGroup = null
const closeGroup = () => {
if (!currentGroup) return
const groupItems = currentGroup.items
const minX = Math.min(...groupItems.map((i) => i.x))
const minY = Math.min(...groupItems.map((i) => i.y))
const maxEndX = Math.max(...groupItems.map((i) => i.x + i.w))
const maxEndY = Math.max(...groupItems.map((i) => i.y + i.h))
currentGroup.x = minX
currentGroup.y = minY
currentGroup.w = maxEndX - minX
currentGroup.h = maxEndY - minY
currentGroup.text = groupItems.map((i) => i.text).join(' ').replace(/\s+/g, ' ').trim()
grouped.push(currentGroup)
currentGroup = null
}
for (const item of items) {
const isTextLike = item.type === 'text_run' || item.type === 'static_value'
if (!isTextLike) {
closeGroup()
grouped.push(item)
continue
}
if (!currentGroup) {
currentGroup = { type: 'text_group', items: [] }
}
currentGroup.items.push(item)
}
closeGroup()
return grouped
}
}
}
</script>
+17 -2
View File
@@ -42,10 +42,25 @@ export default {
const areas = {}
this.field.areas?.forEach((area) => {
areas[area.attachment_uuid + area.page] ||= area
areas[area.attachment_uuid] ||= []
areas[area.attachment_uuid].push(area)
})
return Object.values(areas).slice(0, 6)
const sortedAreas = Object.values(areas).reduce((acc, group) => {
const seen = {}
const sortedGroup = [...group].sort((a, b) => a.page - b.page)
sortedGroup.forEach((area) => {
if (!seen[area.page]) {
seen[area.page] = true
acc.push(area)
}
})
return acc
}, [])
return sortedAreas.slice(0, 6)
}
}
}
+67 -27
View File
@@ -1,9 +1,14 @@
<template>
<div
class="flex absolute lg:text-base -outline-offset-1 field-area"
class="flex absolute lg:text-base -outline-offset-1 focus-visible:outline-blue-500 focus-visible:outline-2 focus-visible:outline field-area"
dir="auto"
:style="[computedStyle, fontStyle]"
:class="{ 'cursor-default': !submittable, 'border border-red-100 bg-red-100 cursor-pointer': submittable, 'border border-red-100': !isActive && submittable, 'bg-opacity-80': !isActive && !isValueSet && submittable, 'outline-red-500 outline-dashed outline-2 z-10 field-area-active': isActive && submittable, 'bg-opacity-40': (isActive || isValueSet) && submittable }"
:role="submittable && !isNativeInputField ? 'button' : undefined"
:tabindex="submittable && !isNativeInputField ? 0 : undefined"
:aria-label="submittable && !isNativeInputField ? fieldAreaLabel : undefined"
@keydown.enter.prevent="submittable && !isNativeInputField ? $el.click() : undefined"
@keydown.space.prevent="submittable && !isNativeInputField ? $el.click() : undefined"
>
<div
v-if="(!withFieldPlaceholder || !field.name || field.type === 'cells') && !isActive && !isValueSet && field.type !== 'checkbox' && submittable && !area.option_uuid"
@@ -18,6 +23,7 @@
width="100%"
height="100%"
class="max-h-10 text-base-content"
aria-hidden="true"
/>
</span>
</div>
@@ -129,6 +135,7 @@
v-if="submittable"
type="checkbox"
:value="false"
:aria-label="field.name || fieldNames[field.type]"
class="aspect-square base-checkbox"
:class="{ '!w-auto !h-full': area.w > area.h, '!w-full !h-auto': area.w <= area.h }"
:checked="!!modelValue"
@@ -149,6 +156,8 @@
v-if="submittable"
type="radio"
:value="false"
:name="`radio-area-${field.uuid}`"
:aria-label="optionValue(option)"
class="aspect-square checked:checkbox checked:checkbox-xs"
:class="{ 'base-radio': !modelValue || modelValue !== optionValue(option), '!w-auto !h-full': area.w > area.h, '!w-full !h-auto': area.w <= area.h }"
:checked="!!modelValue && modelValue === optionValue(option)"
@@ -169,6 +178,7 @@
v-if="submittable"
type="checkbox"
:value="false"
:aria-label="optionValue(option)"
class="aspect-square base-checkbox"
:class="{ '!w-auto !h-full': area.w > area.h, '!w-full !h-auto': area.w <= area.h }"
:checked="!!modelValue && modelValue.includes(optionValue(option))"
@@ -400,6 +410,16 @@ export default {
kba: this.t('kba')
}
},
isNativeInputField () {
return ['checkbox', 'radio', 'multiple'].includes(this.field.type)
},
fieldAreaLabel () {
const name = this.field.name || this.fieldNames[this.field.type] || this.field.type
if (this.area.option_uuid && this.option) {
return `${name} - ${this.optionValue(this.option)}`
}
return name
},
strikethroughWidth () {
if (this.isInlineSize) {
return '0.6cqmin'
@@ -507,7 +527,8 @@ export default {
try {
return this.formatDate(
this.modelValue === '{{date}}' ? new Date() : new Date(this.modelValue),
this.field.preferences?.format || (this.locale.endsWith('-US') ? 'MM/DD/YYYY' : 'DD/MM/YYYY')
this.field.preferences?.format || (this.locale.endsWith('-US') ? 'MM/DD/YYYY' : 'DD/MM/YYYY'),
{ withTimePlaceholders: this.modelValue === '{{date}}' }
)
} catch {
return this.modelValue
@@ -626,36 +647,55 @@ export default {
return number
}
},
formatDate (date, format) {
const monthFormats = {
M: 'numeric',
MM: '2-digit',
MMM: 'short',
MMMM: 'long'
}
formatDate (date, format, { withTimePlaceholders = false } = {}) {
const monthFormats = { M: 'numeric', MM: '2-digit', MMM: 'short', MMMM: 'long' }
const dayFormats = { D: 'numeric', DD: '2-digit' }
const yearFormats = { YYYY: 'numeric', YYY: 'numeric', YY: '2-digit' }
const hourFormats = { H: 'numeric', HH: '2-digit', h: 'numeric', hh: '2-digit' }
const minuteFormats = { m: 'numeric', mm: '2-digit' }
const secondFormats = { s: 'numeric', ss: '2-digit' }
const dayFormats = {
D: 'numeric',
DD: '2-digit'
}
const hasTime = /[HhAasz]/.test(format)
const yearFormats = {
YYYY: 'numeric',
YYY: 'numeric',
YY: '2-digit'
}
const parts = new Intl.DateTimeFormat([], {
const opts = {
day: dayFormats[format.match(/D+/)],
month: monthFormats[format.match(/M+/)],
year: yearFormats[format.match(/Y+/)],
timeZone: 'UTC'
}).formatToParts(date)
year: yearFormats[format.match(/Y+/)]
}
return format
.replace(/D+/, parts.find((p) => p.type === 'day').value)
.replace(/M+/, parts.find((p) => p.type === 'month').value)
.replace(/Y+/, parts.find((p) => p.type === 'year').value)
if (format.match(/H+/)) { opts.hour = hourFormats[format.match(/H+/)[0]]; opts.hour12 = false }
if (format.match(/h+/)) { opts.hour = hourFormats[format.match(/h+/)[0]]; opts.hour12 = true }
if (/[Aa]/.test(format) && opts.hour12 === undefined) opts.hour12 = true
if (format.match(/m+/)) opts.minute = minuteFormats[format.match(/m+/)[0]]
if (format.match(/s+/)) opts.second = secondFormats[format.match(/s+/)[0]]
if (/z/.test(format)) opts.timeZoneName = 'short'
if (!hasTime) opts.timeZone = 'UTC'
const partTypes = {
M: 'month',
D: 'day',
Y: 'year',
H: 'hour',
h: 'hour',
m: 'minute',
s: 'second',
z: 'timeZoneName',
A: 'dayPeriod',
a: 'dayPeriod'
}
const parts = new Intl.DateTimeFormat([], opts).formatToParts(date)
return format.replace(/MMMM|MMM|MM|M|DD|D|YYYY|YYY|YY|HH|hh|H|h|mm|m|ss|s|A|a|z/g, (token) => {
if (withTimePlaceholders && /^(HH|hh|H|h|mm|m|ss|s|A|a)$/.test(token)) return '--'
const value = parts.find((p) => p.type === partTypes[token[0]])?.value
if (token === 'A') return (value || '').toUpperCase()
if (token === 'a') return (value || '').toLowerCase()
return value
})
},
updateMultipleSelectValue (value) {
if (this.modelValue?.includes(value)) {
@@ -1,7 +1,11 @@
<template>
<div>
<div v-if="value.length">
<div
<ul
v-if="value.length"
:aria-label="t('uploaded_files')"
class="list-none p-0 m-0"
>
<li
v-for="(val, index) in value"
:key="index"
class="flex mb-2"
@@ -20,23 +24,27 @@
<IconPaperclip
:width="16"
class="flex-none"
:heigh="16"
:height="16"
aria-hidden="true"
/>
<span>
{{ attachmentsIndex[val].filename }}
</span>
</a>
<button
type="button"
class="remove-attachment-button"
:aria-label="`${t('clear')} ${attachmentsIndex[val].filename}`"
@click.prevent="removeAttachment(val)"
>
<IconTrashX
:width="18"
:heigh="19"
:height="19"
aria-hidden="true"
/>
</button>
</div>
</div>
</li>
</ul>
<template v-else>
<input
value=""
+14 -3
View File
@@ -3,10 +3,13 @@
id="form_completed"
class="mx-auto max-w-md flex flex-col completed-form"
dir="auto"
role="status"
tabindex="-1"
>
<div class="font-medium text-2xl flex items-center space-x-1.5 mx-auto">
<IconCircleCheck
class="inline text-green-600"
aria-hidden="true"
:width="30"
:height="30"
/>
@@ -42,8 +45,12 @@
<IconInnerShadowTop
v-if="isSendingCopy"
class="animate-spin"
aria-hidden="true"
/>
<IconMail
v-else
aria-hidden="true"
/>
<IconMail v-else />
<span>
{{ t('send_copy_via_email') }}
</span>
@@ -57,8 +64,12 @@
<IconInnerShadowTop
v-if="isDownloading"
class="animate-spin"
aria-hidden="true"
/>
<IconDownload
v-else
aria-hidden="true"
/>
<IconDownload v-else />
<span>
{{ t('download') }}
</span>
@@ -199,7 +210,7 @@ export default {
})
}
document.querySelectorAll('#decline_button').forEach((button) => {
document.querySelectorAll('#decline_button, #decline_button_mobile, #delegate_button, #delegate_button_mobile').forEach((button) => {
button.setAttribute('disabled', 'true')
})
},
+63 -17
View File
@@ -30,12 +30,16 @@
class="btn btn-outline btn-sm !normal-case font-normal set-current-date-button"
@click.prevent="[setCurrentDate(), $emit('focus')]"
>
<IconCalendarCheck :width="16" />
<IconCalendarCheck
:width="16"
aria-hidden="true"
/>
{{ t('set_today') }}
</button>
</div>
<div
v-if="field.description"
:id="field.uuid + '-desc'"
class="mb-3 px-1 field-description-text"
dir="auto"
>
@@ -51,12 +55,19 @@
:max="validationMax"
class="base-input !text-2xl text-center w-full"
:required="field.required"
type="date"
:name="`values[${field.uuid}]`"
:aria-describedby="field.description ? field.uuid + '-desc' : undefined"
:type="inputType"
:name="formatType === 'datetime' ? undefined : `values[${field.uuid}]`"
@keydown.enter="onEnter"
@focus="$emit('focus')"
@paste="onPaste"
>
<input
v-if="formatType === 'datetime'"
type="hidden"
:name="`values[${field.uuid}]`"
:value="modelValue"
>
</div>
</div>
</template>
@@ -92,14 +103,19 @@ export default {
},
emits: ['update:model-value', 'focus', 'submit'],
computed: {
formatType () {
const format = this.field.preferences?.format || ''
if (/[HhAasz]/.test(format)) return 'datetime'
if (format && !/[Dd]/.test(format)) return 'month'
return 'date'
},
inputType () {
return { datetime: 'datetime-local', month: 'month', date: 'date' }[this.formatType]
},
dateNowString () {
const today = new Date()
const yyyy = today.getFullYear()
const mm = String(today.getMonth() + 1).padStart(2, '0')
const dd = String(today.getDate()).padStart(2, '0')
return `${yyyy}-${mm}-${dd}`
return this.formatDateValue(new Date())
},
validationMin () {
if (this.field.validation?.min) {
@@ -116,6 +132,8 @@ export default {
}
},
withToday () {
if (this.formatType === 'datetime') return false
const todayDate = new Date().setHours(0, 0, 0, 0)
if (this.validationMin) {
@@ -132,9 +150,25 @@ export default {
},
value: {
set (value) {
if (this.formatType === 'datetime' && value) {
const d = new Date(value)
if (!isNaN(d)) {
this.$emit('update:model-value', d.toISOString())
return
}
}
this.$emit('update:model-value', value)
},
get () {
if (this.formatType === 'datetime') {
const d = new Date(this.modelValue)
return isNaN(d) ? '' : this.formatDateValue(d)
}
return this.modelValue
}
}
@@ -158,20 +192,32 @@ export default {
const parsedDate = new Date(pasteData)
if (!isNaN(parsedDate)) {
const inputEl = this.$refs.input
if (isNaN(parsedDate)) return
inputEl.valueAsDate = new Date(parsedDate.getTime() - parsedDate.getTimezoneOffset() * 60000)
inputEl.dispatchEvent(new Event('input', { bubbles: true }))
}
this.setInputValue(parsedDate)
},
setCurrentDate () {
this.setInputValue(new Date())
},
setInputValue (date) {
const inputEl = this.$refs.input
inputEl.valueAsDate = new Date(new Date().getTime() - new Date().getTimezoneOffset() * 60000)
if (this.formatType === 'date') {
inputEl.valueAsDate = new Date(date.getTime() - date.getTimezoneOffset() * 60000)
} else {
inputEl.value = this.formatDateValue(date)
}
inputEl.dispatchEvent(new Event('input', { bubbles: true }))
},
formatDateValue (date) {
const pad = (n) => String(n).padStart(2, '0')
const ymd = `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
if (this.formatType === 'month') return ymd.slice(0, 7)
if (this.formatType === 'datetime') return `${ymd}T${pad(date.getHours())}:${pad(date.getMinutes())}`
return ymd
}
}
}
+2 -1
View File
@@ -39,8 +39,9 @@
ref="input"
:multiple="multiple"
:accept="accept"
:aria-label="message"
type="file"
class="hidden"
class="sr-only"
@change="onSelectFiles"
>
</label>
+93 -9
View File
@@ -1,5 +1,52 @@
<template>
<Teleport
v-if="withAccessibilityAreas === null && !isAccessibilityMode"
to="#sr_only_content"
>
<button
class="sr-only focus:not-sr-only focus:absolute focus:top-2 focus:left-2 focus:z-[100] focus:px-4 focus:py-2 focus:bg-base-100 focus:text-base-content focus:rounded focus:shadow-lg"
@click="isAccessibilityMode = true"
>
{{ t('enter_screen_reader_mode') }}
</button>
</Teleport>
<Teleport
v-for="item in (withAccessibilityAreas === null ? schema : [])"
:key="item.attachment_uuid"
:to="`#document-${item.attachment_uuid} .sr_only_content`"
>
<button
v-if="!isAccessibilityMode"
class="sr-only focus:not-sr-only focus:absolute focus:top-2 focus:left-2 focus:z-[100] focus:px-4 focus:py-2 focus:bg-base-100 focus:text-base-content focus:rounded focus:shadow-lg"
@click="isAccessibilityMode = true"
>
{{ t('enter_screen_reader_mode') }}
</button>
</Teleport>
<AccessibilityAreas
v-if="withAccessibilityAreas || isAccessibilityMode"
ref="areas"
:submitter-slug="submitterSlug"
:steps="stepFields"
:readonly-conditional-fields="readonlyConditionalFields"
:readonly-conditional-field-values="readonlyConditionalFieldValues"
:formula-fields="formulaFields"
:values="values"
:readonly-values="readonlyFieldValues"
:submitter="submitter"
:scroll-el="scrollEl"
:current-step="currentStepFields"
:with-field-placeholder="withFieldPlaceholder"
:with-signature-id="withSignatureId"
:with-label="withFieldLabels && !isAnonymousChecboxes && showFieldNames"
:scroll-padding="scrollPadding"
:attachments-index="attachmentsIndex"
:fetch-options="fetchOptions"
:filled-fields-index="filledFieldsIndex"
@focus-step="[saveStep(), currentField.type !== 'checkbox' ? isFormVisible = true : '', goToStep($event, false, true)]"
/>
<FieldAreas
v-if="!withAccessibilityAreas && !isAccessibilityMode"
ref="areas"
:steps="stepFields"
:values="values"
@@ -14,6 +61,7 @@
@focus-step="[saveStep(), currentField.type !== 'checkbox' ? isFormVisible = true : '', goToStep($event, false, true)]"
/>
<FieldAreas
v-if="!withAccessibilityAreas && !isAccessibilityMode"
:steps="readonlyConditionalFields.map((e) => [e])"
:values="readonlyConditionalFieldValues"
:submitter="submitter"
@@ -21,7 +69,7 @@
:submittable="false"
/>
<FormulaFieldAreas
v-if="formulaFields.length"
v-if="!withAccessibilityAreas && !isAccessibilityMode && formulaFields.length"
:fields="formulaFields"
:readonly-values="readonlyFieldValues"
:values="values"
@@ -57,6 +105,7 @@
<IconInnerShadowTop
v-if="isSubmittingComplete"
class="mr-1 animate-spin w-5 h-5"
aria-hidden="true"
/>
<span>
{{ t('complete') }}
@@ -88,6 +137,7 @@
class="absolute right-0 mr-4"
:width="20"
:height="20"
aria-hidden="true"
/>
</button>
<div
@@ -103,11 +153,13 @@
class="absolute right-0 top-0 minimize-form-button"
:class="currentField?.description?.length > 100 ? 'mr-1 mt-1 md:mr-2 md:mt-2': 'mr-2 mt-2 hidden md:block'"
:title="t('minimize')"
:aria-label="t('minimize')"
@click.prevent="minimizeForm"
>
<IconArrowsDiagonalMinimize2
:width="20"
:height="20"
aria-hidden="true"
/>
</button>
<div
@@ -193,6 +245,7 @@
/>
<div
v-if="currentField.description"
:id="currentField.uuid + '-desc'"
dir="auto"
class="mb-3 px-1 field-description-text"
>
@@ -203,6 +256,7 @@
:id="currentField.uuid"
dir="auto"
:required="currentField.required"
:aria-describedby="currentField.description ? currentField.uuid + '-desc' : undefined"
class="select base-input !text-2xl w-full text-center font-normal"
:class="{ 'text-gray-300': !values[currentField.uuid] }"
:name="`values[${currentField.uuid}]`"
@@ -250,6 +304,7 @@
</label>
<div
v-if="currentField.description"
:id="currentField.uuid + '-desc'"
dir="auto"
class="mb-3 px-1 field-description-text"
>
@@ -309,6 +364,7 @@
>
<div
v-if="currentField.description"
:id="currentField.uuid + '-desc'"
dir="auto"
class="mb-3 px-1 field-description-text"
>
@@ -511,6 +567,7 @@
<IconInnerShadowTop
v-if="isSubmitting"
class="mr-1 animate-spin"
aria-hidden="true"
/>
<span>
{{ submitButtonText }}
@@ -522,6 +579,7 @@
</button>
<div
v-if="showFillAllRequiredFields"
role="alert"
class="text-center mt-1"
>
{{ t('please_fill_all_required_fields') }}
@@ -554,8 +612,10 @@
:can-send-email="canSendEmail && !!submitter.email"
:submitter-slug="submitterSlug"
/>
<div
<nav
v-if="stepFields.length < 80"
:aria-label="t('form_progress')"
:aria-hidden="isCompleted"
class="flex justify-center mt-3 sm:mt-4 mb-0 sm:mb-1 select-none"
>
<div class="flex items-center flex-wrap steps-progress">
@@ -563,16 +623,18 @@
v-for="(step, index) in stepFields"
:key="step[0].uuid"
>
<a
<button
v-if="!onlyRequiredFields || step.some((f) => f.required)"
href="#"
class="inline border border-base-300 h-3 w-3 rounded-full mx-1 mt-1"
type="button"
:aria-label="`${t('step')} ${index + 1}`"
:aria-current="index === currentStep ? 'step' : undefined"
class="inline border border-base-300 h-3 w-3 rounded-full mx-1 mt-1 p-0"
:class="{ 'bg-base-300 steps-progress-current': index === currentStep, 'bg-base-content': (index < currentStep && stepFields[index].every((f) => !f.required || ![null, undefined, ''].includes(values[f.uuid]))) || isCompleted, 'bg-white': index > currentStep }"
@click.prevent="isCompleted ? '' : [saveStep(), goToStep(index, true)]"
@click="isCompleted ? '' : [saveStep(), goToStep(index, true)]"
/>
</template>
</div>
</div>
</nav>
<div
v-else
class="mt-5"
@@ -584,6 +646,7 @@
<script>
import FieldAreas from './areas'
import FormulaFieldAreas from './formula_areas'
import AccessibilityAreas from './accessibility_areas'
import ImageStep from './image_step'
import SignatureStep from './signature_step'
import InitialsStep from './initials_step'
@@ -642,6 +705,7 @@ export default {
name: 'SubmissionForm',
components: {
FieldAreas,
AccessibilityAreas,
ImageStep,
SignatureStep,
AppearsOn,
@@ -824,6 +888,16 @@ export default {
required: false,
default: ''
},
filledFieldsIndex: {
type: Object,
required: false,
default: null
},
withAccessibilityAreas: {
type: Boolean,
required: false,
default: null
},
fields: {
type: Array,
required: false,
@@ -934,7 +1008,8 @@ export default {
isSubmitting: false,
isSubmittingComplete: false,
submittedValues: {},
recalculateButtonDisabledKey: ''
recalculateButtonDisabledKey: '',
isAccessibilityMode: false
}
},
computed: {
@@ -1458,7 +1533,7 @@ export default {
}
this.enableScrollIntoField = false
this.$refs.form.querySelector('input[type="date"], input[type="number"], input[type="text"], select')?.focus()
this.$refs.form.querySelector('input[type="date"], input[type="number"], input[type="text"], input[type="tel"], textarea, select')?.focus()
this.enableScrollIntoField = true
if (clickUpload && !this.values[this.currentField.uuid] && ['file', 'image'].includes(this.currentField.type)) {
@@ -1629,6 +1704,15 @@ export default {
if (this.completedRedirectUrl) {
window.location.href = sanitizeUrl(this.completedRedirectUrl)
} else {
this.$nextTick(() => {
const root = this.$root.$el.parentNode.getRootNode()
const completedEl = root.getElementById('form_completed')
if (completedEl) {
completedEl.focus()
}
})
}
}
}
+98 -14
View File
@@ -1,4 +1,9 @@
const en = {
step: 'Step',
form_progress: 'Form progress',
close: 'Close',
uploaded_files: 'Uploaded files',
signature_drawing_area: 'Signature drawing area. Use mouse or touch to draw your signature.',
kba: 'KBA',
please_upload_an_image_file: 'Please upload an image file',
must_be_characters_length: 'Must be {number} characters long',
@@ -98,10 +103,16 @@ const en = {
files: 'Files',
signature_is_too_small_or_simple_please_redraw: 'Signature is too small or simple. Please redraw.',
browser_privacy_settings_block_canvas: 'Your browser privacy settings restrict use of the drawing canvas. Please use a different browser or device, or disable privacy settings that block canvas in order to sign.',
wait_countdown_seconds: 'Wait {countdown} seconds'
wait_countdown_seconds: 'Wait {countdown} seconds',
enter_screen_reader_mode: 'Enter screen reader mode'
}
const es = {
step: 'Paso',
form_progress: 'Progreso del formulario',
close: 'Cerrar',
uploaded_files: 'Archivos subidos',
signature_drawing_area: 'Área de dibujo de firma. Use el ratón o toque para dibujar su firma.',
kba: 'KBA',
please_upload_an_image_file: 'Por favor, sube un archivo de imagen',
must_be_characters_length: 'Debe tener {number} caracteres de longitud',
@@ -201,10 +212,16 @@ const es = {
files: 'Archivos',
signature_is_too_small_or_simple_please_redraw: 'La firma es demasiado pequeña o simple. Por favor, vuelve a dibujarla.',
browser_privacy_settings_block_canvas: 'La configuración de privacidad de su navegador restringe el uso del lienzo de dibujo. Utilice un navegador o dispositivo diferente, o desactive la configuración de privacidad que bloquea el lienzo para firmar.',
wait_countdown_seconds: 'Espera {countdown} segundos'
wait_countdown_seconds: 'Espera {countdown} segundos',
enter_screen_reader_mode: 'Activar modo lector de pantalla'
}
const it = {
step: 'Passo',
form_progress: 'Progresso del modulo',
close: 'Chiudi',
uploaded_files: 'File caricati',
signature_drawing_area: 'Area di disegno della firma. Usa il mouse o il tocco per disegnare la tua firma.',
kba: 'KBA',
please_upload_an_image_file: 'Per favore carica un file immagine',
must_be_characters_length: 'Deve essere lungo {number} caratteri',
@@ -304,10 +321,16 @@ const it = {
files: 'File',
signature_is_too_small_or_simple_please_redraw: 'La firma è troppo piccola o semplice. Ridisegnala, per favore.',
browser_privacy_settings_block_canvas: 'Le impostazioni sulla privacy del browser limitano l\'uso dell\'area di disegno. Utilizza un browser o dispositivo diverso oppure disattiva le impostazioni sulla privacy che bloccano il canvas per firmare.',
wait_countdown_seconds: 'Attendi {countdown} secondi'
wait_countdown_seconds: 'Attendi {countdown} secondi',
enter_screen_reader_mode: 'Attiva modalità lettore di schermo'
}
const de = {
step: 'Schritt',
form_progress: 'Formularfortschritt',
close: 'Schließen',
uploaded_files: 'Hochgeladene Dateien',
signature_drawing_area: 'Unterschriftszeichenbereich. Verwenden Sie die Maus oder Berührung, um Ihre Unterschrift zu zeichnen.',
kba: 'KBA',
please_upload_an_image_file: 'Bitte laden Sie eine Bilddatei hoch',
must_be_characters_length: 'Muss {number} Zeichen lang sein',
@@ -407,10 +430,16 @@ const de = {
files: 'Dateien',
signature_is_too_small_or_simple_please_redraw: 'Die Unterschrift ist zu klein oder zu einfach. Bitte neu zeichnen.',
browser_privacy_settings_block_canvas: 'Die Datenschutzeinstellungen Ihres Browsers schränken die Nutzung der Zeichenfläche ein. Bitte verwenden Sie einen anderen Browser oder ein anderes Gerät oder deaktivieren Sie die Datenschutzeinstellungen, die Canvas blockieren, um zu unterschreiben.',
wait_countdown_seconds: 'Bitte {countdown} Sekunden warten'
wait_countdown_seconds: 'Bitte {countdown} Sekunden warten',
enter_screen_reader_mode: 'Screenreader-Modus aktivieren'
}
const fr = {
step: 'Étape',
form_progress: 'Progression du formulaire',
close: 'Fermer',
uploaded_files: 'Fichiers téléchargés',
signature_drawing_area: 'Zone de dessin de signature. Utilisez la souris ou le toucher pour dessiner votre signature.',
kba: 'KBA',
please_upload_an_image_file: 'Veuillez téléverser un fichier image',
must_be_characters_length: 'Doit comporter {number} caractères',
@@ -510,10 +539,16 @@ const fr = {
files: 'Fichiers',
signature_is_too_small_or_simple_please_redraw: 'La signature est trop petite ou trop simple. Veuillez la redessiner.',
browser_privacy_settings_block_canvas: 'Les paramètres de confidentialité de votre navigateur empêchent l\'utilisation du canevas de dessin. Veuillez utiliser un autre navigateur ou appareil, ou désactiver les paramètres de confidentialité qui bloquent le canevas pour signer.',
wait_countdown_seconds: 'Veuillez patienter {countdown} secondes'
wait_countdown_seconds: 'Veuillez patienter {countdown} secondes',
enter_screen_reader_mode: 'Activer le mode lecteur d\'écran'
}
const pl = {
step: 'Krok',
form_progress: 'Postęp formularza',
close: 'Zamknij',
uploaded_files: 'Przesłane pliki',
signature_drawing_area: 'Obszar rysowania podpisu. Użyj myszy lub dotyku, aby narysować swój podpis.',
kba: 'KBA',
please_upload_an_image_file: 'Proszę przesłać plik obrazu',
must_be_characters_length: 'Musi mieć długość {number} znaków',
@@ -613,10 +648,16 @@ const pl = {
files: 'Pliki',
signature_is_too_small_or_simple_please_redraw: 'Podpis jest zbyt mały lub zbyt prosty. Proszę narysować go ponownie.',
browser_privacy_settings_block_canvas: 'Ustawienia prywatności przeglądarki blokują użycie obszaru rysowania. Użyj innej przeglądarki lub urządzenia albo wyłącz ustawienia prywatności blokujące canvas, aby podpisać.',
wait_countdown_seconds: 'Poczekaj {countdown} sekund'
wait_countdown_seconds: 'Poczekaj {countdown} sekund',
enter_screen_reader_mode: 'Włącz tryb czytnika ekranu'
}
const uk = {
step: 'Крок',
form_progress: 'Прогрес форми',
close: 'Закрити',
uploaded_files: 'Завантажені файли',
signature_drawing_area: 'Область малювання підпису. Використовуйте мишу або дотик, щоб намалювати свій підпис.',
kba: 'KBA',
please_upload_an_image_file: 'Будь ласка, завантажте файл зображення',
must_be_characters_length: 'Має містити {number} символів',
@@ -716,10 +757,16 @@ const uk = {
files: 'Файли',
signature_is_too_small_or_simple_please_redraw: 'Підпис занадто маленький або надто простий. Будь ласка, перемалюйте.',
browser_privacy_settings_block_canvas: 'Налаштування конфіденційності вашого браузера блокують використання полотна для малювання. Будь ласка, скористайтеся іншим браузером або пристроєм, або вимкніть налаштування конфіденційності, що блокують canvas, щоб підписати.',
wait_countdown_seconds: 'Зачекайте {countdown} секунд'
wait_countdown_seconds: 'Зачекайте {countdown} секунд',
enter_screen_reader_mode: 'Увімкнути режим читання з екрану'
}
const cs = {
step: 'Krok',
form_progress: 'Průběh formuláře',
close: 'Zavřít',
uploaded_files: 'Nahrané soubory',
signature_drawing_area: 'Oblast pro kreslení podpisu. Použijte myš nebo dotyk k nakreslení podpisu.',
kba: 'KBA',
please_upload_an_image_file: 'Nahrajte prosím obrázkový soubor',
must_be_characters_length: 'Musí mít délku {number} znaků',
@@ -819,10 +866,16 @@ const cs = {
files: 'Soubory',
signature_is_too_small_or_simple_please_redraw: 'Podpis je příliš malý nebo jednoduchý. Nakreslete jej prosím znovu.',
browser_privacy_settings_block_canvas: 'Nastavení soukromí vašeho prohlížeče omezuje použití kreslicího plátna. Použijte prosím jiný prohlížeč nebo zařízení, nebo vypněte nastavení soukromí blokující canvas pro podepsání.',
wait_countdown_seconds: 'Počkejte {countdown} sekund'
wait_countdown_seconds: 'Počkejte {countdown} sekund',
enter_screen_reader_mode: 'Zapnout režim čtečky obrazovky'
}
const pt = {
step: 'Passo',
form_progress: 'Progresso do formulário',
close: 'Fechar',
uploaded_files: 'Arquivos enviados',
signature_drawing_area: 'Área de desenho da assinatura. Use o mouse ou toque para desenhar sua assinatura.',
kba: 'KBA',
please_upload_an_image_file: 'Por favor, envie um arquivo de imagem',
must_be_characters_length: 'Deve ter {number} caracteres',
@@ -922,10 +975,16 @@ const pt = {
files: 'Arquivos',
signature_is_too_small_or_simple_please_redraw: 'A assinatura é muito pequena ou simples. Por favor, redesenhe.',
browser_privacy_settings_block_canvas: 'As configurações de privacidade do seu navegador restringem o uso da área de desenho. Use um navegador ou dispositivo diferente, ou desative as configurações de privacidade que bloqueiam o canvas para assinar.',
wait_countdown_seconds: 'Aguarde {countdown} segundos'
wait_countdown_seconds: 'Aguarde {countdown} segundos',
enter_screen_reader_mode: 'Ativar modo leitor de tela'
}
const he = {
step: 'שלב',
form_progress: 'התקדמות הטופס',
close: 'סגור',
uploaded_files: 'קבצים שהועלו',
signature_drawing_area: 'אזור ציור חתימה. השתמש בעכבר או במגע כדי לצייר את החתימה שלך.',
kba: 'KBA',
please_upload_an_image_file: 'אנא העלה קובץ תמונה',
must_be_characters_length: 'חייב להיות באורך של {number} תווים',
@@ -1025,10 +1084,16 @@ const he = {
files: 'קבצים',
signature_is_too_small_or_simple_please_redraw: 'החתימה קטנה או פשוטה מדי. אנא חתום מחדש.',
browser_privacy_settings_block_canvas: 'הגדרות הפרטיות של הדפדפן שלך מגבילות את השימוש באזור הציור. אנא השתמש בדפדפן או מכשיר אחר, או בטל את הגדרות הפרטיות החוסמות canvas כדי לחתום.',
wait_countdown_seconds: 'המתן {countdown} שניות'
wait_countdown_seconds: 'המתן {countdown} שניות',
enter_screen_reader_mode: 'הפעל מצב קורא מסך'
}
const nl = {
step: 'Stap',
form_progress: 'Formuliervoortgang',
close: 'Sluiten',
uploaded_files: 'Geüploade bestanden',
signature_drawing_area: 'Handtekening tekengebied. Gebruik de muis of aanraking om uw handtekening te tekenen.',
kba: 'KBA',
please_upload_an_image_file: 'Upload alstublieft een afbeeldingsbestand',
must_be_characters_length: 'Moet {number} tekens lang zijn',
@@ -1128,10 +1193,16 @@ const nl = {
files: 'Bestanden',
signature_is_too_small_or_simple_please_redraw: 'De handtekening is te klein of te eenvoudig. Teken opnieuw.',
browser_privacy_settings_block_canvas: 'De privacyinstellingen van uw browser beperken het gebruik van het tekenveld. Gebruik een andere browser of ander apparaat, of schakel de privacyinstellingen uit die canvas blokkeren om te ondertekenen.',
wait_countdown_seconds: 'Wacht {countdown} seconden'
wait_countdown_seconds: 'Wacht {countdown} seconden',
enter_screen_reader_mode: 'Schermlezer-modus inschakelen'
}
const ar = {
step: 'خطوة',
form_progress: 'تقدم النموذج',
close: 'إغلاق',
uploaded_files: 'الملفات المرفوعة',
signature_drawing_area: 'منطقة رسم التوقيع. استخدم الماوس أو اللمس لرسم توقيعك.',
kba: 'KBA',
please_upload_an_image_file: 'يرجى تحميل ملف صورة',
must_be_characters_length: 'يجب أن يكون الطول {number} حرفًا',
@@ -1231,10 +1302,16 @@ const ar = {
files: 'الملفات',
signature_is_too_small_or_simple_please_redraw: 'التوقيع صغير جدًا أو بسيط جدًا. يرجى إعادة رسمه.',
browser_privacy_settings_block_canvas: 'إعدادات الخصوصية في متصفحك تمنع استخدام لوحة الرسم. يرجى استخدام متصفح أو جهاز مختلف، أو تعطيل إعدادات الخصوصية التي تحظر canvas للتوقيع.',
wait_countdown_seconds: 'انتظر {countdown} ثانية'
wait_countdown_seconds: 'انتظر {countdown} ثانية',
enter_screen_reader_mode: 'تفعيل وضع قارئ الشاشة'
}
const ko = {
step: '단계',
form_progress: '양식 진행',
close: '닫기',
uploaded_files: '업로드된 파일',
signature_drawing_area: '서명 그리기 영역. 마우스 또는 터치를 사용하여 서명을 그리세요.',
kba: 'KBA',
please_upload_an_image_file: '이미지 파일을 업로드해 주세요',
must_be_characters_length: '{number}자여야 합니다',
@@ -1334,10 +1411,16 @@ const ko = {
files: '파일',
signature_is_too_small_or_simple_please_redraw: '서명이 너무 작거나 단순합니다. 다시 그려주세요.',
browser_privacy_settings_block_canvas: '브라우저 개인정보 보호 설정으로 인해 그리기 캔버스를 사용할 수 없습니다. 다른 브라우저나 기기를 사용하거나, 서명을 위해 캔버스를 차단하는 개인정보 보호 설정을 비활성화해 주세요.',
wait_countdown_seconds: '{countdown}초 기다리세요'
wait_countdown_seconds: '{countdown}초 기다리세요',
enter_screen_reader_mode: '스크린 리더 모드 활성화'
}
const ja = {
step: 'ステップ',
form_progress: 'フォームの進捗',
close: '閉じる',
uploaded_files: 'アップロードされたファイル',
signature_drawing_area: '署名描画エリア。マウスまたはタッチを使用して署名を描いてください。',
kba: 'KBA',
please_upload_an_image_file: '画像ファイルをアップロードしてください',
must_be_characters_length: '{number}文字でなければなりません',
@@ -1437,7 +1520,8 @@ const ja = {
files: 'ファイル',
signature_is_too_small_or_simple_please_redraw: '署名が小さすぎるか単純すぎます。もう一度描いてください。',
browser_privacy_settings_block_canvas: 'ブラウザのプライバシー設定により、描画キャンバスの使用が制限されています。別のブラウザまたはデバイスを使用するか、署名するためにキャンバスをブロックするプライバシー設定を無効にしてください。',
wait_countdown_seconds: '{countdown} 秒お待ちください'
wait_countdown_seconds: '{countdown} 秒お待ちください',
enter_screen_reader_mode: 'スクリーンリーダーモードを有効にする'
}
const i18n = { en, es, it, de, fr, pl, uk, cs, pt, he, nl, ar, ko, ja }
@@ -18,13 +18,17 @@
class="btn btn-outline btn-sm reupload-button"
@click.prevent="remove"
>
<IconReload :width="16" />
<IconReload
:width="16"
aria-hidden="true"
/>
{{ t('reupload') }}
</button>
</div>
<div>
<img
:src="attachmentsIndex[modelValue].url"
:alt="field.name || t('image')"
class="h-52 border border-base-300 rounded mx-auto uploaded-image-preview"
>
</div>
@@ -22,43 +22,60 @@
class="md:tooltip"
:data-tip="t('type_initial')"
>
<a
<button
id="type_text_button"
href="#"
type="button"
:aria-label="t('type_initial')"
class="btn btn-outline font-medium btn-sm type-text-button"
@click.prevent="toggleTextInput"
@click="toggleTextInput"
>
<IconTextSize :width="16" />
<IconTextSize
:width="16"
aria-hidden="true"
/>
<span class="hidden sm:inline">
{{ t('type') }}
</span>
</a>
</button>
</span>
<span
v-else
class="md:tooltip ml-2"
:data-tip="t('draw_initials')"
>
<a
<button
id="type_text_button"
href="#"
type="button"
:aria-label="t('draw_initials')"
class="btn btn-outline font-medium btn-sm type-text-button"
@click.prevent="toggleTextInput"
@click="toggleTextInput"
>
<IconSignature :width="16" />
<IconSignature
:width="16"
aria-hidden="true"
/>
<span class="hidden sm:inline">
{{ t('draw') }}
</span>
</a>
</button>
</span>
<span
class="md:tooltip"
:data-tip="t('click_to_upload')"
>
<label class="btn btn-outline btn-sm font-medium inline-flex flex-nowrap upload-image-button">
<IconUpload :width="16" />
<button
type="button"
:aria-label="t('click_to_upload')"
class="btn btn-outline btn-sm font-medium inline-flex flex-nowrap upload-image-button"
@click="$refs.uploadInput.click()"
>
<IconUpload
:width="16"
aria-hidden="true"
/>
<input
:key="uploadImageInputKey"
ref="uploadInput"
type="file"
hidden
accept="image/*"
@@ -67,37 +84,45 @@
<span class="hidden sm:inline">
{{ t('upload') }}
</span>
</label>
</button>
</span>
<a
<button
v-if="modelValue || computedPreviousValue"
href="#"
type="button"
class="btn font-medium btn-outline btn-sm clear-canvas-button"
@click.prevent="remove"
@click="remove"
>
<IconReload :width="16" />
<IconReload
:width="16"
aria-hidden="true"
/>
{{ t('clear') }}
</a>
<a
</button>
<button
v-else
href="#"
type="button"
class="btn font-medium btn-outline btn-sm clear-canvas-button"
@click.prevent="clear"
@click="clear"
>
<IconReload :width="16" />
<IconReload
:width="16"
aria-hidden="true"
/>
{{ t('clear') }}
</a>
<a
</button>
<button
type="button"
:title="t('minimize')"
href="#"
:aria-label="t('minimize')"
class="py-1.5 inline md:hidden"
@click.prevent="$emit('minimize')"
@click="$emit('minimize')"
>
<IconArrowsDiagonalMinimize2
:width="20"
:height="20"
aria-hidden="true"
/>
</a>
</button>
</div>
</div>
<div
@@ -19,6 +19,7 @@
</label>
<div
v-if="field.description"
:id="field.uuid + '-desc'"
dir="auto"
class="mb-3 px-1 field-description-text"
>
@@ -59,6 +60,7 @@
type="checkbox"
:name="`values[${field.uuid}][]`"
:value="optionValue(option, index)"
:aria-describedby="field.description ? field.uuid + '-desc' : undefined"
class="base-checkbox !h-7 !w-7"
:checked="(modelValue || []).includes(optionValue(option, index))"
@change="onChange"
@@ -23,6 +23,7 @@
/>
<div
v-if="field.description"
:id="field.uuid + '-desc'"
dir="auto"
class="mb-3 px-1 field-description-text"
>
@@ -44,6 +45,7 @@
:max="field.validation?.max"
class="base-input !text-2xl w-full"
:required="field.required"
:aria-describedby="field.description ? field.uuid + '-desc' : undefined"
:placeholder="`${t('type_here_')}${field.required ? '' : ` (${t('optional')})`}`"
:name="`values[${field.uuid}]`"
@focus="$emit('focus')"
+10 -8
View File
@@ -16,6 +16,7 @@
</label>
<div
v-if="field.description"
:id="field.uuid + '-desc'"
dir="auto"
class="mb-3 px-1 field-description-text"
>
@@ -42,28 +43,28 @@
@input="onInputCode"
>
<div class="flex justify-between mt-2 -mb-2 md:-mb-4">
<a
<button
v-if="!defaultValue"
href="#"
type="button"
class="link change-phone-number-link"
@click.prevent="isCodeSent = false"
@click="isCodeSent = false"
>
{{ t('change_phone_number') }}
</a>
</button>
<span
v-if="resendCodeCountdown > 0"
class="link"
>
{{ t('wait_countdown_seconds').replace('{countdown}', resendCodeCountdown) }}
</span>
<a
<button
v-else
href="#"
type="button"
class="link resend-code-link"
@click.prevent="resendCode"
@click="resendCode"
>
{{ isResendLoading ? t('sending') : t('resend_code') }}
</a>
</button>
</div>
</div>
<div
@@ -107,6 +108,7 @@
type="tel"
inputmode="tel"
:required="field.required"
:aria-describedby="field.description ? field.uuid + '-desc' : undefined"
placeholder="234 567-8900"
@input="onPhoneInput"
@focus="$emit('focus')"
@@ -25,17 +25,21 @@
class="md:tooltip"
:data-tip="t('draw_signature')"
>
<a
<button
id="type_text_button"
href="#"
type="button"
:aria-label="t('draw_signature')"
class="btn btn-outline btn-sm font-medium type-text-button"
@click.prevent="[toggleTextInput(), hideQr()]"
@click="[toggleTextInput(), hideQr()]"
>
<IconSignature :width="16" />
<IconSignature
:width="16"
aria-hidden="true"
/>
<span class="hidden sm:inline">
{{ t('draw') }}
</span>
</a>
</button>
</span>
<span
v-else-if="withTypedSignature && format !== 'drawn_or_upload' && format !== 'typed_or_upload' && format !== 'typed' && format !== 'drawn' && format !== 'upload'"
@@ -43,17 +47,21 @@
:class="{ 'hidden sm:inline': modelValue || computedPreviousValue }"
:data-tip="t('type_text')"
>
<a
<button
id="type_text_button"
href="#"
type="button"
:aria-label="t('type_text')"
class="btn btn-outline btn-sm font-medium inline-flex flex-nowrap type-text-button"
@click.prevent="[toggleTextInput(), hideQr()]"
@click="[toggleTextInput(), hideQr()]"
>
<IconTextSize :width="16" />
<IconTextSize
:width="16"
aria-hidden="true"
/>
<span class="hidden sm:inline">
{{ t('type') }}
</span>
</a>
</button>
</span>
<span
v-if="format !== 'typed' && format !== 'drawn' && format !== 'upload' && format !== 'drawn_or_typed'"
@@ -61,10 +69,19 @@
:class="{ 'hidden sm:inline': modelValue || computedPreviousValue }"
:data-tip="t('take_photo')"
>
<label class="btn btn-outline btn-sm font-medium inline-flex flex-nowrap upload-image-button">
<IconCamera :width="16" />
<button
type="button"
:aria-label="t('take_photo')"
class="btn btn-outline btn-sm font-medium inline-flex flex-nowrap upload-image-button"
@click="$refs.takePhotoInput.click()"
>
<IconCamera
:width="16"
aria-hidden="true"
/>
<input
:key="uploadImageInputKey"
ref="takePhotoInput"
type="file"
hidden
accept="image/*"
@@ -73,49 +90,57 @@
<span class="hidden sm:inline">
{{ t('upload') }}
</span>
</label>
</button>
</span>
<a
<button
v-if="modelValue || computedPreviousValue"
href="#"
type="button"
class="btn btn-outline btn-sm font-medium reupload-button"
@click.prevent="remove"
@click="remove"
>
<IconReload :width="16" />
<IconReload
:width="16"
aria-hidden="true"
/>
{{ t(format === 'upload' ? 'reupload' : 'redraw') }}
</a>
</button>
<span
v-if="withQrButton && !modelValue && !computedPreviousValue && format !== 'typed_or_upload' && format !== 'typed' && format !== 'upload'"
class="md:tooltip before:translate-x-[-90%]"
:data-tip="t('sign_on_the_touchscreen')"
>
<a
href="#"
<button
type="button"
:aria-label="t('sign_on_the_touchscreen')"
class="btn btn-sm btn-neutral font-medium hidden md:flex"
:class="{ 'btn-outline': !isShowQr, 'text-white': isShowQr }"
@click.prevent="isShowQr ? hideQr() : [isTextSignature = false, showQr()]"
@click="isShowQr ? hideQr() : [isTextSignature = false, showQr()]"
>
<IconQrcode
:width="19"
:height="19"
aria-hidden="true"
/>
</a>
</button>
</span>
<a
href="#"
<button
type="button"
:title="t('minimize')"
:aria-label="t('minimize')"
class="py-1.5 inline md:hidden"
@click.prevent="$emit('minimize')"
@click="$emit('minimize')"
>
<IconArrowsDiagonalMinimize2
:width="20"
:height="20"
aria-hidden="true"
/>
</a>
</button>
</div>
</div>
<div
v-if="field.description"
:id="field.uuid + '-desc'"
dir="auto"
class="mb-3 px-1 field-description-text"
>
@@ -136,6 +161,7 @@
<img
v-if="modelValue || computedPreviousValue"
:src="attachmentsIndex[modelValue || computedPreviousValue].url"
:alt="field.name || t('signature')"
class="mx-auto bg-white border border-base-300 rounded max-h-44"
>
<FileDropzone
@@ -154,14 +180,17 @@
v-if="!modelValue && !computedPreviousValue && !isShowQr && !isTextSignature && isSignatureStarted"
class="absolute top-0.5 right-0.5"
>
<a
href="#"
<button
type="button"
class="btn btn-ghost font-medium btn-xs md:btn-sm"
@click.prevent="[clear(), hideQr()]"
@click="[clear(), hideQr()]"
>
<IconReload :width="16" />
<IconReload
:width="16"
aria-hidden="true"
/>
{{ t('clear') }}
</a>
</button>
</div>
<div
v-if="isTextSignature"
@@ -170,6 +199,8 @@
<canvas
v-show="!modelValue && !computedPreviousValue"
ref="canvas"
role="application"
:aria-label="t('signature_drawing_area')"
style="padding: 1px; 0"
class="bg-white border border-base-300 rounded-2xl w-full draw-canvas"
/>
@@ -182,13 +213,14 @@
class="top-0 bottom-0 right-0 left-0 absolute bg-base-content/10 rounded-2xl"
>
<div class="absolute top-1.5 right-1.5">
<a
href="#"
<button
type="button"
class="btn btn-sm btn-circle btn-normal btn-outline"
@click.prevent="hideQr"
:aria-label="t('close')"
@click="hideQr"
>
<IconX />
</a>
<IconX aria-hidden="true" />
</button>
</div>
<div class="flex items-center justify-center w-full h-full p-4">
<div
@@ -512,6 +544,7 @@ export default {
this.resizeObserver = new ResizeObserver(() => {
requestAnimationFrame(() => {
if (!this.$refs.canvas) return
if (!this.$refs.canvas.parentNode?.clientWidth) return
const { width, height } = this.$refs.canvas
@@ -554,7 +587,7 @@ export default {
redrawCanvas (oldWidth, oldHeight) {
const canvas = this.$refs.canvas
if (this.pad && !this.isTextSignature && !this.pad.isEmpty() && oldWidth > 0 && oldHeight > 0) {
if (this.pad && !this.isTextSignature && !this.pad.isEmpty() && oldWidth > 0 && oldHeight > 0 && canvas.width > 0 && canvas.height > 0) {
const sx = canvas.width / oldWidth
const sy = canvas.height / oldHeight
+9 -5
View File
@@ -23,6 +23,7 @@
/>
<div
v-if="field.description"
:id="field.uuid + '-desc'"
dir="auto"
class="mb-3 px-1 field-description-text"
>
@@ -40,6 +41,7 @@
:class="{ '!pr-11 -mr-10': !field.validation?.pattern }"
:required="field.required"
:pattern="field.validation?.pattern"
:aria-describedby="field.description ? field.uuid + '-desc' : undefined"
:placeholder="`${t('type_here_')}${field.required ? '' : ` (${t('optional')})`}`"
type="text"
:name="`values[${field.uuid}]`"
@@ -54,6 +56,7 @@
v-model="text"
dir="auto"
class="base-textarea !text-2xl w-full"
:aria-describedby="field.description ? field.uuid + '-desc' : undefined"
:placeholder="`${t('type_here_')}${field.required ? '' : ` (${t('optional')})`}`"
:required="field.required"
:name="`values[${field.uuid}]`"
@@ -65,13 +68,14 @@
class="tooltip"
:data-tip="t('toggle_multiline_text')"
>
<a
href="#"
<button
type="button"
:aria-label="t('toggle_multiline_text')"
class="btn btn-ghost btn-circle btn-sm toggle-multiline-text-button"
@click.prevent="toggleTextArea"
@click="toggleTextArea"
>
<IconAlignBoxLeftTop />
</a>
<IconAlignBoxLeftTop aria-hidden="true" />
</button>
</div>
</div>
</template>
@@ -30,6 +30,7 @@
<IconInnerShadowTop
width="40"
class="animate-spin h-10"
aria-hidden="true"
/>
</div>
<div v-else-if="redirectUrl">
+48 -8
View File
@@ -113,7 +113,7 @@
<div
ref="textContainer"
class="flex items-center px-0.5"
:style="{ color: field.preferences?.color }"
:style="{ color: isConditionMatch ? field.preferences?.color : '#9ca3af' }"
:class="{ 'w-full h-full': isWFullType }"
>
<IconCheck
@@ -131,13 +131,13 @@
/>
</template>
<span
v-else-if="field.type === 'number' && !isValueInput && (field.default_value || field.default_value == 0)"
v-else-if="field.type === 'number' && !isContenteditable && (displayValue || displayValue == 0)"
class="whitespace-pre-wrap"
>{{ formatNumber(field.default_value, field.preferences?.format) }}</span>
>{{ formatNumber(displayValue, field.preferences?.format) }}</span>
<span
v-else-if="field.default_value === '{{date}}'"
>
{{ t('signing_date') }}
{{ /[HhAasz]/.test(field.preferences?.format || '') ? t('signing_date_and_time') : t('signing_date') }}
</span>
<div
v-else-if="field.type === 'cells' && field.default_value"
@@ -183,12 +183,12 @@
:contenteditable="isValueInput"
class="whitespace-pre-wrap outline-none empty:before:content-[attr(placeholder)] before:text-base-content/30"
:class="{ 'cursor-text': isValueInput }"
:placeholder="withFieldPlaceholder && !isValueInput ? defaultField?.title || field.title || field.name || defaultName : (field.type === 'date' ? field.preferences?.format || t('type_value') : t('type_value'))"
:placeholder="withFieldPlaceholder && !isValueInput ? defaultField?.title || field.title || field.name || defaultName : (isConditionMatch ? (field.type === 'date' ? field.preferences?.format || t('type_value') : t('type_value')) : '')"
@blur="onDefaultValueBlur"
@focus="selectedAreasRef.value = [area]"
@paste.prevent="onPaste"
@keydown.enter="onDefaultValueEnter"
>{{ field.default_value }}</span>
>{{ displayValue }}</span>
</div>
</div>
<component
@@ -241,6 +241,16 @@ export default {
required: false,
default: false
},
conditionalFieldIndex: {
type: Object,
required: false,
default: () => ({})
},
formulaValuesIndex: {
type: Object,
required: false,
default: () => ({})
},
isDraw: {
type: Boolean,
required: false,
@@ -323,11 +333,27 @@ export default {
fieldNames: FieldType.computed.fieldNames,
fieldLabels: FieldType.computed.fieldLabels,
fieldIcons: FieldType.computed.fieldIcons,
isConditionMatch () {
return !this.inputMode || this.conditionalFieldIndex[this.field.uuid] !== false
},
displayValue () {
if (this.field.preferences?.formula && this.field.type !== 'payment') {
const computed = this.formulaValuesIndex[this.field.uuid]
if (computed != null) {
return computed
}
}
return this.field.default_value
},
bgClasses () {
if (this.field.type === 'heading') {
return 'bg-gray-50'
} else if (this.field.type === 'strikethrough') {
return 'bg-transparent'
} else if (!this.isConditionMatch) {
return 'bg-gray-100'
} else {
return this.bgColors[this.submitterIndex % this.bgColors.length]
}
@@ -337,6 +363,8 @@ export default {
return ''
} else if (this.field.type === 'strikethrough') {
return 'border-dashed border-gray-300'
} else if (!this.isConditionMatch) {
return 'border-gray-300'
} else {
return this.borderColors[this.submitterIndex % this.borderColors.length]
}
@@ -390,7 +418,7 @@ export default {
return this.basePageWidth / 612.0
},
isDefaultValuePresent () {
return this.field?.default_value || this.field?.default_value === 0
return this.field?.default_value || this.field?.default_value === 0 || this.displayValue || this.displayValue === 0
},
isSelectInput () {
return this.inputMode && (this.field.type === 'select' || (this.field.type === 'radio' && this.field.areas?.length < 2))
@@ -399,6 +427,8 @@ export default {
return this.inputMode && (this.field.type === 'checkbox' || (['radio', 'multiple'].includes(this.field.type) && this.area.option_uuid))
},
isValueInput () {
if (this.inputMode && this.field.preferences?.formula) return false
return (this.field.type === 'heading' && this.isHeadingSelected) || this.isContenteditable ||
(this.inputMode && (['text', 'number'].includes(this.field.type) || (this.field.type === 'date' && this.field.default_value !== '{{date}}')))
},
@@ -511,7 +541,7 @@ export default {
return option?.value || `${this.t('option')} ${this.field.options.indexOf(option) + 1}`
},
maybeToggleDefaultValue () {
if (!this.editable || this.isCmdKeyRef.value) {
if (!this.editable || this.isCmdKeyRef.value || this.field.preferences?.formula) {
return
}
@@ -559,6 +589,10 @@ export default {
}
},
focusValueInput (e) {
if (this.inputMode && this.field.type === 'number' && !this.isContenteditable && !this.field.preferences?.formula) {
this.isContenteditable = true
}
this.$nextTick(() => {
if (this.$refs.defaultValue && this.$refs.defaultValue !== document.activeElement) {
this.$refs.defaultValue.focus()
@@ -624,6 +658,12 @@ export default {
}
},
onDefaultValueBlur (e) {
if (this.field.preferences?.formula) {
this.isContenteditable = false
return
}
const text = this.$refs.defaultValue.innerText.trim()
this.isContenteditable = false
+331 -47
View File
@@ -381,6 +381,9 @@
:document="document"
:is-drag="!!dragField"
:input-mode="inputMode"
:conditional-field-index="conditionalFieldIndex"
:formula-values-index="formulaValuesIndex"
:page-preview-format="pagePreviewFormat"
:default-fields="[...defaultRequiredFields, ...defaultFields]"
:allow-draw="!onlyDefinedFields || drawField || drawCustomField"
:with-signature-id="withSignatureId"
@@ -504,11 +507,14 @@
:with-custom-fields="withCustomFields"
:with-fields-search="withFieldsSearch"
:default-fields="[...defaultRequiredFields, ...defaultFields]"
:with-custom-fields-tab="withCustomFieldsTab"
:template="template"
:default-required-fields="defaultRequiredFields"
:detect-custom-fields-index="detectCustomFieldsIndex"
:field-types="fieldTypes"
:with-sticky-submitters="withStickySubmitters"
:with-fields-detection="withFieldsDetection"
:with-detect-existing-fields="withDetectExistingFields"
:with-signature-id="withSignatureId"
:with-prefillable="withPrefillable"
:only-defined-fields="onlyDefinedFields"
@@ -617,6 +623,16 @@ import { v4 } from 'uuid'
import { ref, computed, toRaw, defineAsyncComponent } from 'vue'
import * as i18n from './i18n'
const isEmpty = (obj) => {
if (obj == null) return true
if (Array.isArray(obj)) return obj.length === 0
if (typeof obj === 'string') return obj.trim().length === 0
if (typeof obj === 'object') return Object.keys(obj).length === 0
if (obj === false) return true
return false
}
export default {
name: 'TemplateBuilder',
components: {
@@ -654,6 +670,7 @@ export default {
locale: this.locale,
baseFetch: this.baseFetch,
fieldTypes: this.fieldTypes,
dateFormats: this.dateFormats,
backgroundColor: this.backgroundColor,
withPhone: this.withPhone,
withVerification: this.withVerification,
@@ -738,6 +755,11 @@ export default {
required: false,
default: false
},
withDetectExistingFields: {
type: Boolean,
required: false,
default: false
},
withCustomFields: {
type: Boolean,
required: false,
@@ -778,6 +800,11 @@ export default {
required: false,
default: () => []
},
withCustomFieldsTab: {
type: Boolean,
required: false,
default: false
},
withSelectedFieldType: {
type: Boolean,
required: false,
@@ -798,6 +825,11 @@ export default {
required: false,
default: () => []
},
dateFormats: {
type: Array,
required: false,
default: () => []
},
defaultSubmitters: {
type: Array,
required: false,
@@ -808,6 +840,11 @@ export default {
required: false,
default: () => []
},
pagePreviewFormat: {
type: String,
required: false,
default: '.jpg'
},
acceptFileTypes: {
type: String,
required: false,
@@ -953,6 +990,7 @@ export default {
isLoadingBlankPage: false,
isSaving: false,
isDetectingPageFields: false,
detectFieldsQueue: [],
detectingAnalyzingProgress: null,
detectingFieldsAddedCount: null,
selectedSubmitter: null,
@@ -963,7 +1001,8 @@ export default {
drawCustomField: null,
drawOption: null,
dragField: null,
isDragFile: false
isDragFile: false,
isMathLoaded: false
}
},
computed: {
@@ -973,6 +1012,13 @@ export default {
fieldsDragFieldRef: () => ref(),
customDragFieldRef: () => ref(),
selectedAreasRef: () => ref([]),
attachmentUuidsIndex () {
return this.template.schema.reduce((acc, e, index) => {
acc[e.attachment_uuid] = index
return acc
}, {})
},
language () {
return this.locale.split('-')[0].toLowerCase()
},
@@ -1003,6 +1049,8 @@ export default {
return isMobileSafariIos || /android|iphone|ipad/i.test(navigator.userAgent)
},
defaultDateFormat () {
if (this.dateFormats.length) return this.dateFormats[0]
const isUsBrowser = Intl.DateTimeFormat().resolvedOptions().locale.endsWith('-US')
const isUsTimezone = new Intl.DateTimeFormat('en-US', { timeZoneName: 'short' }).format(new Date()).match(/\s(?:CST|CDT|PST|PDT|EST|EDT)$/)
@@ -1044,6 +1092,43 @@ export default {
return map
},
fieldsUuidIndex () {
return this.template.fields.reduce((acc, f) => {
acc[f.uuid] = f
return acc
}, {})
},
conditionalFieldIndex () {
if (!this.inputMode) return {}
const cache = {}
return this.template.fields.reduce((acc, f) => {
acc[f.uuid] = this.checkFieldConditions(f, cache)
return acc
}, {})
},
formulaValuesIndex () {
const formulaFields = this.template.fields.filter((f) => f.preferences?.formula && f.type !== 'payment' && this.hasFormulaDependencyValue(f))
if (!formulaFields.length) return {}
if (!this.isMathLoaded) {
this.loadCalculator()
return {}
}
return formulaFields.reduce((acc, f) => {
if (this.conditionalFieldIndex[f.uuid] !== false) {
acc[f.uuid] = this.calculateFormula(f)
}
return acc
}, {})
},
isAllRequiredFieldsAdded () {
return !this.defaultRequiredFields?.some((f) => {
return !this.template.fields?.some((field) => field.name === f.name)
@@ -1052,6 +1137,39 @@ export default {
selectedField () {
return this.template.fields.find((f) => f.areas?.includes(this.lastSelectedArea))
},
detectFieldsIndex () {
const submittersByUuid = {}
this.template.submitters.forEach((s) => {
submittersByUuid[s.uuid] = s
})
const index = {}
this.template.fields.forEach((f) => {
if (!f.name) return
const role = submittersByUuid[f.submitter_uuid]?.name
const key = [f.name, role].filter(Boolean).join(':').toLowerCase()
if (!index[key]) index[key] = f
})
return index
},
detectCustomFieldsIndex () {
const index = {}
;[...this.customFields, ...this.defaultRequiredFields, ...this.defaultFields].forEach((c) => {
if (!c.name) return
const key = [c.name, c.role].filter(Boolean).join(':').toLowerCase()
if (!index[key]) index[key] = c
})
return index
},
sortedDocuments () {
return this.template.schema.map((item) => {
return this.template.documents.find(doc => doc.uuid === item.attachment_uuid)
@@ -1097,6 +1215,16 @@ export default {
}
})
const deduplicateUuidsIndex = {}
this.template.submitters.forEach((submitter) => {
if (deduplicateUuidsIndex[submitter.uuid]) {
submitter.uuid = v4()
}
deduplicateUuidsIndex[submitter.uuid] = true
})
this.selectedSubmitter = this.template.submitters[0]
},
mounted () {
@@ -1137,6 +1265,132 @@ export default {
},
methods: {
toRaw,
applyCustomFieldAttributes: Fields.methods.applyCustomFieldAttributes,
buildExistingFields: Fields.methods.buildExistingFields,
async loadCalculator () {
if (this.math) return
const { Calculator } = await import('../submission_form/calculator')
this.math = new Calculator()
this.isMathLoaded = true
},
optionValue (option, index) {
if (option.value) {
return option.value
} else {
return `${this.t('option')} ${index + 1}`
}
},
checkFieldConditions (field, cache = {}) {
const cacheKey = field.uuid || field.attachment_uuid
if (cache[cacheKey] !== undefined) {
return cache[cacheKey]
}
if (field.conditions?.length) {
const result = field.conditions.reduce((acc, cond) => {
if (cond.operation === 'or') {
acc.push(acc.pop() || this.checkFieldCondition(cond, cache))
} else {
acc.push(this.checkFieldCondition(cond, cache))
}
return acc
}, [])
cache[cacheKey] = !result.includes(false)
} else {
cache[cacheKey] = true
}
return cache[cacheKey]
},
checkFieldCondition (condition, cache = {}) {
const field = this.fieldsUuidIndex[condition.field_uuid]
if (['not_empty', 'checked', 'equal', 'contains', 'greater_than', 'less_than'].includes(condition.action) && field && !this.checkFieldConditions(field, cache)) {
return false
}
const defaultValue = !field || isEmpty(field.default_value) ? null : field.default_value
if (['empty', 'unchecked'].includes(condition.action)) {
return isEmpty(defaultValue)
} else if (['not_empty', 'checked'].includes(condition.action)) {
return !isEmpty(defaultValue)
} else if (field?.type === 'number' && ['equal', 'not_equal', 'greater_than', 'less_than'].includes(condition.action)) {
const value = defaultValue
if (isEmpty(value) || isEmpty(condition.value)) return false
const actual = parseFloat(value)
const expected = parseFloat(condition.value)
if (Number.isNaN(actual) || Number.isNaN(expected)) return false
if (condition.action === 'equal') return Math.abs(actual - expected) < Number.EPSILON
if (condition.action === 'not_equal') return Math.abs(actual - expected) > Number.EPSILON
if (condition.action === 'greater_than') return actual > expected
if (condition.action === 'less_than') return actual < expected
return false
} else if (['equal', 'contains'].includes(condition.action) && field) {
if (field.options) {
const option = field.options.find((o) => o.uuid === condition.value)
if (option) {
const values = [defaultValue].flat()
return values.includes(this.optionValue(option, field.options.indexOf(option)))
} else {
return false
}
} else {
return [defaultValue].flat().includes(condition.value)
}
} else if (['not_equal', 'does_not_contain'].includes(condition.action) && field) {
if (field.options) {
const option = field.options.find((o) => o.uuid === condition.value)
if (option) {
const values = [defaultValue].flat()
return !values.includes(this.optionValue(option, field.options.indexOf(option)))
} else {
return false
}
} else {
return false
}
} else {
return true
}
},
normalizeFormula (formula, depth = 0) {
if (depth > 10) return formula
return formula.replace(/{{(.*?)}}/g, (match, uuid) => {
if (this.fieldsUuidIndex[uuid]?.preferences?.formula) {
return `(${this.normalizeFormula(this.fieldsUuidIndex[uuid].preferences.formula, depth + 1)})`
} else {
return match
}
})
},
calculateFormula (field) {
const transformedFormula = this.normalizeFormula(field.preferences.formula).replace(/{{(.*?)}}/g, (match, uuid) => {
return this.fieldsUuidIndex[uuid]?.default_value || 0.0
})
return this.math.evaluate(transformedFormula.toLowerCase())
},
hasFormulaDependencyValue (field) {
const normalized = this.normalizeFormula(field.preferences.formula)
return [...normalized.matchAll(/{{(.*?)}}/g)].some(([, uuid]) => !isEmpty(this.fieldsUuidIndex[uuid]?.default_value))
},
addCustomField (field) {
return this.$refs.fields.addCustomField(field)
},
@@ -1416,33 +1670,26 @@ export default {
this.save()
}
},
compareAreas (a, b) {
const aAttIdx = this.attachmentUuidsIndex[a.attachment_uuid]
const bAttIdx = this.attachmentUuidsIndex[b.attachment_uuid]
if (aAttIdx !== bAttIdx) return aAttIdx - bAttIdx
if (a.page !== b.page) return a.page - b.page
const aY = a.y + a.h
const bY = b.y + b.h
if (Math.abs(aY - bY) < 0.01) return a.x - b.x
if (a.h < b.h ? a.y >= b.y && aY <= bY : b.y >= a.y && bY <= aY) return a.x - b.x
return aY - bY
},
findFieldInsertIndex (field) {
if (!field.areas?.length) return -1
const area = field.areas[0]
const attachmentUuidsIndex = this.template.schema.reduce((acc, e, index) => {
acc[e.attachment_uuid] = index
return acc
}, {})
const compareAreas = (a, b) => {
const aAttIdx = attachmentUuidsIndex[a.attachment_uuid]
const bAttIdx = attachmentUuidsIndex[b.attachment_uuid]
if (aAttIdx !== bAttIdx) return aAttIdx - bAttIdx
if (a.page !== b.page) return a.page - b.page
const aY = a.y + a.h
const bY = b.y + b.h
if (Math.abs(aY - bY) < 0.01) return a.x - b.x
if (a.h < b.h ? a.y >= b.y && aY <= bY : b.y >= a.y && bY <= aY) return a.x - b.x
return aY - bY
}
let closestBeforeIndex = -1
let closestBeforeArea = null
let closestAfterIndex = -1
@@ -1451,15 +1698,15 @@ export default {
this.template.fields.forEach((f, index) => {
if (f.submitter_uuid === field.submitter_uuid) {
(f.areas || []).forEach((a) => {
const cmp = compareAreas(a, area)
const cmp = this.compareAreas(a, area)
if (cmp < 0) {
if (!closestBeforeArea || (compareAreas(a, closestBeforeArea) > 0 && closestBeforeIndex < index)) {
if (!closestBeforeArea || (this.compareAreas(a, closestBeforeArea) > 0 && closestBeforeIndex < index)) {
closestBeforeIndex = index
closestBeforeArea = a
}
} else {
if (!closestAfterArea || (compareAreas(a, closestAfterArea) < 0 && closestAfterIndex > index)) {
if (!closestAfterArea || (this.compareAreas(a, closestAfterArea) < 0 && closestAfterIndex > index)) {
closestAfterIndex = index
closestAfterArea = a
}
@@ -1482,6 +1729,41 @@ export default {
this.template.fields.push(field)
}
},
insertArea (field, area) {
field.areas ||= []
const insertIndex = field.areas.findIndex((a) => this.compareAreas(a, area) > 0)
if (insertIndex === -1) {
field.areas.push(area)
} else {
field.areas.splice(insertIndex, 0, area)
}
},
insertDetectedField (field) {
if (!this.withDetectExistingFields || !field.name) {
this.insertField(field)
return
}
const role = this.template.submitters.find((s) => s.uuid === field.submitter_uuid)?.name
const nameKey = field.name.toLowerCase()
const indexKey = [field.name, role].filter(Boolean).join(':').toLowerCase()
const existingField = this.detectFieldsIndex[indexKey]
if (existingField) {
existingField.areas = existingField.areas || []
field.areas.forEach((area) => this.insertArea(existingField, area))
} else {
const customField = this.detectCustomFieldsIndex[indexKey] || this.detectCustomFieldsIndex[nameKey]
if (customField) this.applyCustomFieldAttributes(field, customField)
this.insertField(field)
}
},
closeDropdown () {
document.activeElement.blur()
},
@@ -1978,7 +2260,7 @@ export default {
fieldUuidIndex[field.uuid] = newField
newField.areas.push(newArea)
this.insertArea(newField, newArea)
newAreas.push(newArea)
if (['radio', 'multiple'].includes(field.type) && field.options?.length) {
@@ -2091,17 +2373,7 @@ export default {
area.y -= area.h / 2
}
this.drawField.areas ||= []
const insertBeforeAreaIndex = this.drawField.areas.findIndex((a) => {
return a.attachment_uuid === area.attachment_uuid && a.page > area.page
})
if (insertBeforeAreaIndex !== -1) {
this.drawField.areas.splice(insertBeforeAreaIndex, 0, area)
} else {
this.drawField.areas.push(area)
}
this.insertArea(this.drawField, area)
if (this.template.fields.indexOf(this.drawField) === -1) {
this.insertField(this.drawField)
@@ -2242,9 +2514,7 @@ export default {
delete field.height
}
field.areas ||= []
field.areas.push(fieldArea)
this.insertArea(field, fieldArea)
if (this.selectedAreasRef.value.length < 2) {
this.selectedAreasRef.value = [fieldArea]
@@ -2314,7 +2584,7 @@ export default {
}
}
field.areas.push(fieldArea)
this.insertArea(field, fieldArea)
})
} else {
const fieldArea = {
@@ -2773,6 +3043,12 @@ export default {
})
},
detectFieldsForPage ({ page, attachmentUuid }) {
if (this.isDetectingPageFields) {
this.detectFieldsQueue.push({ page, attachmentUuid })
return
}
this.isDetectingPageFields = true
this.detectingAnalyzingProgress = null
this.detectingFieldsAddedCount = null
@@ -2811,7 +3087,11 @@ export default {
this.baseFetch(`/templates/${this.template.id}/detect_fields`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ attachment_uuid: attachmentUuid, page })
body: JSON.stringify({
attachment_uuid: attachmentUuid,
page,
...(this.withDetectExistingFields ? { fields: this.buildExistingFields() } : {})
})
}).then(async (response) => {
const reader = response.body.getReader()
const decoder = new TextDecoder('utf-8')
@@ -2840,7 +3120,7 @@ export default {
if (!f.submitter_uuid) {
f.submitter_uuid = this.template.submitters[0].uuid
}
this.insertField(f)
this.insertDetectedField(f)
})
totalFieldsAdded += errorFields.length
@@ -2869,7 +3149,7 @@ export default {
const nonOverlappingFields = filterNonOverlappingFields(finalFields)
nonOverlappingFields.forEach((f) => this.insertField(f))
nonOverlappingFields.forEach((f) => this.insertDetectedField(f))
totalFieldsAdded += nonOverlappingFields.length
if (nonOverlappingFields.length) {
@@ -2907,7 +3187,7 @@ export default {
const nonOverlappingFields = filterNonOverlappingFields(finalFields)
nonOverlappingFields.forEach((f) => this.insertField(f))
nonOverlappingFields.forEach((f) => this.insertDetectedField(f))
totalFieldsAdded += nonOverlappingFields.length
if (nonOverlappingFields.length) {
@@ -2925,7 +3205,7 @@ export default {
const nonOverlappingFields = filterNonOverlappingFields(finalFields)
nonOverlappingFields.forEach((f) => this.insertField(f))
nonOverlappingFields.forEach((f) => this.insertDetectedField(f))
totalFieldsAdded += nonOverlappingFields.length
if (nonOverlappingFields.length) {
@@ -2958,6 +3238,10 @@ export default {
setTimeout(() => {
this.detectingFieldsAddedCount = null
}, 1000)
if (this.detectFieldsQueue.length) {
this.detectFieldsForPage(this.detectFieldsQueue.shift())
}
})
},
save ({ force } = { force: false }) {
+18 -1
View File
@@ -5,6 +5,8 @@
:key="image.id"
:ref="setPageRefs"
:input-mode="inputMode"
:conditional-field-index="conditionalFieldIndex"
:formula-values-index="formulaValuesIndex"
:number="index"
:editable="editable"
:data-page="index"
@@ -64,6 +66,16 @@ export default {
required: false,
default: false
},
conditionalFieldIndex: {
type: Object,
required: false,
default: () => ({})
},
formulaValuesIndex: {
type: Object,
required: false,
default: () => ({})
},
areasIndex: {
type: Object,
required: false,
@@ -138,6 +150,11 @@ export default {
required: false,
default: false
},
pagePreviewFormat: {
type: String,
required: false,
default: '.jpg'
},
withFieldsDetection: {
type: Boolean,
required: false,
@@ -168,7 +185,7 @@ export default {
return this.previewImagesIndex[i] || reactive({
metadata: { ...lazyloadMetadata },
id: Math.random().toString(),
url: this.basePreviewUrl + `/preview/${this.document.signed_key || this.document.signed_uuid || this.document.uuid}/${i}.jpg`
url: this.basePreviewUrl + `/preview/${this.document.signed_key || this.document.signed_uuid || this.document.uuid}/${i}${this.pagePreviewFormat}`
})
})
},
@@ -25,6 +25,13 @@ tiptapStylesheet.replaceSync(
-webkit-font-variant-ligatures: none;
font-variant-ligatures: none;
font-feature-settings: "liga" 0;
display: flex;
flex-flow: column nowrap;
min-height: inherit;
}
.ProseMirror > article {
margin-bottom: auto;
}
.ProseMirror [contenteditable="false"] {
@@ -188,6 +195,8 @@ const CustomHeading = Node.create({
const SectionNode = createBlockNode('section', 'section')
const ArticleNode = createBlockNode('article', 'article')
const HeaderNode = createBlockNode('header', 'header')
const FooterNode = createBlockNode('footer', 'footer')
const DivNode = createBlockNode('div', 'div')
const BlockquoteNode = createBlockNode('blockquote', 'blockquote')
const PreNode = createBlockNode('pre', 'pre')
@@ -738,14 +747,12 @@ export function buildEditor ({ dynamicAreaProps, attachmentsIndex, renderHtmlFor
History,
Gapcursor,
Dropcursor,
CustomBold,
CustomItalic,
CustomUnderline,
CustomStrike,
CustomParagraph,
CustomHeading,
SectionNode,
ArticleNode,
HeaderNode,
FooterNode,
DivNode,
BlockquoteNode,
PreNode,
@@ -764,6 +771,10 @@ export function buildEditor ({ dynamicAreaProps, attachmentsIndex, renderHtmlFor
EmptySpanNode,
LinkMark,
SpanMark,
CustomBold,
CustomItalic,
CustomUnderline,
CustomStrike,
SubscriptMark,
SuperscriptMark,
VariableHighlight,
@@ -119,7 +119,7 @@
@change="[schema.format = $event.target.value, save()]"
>
<option
v-for="format in dateFormats"
v-for="format in availableDateFormats"
:key="format"
:value="format"
>{{ formatDate(new Date(), format) }}</option>
@@ -248,7 +248,7 @@ export default {
FieldType,
IconSettings
},
inject: ['t', 'save', 'backgroundColor'],
inject: ['t', 'save', 'backgroundColor', 'dateFormats'],
provide () {
return {
fieldTypes: ['text', 'number', 'date', 'checkbox', 'radio', 'select']
@@ -318,20 +318,23 @@ export default {
'space'
]
},
dateFormats () {
const formats = [
'MM/DD/YYYY',
'DD/MM/YYYY',
'YYYY-MM-DD',
'DD-MM-YYYY',
'DD.MM.YYYY',
'MMM D, YYYY',
'MMMM D, YYYY',
'D MMM YYYY',
'D MMMM YYYY'
]
availableDateFormats () {
const formats = this.dateFormats.length
? [...this.dateFormats]
: [
'MM/DD/YYYY',
'DD/MM/YYYY',
'YYYY-MM-DD',
'DD-MM-YYYY',
'DD.MM.YYYY',
'MMM D, YYYY',
'MMMM D, YYYY',
'MMMM YYYY',
'D MMM YYYY',
'D MMMM YYYY'
]
if (Intl.DateTimeFormat().resolvedOptions().timeZone?.includes('Seoul') || navigator.language?.startsWith('ko')) {
if (!this.dateFormats.length && (Intl.DateTimeFormat().resolvedOptions().timeZone?.includes('Seoul') || navigator.language?.startsWith('ko'))) {
formats.push('YYYY년 MM월 DD일')
}
@@ -401,18 +404,47 @@ export default {
formatDate (date, format) {
const monthFormats = { M: 'numeric', MM: '2-digit', MMM: 'short', MMMM: 'long' }
const dayFormats = { D: 'numeric', DD: '2-digit' }
const yearFormats = { YYYY: 'numeric', YY: '2-digit' }
const yearFormats = { YYYY: 'numeric', YYY: 'numeric', YY: '2-digit' }
const hourFormats = { H: 'numeric', HH: '2-digit', h: 'numeric', hh: '2-digit' }
const minuteFormats = { m: 'numeric', mm: '2-digit' }
const secondFormats = { s: 'numeric', ss: '2-digit' }
const parts = new Intl.DateTimeFormat([], {
const opts = {
day: dayFormats[format.match(/D+/)],
month: monthFormats[format.match(/M+/)],
year: yearFormats[format.match(/Y+/)]
}).formatToParts(date)
}
return format
.replace(/D+/, parts.find((p) => p.type === 'day').value)
.replace(/M+/, parts.find((p) => p.type === 'month').value)
.replace(/Y+/, parts.find((p) => p.type === 'year').value)
if (format.match(/H+/)) { opts.hour = hourFormats[format.match(/H+/)[0]]; opts.hour12 = false }
if (format.match(/h+/)) { opts.hour = hourFormats[format.match(/h+/)[0]]; opts.hour12 = true }
if (/[Aa]/.test(format) && opts.hour12 === undefined) opts.hour12 = true
if (format.match(/m+/)) opts.minute = minuteFormats[format.match(/m+/)[0]]
if (format.match(/s+/)) opts.second = secondFormats[format.match(/s+/)[0]]
if (/z/.test(format)) opts.timeZoneName = 'short'
const partTypes = {
M: 'month',
D: 'day',
Y: 'year',
H: 'hour',
h: 'hour',
m: 'minute',
s: 'second',
z: 'timeZoneName',
A: 'dayPeriod',
a: 'dayPeriod'
}
const parts = new Intl.DateTimeFormat([], opts).formatToParts(date)
return format.replace(/MMMM|MMM|MM|M|DD|D|YYYY|YYY|YY|HH|hh|H|h|mm|m|ss|s|A|a|z/g, (token) => {
const value = parts.find((p) => p.type === partTypes[token[0]])?.value
if (token === 'A') return (value || '').toUpperCase()
if (token === 'a') return (value || '').toLowerCase()
return value
})
},
closeDropdown () {
this.$el.getRootNode().activeElement.blur()
+3 -2
View File
@@ -345,7 +345,7 @@ export default {
IconMathFunction,
FieldType
},
inject: ['template', 'backgroundColor', 'selectedAreasRef', 't', 'locale', 'getFieldTypeIndex'],
inject: ['template', 'backgroundColor', 'selectedAreasRef', 't', 'locale', 'getFieldTypeIndex', 'dateFormats'],
props: {
field: {
type: Object,
@@ -428,7 +428,8 @@ export default {
if (this.field.type === 'date') {
this.field.preferences.format ||=
({ 'de-DE': 'DD.MM.YYYY' }[this.locale] || ((Intl.DateTimeFormat().resolvedOptions().locale.endsWith('-US') || new Intl.DateTimeFormat('en-US', { timeZoneName: 'short' }).format(new Date()).match(/\s(?:CST|CDT|PST|PDT|EST|EDT)$/)) ? 'MM/DD/YYYY' : 'DD/MM/YYYY'))
this.dateFormats[0] ||
({ 'de-DE': 'DD.MM.YYYY' }[this.locale] || ((Intl.DateTimeFormat().resolvedOptions().locale.endsWith('-US') || new Intl.DateTimeFormat('en-US', { timeZoneName: 'short' }).format(new Date()).match(/\s(?:CST|CDT|PST|PDT|EST|EDT)$/)) ? 'MM/DD/YYYY' : 'DD/MM/YYYY'))
}
},
methods: {
@@ -514,7 +514,7 @@ export default {
ContextSubmenu,
ContextModal
},
inject: ['t', 'getFieldTypeIndex', 'template', 'withCustomFields', 'currencies'],
inject: ['t', 'getFieldTypeIndex', 'template', 'withCustomFields', 'currencies', 'dateFormats'],
props: {
contextMenu: {
type: Object,
@@ -580,7 +580,7 @@ export default {
fieldNames: FieldType.computed.fieldNames,
fieldLabels: FieldType.computed.fieldLabels,
validationOptions: FieldSettings.computed.validations,
dateFormats: FieldSettings.computed.dateFormats,
availableDateFormats: FieldSettings.computed.availableDateFormats,
numberFormats: FieldSettings.computed.numberFormats,
prefillableFieldTypes: FieldSettings.computed.prefillableFieldTypes,
verificationMethods: FieldSettings.computed.verificationMethods,
@@ -686,7 +686,7 @@ export default {
},
formatOptions () {
switch (this.field.type) {
case 'date': return this.dateFormats.map(f => ({ value: f, label: this.formatDate(new Date(), f) }))
case 'date': return this.availableDateFormats.map(f => ({ value: f, label: this.formatDate(new Date(), f) }))
case 'number': return this.numberFormats.map(f => ({ value: f, label: this.formatNumber(123456789.567, f) }))
case 'signature': return this.signatureFormats.map(f => ({ value: f, label: this.t(f) }))
default: return []
@@ -290,7 +290,7 @@
@change="$emit('save')"
>
<option
v-for="format in dateFormats"
v-for="format in availableDateFormats"
:key="format"
:value="format"
>
@@ -610,7 +610,7 @@ export default {
IconTypography,
IconX
},
inject: ['template', 't'],
inject: ['template', 't', 'dateFormats'],
props: {
field: {
type: Object,
@@ -701,20 +701,23 @@ export default {
'space'
]
},
dateFormats () {
const formats = [
'MM/DD/YYYY',
'DD/MM/YYYY',
'YYYY-MM-DD',
'DD-MM-YYYY',
'DD.MM.YYYY',
'MMM D, YYYY',
'MMMM D, YYYY',
'D MMM YYYY',
'D MMMM YYYY'
]
availableDateFormats () {
const formats = this.dateFormats.length
? [...this.dateFormats]
: [
'MM/DD/YYYY',
'DD/MM/YYYY',
'YYYY-MM-DD',
'DD-MM-YYYY',
'DD.MM.YYYY',
'MMM D, YYYY',
'MMMM D, YYYY',
'MMMM YYYY',
'D MMM YYYY',
'D MMMM YYYY'
]
if (Intl.DateTimeFormat().resolvedOptions().timeZone?.includes('Seoul') || navigator.language?.startsWith('ko')) {
if (!this.dateFormats.length && (Intl.DateTimeFormat().resolvedOptions().timeZone?.includes('Seoul') || navigator.language?.startsWith('ko'))) {
formats.push('YYYY년 MM월 DD일')
}
@@ -783,17 +786,27 @@ export default {
},
copyToAllPages (field) {
const areaString = JSON.stringify(field.areas[0])
const newAreas = []
const existingAreasIndex = field.areas.reduce((acc, area) => {
acc[`${area.attachment_uuid}-${area.page}`] = area
return acc
}, {})
this.template.schema.forEach((item) => {
const attachment = this.template.documents.find((d) => d.uuid === item.attachment_uuid)
this.template.documents.forEach((attachment) => {
const numberOfPages = attachment.metadata?.pdf?.number_of_pages || attachment.preview_images.length
for (let page = 0; page <= numberOfPages - 1; page++) {
if (!field.areas.find((area) => area.attachment_uuid === attachment.uuid && area.page === page)) {
field.areas.push({ ...JSON.parse(areaString), attachment_uuid: attachment.uuid, page })
}
const existing = existingAreasIndex[`${attachment.uuid}-${page}`]
newAreas.push(existing || { ...JSON.parse(areaString), attachment_uuid: attachment.uuid, page })
}
})
field.areas = newAreas
this.$emit('scroll-to', this.field.areas[this.field.areas.length - 1])
this.$emit('save')
@@ -819,33 +832,49 @@ export default {
return pattern?.match(/^\.{(?<min>\d+),(?<max>\d+)?}$/)?.groups || null
},
formatDate (date, format) {
const monthFormats = {
M: 'numeric',
MM: '2-digit',
MMM: 'short',
MMMM: 'long'
}
const monthFormats = { M: 'numeric', MM: '2-digit', MMM: 'short', MMMM: 'long' }
const dayFormats = { D: 'numeric', DD: '2-digit' }
const yearFormats = { YYYY: 'numeric', YYY: 'numeric', YY: '2-digit' }
const hourFormats = { H: 'numeric', HH: '2-digit', h: 'numeric', hh: '2-digit' }
const minuteFormats = { m: 'numeric', mm: '2-digit' }
const secondFormats = { s: 'numeric', ss: '2-digit' }
const dayFormats = {
D: 'numeric',
DD: '2-digit'
}
const yearFormats = {
YYYY: 'numeric',
YY: '2-digit'
}
const parts = new Intl.DateTimeFormat([], {
const opts = {
day: dayFormats[format.match(/D+/)],
month: monthFormats[format.match(/M+/)],
year: yearFormats[format.match(/Y+/)]
}).formatToParts(date)
}
return format
.replace(/D+/, parts.find((p) => p.type === 'day').value)
.replace(/M+/, parts.find((p) => p.type === 'month').value)
.replace(/Y+/, parts.find((p) => p.type === 'year').value)
if (format.match(/H+/)) { opts.hour = hourFormats[format.match(/H+/)[0]]; opts.hour12 = false }
if (format.match(/h+/)) { opts.hour = hourFormats[format.match(/h+/)[0]]; opts.hour12 = true }
if (/[Aa]/.test(format) && opts.hour12 === undefined) opts.hour12 = true
if (format.match(/m+/)) opts.minute = minuteFormats[format.match(/m+/)[0]]
if (format.match(/s+/)) opts.second = secondFormats[format.match(/s+/)[0]]
if (/z/.test(format)) opts.timeZoneName = 'short'
const partTypes = {
M: 'month',
D: 'day',
Y: 'year',
H: 'hour',
h: 'hour',
m: 'minute',
s: 'second',
z: 'timeZoneName',
A: 'dayPeriod',
a: 'dayPeriod'
}
const parts = new Intl.DateTimeFormat([], opts).formatToParts(date)
return format.replace(/MMMM|MMM|MM|M|DD|D|YYYY|YYY|YY|HH|hh|H|h|mm|m|ss|s|A|a|z/g, (token) => {
const value = parts.find((p) => p.type === partTypes[token[0]])?.value
if (token === 'A') return (value || '').toUpperCase()
if (token === 'a') return (value || '').toLowerCase()
return value
})
}
}
}
+119 -8
View File
@@ -65,13 +65,33 @@
@set-draw="$emit('set-draw', $event)"
/>
</div>
<div v-if="!isShowVariables && submitterDefaultFields.length && editable">
<hr class="mb-2">
<div
v-if="editable && withCustomFieldsTab"
class="tabs w-full mb-1.5 custom-fields-tabs"
>
<a
class="tab tab-bordered w-1/2 border-base-300 custom-fields-tab"
:class="{ 'tab-active': !showCustomTab }"
:style="{ '--tab-border': showCustomTab ? '0px' : '0.5px' }"
@click="setFieldsTab('default')"
>{{ t('default') }}</a>
<a
class="tab tab-bordered w-1/2 border-base-300 custom-fields-tab"
:class="{ 'tab-active': showCustomTab }"
:style="{ '--tab-border': showCustomTab ? '0.5px' : '0px' }"
@click="setFieldsTab('custom')"
>{{ t('custom') }}</a>
</div>
<div v-if="!isShowVariables && submitterDefaultFields.length && editable && (!withCustomFieldsTab || showCustomTab)">
<hr
v-if="!withCustomFieldsTab"
class="mb-1.5"
>
<template v-if="isShowFieldSearch">
<input
v-model="defaultFieldsSearch"
:placeholder="t('search_field')"
class="input input-ghost input-xs px-0 text-base mb-2 !outline-0 !rounded bg-transparent w-full"
class="input input-ghost input-xs px-0 text-base mb-1.5 !outline-0 !rounded bg-transparent w-full"
>
<hr class="mb-2">
</template>
@@ -218,7 +238,7 @@
</div>
</div>
<div
v-if="!isShowVariables && editable && !onlyDefinedFields && (!showCustomTab || (!customFields.length && !newCustomField))"
v-if="!isShowVariables && editable && !onlyDefinedFields && (!showCustomTab || (!customFields.length && !newCustomField)) && (!withCustomFieldsTab || !showCustomTab)"
id="field-types-grid"
class="grid grid-cols-3 gap-1 pb-2 fields-grid"
>
@@ -418,6 +438,11 @@ export default {
required: false,
default: false
},
withCustomFieldsTab: {
type: Boolean,
required: false,
default: false
},
withFieldsSearch: {
type: Boolean,
required: false,
@@ -428,6 +453,11 @@ export default {
required: false,
default: false
},
withDetectExistingFields: {
type: Boolean,
required: false,
default: false
},
withSignatureId: {
type: Boolean,
required: false,
@@ -495,6 +525,11 @@ export default {
type: Object,
required: true
},
detectCustomFieldsIndex: {
type: Object,
required: false,
default: () => ({})
},
showTourStartForm: {
type: Boolean,
required: false,
@@ -557,7 +592,8 @@ export default {
},
submitterDefaultFields () {
return this.defaultFields.filter((f) => {
return !this.submitterFields.find((field) => field.name === f.name) && (!f.role || f.role === this.selectedSubmitter.name)
return (this.withCustomFieldsTab ? true : !this.submitterFields.find((field) => field.name === f.name)) &&
(!f.role || f.role === this.selectedSubmitter.name)
})
},
filteredSubmitterDefaultFields () {
@@ -656,6 +692,78 @@ export default {
this.customFields.splice(0, this.customFields.length, ...fields)
})
},
buildExistingFields () {
const existing = []
const seen = new Set()
const submittersByUuid = this.template.submitters.reduce((acc, s) => {
acc[s.uuid] = s
return acc
}, {})
const add = (field, role) => {
if (!field?.name) return
const key = field.name.toLowerCase() + ':' + (role || '').toLowerCase()
if (seen.has(key)) return
seen.add(key)
const item = { name: field.name, type: field.type || 'text' }
if (role) item.role = role
const optionValues = Array.isArray(field.options)
? field.options.map((o) => (typeof o === 'string' ? o : o?.value)).filter(Boolean)
: []
if (optionValues.length) item.options = optionValues
existing.push(item)
}
this.template.fields.forEach((f) => add(f, submittersByUuid[f.submitter_uuid]?.name))
this.defaultRequiredFields.forEach((f) => add(f, f.role))
this.defaultFields.forEach((f) => add(f, f.role))
this.customFields.forEach((f) => add(f, f.role))
return existing
},
enrichDetectedField (field) {
if (!this.withDetectExistingFields || !field.name) return field
const role = this.template.submitters.find((s) => s.uuid === field.submitter_uuid)?.name
const nameKey = field.name.toLowerCase()
const indexKey = [field.name, role].filter(Boolean).join(':').toLowerCase()
const customField = this.detectCustomFieldsIndex[indexKey] || this.detectCustomFieldsIndex[nameKey]
if (customField) this.applyCustomFieldAttributes(field, customField)
return field
},
applyCustomFieldAttributes (field, customField) {
const skipKeys = new Set(['uuid', 'areas', 'submitter_uuid', 'conditions', 'prefillable', 'role'])
Object.entries(customField).forEach(([key, value]) => {
if (skipKeys.has(key)) return
if (value === null || value === undefined) return
if (key === 'options') {
if (Array.isArray(value) && !Array.isArray(field.options)) {
field.options = value.map((o) => (
typeof o === 'string' ? { value: o, uuid: v4() } : { ...o, uuid: v4() }
))
}
} else if (value && typeof value === 'object' && !Array.isArray(value)) {
field[key] = JSON.parse(JSON.stringify(value))
} else {
field[key] = value
}
})
},
detectFields () {
const fields = []
@@ -665,7 +773,10 @@ export default {
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
},
...(this.withDetectExistingFields
? { body: JSON.stringify({ fields: this.buildExistingFields() }) }
: {})
}).then(async (response) => {
const reader = response.body.getReader()
const decoder = new TextDecoder('utf-8')
@@ -687,7 +798,7 @@ export default {
if (data.error) {
if ((data.fields || fields).length) {
this.template.fields = data.fields || fields
this.template.fields = (data.fields || fields).map((f) => this.enrichDetectedField(f))
this.save()
} else {
@@ -705,7 +816,7 @@ export default {
this.$emit('select-submitter', this.template.submitters[0])
}
this.template.fields = data.fields || fields
this.template.fields = (data.fields || fields).map((f) => this.enrichDetectedField(f))
this.save()
+7
View File
@@ -70,6 +70,7 @@ const en = {
sign_yourself: 'Sign Yourself',
set_signing_date: 'Set signing date',
signing_date: 'Signing Date',
signing_date_and_time: 'Signing Date and Time',
send: 'Send',
remove: 'Remove',
edit: 'Edit',
@@ -271,6 +272,7 @@ const es = {
with_logo: 'Con logotipo',
description: 'Descripción',
signing_date: 'Fecha de Firma',
signing_date_and_time: 'Fecha y Hora de Firma',
display_title: 'Título de visualización',
unchecked: 'No marcado',
price: 'Precio',
@@ -508,6 +510,7 @@ const it = {
sign_yourself: 'Firma te stesso',
set_signing_date: 'Imposta data di firma',
signing_date: 'Data di firma',
signing_date_and_time: 'Data e ora di firma',
send: 'Invia',
remove: 'Rimuovi',
edit: 'Modifica',
@@ -710,6 +713,7 @@ const pt = {
description: 'Descrição',
display_title: 'Título de exibição',
signing_date: 'Data da Assinatura',
signing_date_and_time: 'Data e Hora da Assinatura',
unchecked: 'Não marcado',
price: 'Preço',
equal: 'Igual',
@@ -946,6 +950,7 @@ const fr = {
sign_yourself: 'Signer vous-même',
set_signing_date: 'Définir la date de signature',
signing_date: 'Date de signature',
signing_date_and_time: 'Date et heure de signature',
send: 'Envoyer',
remove: 'Supprimer',
edit: 'Modifier',
@@ -1165,6 +1170,7 @@ const de = {
sign_yourself: 'Selbst unterschreiben',
set_signing_date: 'Unterzeichnungsdatum festlegen',
signing_date: 'Unterzeichnungsdatum',
signing_date_and_time: 'Unterzeichnungsdatum und -uhrzeit',
send: 'Senden',
remove: 'Entfernen',
edit: 'Bearbeiten',
@@ -1384,6 +1390,7 @@ const nl = {
sign_yourself: 'Zelf ondertekenen',
set_signing_date: 'Ondertekeningsdatum instellen',
signing_date: 'Ondertekeningsdatum',
signing_date_and_time: 'Ondertekeningsdatum en -tijd',
send: 'Verzenden',
remove: 'Verwijderen',
edit: 'Bewerken',
+12
View File
@@ -37,6 +37,8 @@
:ref="setAreaRefs"
:area="item.area"
:input-mode="inputMode"
:conditional-field-index="conditionalFieldIndex"
:formula-values-index="formulaValuesIndex"
:page-width="width"
:page-height="height"
:field="item.field"
@@ -180,6 +182,16 @@ export default {
required: false,
default: false
},
conditionalFieldIndex: {
type: Object,
required: false,
default: () => ({})
},
formulaValuesIndex: {
type: Object,
required: false,
default: () => ({})
},
defaultFields: {
type: Array,
required: false,
@@ -143,8 +143,8 @@
>
<input
type="hidden"
name="state"
:value="oauthState"
name="oauth_data"
:value="oauthData"
autocomplete="off"
>
<input
@@ -334,7 +334,7 @@ export default {
authenticityToken () {
return document.querySelector('meta[name="csrf-token"]')?.content
},
oauthState () {
oauthData () {
const params = new URLSearchParams('')
params.set('redir', document.location.href)
+1 -1
View File
@@ -228,7 +228,7 @@ export default {
'https://www.googleapis.com/auth/userinfo.email',
'https://www.googleapis.com/auth/drive.file'
].join(' '),
state: new URLSearchParams({
oauth_data: new URLSearchParams({
redir: `/templates/${this.templateId}/edit?google_drive_open=1`
}).toString()
}
+1
View File
@@ -32,6 +32,7 @@ class Account < ApplicationRecord
has_many :submitters, dependent: :destroy
has_many :account_linked_accounts, dependent: :destroy
has_many :email_events, dependent: :destroy
has_many :document_metadata, class_name: 'DocumentMetadata', dependent: :destroy
has_many :webhook_urls, dependent: :destroy
has_many :webhook_events, dependent: nil
has_many :account_accesses, dependent: :destroy
+1
View File
@@ -43,6 +43,7 @@ class AccountConfig < ApplicationRecord
DOWNLOAD_LINKS_EXPIRE_KEY = 'download_links_expire'
FORCE_SSO_AUTH_KEY = 'force_sso_auth'
FLATTEN_RESULT_PDF_KEY = 'flatten_result_pdf'
ROTATE_INCREMENTAL_PDF_KEY = 'rotate_incremental_pdf'
WITH_SIGNATURE_ID = 'with_signature_id'
WITH_FILE_LINKS_KEY = 'with_file_links'
WITH_SIGNATURE_ID_REASON_KEY = 'with_signature_id_reason'
+27
View File
@@ -0,0 +1,27 @@
# frozen_string_literal: true
# == Schema Information
#
# Table name: document_metadata
#
# id :bigint not null, primary key
# blob_checksum :string not null
# text_runs :text not null
# created_at :datetime not null
# account_id :bigint not null
#
# Indexes
#
# index_document_metadata_on_account_id_and_blob_checksum (account_id,blob_checksum) UNIQUE
#
# Foreign Keys
#
# fk_rails_... (account_id => accounts.id)
#
class DocumentMetadata < ApplicationRecord
belongs_to :account
attribute :text_runs, :string, default: -> { {} }
serialize :text_runs, coder: JSON
end
+6 -1
View File
@@ -101,13 +101,18 @@ class Submission < ApplicationRecord
where(Submitter.where(Submitter.arel_table[:submission_id].eq(Submission.arel_table[:id])
.and(Submitter.arel_table[:declined_at].not_eq(nil))).select(1).arel.exists)
}
scope :expired, -> { pending.where(expire_at: ..Time.current) }
scope :expired, lambda {
where(expire_at: ..Time.current)
.where(Submitter.where(Submitter.arel_table[:submission_id].eq(Submission.arel_table[:id])
.and(Submitter.arel_table[:completed_at].eq(nil))).select(1).arel.exists)
}
enum :source, {
invite: 'invite',
bulk: 'bulk',
api: 'api',
embed: 'embed',
mcp: 'mcp',
link: 'link'
}, scope: false, prefix: true
Whitespace-only changes.
+1 -18
View File
@@ -18,24 +18,7 @@
<%= f.button button_title(title: t(:sign_in), disabled_with: t(:signing_in)), class: 'base-button' %>
</div>
<% end %>
<% if devise_mapping.omniauthable? %>
<div class="space-y-4">
<% if User.omniauth_providers.include?(:google_oauth2) %>
<%= form_for '', url: omniauth_authorize_path(resource_name, :google_oauth2), data: { turbo: false }, method: :post do |f| %>
<set-timezone data-input-id="state" data-params="true"></set-timezone>
<%= hidden_field_tag :state, { redir: params[:redir].to_s }.compact_blank.to_query %>
<%= f.button button_title(title: t('sign_in_with_google'), icon: svg_icon('brand_google', class: 'w-6 h-6')), class: 'white-button w-full mt-4' %>
<% end %>
<% end %>
<% if User.omniauth_providers.include?(:microsoft_office365) %>
<%= form_for '', url: omniauth_authorize_path(resource_name, :microsoft_office365), data: { turbo: false }, method: :post do |f| %>
<set-timezone data-input-id="state_microsoft" data-params="true"></set-timezone>
<%= hidden_field_tag :state, { redir: params[:redir].to_s }.compact_blank.to_query, id: 'state_microsoft' %>
<%= f.button button_title(title: t('sign_in_with_microsoft'), icon: svg_icon('brand_microsoft', class: 'w-6 h-6')), class: 'white-button w-full' %>
<% end %>
<% end %>
</div>
<% end %>
<%= render 'omniauthable' %>
<%= render 'extra_links' %>
<%= render 'devise/shared/links' %>
</div>
+1 -1
View File
@@ -1,4 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" class="<%= local_assigns[:class] %>" width="44" height="44" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" class="<%= local_assigns[:class] %>" width="44" height="44" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M12 12m-9 0a9 9 0 1 0 18 0a9 9 0 1 0 -18 0"></path>
<path d="M9 12l2 2l4 -4"></path>

Before

Width:  |  Height:  |  Size: 389 B

After

Width:  |  Height:  |  Size: 408 B

+1 -1
View File
@@ -1,4 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" class="<%= local_assigns[:class] %>" width="44" height="44" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" class="<%= local_assigns[:class] %>" width="44" height="44" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2 -2v-2" />
<path d="M7 11l5 5l5 -5" />

Before

Width:  |  Height:  |  Size: 397 B

After

Width:  |  Height:  |  Size: 416 B

+1 -1
View File
@@ -1,4 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" class="<%= local_assigns[:class] %>" width="44" height="44" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" class="<%= local_assigns[:class] %>" width="44" height="44" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M3 12a9 9 0 1 0 18 0a9 9 0 0 0 -18 0"></path>
<path d="M12 9h.01"></path>

Before

Width:  |  Height:  |  Size: 412 B

After

Width:  |  Height:  |  Size: 431 B

+1 -1
View File
@@ -1,4 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" class="<%= local_assigns[:class] %>" width="44" height="44" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" class="<%= local_assigns[:class] %>" width="44" height="44" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M12 3a9 9 0 1 0 9 9" />
</svg>

Before

Width:  |  Height:  |  Size: 318 B

After

Width:  |  Height:  |  Size: 337 B

+1 -1
View File
@@ -1,4 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" class="<%= local_assigns[:class] %>" width="44" height="44" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" class="<%= local_assigns[:class] %>" width="44" height="44" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M12 18h-7a2 2 0 0 1 -2 -2v-10a2 2 0 0 1 2 -2h14a2 2 0 0 1 2 2v7.5" />
<path d="M3 6l9 6l9 -6" />

Before

Width:  |  Height:  |  Size: 448 B

After

Width:  |  Height:  |  Size: 467 B

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="<%= local_assigns[:class] %>">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M17 17h2a2 2 0 0 0 2 -2v-4a2 2 0 0 0 -2 -2h-14a2 2 0 0 0 -2 2v4a2 2 0 0 0 2 2h2" />
<path d="M17 9v-4a2 2 0 0 0 -2 -2h-6a2 2 0 0 0 -2 2v4" />
<path d="M7 15a2 2 0 0 1 2 -2h6a2 2 0 0 1 2 2v4a2 2 0 0 1 -2 2h-6a2 2 0 0 1 -2 -2l0 -4" />
</svg>

After

Width:  |  Height:  |  Size: 529 B

+15
View File
@@ -0,0 +1,15 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="<%= local_assigns[:class] %>">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M4 5a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v4a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1l0 -4" />
<path d="M7 17l0 .01" />
<path d="M14 5a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v4a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1l0 -4" />
<path d="M7 7l0 .01" />
<path d="M4 15a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v4a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1l0 -4" />
<path d="M17 7l0 .01" />
<path d="M14 14l3 0" />
<path d="M20 14l0 .01" />
<path d="M14 14l0 3" />
<path d="M14 20l3 0" />
<path d="M17 17l3 0" />
<path d="M20 17l0 3" />
</svg>

After

Width:  |  Height:  |  Size: 797 B

+1 -1
View File
@@ -1,4 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" class="<%= local_assigns[:class] %>" width="44" height="44" viewBox="0 0 24 24" stroke-width="2" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round">
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" class="<%= local_assigns[:class] %>" width="44" height="44" viewBox="0 0 24 24" stroke-width="2" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M19.933 13.041a8 8 0 1 1 -9.925 -8.788c3.899 -1 7.935 1.007 9.425 4.747"></path>
<path d="M20 4v5h-5"></path>

Before

Width:  |  Height:  |  Size: 409 B

After

Width:  |  Height:  |  Size: 428 B

+1 -1
View File
@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="<%= local_assigns[:class] %>"><path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M8 7a4 4 0 1 0 8 0a4 4 0 0 0 -8 0" /><path d="M6 21v-2a4 4 0 0 1 4 -4h3" /><path d="M16 22l5 -5" /><path d="M21 21.5v-4.5h-4.5" /></svg>
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="<%= local_assigns[:class] %>"><path stroke="none" d="M0 0h24v24H0z" fill="none" /><path d="M8 7a4 4 0 1 0 8 0a4 4 0 0 0 -8 0" /><path d="M6 21v-2a4 4 0 0 1 4 -4h3" /><path d="M16 22l5 -5" /><path d="M21 21.5v-4.5h-4.5" /></svg>

Before

Width:  |  Height:  |  Size: 416 B

After

Width:  |  Height:  |  Size: 435 B

+1 -1
View File
@@ -1,4 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" class="<%= local_assigns[:class] %>" width="44" height="44" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" class="<%= local_assigns[:class] %>" width="44" height="44" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M3 19c3.333 -2 5 -4 5 -6c0 -3 -1 -3 -2 -3s-2.032 1.085 -2 3c.034 2.048 1.658 2.877 2.5 4c1.5 2 2.5 2.5 3.5 1c.667 -1 1.167 -1.833 1.5 -2.5c1 2.333 2.333 3.5 4 3.5h2.5"></path>
<path d="M20 17v-12c0 -1.121 -.879 -2 -2 -2s-2 .879 -2 2v12l2 2l2 -2z"></path>

Before

Width:  |  Height:  |  Size: 584 B

After

Width:  |  Height:  |  Size: 603 B

+1 -1
View File
@@ -1,4 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" class="<%= local_assigns[:class] %>" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" width="24" height="24" stroke-width="2">
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" class="<%= local_assigns[:class] %>" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" width="24" height="24" stroke-width="2">
<path d="M18 6l-12 12"></path>
<path d="M6 6l12 12"></path>
</svg>

Before

Width:  |  Height:  |  Size: 290 B

After

Width:  |  Height:  |  Size: 309 B

+3 -2
View File
@@ -20,9 +20,10 @@
</head>
<body>
<% if params[:modal].present? %>
<% url_params = Rails.application.routes.recognize_path(params[:modal], method: :get) %>
<% modal_uri = Addressable::URI.parse(params[:modal]) %>
<% url_params = Rails.application.routes.recognize_path(modal_uri.path, method: :get) %>
<% if url_params[:action] == 'new' %>
<open-modal src="<%= url_for(url_params) %>"></open-modal>
<open-modal src="<%= url_for(**url_params, params: modal_uri.query_values) %>"></open-modal>
<% end %>
<% end %>
<turbo-frame id="modal"></turbo-frame>
+1 -1
View File
@@ -1,6 +1,6 @@
<a target="_blank" href="<%= Docuseal::GITHUB_URL %>" rel="noopener noreferrer nofollow" class="relative flex items-center rounded-full px-2 py-0.5 text-xs leading-4 mt-1 text-base-content border border-base-300 tooltip tooltip-bottom" data-tip="Give a star on GitHub">
<span class="flex items-center justify-between space-x-0.5 font-medium">
<%= svg_icon('start', class: 'h-3 w-3') %>
<span>11k</span>
<span>12k</span>
</span>
</a>
+10 -6
View File
@@ -1,18 +1,22 @@
<% uuid = local_assigns[:uuid] || SecureRandom.uuid %>
<input type="checkbox" id="<%= uuid %>" class="modal-toggle">
<div id="<%= local_assigns[:id] %>" class="modal items-start !animate-none overflow-y-auto">
<% title_id = "#{uuid}-title" %>
<%= tag.dialog id: uuid, class: 'modal items-start overflow-y-auto', inert: true, 'aria-labelledby': (title_id if local_assigns[:title]) do %>
<div class="modal-box pt-4 pb-6 px-6 mt-20 max-h-none">
<% if local_assigns[:title] %>
<div class="flex justify-between items-center border-b pb-2 mb-2 font-medium">
<span>
<span id="<%= title_id %>">
<%= local_assigns[:title] %>
</span>
<label for="<%= uuid %>" class="text-xl">&times;</label>
<form method="dialog">
<button class="text-xl cursor-pointer w-6 h-6" aria-label="<%= t('close') %>">&times;</button>
</form>
</div>
<% end %>
<div>
<%= yield %>
</div>
</div>
<label class="modal-backdrop" for="<%= uuid %>"></label>
</div>
<form method="dialog" class="modal-backdrop">
<button aria-label="<%= t('close') %>"></button>
</form>
<% end %>
+1 -1
View File
@@ -59,7 +59,7 @@
</li>
<% end %>
<% if (can?(:manage, EncryptedConfig) && current_user == true_user) || (current_user != true_user && current_account.testing?) %>
<%= form_for '', url: testing_account_path, method: current_account.testing? ? :delete : :get, html: { class: 'w-full py-1' } do |f| %>
<%= form_for '', url: testing_account_path, method: current_account.testing? ? :delete : :post, html: { class: 'w-full py-1' } do |f| %>
<label class="flex items-center pl-6 pr-4 py-2 border-y border-base-300 -ml-2 -mr-2" for="testing_toggle">
<submit-form data-on="change" class="flex">
<%= f.check_box :testing_toggle, class: 'toggle', checked: current_account.testing?, style: 'height: 0.885rem; width: 1.35rem; --handleoffset: 0.395rem; margin-left: -2px; margin-right: 8px' %>
+1 -1
View File
@@ -97,7 +97,7 @@
<% end %>
<%= render 'shared/settings_nav_extra2' %>
<% if (can?(:manage, EncryptedConfig) && current_user == true_user) || (current_user != true_user && current_account.testing?) %>
<%= form_for '', url: testing_account_path, method: current_account.testing? ? :delete : :get, html: { class: 'w-full' } do |f| %>
<%= form_for '', url: testing_account_path, method: current_account.testing? ? :delete : :post, html: { class: 'w-full' } do |f| %>
<li>
<label class="flex items-center text-base hover:bg-base-300 w-full justify-between" for="testing_toggle">
<span class="mr-2 w-full">
+1 -1
View File
@@ -1,5 +1,5 @@
<% if can?(:manage, EncryptedConfig) || (current_user != true_user && current_account.testing?) %>
<%= form_for '', url: testing_account_path, method: current_account.testing? ? :delete : :get, html: { class: 'flex' } do |f| %>
<%= form_for '', url: testing_account_path, method: current_account.testing? ? :delete : :post, html: { class: 'flex' } do |f| %>
<label class="flex items-center justify-between" for="testing_toggle">
<span class="mr-2 text-lg">
<%= t('test_mode') %>
+2 -2
View File
@@ -1,4 +1,4 @@
<div class="max-w-md mx-auto px-2 mt-12 mb-4">
<main class="max-w-md mx-auto px-2 mt-12 mb-4">
<div class="space-y-6 mx-auto">
<div class="space-y-6">
<div class="flex items-center justify-center">
@@ -32,5 +32,5 @@
</toggle-submit>
<% end %>
</div>
</div>
</main>
<%= render 'shared/attribution', link_path: '/start', account: @submitter.account %>
@@ -2,7 +2,7 @@
<% I18n.with_locale(@template.account.locale) do %>
<% content_for(:html_description, t('account_name_has_invited_you_to_fill_and_sign_documents_online_effortlessly_with_a_secure_fast_and_user_friendly_digital_document_signing_solution', account_name: @template.account.name)) %>
<% end %>
<div class="max-w-md mx-auto px-2 mt-12 mb-4">
<main class="max-w-md mx-auto px-2 mt-12 mb-4">
<div class="space-y-6 mx-auto">
<div class="space-y-6">
<div class="flex items-center justify-center">
@@ -51,4 +51,4 @@
<% end %>
<%= button_to t(:re_send_email), start_form_email_2fa_send_index_path, params: { slug: @template.slug, resend: true, submitter: { name: params[:name] || @submitter&.name, email: params[:email] || @submitter&.email, phone: params[:phone] || @submitter&.phone } }.compact, method: :post, id: 'resend_code', class: 'hidden' %>
</div>
</div>
</main>
+2 -2
View File
@@ -2,7 +2,7 @@
<% I18n.with_locale(@template.account.locale) do %>
<% content_for(:html_description, t('share_link_is_currently_disabled')) %>
<% end %>
<div class="max-w-md space-y-6 mx-auto px-2 mt-12 mb-4">
<main class="max-w-md space-y-6 mx-auto px-2 mt-12 mb-4">
<div class="text-center w-full space-y-6">
<%= render 'banner' %>
<p class="text-xl font-semibold text-center">
@@ -27,5 +27,5 @@
<%= button_to button_title(title: t('enable_shared_link'), icon: svg_icon('lock_open', class: 'w-6 h-6')), template_share_link_path(@template), params: { template: { shared_link: true }, redir: start_form_path(slug: @template.slug) }, method: :post, class: 'white-button w-full' %>
</toggle-submit>
<% end %>
</div>
</main>
<%= render 'shared/attribution', link_path: '/start', account: @template.account %>
+6 -4
View File
@@ -2,7 +2,7 @@
<% I18n.with_locale(@template.account.locale) do %>
<% content_for(:html_description, t('account_name_has_invited_you_to_fill_and_sign_documents_online_effortlessly_with_a_secure_fast_and_user_friendly_digital_document_signing_solution', account_name: @template.account.name)) %>
<% end %>
<div class="max-w-md mx-auto px-2 mt-12 mb-4">
<main class="max-w-md mx-auto px-2 mt-12 mb-4">
<div class="space-y-6 mx-auto">
<div class="space-y-6">
<div class="text-center w-full space-y-6">
@@ -63,6 +63,8 @@
<% end %>
<% end %>
</div>
</div>
<%= render 'shared/attribution', link_path: '/start', account: @template.account %>
<%= render 'start_form/policy', account: @template.account %>
<div class="mt-4">
<%= render 'shared/attribution', link_path: '/start', account: @template.account %>
<%= render 'start_form/policy', account: @template.account %>
</div>
</main>
+14 -12
View File
@@ -5,12 +5,12 @@
<% font = field.dig('preferences', 'font') %>
<% font_type = field.dig('preferences', 'font_type') %>
<% font_size_px = (field.dig('preferences', 'font_size').presence || Submissions::GenerateResultAttachments::FONT_SIZE).to_i * local_assigns.fetch(:font_scale) { 1000.0 / PdfUtils::US_LETTER_W } %>
<field-value dir="auto" class="flex absolute <%= 'font-courier' if font == 'Courier' %> <%= 'font-times' if font == 'Times' %> <%= 'font-bold' if font_type == 'bold' || font_type == 'bold_italic' %> <%= 'italic' if font_type == 'italic' || font_type == 'bold_italic' %> <%= align == 'right' ? 'text-right' : (align == 'center' ? 'text-center' : '') %>" style="<%= "color: #{color}; " if color.present? %><%= "background: #{bg_color}; " if bg_color.present? %>width: <%= area['w'] * 100 %>%; height: <%= area['h'] * 100 %>%; left: <%= area['x'] * 100 %>%; top: <%= area['y'] * 100 %>%; font-size: <%= fs = "clamp(1pt, #{font_size_px / 10}vw, #{font_size_px}px)" %>; line-height: calc(<%= fs %> * 1.3); font-size: <%= fs = "#{font_size_px / 10}cqmin" %>; line-height: calc(<%= fs %> * 1.3)">
<field-value dir="auto" aria-hidden="true" class="flex absolute <%= 'font-courier' if font == 'Courier' %> <%= 'font-times' if font == 'Times' %> <%= 'font-bold' if font_type == 'bold' || font_type == 'bold_italic' %> <%= 'italic' if font_type == 'italic' || font_type == 'bold_italic' %> <%= align == 'right' ? 'text-right' : (align == 'center' ? 'text-center' : '') %>" style="<%= "color: #{color}; " if color.present? && color.match?(Templates::COLOR_REGEXP) %><%= "background: #{bg_color}; " if bg_color.present? && bg_color.match?(Templates::COLOR_REGEXP) %>width: <%= area['w'].to_f * 100 %>%; height: <%= area['h'].to_f * 100 %>%; left: <%= area['x'].to_f * 100 %>%; top: <%= area['y'].to_f * 100 %>%; font-size: <%= fs = "clamp(1pt, #{font_size_px / 10}vw, #{font_size_px}px)" %>; line-height: calc(<%= fs %> * 1.3); font-size: <%= fs = "#{font_size_px / 10}cqmin" %>; line-height: calc(<%= fs %> * 1.3)">
<% if field['type'] == 'signature' %>
<% is_narrow = area['h'].positive? && ((area['w'] * local_assigns[:page_width]).to_f / (area['h'] * local_assigns[:page_height])) > 4.5 %>
<% is_narrow = area['h'].to_f.positive? && ((area['w'].to_f * local_assigns[:page_width]) / (area['h'].to_f * local_assigns[:page_height])) > 4.5 %>
<div class="flex justify-between w-full h-full gap-1 <%= is_narrow && (local_assigns[:with_signature_id] || field.dig('preferences', 'reason_field_uuid').present?) ? 'flex-row' : 'flex-col' %>">
<div class="flex overflow-hidden <%= is_narrow && (local_assigns[:with_signature_id] || field.dig('preferences', 'reason_field_uuid').present?) ? 'w-1/2' : 'flex-grow' %>" style="min-height: 50%">
<img class="object-contain mx-auto" src="<%= attachments_index[value].url %>">
<img class="object-contain mx-auto" src="<%= attachments_index[value].url %>" alt="<%= field['name'].presence || field['title'].presence || field['type'] %>">
</div>
<% if (local_assigns[:with_signature_id] || field.dig('preferences', 'reason_field_uuid').present?) && attachment = attachments_index[value] %>
<div class="text-[1vw] lg:text-[0.55rem] lg:leading-[0.65rem] <%= is_narrow ? 'w-1/2' : 'w-full' %>">
@@ -35,7 +35,7 @@
<% end %>
</div>
<% elsif field['type'].in?(['image', 'initials', 'stamp', 'kba']) && attachments_index[value].image? %>
<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" alt="<%= field['name'].presence || field['title'].presence || field['type'] %>">
<% elsif field['type'].in?(['file', 'payment', 'image']) %>
<autosize-field></autosize-field>
<div class="px-0.5 flex flex-col justify-center">
@@ -48,6 +48,7 @@
</div>
<% elsif field['type'] == 'checkbox' %>
<div class="w-full flex items-center justify-center">
<span class="sr-only">☑</span>
<%= svg_icon('check', class: "aspect-square #{area['w'] > area['h'] ? '!w-auto !h-full' : '!w-full !h-auto'}") %>
</div>
<% elsif field['type'].in?(%w[multiple radio]) && area['option_uuid'] %>
@@ -55,14 +56,15 @@
<% option_name = option['value'].presence || "Option #{field['options'].index(option) + 1}" %>
<% if Array.wrap(value).include?(option_name) %>
<div class="w-full flex items-center justify-center">
<span class="sr-only">☑</span>
<%= svg_icon('check', class: "aspect-square #{area['w'] > area['h'] ? '!w-auto !h-full' : '!w-full !h-auto'}") %>
</div>
<% end %>
<% end %>
<% elsif field['type'] == 'cells' && area['cell_w'].to_f > 0.0 %>
<% cell_width = area['cell_w'] / area['w'] * 100 %>
<% cell_width = area['cell_w'].to_f / area['w'] * 100 %>
<div class="w-full flex <%= valign == 'top' ? 'items-start' : (valign == 'bottom' ? 'items-end' : 'items-center') %> <%= 'justify-end' if align == 'right' %>">
<% (0..(area['w'] / area['cell_w']).ceil).each do |index| %>
<% (0..(area['w'].to_f / area['cell_w']).ceil).each do |index| %>
<% if value[index] %>
<div class="text-center flex-none" style="width: <%= cell_width %>%;"><%= value[index] %></div>
<% end %>
@@ -71,8 +73,8 @@
<% elsif field['type'] == 'date' %>
<autosize-field></autosize-field>
<div class="flex w-full px-0.5 <%= valign == 'top' ? 'items-start' : (valign == 'bottom' ? 'items-end' : 'items-center') %>">
<% value = Time.current.in_time_zone(local_assigns[:timezone]).to_date.to_s if value == '{{date}}' %>
<div class="w-full"><%= TimeUtils.format_date_string(value, field.dig('preferences', 'format'), local_assigns[:locale]) %></div>
<% tz = local_assigns[:with_submitter_timezone] ? (submitter.timezone.presence || local_assigns[:timezone]) : local_assigns[:timezone] %>
<div class="w-full"><%= value == '{{date}}' ? TimeUtils.format_date_preview(field.dig('preferences', 'format'), local_assigns[:locale], tz) : TimeUtils.format_date_string(value, field.dig('preferences', 'format'), local_assigns[:locale], timezone: tz) %></div>
</div>
<% elsif field['type'] == 'number' %>
<autosize-field></autosize-field>
@@ -81,14 +83,14 @@
</div>
<% elsif field['type'] == 'strikethrough' %>
<div class="w-full h-full flex items-center justify-center">
<% if (((1000.0 / local_assigns[:page_width]) * local_assigns[:page_height]) * area['h']) < 40 %>
<% if (((1000.0 / local_assigns[:page_width]) * local_assigns[:page_height]) * area['h'].to_f) < 40 %>
<svg width="100%" height="100%">
<line x1="0" y1="50%" x2="100%" y2="50%" stroke="<%= field.dig('preferences', 'color').presence || 'red' %>" style="stroke-width: clamp(0px, 0.5vw, 6px); stroke-width: 0.6cqmin"></line>
<line x1="0" y1="50%" x2="100%" y2="50%" stroke="<%= color.present? && color.match?(Templates::COLOR_REGEXP) ? color : 'red' %>" style="stroke-width: clamp(0px, 0.5vw, 6px); stroke-width: 0.6cqmin"></line>
</svg>
<% else %>
<svg xmlns="http://www.w3.org/2000/svg" style="overflow: visible; width: calc(100% - 6px); height: calc(100% - 6px); width: calc(100% - 0.6cqmin); height: calc(100% - 0.6cqmin)">
<line x1="0" y1="0" x2="100%" y2="100%" stroke="<%= field.dig('preferences', 'color').presence || 'red' %>" style="stroke-width: clamp(0px, 0.5vw, 6px); stroke-width: 0.6cqmin"></line>
<line x1="100%" y1="0" x2="0" y2="100%" stroke="<%= field.dig('preferences', 'color').presence || 'red' %>" style="stroke-width: clamp(0px, 0.5vw, 6px); stroke-width: 0.6cqmin"></line>
<line x1="0" y1="0" x2="100%" y2="100%" stroke="<%= color.present? && color.match?(Templates::COLOR_REGEXP) ? color : 'red' %>" style="stroke-width: clamp(0px, 0.5vw, 6px); stroke-width: 0.6cqmin"></line>
<line x1="100%" y1="0" x2="0" y2="100%" stroke="<%= color.present? && color.match?(Templates::COLOR_REGEXP) ? color : 'red' %>" style="stroke-width: clamp(0px, 0.5vw, 6px); stroke-width: 0.6cqmin"></line>
</svg>
<% end %>
</div>
+17 -17
View File
@@ -8,11 +8,11 @@
<% with_submitter_timezone = configs.find { |e| e.key == AccountConfig::WITH_SUBMITTER_TIMEZONE_KEY }&.value == true %>
<% with_timestamp_seconds = configs.find { |e| e.key == AccountConfig::WITH_TIMESTAMP_SECONDS_KEY }&.value == true %>
<% with_signature_id_reason = configs.find { |e| e.key == AccountConfig::WITH_SIGNATURE_ID_REASON_KEY }&.value != false %>
<div style="max-width: 1600px" class="mx-auto pl-4">
<main style="max-width: 1600px" class="mx-auto pl-4">
<div class="flex justify-between py-1.5 items-center pr-4 sticky top-0 md:relative z-10 bg-base-100">
<a href="<%= signed_in? && @submission.account_id == current_account&.id && @submission.template ? template_path(@submission.template) : '/' %>" class="flex items-center space-x-3 py-1">
<span><%= render 'submissions/logo' %></span>
<span class="text-xl md:text-3xl font-semibold focus:text-clip" style="overflow: hidden; display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2;"><% (@submission.name || @submission.template.name).split(/(_)/).each do |item| %><%= item %><wbr><% end %></span>
<h1 class="text-xl md:text-3xl font-semibold focus:text-clip" style="overflow: hidden; display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2;"><% (@submission.name || @submission.template.name).split(/(_)/).each do |item| %><%= item %><wbr><% end %></h1>
</a>
<div class="space-x-3 flex items-center">
<% is_all_completed = @submission.submitters.to_a.all?(&:completed_at?) %>
@@ -33,7 +33,7 @@
<% if @submission.submitters.to_a.any?(&:completed_at?) %>
<% if is_all_completed || !is_combined_enabled %>
<div class="join relative">
<download-button data-src="<%= local_assigns[:is_preview] ? (@sig_submitter ? submit_form_documents_path(@sig_submitter.slug, { sig: params[:sig], combined: is_combined_enabled }.compact_blank) : submissions_preview_download_index_path(@submission.slug, combined: is_combined_enabled.presence)) : submission_download_index_path(@submission, combined: is_combined_enabled.presence) %>" class="base-button <%= '!rounded-r-none !pr-2' if is_all_completed && !is_combined_enabled %>">
<download-button role="button" tabindex="0" aria-label="<%= t('download') %>" data-src="<%= local_assigns[:is_preview] ? (@sig_submitter ? submit_form_documents_path(@sig_submitter.slug, { sig: params[:sig], combined: is_combined_enabled }.compact_blank) : submissions_preview_download_index_path(@submission.slug, combined: is_combined_enabled.presence)) : submission_download_index_path(@submission, combined: is_combined_enabled.presence) %>" class="base-button <%= '!rounded-r-none !pr-2' if is_all_completed && !is_combined_enabled %>">
<span class="flex items-center justify-center space-x-2" data-target="download-button.defaultButton">
<%= svg_icon('download', class: 'w-6 h-6') %>
<span class="hidden md:inline"><%= t('download') %></span>
@@ -45,14 +45,14 @@
</download-button>
<% if is_all_completed && !is_combined_enabled %>
<div class="dropdown dropdown-end">
<label tabindex="0" class="base-button !rounded-l-none !pl-1 !pr-2 !border-l-neutral-500">
<label tabindex="0" aria-label="<%= t('download') %>" class="base-button !rounded-l-none !pl-1 !pr-2 !border-l-neutral-500">
<span class="text-sm align-text-top">
<%= svg_icon('chevron_down', class: 'w-6 h-6 flex-shrink-0 stroke-2') %>
</span>
</label>
<ul tabindex="0" class="z-10 dropdown-content p-2 mt-2 shadow menu text-base bg-base-100 rounded-box text-right">
<ul class="z-10 dropdown-content p-2 mt-2 shadow menu text-base bg-base-100 rounded-box text-right">
<li>
<download-button data-src="<%= local_assigns[:is_preview] ? (@sig_submitter ? submit_form_documents_path(@sig_submitter.slug, { sig: params[:sig], combined: true }.compact) : submissions_preview_download_index_path(@submission.slug, combined: true)) : submission_download_index_path(@submission, combined: true) %>" class="flex items-center">
<download-button role="button" tabindex="0" data-src="<%= local_assigns[:is_preview] ? (@sig_submitter ? submit_form_documents_path(@sig_submitter.slug, { sig: params[:sig], combined: true }.compact) : submissions_preview_download_index_path(@submission.slug, combined: true)) : submission_download_index_path(@submission, combined: true) %>" class="flex items-center">
<span class="flex items-center justify-center space-x-2" data-target="download-button.defaultButton">
<%= svg_icon('download', class: 'w-6 h-6 flex-shrink-0') %>
<span class="whitespace-nowrap"><%= t('download_combined_pdf') %></span>
@@ -74,14 +74,14 @@
</div>
</div>
<div class="flex md:max-h-[calc(100vh-60px)]">
<div class="overflow-y-auto overflow-x-hidden hidden lg:block w-52 flex-none pr-3 mt-0.5 pt-0.5">
<div class="overflow-y-auto overflow-x-hidden hidden lg:block w-52 flex-none pr-3 mt-0.5 pt-0.5" tabindex="0">
<% values = @submission.submitters.reduce({}) { |acc, sub| acc.merge(sub.values) } %>
<% schema = Submissions.filtered_conditions_schema(@submission, values:) %>
<% schema.each do |item| %>
<% document = @submission.schema_documents.find { |a| item['attachment_uuid'] == a.uuid } %>
<% if document.preview_images.first %>
<scroll-to data-selector-id="page-<%= document.uuid %>-0" class="block cursor-pointer">
<img src="<%= (document.preview_images.find { |e| e.filename.base.to_i.zero? } || document.preview_images.first).url %>" width="<%= document.preview_images.first.metadata['width'] %>" height="<%= document.preview_images.first.metadata['height'] %>" class="rounded border" loading="lazy">
<img src="<%= (document.preview_images.find { |e| e.filename.base.to_i.zero? } || document.preview_images.first).url %>" width="<%= document.preview_images.first.metadata['width'] %>" height="<%= document.preview_images.first.metadata['height'] %>" class="rounded border" loading="lazy" alt="<%= item['name'].presence || document.filename.base %>">
<div class="pb-2 pt-1.5 text-center" dir="auto">
<%= item['name'].presence || document.filename.base %>
</div>
@@ -89,7 +89,7 @@
<% end %>
<% end %>
</div>
<div id="document_view" class="w-full overflow-y-auto overflow-x-hidden mt-0.5 pt-0.5">
<div id="document_view" class="w-full overflow-y-auto overflow-x-hidden mt-0.5 pt-0.5" tabindex="0">
<div class="pr-3.5 pl-0.5">
<% fields_index = Templates.build_field_areas_index(@submission.template_fields || @submission.template.fields) %>
<% submitters_index = @submission.submitters.index_by(&:uuid) %>
@@ -103,9 +103,9 @@
<% preview_images_index = document.preview_images.loaded? ? document.preview_images.index_by { |e| e.filename.base.to_i } : {} %>
<% lazyload_metadata = document.preview_images.first&.metadata || Templates::ProcessDocument::US_LETTER_SIZE %>
<% (document.metadata.dig('pdf', 'number_of_pages') || (document.preview_images.loaded? ? preview_images_index.size : document.preview_images.size)).times do |index| %>
<% page = preview_images_index[index] || page_blob_struct.new(metadata: lazyload_metadata, url: preview_document_page_path(document.signed_key, "#{index}.jpg")) %>
<% page = preview_images_index[index] || page_blob_struct.new(metadata: lazyload_metadata, url: preview_document_page_path(document.signed_key, "#{index}#{Templates::ProcessDocument::PREVIEW_FORMAT}")) %>
<page-container id="<%= "page-#{document.uuid}-#{index}" %>" class="block before:border before:absolute before:top-0 before:bottom-0 before:left-0 before:right-0 before:rounded relative mb-4" style="container-type: size; aspect-ratio: <%= width = page.metadata['width'] %> / <%= height = page.metadata['height'] %>">
<img loading="lazy" src="<%= page.url %>" width="<%= width %>" class="rounded" height="<%= height %>">
<img loading="lazy" src="<%= page.url %>" width="<%= width %>" class="rounded" height="<%= height %>" alt="<%= "#{item['name']} - #{t('page')} #{index + 1}" %>">
<div class="top-0 bottom-0 left-0 right-0 absolute">
<% document_annots_index[index]&.each do |annot| %>
<%= render 'submissions/annotation', annot: %>
@@ -131,7 +131,7 @@
<% submitters_order_index ||= (@submission.template_submitters || @submission.template.submitters).each_with_index.to_h { |s, i| [s['uuid'], i] } %>
<% submitter_index = submitters_order_index[submitter.uuid] %>
<% bg_class = bg_classes[submitter_index % bg_classes.size] %>
<div class="absolute overflow-visible" style="width: <%= area['w'] * 100 %>%; height: <%= area['h'] * 100 %>%; left: <%= area['x'] * 100 %>%; top: <%= area['y'] * 100 %>%;">
<div class="absolute overflow-visible" style="width: <%= area['w'].to_f * 100 %>%; height: <%= area['h'].to_f * 100 %>%; left: <%= area['x'].to_f * 100 %>%; top: <%= area['y'].to_f * 100 %>%;">
<div class="flex h-full w-full bg-opacity-80 justify-center items-center <%= bg_class %>">
<%= svg_icon(SubmissionsController::FIELD_ICONS.fetch(field['type'], 'text_size'), class: 'max-h-10 w-full h-full stroke-2 opacity-50') %>
</div>
@@ -144,7 +144,7 @@
<% end %>
</div>
</div>
<div id="parties_view" class="hidden md:block relative w-full md:w-80 flex-none pt-0.5 pr-4 pl-0.5 overflow-auto space">
<div id="parties_view" class="hidden md:block relative w-full md:w-80 flex-none pt-0.5 pr-4 pl-0.5 overflow-auto space" tabindex="0">
<% colors = %w[bg-red-500 bg-sky-500 bg-emerald-500 bg-yellow-300 bg-purple-600 bg-pink-500 bg-cyan-500 bg-orange-500 bg-lime-500 bg-indigo-500] %>
<% submitter_fields_index = (@submission.template_fields || @submission.template.fields).group_by { |f| f['submitter_uuid'] } %>
<% submitter_field_counters = Hash.new { 0 } %>
@@ -260,10 +260,10 @@
<div dir="auto">
<% if field['type'].in?(%w[signature initials]) %>
<div class="w-full bg-base-300 py-1">
<img class="object-contain mx-auto" style="max-height: <%= field['type'] == 'signature' ? 100 : 50 %>px" height="<%= attachments_index[value].metadata['height'] %>" width="<%= attachments_index[value].metadata['width'] %>" src="<%= attachments_index[value].url %>" loading="lazy">
<img class="object-contain mx-auto" style="max-height: <%= field['type'] == 'signature' ? 100 : 50 %>px" height="<%= attachments_index[value].metadata['height'] %>" width="<%= attachments_index[value].metadata['width'] %>" src="<%= attachments_index[value].url %>" loading="lazy" alt="<%= field['name'] || field['title'] || field['type'] %>">
</div>
<% elsif field['type'].in?(['image', 'stamp', 'kba']) && attachments_index[value].image? %>
<img class="object-contain mx-auto max-h-28" style="max-height: 200px" height="<%= attachments_index[value].metadata['height'] %>" width="<%= attachments_index[value].metadata['width'] %>" src="<%= attachments_index[value].url %>" loading="lazy">
<img class="object-contain mx-auto max-h-28" style="max-height: 200px" height="<%= attachments_index[value].metadata['height'] %>" width="<%= attachments_index[value].metadata['width'] %>" src="<%= attachments_index[value].url %>" loading="lazy" alt="<%= field['name'] || field['title'] || field['type'] %>">
<% elsif field['type'].in?(['file', 'payment', 'image']) %>
<div class="flex flex-col justify-center">
<% Array.wrap(value).each do |val| %>
@@ -281,7 +281,7 @@
<% if field['type'] == 'number' %>
<% value = NumberUtils.format_number(value, field.dig('preferences', 'format')) %>
<% elsif field['type'] == 'date' %>
<% value = TimeUtils.format_date_string(value, field.dig('preferences', 'format'), @submission.account.locale) %>
<% value = TimeUtils.format_date_string(value, field.dig('preferences', 'format'), @submission.account.locale, timezone: (with_submitter_timezone ? (submitter.timezone.presence || @submission.account.timezone) : @submission.account.timezone)) %>
<% end %>
<% if (mask = field.dig('preferences', 'mask').presence) %>
<% if signed_in? && can?(:read, @submission) %>
@@ -313,7 +313,7 @@
</span>
</label>
</toggle-visible>
</div>
</main>
<% unless request.headers['HTTP_X_TURBO'] %>
<%= render 'scripts/autosize_field' %>
<% end %>
+2 -4
View File
@@ -1,8 +1,6 @@
<%= form_for '', url: submit_form_decline_index_path(submitter.slug), method: :post do |f| %>
<div class="mt-4 text-center">
<%= t(:notify_the_sender_with_the_reason_you_declined) %>
</div>
<div class="form-control mt-2">
<div class="form-control mt-4">
<label class="label justify-center" for="reason"><%= t(:notify_the_sender_with_the_reason_you_declined) %></label>
<%= f.text_area :reason, required: true, class: 'base-input w-full py-2 h-40', dir: 'auto', placeholder: t('provide_a_reason'), style: 'height: 200px', rows: '10' %>
</div>
<toggle-submit dir="auto" class="form-control mt-4">
+3 -3
View File
@@ -1,4 +1,4 @@
<div class="max-w-md mx-auto px-2 mt-12 mb-4">
<main class="max-w-md mx-auto px-2 mt-12 mb-4">
<div class="space-y-6 mx-auto">
<div class="space-y-6">
<div class="flex items-center justify-center">
@@ -30,7 +30,7 @@
<% end %>
<% end %>
<% if @submitter.completed_at > 30.minutes.ago || (current_user && current_user.account.submitters.exists?(id: @submitter.id)) %>
<download-button data-src="<%= submit_form_documents_path(@submitter.slug) %>" class="base-button w-full">
<download-button role="button" tabindex="0" aria-label="<%= t('download_documents') %>" data-src="<%= submit_form_documents_path(@submitter.slug) %>" class="base-button w-full">
<span class="flex items-center justify-center space-x-2" data-target="download-button.defaultButton">
<%= svg_icon('download', class: 'w-6 h-6') %>
<span><%= t('download_documents') %></span>
@@ -50,5 +50,5 @@
</toggle-submit>
<% end %>
</div>
</div>
</main>
<%= render 'shared/attribution', link_path: '/start', account: @submitter.account %>
+4 -4
View File
@@ -2,7 +2,7 @@
<% I18n.with_locale(@submitter.account.locale) do %>
<% content_for(:html_description, t('account_name_has_invited_you_to_fill_and_sign_documents_online_effortlessly_with_a_secure_fast_and_user_friendly_digital_document_signing_solution', account_name: @submitter.account.name)) %>
<% end %>
<div class="max-w-md mx-auto px-2 mt-12 mb-4">
<main class="max-w-md mx-auto px-2 mt-12 mb-4">
<div class="space-y-6 mx-auto">
<div class="space-y-6">
<div class="flex items-center justify-center">
@@ -46,7 +46,7 @@
<% end %>
</span>
<span>
<label for="resend_code" id="resend_label" class="link"><%= t(:re_send_email) %></label>
<button type="submit" form="resend_code_form" id="resend_label" class="link"><%= t(:re_send_email) %></button>
</span>
</div>
</div>
@@ -54,7 +54,7 @@
<%= f.button button_title(title: t('submit')), class: 'base-button' %>
</toggle-submit>
<% end %>
<%= button_to t(:re_send_email), submit_form_email_2fa_path, params: { submitter_slug: @submitter.slug, resend: true }, method: :put, id: 'resend_code', class: 'hidden' %>
<%= button_to t(:re_send_email), submit_form_email_2fa_path, params: { submitter_slug: @submitter.slug, resend: true }, method: :put, form: { id: 'resend_code_form', class: 'hidden' } %>
<% else %>
<% if params[:t] %>
<fetch-form data-onload="true">
@@ -69,4 +69,4 @@
<div><%= t('please_contact_the_requester_to_specify_your_email_for_two_factor_authentication') %></div>
<% end %>
</div>
</div>
</main>
+55 -41
View File
@@ -8,28 +8,33 @@
<% page_blob_struct = Struct.new(:url, :metadata) %>
<% schema = Submissions.filtered_conditions_schema(@submitter.submission, values:, include_submitter_uuid: @submitter.uuid) %>
<% font_scale = 1000.0 / PdfUtils::US_LETTER_W %>
<% decline_modal_checkbox_uuid = nil %>
<% delegate_modal_checkbox_uuid = nil %>
<% decline_modal_id = nil %>
<% delegate_modal_id = nil %>
<div style="max-height: -webkit-fill-available;">
<div id="scrollbox">
<main id="scrollbox">
<div id="sr_only_content"></div>
<div class="mx-auto block pb-72" style="max-width: 1000px">
<%# flex block w-full sticky top-0 z-50 space-x-2 items-center bg-yellow-100 p-2 border-y border-yellow-200 transition-transform duration-300 %>
<%= local_assigns[:banner_html] || capture do %>
<%= render('submit_form/banner') %>
<div id="signing_form_header" class="sticky min-[1230px]:static top-0 z-50 bg-base-100 py-2 px-2 flex items-center md:-mx-[8px]" style="margin-bottom: -16px">
<div class="text-xl md:text-2xl font-medium focus:text-clip" style="width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">
<header id="signing_form_header" class="sticky min-[1230px]:static top-0 z-50 bg-base-100 py-2 px-2 flex items-center md:-mx-[8px]" style="margin-bottom: -16px">
<h1 class="text-xl md:text-2xl font-medium focus:text-clip" style="width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">
<%= @submitter.submission.name || @submitter.submission.template.name %>
</div>
</h1>
<div class="flex items-center space-x-2" style="margin-left: 20px; flex-shrink: 0">
<% if @form_configs[:with_delegate] %>
<label id="delegate_button" for="<%= delegate_modal_checkbox_uuid = SecureRandom.uuid %>" class="btn btn-sm !px-5"><%= t(:delegate) %></label>
<modal-button data-target="<%= delegate_modal_id = SecureRandom.uuid %>">
<button id="delegate_button" type="button" class="btn btn-sm !px-5"><%= t(:delegate) %></button>
</modal-button>
<% if @form_configs[:with_decline] %>
<label id="decline_button" for="<%= decline_modal_checkbox_uuid = SecureRandom.uuid %>" class="btn btn-sm px-2" title="<%= t(:decline) %>">
<%= svg_icon('x', class: 'w-5 h-5') %>
</label>
<modal-button data-target="<%= decline_modal_id = SecureRandom.uuid %>">
<button id="decline_button" type="button" class="btn btn-sm px-2" aria-label="<%= t(:decline) %>">
<%= svg_icon('x', class: 'w-5 h-5') %>
</button>
</modal-button>
<% end %>
<% if @form_configs[:with_partial_download] %>
<download-button data-src="<%= submit_form_download_index_path(@submitter.slug) %>" class="btn btn-neutral text-white btn-sm px-2" title="<%= t('download') %>">
<download-button role="button" tabindex="0" data-src="<%= submit_form_download_index_path(@submitter.slug) %>" class="btn btn-neutral text-white btn-sm px-2" title="<%= t('download') %>" aria-label="<%= t('download') %>">
<span data-target="download-button.defaultButton">
<%= svg_icon('download', class: 'w-5 h-5') %>
</span>
@@ -40,10 +45,12 @@
<% end %>
<% else %>
<% if @form_configs[:with_decline] %>
<label id="decline_button" for="<%= decline_modal_checkbox_uuid = SecureRandom.uuid %>" class="btn btn-sm !px-5"><%= t(:decline) %></label>
<modal-button data-target="<%= decline_modal_id = SecureRandom.uuid %>">
<button id="decline_button" type="button" class="btn btn-sm !px-5"><%= t(:decline) %></button>
</modal-button>
<% end %>
<% if @form_configs[:with_partial_download] %>
<download-button data-src="<%= submit_form_download_index_path(@submitter.slug) %>" class="btn btn-neutral text-white btn-sm !px-4">
<download-button role="button" tabindex="0" data-src="<%= submit_form_download_index_path(@submitter.slug) %>" class="btn btn-neutral text-white btn-sm !px-4" aria-label="<%= t('download') %>">
<span class="flex items-center justify-center" data-target="download-button.defaultButton">
<%= svg_icon('download', class: 'w-6 h-6 inline md:hidden') %>
<span class="hidden md:inline"><%= t('download') %></span>
@@ -56,23 +63,27 @@
<% end %>
<% end %>
</div>
</div>
<scroll-buttons class="fixed right-5 top-2 hidden md:flex gap-1 z-50 ease-in-out opacity-0 -translate-y-10">
</header>
<scroll-buttons inert class="fixed right-5 top-2 hidden md:flex gap-1 z-50 ease-in-out opacity-0 -translate-y-10">
<% if @form_configs[:with_delegate] %>
<label id="delegate_button" for="<%= delegate_modal_checkbox_uuid %>" class="btn btn-sm px-0">
<span class="min-[1366px]:inline hidden px-3">
<%= t(:delegate) %>
</span>
<span class="inline min-[1366px]:hidden px-2">
<%= svg_icon('user_share', class: 'w-5 h-5') %>
</span>
</label>
<modal-button data-target="<%= delegate_modal_id %>">
<button id="delegate_button_mobile" type="button" class="btn btn-sm px-0" aria-label="<%= t(:delegate) %>">
<span class="min-[1366px]:inline hidden px-3">
<%= t(:delegate) %>
</span>
<span class="inline min-[1366px]:hidden px-2">
<%= svg_icon('user_share', class: 'w-5 h-5') %>
</span>
</button>
</modal-button>
<% if @form_configs[:with_decline] %>
<label id="decline_button" for="<%= decline_modal_checkbox_uuid %>" class="btn btn-sm px-2">
<%= svg_icon('x', class: 'w-5 h-5') %>
</label>
<modal-button data-target="<%= decline_modal_id %>">
<button id="decline_button_mobile" type="button" class="btn btn-sm px-2" aria-label="<%= t(:decline) %>">
<%= svg_icon('x', class: 'w-5 h-5') %>
</button>
</modal-button>
<% end %>
<download-button data-src="<%= submit_form_download_index_path(@submitter.slug) %>" class="btn btn-neutral text-white btn-sm px-2">
<download-button role="button" tabindex="0" data-src="<%= submit_form_download_index_path(@submitter.slug) %>" class="btn btn-neutral text-white btn-sm px-2" aria-label="<%= t('download') %>">
<span data-target="download-button.defaultButton">
<%= svg_icon('download', class: 'w-5 h-5') %>
</span>
@@ -82,16 +93,18 @@
</download-button>
<% else %>
<% if @form_configs[:with_decline] %>
<label id="decline_button" for="<%= decline_modal_checkbox_uuid %>" class="btn btn-sm px-0">
<span class="min-[1366px]:inline hidden px-3">
<%= t(:decline) %>
</span>
<span class="inline min-[1366px]:hidden px-2">
<%= svg_icon('x', class: 'w-5 h-5') %>
</span>
</label>
<modal-button data-target="<%= decline_modal_id %>">
<button id="decline_button_mobile" type="button" class="btn btn-sm px-0" aria-label="<%= t(:decline) %>">
<span class="min-[1366px]:inline hidden px-3">
<%= t(:decline) %>
</span>
<span class="inline min-[1366px]:hidden px-2">
<%= svg_icon('x', class: 'w-5 h-5') %>
</span>
</button>
</modal-button>
<% end %>
<download-button data-src="<%= submit_form_download_index_path(@submitter.slug) %>" class="btn btn-neutral text-white btn-sm px-2">
<download-button role="button" tabindex="0" data-src="<%= submit_form_download_index_path(@submitter.slug) %>" class="btn btn-neutral text-white btn-sm px-2" aria-label="<%= t('download') %>">
<span data-target="download-button.defaultButton">
<%= svg_icon('download', class: 'w-5 h-5') %>
</span>
@@ -105,13 +118,14 @@
<% schema.each do |item| %>
<% document = @submitter.submission.schema_documents.find { |a| a.uuid == item['attachment_uuid'] } %>
<div id="document-<%= document.uuid %>">
<div class="sr_only_content"></div>
<% document_annots_index = document.metadata.dig('pdf', 'annotations')&.group_by { |e| e['page'] } || {} %>
<% preview_images_index = document.preview_images.loaded? ? document.preview_images.index_by { |e| e.filename.base.to_i } : {} %>
<% lazyload_metadata = document.preview_images.last&.metadata || Templates::ProcessDocument::US_LETTER_SIZE %>
<% (document.metadata.dig('pdf', 'number_of_pages') || (document.preview_images.loaded? ? preview_images_index.size : document.preview_images.size)).times do |index| %>
<% page = preview_images_index[index] || page_blob_struct.new(metadata: lazyload_metadata, url: preview_document_page_path(document.signed_key, "#{index}.jpg")) %>
<% page = preview_images_index[index] || page_blob_struct.new(metadata: lazyload_metadata, url: preview_document_page_path(document.signed_key, "#{index}#{Templates::ProcessDocument::PREVIEW_FORMAT}")) %>
<page-container class="block relative my-4 shadow-md" style="container-type: size; aspect-ratio: <%= width = page.metadata['width'] %> / <%= height = page.metadata['height'] %>">
<img loading="lazy" src="<%= page.url %>" width="<%= width %>" height="<%= height %>">
<img loading="lazy" src="<%= page.url %>" width="<%= width %>" height="<%= height %>" alt="<%= "#{item['name']} - #{t('page')} #{index + 1}" %>">
<div id="page-<%= [document.uuid, index].join('-') %>" class="top-0 bottom-0 left-0 right-0 absolute">
<% if annots = document_annots_index[index] %>
<%= render 'submit_form/annotations', annots: %>
@@ -140,7 +154,7 @@
</div>
<% end %>
</div>
</div>
</main>
</div>
<div class="fixed bottom-0 w-full h-0 z-50">
<div class="mx-auto" style="max-width: 1000px">
@@ -150,12 +164,12 @@
</div>
</div>
<% if @form_configs[:with_decline] %>
<%= render 'shared/html_modal', title: t(:decline), uuid: decline_modal_checkbox_uuid do %>
<%= render 'shared/html_modal', title: t(:decline), uuid: decline_modal_id do %>
<%= render 'submit_form/decline_form', submitter: @submitter %>
<% end %>
<% end %>
<% if @form_configs[:with_delegate] %>
<%= render 'shared/html_modal', title: t(:delegate), uuid: delegate_modal_checkbox_uuid do %>
<%= render 'shared/html_modal', title: t(:delegate), uuid: delegate_modal_id do %>
<%= render 'submit_form/delegate_form', submitter: @submitter %>
<% end %>
<% end %>
+8 -1
View File
@@ -23,7 +23,14 @@
</label>
<% end %>
<div class="flex gap-2 mt-3">
<input id="embedding_url" type="text" value="<%= start_form_url(slug: @template.slug, host: form_link_host) %>" class="base-input w-full" autocomplete="off" readonly>
<div class="relative flex-grow">
<input id="embedding_url" type="text" value="<%= start_form_url(slug: @template.slug, host: form_link_host) %>" class="base-input w-full pr-10" autocomplete="off" readonly>
<% if @template.variables_schema.blank? && !@template.preferences&.dig('require_email_2fa') && !@template.preferences&.dig('require_phone_2fa') %>
<a href="<%= template_share_link_qr_path(@template) %>" target="_blank" rel="noopener" class="absolute top-1/2 -translate-y-1/2 right-2 flex items-center justify-center tooltip text-base-content/70 hover:text-base-content bg-white rounded px-1 py-0.5" data-tip="<%= t('qr_code') %>" aria-label="<%= t('qr_code') %>">
<%= svg_icon('qrcode', class: 'w-6 h-6') %>
</a>
<% end %>
</div>
<check-on-click data-element-id="template_shared_link">
<%= render 'shared/clipboard_copy', icon: 'copy', text: start_form_url(slug: @template.slug, host: form_link_host), class: 'base-button', icon_class: 'w-6 h-6 text-white', copy_title: t('copy'), copied_title: t('copied') %>
</check-on-click>
@@ -0,0 +1,2 @@
<%= t('powered_by') %>
<a href="<%= Docuseal::PRODUCT_URL %>" target="_blank" rel="noopener"><%= Docuseal.product_name %></a>
@@ -0,0 +1,2 @@
<%= render 'shared/logo' %>
<span><%= Docuseal.product_name %></span>
@@ -0,0 +1,10 @@
<div class="max-w-md space-y-6 mx-auto px-2 mt-12 mb-4">
<p class="text-xl font-semibold text-center">
<%= t('share_link_is_currently_disabled') %>
</p>
<% if can?(:update, @template) %>
<toggle-submit class="block">
<%= button_to button_title(title: t('enable_shared_link'), icon: svg_icon('lock_open', class: 'w-6 h-6')), template_share_link_path(@template), params: { template: { shared_link: true }, redir: template_share_link_qr_path(@template) }, method: :post, data: { turbo: false }, class: 'base-button w-full' %>
</toggle-submit>
<% end %>
</div>
@@ -0,0 +1,288 @@
<!DOCTYPE html>
<% page_width_css = @page_size == 'Letter' ? 8.5 * 96.0 : 210.0 * 96.0 / 25.4 %>
<% page_height_css = @page_size == 'Letter' ? 11.0 * 96.0 : 297.0 * 96.0 / 25.4 %>
<% page_width = @page_size == 'Letter' ? '8.5in' : '210mm' %>
<% page_cqw = ->(px) { format('%.6fcqw', px / page_width_css * 100.0) } %>
<html lang="<%= I18n.locale %>">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><%= @template.name %></title>
<style>
@page {
size: <%= @page_size %> portrait;
margin: 0.5in;
}
html, body {
margin: 0;
padding: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
color: #111;
background: #faf7f5;
}
.qr-page-wrapper {
container-type: size;
width: min(100vw, <%= page_width %>);
max-width: 100%;
aspect-ratio: <%= format('%<width>.6f / %<height>.6f', width: page_width_css, height: page_height_css) %>;
margin: 24px auto;
}
.qr-page {
box-sizing: border-box;
width: 100%;
height: 100%;
padding: <%= page_cqw.call(72) %>;
background: #ffffff;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.12);
display: grid;
grid-template-rows: auto 1fr auto;
}
.qr-logo {
display: flex;
align-items: center;
justify-content: center;
gap: <%= page_cqw.call(10) %>;
font-size: <%= page_cqw.call(20) %>;
font-weight: 700;
letter-spacing: -0.01em;
}
.qr-logo svg {
width: <%= page_cqw.call(32) %>;
height: <%= page_cqw.call(32) %>;
}
.qr-logo img {
height: <%= page_cqw.call(50) %>;
}
.qr-content {
align-self: center;
text-align: center;
min-width: 0;
margin-bottom: <%= page_cqw.call(80) %>;
}
.qr-header {
font-size: <%= page_cqw.call(36) %>;
font-weight: 700;
line-height: 1.2;
padding: 0 <%= page_cqw.call(8) %>;
margin-bottom: <%= page_cqw.call(48) %>;
word-break: break-word;
}
.qr-main svg {
display: block;
width: <%= page_cqw.call(480) %>;
height: auto;
max-width: 100%;
margin: 0 auto;
shape-rendering: crispEdges;
}
.qr-footer {
font-size: <%= page_cqw.call(22) %>;
line-height: 1.4;
padding: 0 <%= page_cqw.call(8) %>;
margin-top: <%= page_cqw.call(48) %>;
word-break: break-word;
}
.qr-branding {
align-self: end;
text-align: center;
font-size: <%= page_cqw.call(11) %>;
color: #6b7280;
padding-top: <%= page_cqw.call(24) %>;
}
.qr-branding a {
color: #4b5563;
text-decoration: none;
}
[contenteditable="true"] {
outline: 1px dashed #cbd5e1;
outline-offset: 6px;
cursor: text;
transition: outline-color 0.15s ease;
}
[contenteditable="true"]:hover {
outline-color: #94a3b8;
}
[contenteditable="true"]:focus {
outline: 1px dashed #291334;
outline-offset: 6px;
}
.print-button {
position: fixed;
right: 24px;
top: 24px;
z-index: 100;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
min-height: 3rem;
height: 3rem;
padding-left: 1rem;
padding-right: 1rem;
border: 1px solid #291334;
border-radius: 1.9rem;
background-color: #291334;
color: #ffffff;
font-size: 1rem;
font-weight: 500;
line-height: 1em;
font-family: inherit;
text-transform: none;
letter-spacing: normal;
cursor: pointer;
user-select: none;
transition: background-color 0.2s ease, border-color 0.2s ease, transform 0.1s ease;
}
.print-button:hover {
background-color: #1a0c22;
border-color: #1a0c22;
}
.print-button:active {
transform: scale(0.97);
}
.print-button:focus-visible {
outline: 2px solid #291334;
outline-offset: 2px;
}
.print-button svg {
width: 1.25rem;
height: 1.25rem;
}
@media screen and (max-width: 820px) {
.print-button {
top: auto;
right: 12px;
bottom: 12px;
}
}
@media print {
html, body {
background: #ffffff;
margin: 0;
padding: 0;
width: 100%;
height: 100%;
}
.qr-page-wrapper {
width: 100%;
height: 100%;
aspect-ratio: auto;
margin: 0;
}
.qr-page {
margin: 0;
box-shadow: none;
width: 100%;
height: 100%;
min-height: 100%;
padding: 0.25in;
page-break-inside: avoid;
page-break-after: avoid;
}
.qr-logo {
gap: 10px;
font-size: 20px;
}
.qr-logo svg {
width: 32px;
height: 32px;
}
.qr-logo img {
height: 50px;
}
.qr-content {
margin-bottom: 80px;
}
.qr-header {
font-size: 36px;
padding: 0 8px;
margin-bottom: 48px;
}
.qr-main svg {
width: 5in;
height: 5in;
}
.qr-footer {
font-size: 22px;
padding: 0 8px;
margin-top: 48px;
}
.qr-branding {
font-size: 11px;
padding-top: 24px;
}
.print-button {
display: none !important;
}
[contenteditable="true"],
[contenteditable="true"]:focus {
outline: none !important;
}
}
</style>
</head>
<body>
<div class="qr-page-wrapper">
<div class="qr-page">
<div class="qr-logo">
<%= render 'logo' %>
</div>
<div class="qr-content">
<div class="qr-header" contenteditable="true" spellcheck="false"><%= @template.name %></div>
<div class="qr-main">
<%== @qr_svg_code %>
</div>
<div class="qr-footer" contenteditable="true" spellcheck="false">
<%= t('scan_the_qr_code_above_with_your_phone_camera_to_open_and_sign_this_document') %>
</div>
</div>
<div class="qr-branding">
<%= render 'branding' %>
</div>
</div>
</div>
<button type="button" id="qr-print-button" class="print-button">
<%= svg_icon('printer') %>
<span><%= t('print') %></span>
</button>
<script nonce="<%= content_security_policy_nonce %>">
document.getElementById('qr-print-button').addEventListener('click', function () {
window.print();
});
</script>
</body>
</html>
+16 -6
View File
@@ -8,11 +8,21 @@
<%= render 'shared/clipboard_copy', icon: 'copy', text: current_user.access_token.token, class: 'base-button', icon_class: 'w-6 h-6 text-white', copy_title: t('copy'), copied_title: t('copied') %>
</div>
</div>
<%= form_for @webhook_url, url: settings_webhooks_path, method: :post, html: { autocomplete: 'off' }, data: { turbo_frame: :_top } do |f| %>
<%= f.label :url, 'Webhook URL', class: 'text-sm font-semibold' %>
<div class="space-y-2 md:flex-nowrap mt-2">
<%= f.url_field :url, class: 'base-input w-full', placeholder: 'https://example.com/hook' %>
<%= f.button button_title(title: t('save'), disabled_with: t('saving')), class: 'base-button w-full' %>
</div>
<% if @webhook_url.new_record? %>
<%= form_for @webhook_url, url: settings_webhooks_path, method: :post, html: { autocomplete: 'off' }, data: { turbo_frame: :_top } do |f| %>
<%= f.label :url, 'Webhook URL', class: 'text-sm font-semibold' %>
<div class="space-y-2 md:flex-nowrap mt-2">
<%= f.url_field :url, class: 'base-input w-full', placeholder: 'https://example.com/hook' %>
<%= f.button button_title(title: t('save'), disabled_with: t('saving')), class: 'base-button w-full' %>
</div>
<% end %>
<% else %>
<%= form_for @webhook_url, url: settings_webhook_path(@webhook_url), method: :put, html: { autocomplete: 'off' }, data: { turbo_frame: :_top } do |f| %>
<%= f.label :url, 'Webhook URL', class: 'text-sm font-semibold' %>
<div class="space-y-2 md:flex-nowrap mt-2">
<%= f.url_field :url, class: 'base-input w-full', placeholder: 'https://example.com/hook', required: true %>
<%= f.button button_title(title: t('save'), disabled_with: t('saving')), class: 'base-button w-full' %>
</div>
<% end %>
<% end %>
<% end %>
+42
View File
@@ -162,6 +162,7 @@ en: &en
download_documents: Download documents
downloading: Downloading
download: Download
page: Page
decline: Decline
declined: Declined
delegate: Delegate
@@ -367,6 +368,9 @@ en: &en
sign_out: Sign out
page_number: 'Page %{number}'
powered_by: Powered by
qr_code: QR Code
print: Print
scan_the_qr_code_above_with_your_phone_camera_to_open_and_sign_this_document: Scan the QR code above with your phone camera to open and sign this document.
count_documents_signed_with_html: '<b>%{count}</b> documents signed with'
storage: Storage
notifications: Notifications
@@ -938,6 +942,7 @@ en: &en
embed: Embedding
invite: Invite
link: Link
mcp: MCP
submission_event_names:
send_email_to_html: '<b>Email sent</b> to %{submitter_name}'
bounce_email_html: '<b>Email bounced</b> %{submitter_name}'
@@ -1201,6 +1206,7 @@ es: &es
download_documents: Descargar documentos
downloading: Descargando
download: Descargar
page: Página
decline: Rechazar
delegate: Delegar
enter_the_email_address_of_the_person_you_want_to_delegate_to: Ingrese la dirección de correo electrónico de la persona a quien desea delegar
@@ -1406,6 +1412,9 @@ es: &es
sign_out: Cerrar sesión
page_number: 'Página %{number}'
powered_by: Desarrollado por
qr_code: Código QR
print: Imprimir
scan_the_qr_code_above_with_your_phone_camera_to_open_and_sign_this_document: Escanea el código QR de arriba con la cámara de tu teléfono para abrir y firmar este documento.
count_documents_signed_with_html: '<b>%{count}</b> documentos firmados con'
storage: Almacenamiento
notifications: Notificaciones
@@ -1974,6 +1983,7 @@ es: &es
embed: Integración
invite: Invitación
link: Enlace
mcp: MCP
submission_event_names:
send_email_to_html: '<b>Correo electrónico enviado</b> a %{submitter_name}'
bounce_email_html: '<b>Correo electrónico rebotado</b> %{submitter_name}'
@@ -2237,6 +2247,7 @@ it: &it
download_documents: Scarica documenti
downloading: Scaricamento
download: Scarica
page: Pagina
decline: Rifiuta
delegate: Delega
enter_the_email_address_of_the_person_you_want_to_delegate_to: Inserisci l'indirizzo email della persona a cui vuoi delegare
@@ -2442,6 +2453,9 @@ it: &it
sign_out: Esci
page_number: 'Pagina %{number}'
powered_by: Fornito da
qr_code: Codice QR
print: Stampa
scan_the_qr_code_above_with_your_phone_camera_to_open_and_sign_this_document: Scansiona il codice QR qui sopra con la fotocamera del tuo telefono per aprire e firmare questo documento.
count_documents_signed_with_html: '<b>%{count}</b> documenti firmati con'
storage: Archiviazione
notifications: Notifiche
@@ -3011,6 +3025,7 @@ it: &it
embed: Incorporamento
invite: Invito
link: Link
mcp: MCP
submission_event_names:
send_email_to_html: '<b>Email inviata</b> a %{submitter_name}'
bounce_email_html: '<b>Email respinta</b> %{submitter_name}'
@@ -3274,6 +3289,7 @@ fr: &fr
download_documents: Télécharger les documents
downloading: Téléchargement
download: Télécharger
page: Page
decline: Refuser
delegate: Déléguer
enter_the_email_address_of_the_person_you_want_to_delegate_to: Saisissez l'adresse e-mail de la personne à qui vous souhaitez déléguer
@@ -3479,6 +3495,9 @@ fr: &fr
sign_out: Se déconnecter
page_number: Page %{number}
powered_by: Propulsé par
qr_code: Code QR
print: Imprimer
scan_the_qr_code_above_with_your_phone_camera_to_open_and_sign_this_document: Scannez le code QR ci-dessus avec l'appareil photo de votre téléphone pour ouvrir et signer ce document.
count_documents_signed_with_html: "<b>%{count}</b> documents signés avec"
storage: Stockage
notifications: Notifications
@@ -4044,6 +4063,7 @@ fr: &fr
embed: Embedding
invite: Invitation
link: Lien
mcp: MCP
submission_event_names:
send_email_to_html: "<b>E‑mail envoyé</b> à %{submitter_name}"
bounce_email_html: "<b>E‑mail rejeté</b> %{submitter_name}"
@@ -4307,6 +4327,7 @@ pt: &pt
download_documents: Baixar documentos
downloading: Baixando
download: Baixar
page: Página
decline: Recusar
delegate: Delegar
enter_the_email_address_of_the_person_you_want_to_delegate_to: Insira o endereço de e-mail da pessoa para quem deseja delegar
@@ -4512,6 +4533,9 @@ pt: &pt
sign_out: Sair
page_number: 'Página %{number}'
powered_by: Desenvolvido por
qr_code: Código QR
print: Imprimir
scan_the_qr_code_above_with_your_phone_camera_to_open_and_sign_this_document: Escaneie o código QR acima com a câmera do seu telefone para abrir e assinar este documento.
count_documents_signed_with_html: '<b>%{count}</b> documentos assinados com'
storage: Armazenamento
notifications: Notificações
@@ -5080,6 +5104,7 @@ pt: &pt
embed: Incorporação
invite: Convite
link: Link
mcp: MCP
submission_event_names:
send_email_to_html: '<b>Email enviado</b> para %{submitter_name}'
bounce_email_html: '<b>Email não entregue</b> %{submitter_name}'
@@ -5343,6 +5368,7 @@ de: &de
download_documents: Dokumente herunterladen
downloading: Wird heruntergeladen
download: Download
page: Seite
decline: Ablehnen
delegate: Delegieren
enter_the_email_address_of_the_person_you_want_to_delegate_to: Geben Sie die E-Mail-Adresse der Person ein, an die Sie delegieren möchten
@@ -5548,6 +5574,9 @@ de: &de
sign_out: Abmelden
page_number: 'Seite %{number}'
powered_by: Bereitgestellt von
qr_code: QR-Code
print: Drucken
scan_the_qr_code_above_with_your_phone_camera_to_open_and_sign_this_document: Scannen Sie den QR-Code oben mit Ihrer Handykamera, um dieses Dokument zu öffnen und zu unterzeichnen.
count_documents_signed_with_html: '<b>%{count}</b> Dokumente signiert mit'
storage: Speicher
notifications: Benachrichtigungen
@@ -6116,6 +6145,7 @@ de: &de
embed: Einbettung
invite: Einladung
link: Link
mcp: MCP
submission_event_names:
send_email_to_html: '<b>E-Mail gesendet</b> an %{submitter_name}'
bounce_email_html: '<b>E-Mail unzustellbar</b> %{submitter_name}'
@@ -6243,6 +6273,7 @@ pl:
view: Widok
hi_there: Cześć,
download: Pobierz
page: Strona
decline: Odrzuć
delegate: Deleguj
enter_the_email_address_of_the_person_you_want_to_delegate_to: Wprowadź adres e-mail osoby, do której chcesz delegować
@@ -6343,6 +6374,7 @@ uk:
view: Переглянути
hi_there: Привіт,
download: Завантажити
page: Сторінка
decline: Відхилити
delegate: Делегувати
enter_the_email_address_of_the_person_you_want_to_delegate_to: Введіть адресу електронної пошти особи, якій ви хочете делегувати
@@ -6443,6 +6475,7 @@ cs:
view: Zobrazit
hi_there: Ahoj,
download: Stáhnout
page: Stránka
decline: Odmítnout
delegate: Delegovat
enter_the_email_address_of_the_person_you_want_to_delegate_to: Zadejte e-mailovou adresu osoby, na kterou chcete delegovat
@@ -6543,6 +6576,7 @@ he:
view: תצוגה
hi_there: שלום,
download: הורד
page: עמוד
decline: דחייה
delegate: הואלה
enter_the_email_address_of_the_person_you_want_to_delegate_to: הזן את כתובת הדוא"ל של האדם שברצונך להאציל אליו
@@ -6780,6 +6814,7 @@ nl: &nl
download_documents: Documenten downloaden
downloading: Downloaden
download: Downloaden
page: Pagina
decline: Weigeren
delegate: Delegeren
enter_the_email_address_of_the_person_you_want_to_delegate_to: Voer het e-mailadres in van de persoon aan wie u wilt delegeren
@@ -6985,6 +7020,9 @@ nl: &nl
sign_out: Afmelden
page_number: Pagina %{number}
powered_by: Aangedreven door
qr_code: QR-code
print: Afdrukken
scan_the_qr_code_above_with_your_phone_camera_to_open_and_sign_this_document: Scan de bovenstaande QR-code met je telefooncamera om dit document te openen en te ondertekenen.
count_documents_signed_with_html: "<b>%{count}</b> documenten ondertekend met"
storage: Opslag
notifications: Meldingen
@@ -7549,6 +7587,7 @@ nl: &nl
embed: Insluiten
invite: Uitnodiging
link: Link
mcp: MCP
submission_event_names:
send_email_to_html: "<b>E-mail verzonden</b> naar %{submitter_name}"
bounce_email_html: "<b>E-mail gebounced</b> %{submitter_name}"
@@ -7676,6 +7715,7 @@ ar:
view: عرض
hi_there: مرحبا,
download: تحميل
page: صفحة
decline: رفض
delegate: تفويض
enter_the_email_address_of_the_person_you_want_to_delegate_to: أدخل عنوان البريد الإلكتروني للشخص الذي تريد التفويض إليه
@@ -7776,6 +7816,7 @@ ko:
view: 보기
hi_there: 안녕하세요,
download: 다운로드
page: 페이지
decline: 거절
delegate: 위임
enter_the_email_address_of_the_person_you_want_to_delegate_to: 위임할 사람의 이메일 주소를 입력하세요
@@ -7876,6 +7917,7 @@ ja:
view: 表示
hi_there: こんにちは
download: ダウンロード
page: ページ
decline: 辞退
delegate: 委任
enter_the_email_address_of_the_person_you_want_to_delegate_to: 委任したい人のメールアドレスを入力してください
+3 -1
View File
@@ -77,7 +77,7 @@ Rails.application.routes.draw do
resources :console_redirect, only: %i[index]
resources :upgrade, only: %i[index], controller: 'console_redirect'
resources :manage, only: %i[index], controller: 'console_redirect'
resource :testing_account, only: %i[show destroy]
resource :testing_account, only: %i[create destroy]
resources :testing_api_settings, only: %i[index]
resources :submitters_autocomplete, only: %i[index]
resources :submitters_resubmit, only: %i[update]
@@ -108,6 +108,7 @@ Rails.application.routes.draw do
resource :code_modal, only: %i[show], controller: 'templates_code_modal'
resource :preferences, only: %i[show create destroy], controller: 'templates_preferences'
resource :share_link, only: %i[show create], controller: 'templates_share_link'
resource :share_link_qr, only: %i[show], controller: 'templates_share_link_qr'
resources :recipients, only: %i[create], controller: 'templates_recipients'
resources :prefillable_fields, only: %i[create], controller: 'templates_prefillable_fields'
resources :submissions_export, only: %i[index new]
@@ -149,6 +150,7 @@ Rails.application.routes.draw do
resources :decline, only: %i[create], controller: 'submit_form_decline'
resources :delegate, only: %i[create], controller: 'submit_form_delegate'
resources :invite, only: %i[create], controller: 'submit_form_invite'
resources :metadata, only: %i[index], controller: 'submit_form_metadata'
resources :debug, only: %i[index], controller: 'submissions_debug' if Rails.env.development?
get :completed
get :delegated
@@ -0,0 +1,15 @@
# frozen_string_literal: true
class CreateDocumentMetadata < ActiveRecord::Migration[8.1]
def change
create_table :document_metadata do |t|
t.references :account, null: false, foreign_key: true, index: false
t.string :blob_checksum, null: false
t.text :text_runs, null: false
t.datetime :created_at, null: false
end
add_index :document_metadata, %i[account_id blob_checksum], unique: true
end
end
+10 -1
View File
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.1].define(version: 2026_03_27_100000) do
ActiveRecord::Schema[8.1].define(version: 2026_04_16_100000) do
# These are extensions that must be enabled in order to support this database
enable_extension "btree_gin"
enable_extension "pg_catalog.plpgsql"
@@ -168,6 +168,14 @@ ActiveRecord::Schema[8.1].define(version: 2026_03_27_100000) do
t.index ["submitter_id"], name: "index_document_generation_events_on_submitter_id"
end
create_table "document_metadata", force: :cascade do |t|
t.bigint "account_id", null: false
t.string "blob_checksum", null: false
t.datetime "created_at", null: false
t.text "text_runs", null: false
t.index ["account_id", "blob_checksum"], name: "index_document_metadata_on_account_id_and_blob_checksum", unique: true
end
create_table "dynamic_document_versions", force: :cascade do |t|
t.text "areas", null: false
t.datetime "created_at", null: false
@@ -553,6 +561,7 @@ ActiveRecord::Schema[8.1].define(version: 2026_03_27_100000) do
add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"
add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
add_foreign_key "document_generation_events", "submitters"
add_foreign_key "document_metadata", "accounts"
add_foreign_key "dynamic_document_versions", "dynamic_documents"
add_foreign_key "dynamic_documents", "templates"
add_foreign_key "email_events", "accounts"
+34
View File
@@ -0,0 +1,34 @@
# frozen_string_literal: true
module DocumentMetadatas
module_function
def find_or_create_for_document(document, account_id:)
checksum = document.blob.checksum
metadata = DocumentMetadata.find_by(account_id:, blob_checksum: checksum)
metadata ||= DocumentMetadata.create!(account_id:, blob_checksum: checksum, text_runs: build_text_runs(document))
metadata
rescue ActiveRecord::RecordNotUnique
retry
end
def build_text_runs(document)
number_of_pages = document.metadata.dig('pdf', 'number_of_pages').to_i
return {} if number_of_pages.zero?
Pdfium::Document.open_bytes(document.download) do |doc|
(0...doc.page_count).each_with_object({}) do |page_index, acc|
page = doc.get_page(page_index)
acc[page_index] = page.text_objects.map do |node|
{ text: node.content, x: node.x, y: node.y, w: node.w, h: node.h, font_size: node.font_size }
end
ensure
page&.close
end
end
end
end
-1
View File
@@ -1,7 +1,6 @@
# frozen_string_literal: true
module Docuseal
URL_CACHE = ActiveSupport::Cache::MemoryStore.new
PRODUCT_URL = 'https://www.docuseal.com'
PRODUCT_EMAIL_URL = ENV.fetch('PRODUCT_EMAIL_URL', PRODUCT_URL)
NEWSLETTER_URL = "#{PRODUCT_URL}/newsletters".freeze
+1
View File
@@ -4,6 +4,7 @@ module Mcp
module HandleRequest
TOOLS = [
Mcp::Tools::SearchTemplates,
Mcp::Tools::LoadTemplate,
Mcp::Tools::CreateTemplate,
Mcp::Tools::SendDocuments,
Mcp::Tools::SearchDocuments
+41 -49
View File
@@ -6,33 +6,28 @@ module Mcp
SCHEMA = {
name: 'create_template',
title: 'Create Template',
description: 'Create a template from a PDF. Provide a URL or base64-encoded file content.',
description: 'Create a document template. Provide a URL to upload a PDF/DOCX file, or provide only a name ' \
'to create an empty template and receive an edit URL where the file can be uploaded via the UI.',
inputSchema: {
type: 'object',
properties: {
url: {
type: 'string',
description: 'URL of the document file to upload'
},
file: {
type: 'string',
description: 'Base64-encoded file content'
},
filename: {
type: 'string',
description: 'Filename with extension (required when using file)'
},
name: {
type: 'string',
description: 'Template name (defaults to filename)'
description: 'Template name (used as the template name and required when url is not provided)'
},
url: {
type: 'string',
description: 'Optional URL of a PDF or DOCX file to upload. If omitted, an empty template is ' \
'created and the returned edit_url can be used to upload a file via the UI.'
}
}
},
required: %w[name]
},
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
openWorldHint: false
openWorldHint: true
}
}.freeze
@@ -44,48 +39,45 @@ module Mcp
account = current_user.account
if arguments['file'].present?
tempfile = Tempfile.new
tempfile.binmode
tempfile.write(Base64.decode64(arguments['file']))
tempfile.rewind
template = Template.new(
account:,
author: current_user,
folder: account.default_template_folder,
source: :mcp,
name: arguments['name'].to_s.presence || 'New Template',
fields: [],
schema: []
)
filename = arguments['filename'] || 'document.pdf'
elsif arguments['url'].present?
if arguments['url'].present?
tempfile = Tempfile.new
tempfile.binmode
tempfile.write(DownloadUtils.call(arguments['url'], validate: true).body)
tempfile.rewind
filename = File.basename(URI.decode_www_form_component(arguments['url']))
file = ActionDispatch::Http::UploadedFile.new(
tempfile:,
filename:,
type: Marcel::MimeType.for(tempfile)
)
template.name = arguments['name'].presence || File.basename(filename, '.*')
template.save!
documents, = Templates::CreateAttachments.call(template, { files: [file] }, extract_fields: true)
schema = documents.map { |doc| { attachment_uuid: doc.uuid, name: doc.filename.base } }
if template.fields.blank?
template.fields = Templates::ProcessDocument.normalize_attachment_fields(template, documents)
end
template.update!(schema:)
else
return { content: [{ type: 'text', text: 'Provide either url or file' }], isError: true }
template.save!
end
file = ActionDispatch::Http::UploadedFile.new(
tempfile:,
filename:,
type: Marcel::MimeType.for(tempfile)
)
template = Template.new(
account:,
author: current_user,
folder: account.default_template_folder,
name: arguments['name'].presence || File.basename(filename, '.*')
)
template.save!
documents, = Templates::CreateAttachments.call(template, { files: [file] }, extract_fields: true)
schema = documents.map { |doc| { attachment_uuid: doc.uuid, name: doc.filename.base } }
if template.fields.blank?
template.fields = Templates::ProcessDocument.normalize_attachment_fields(template, documents)
end
template.update!(schema:)
WebhookUrls.enqueue_events(template, 'template.created')
SearchEntries.enqueue_reindex(template)
@@ -104,7 +96,7 @@ module Mcp
]
}
end
# rubocop:enable Metrics/AbcSize, Metrics/MethodLength
end
# rubocop:enable Metrics/AbcSize, Metrics/MethodLength
end
end
+67
View File
@@ -0,0 +1,67 @@
# frozen_string_literal: true
module Mcp
module Tools
module LoadTemplate
SCHEMA = {
name: 'load_template',
title: 'Load Template',
description: 'Load a template with its fields. Each field includes name, type, and the signing role name.',
inputSchema: {
type: 'object',
properties: {
template_id: {
type: 'integer',
description: 'Template identifier'
}
},
required: %w[template_id]
},
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false
}
}.freeze
module_function
def call(arguments, _current_user, current_ability)
template = Template.accessible_by(current_ability).find_by(id: arguments['template_id'])
return { content: [{ type: 'text', text: 'Template not found' }], isError: true } unless template
current_ability.authorize!(:read, template)
submitters_index = template.submitters.index_by { |s| s['uuid'] }
roles = template.submitters.pluck('name')
fields = template.fields.filter_map do |field|
next if field['name'].blank?
{
name: field['name'],
type: field['type'],
role: submitters_index[field['submitter_uuid']]&.dig('name')
}
end
{
content: [
{
type: 'text',
text: {
id: template.id,
name: template.name,
roles: roles,
fields: fields
}.to_json
}
]
}
end
end
end
end
+34 -5
View File
@@ -31,6 +31,27 @@ module Mcp
phone: {
type: 'string',
description: 'Submitter phone number in E.164 format'
},
role: {
type: 'string',
description: 'Signing role name from the template'
},
fields: {
type: 'array',
description: 'Prefill field values for this submitter (fields become readonly)',
items: {
type: 'object',
properties: {
name: {
type: 'string',
description: 'Field name'
},
value: {
description: 'Prefilled value for the field'
}
},
required: %w[name value]
}
}
}
}
@@ -40,7 +61,7 @@ module Mcp
},
annotations: {
readOnlyHint: false,
destructiveHint: false,
destructiveHint: true,
idempotentHint: false,
openWorldHint: true
}
@@ -59,15 +80,23 @@ module Mcp
return { content: [{ type: 'text', text: 'Template has no fields' }], isError: true } if template.fields.blank?
submitters = (arguments['submitters'] || []).map do |s|
s.slice('email', 'name', 'role', 'phone')
.compact_blank
.with_indifferent_access
attrs = s.slice('email', 'name', 'role', 'phone').compact_blank
fields = Array.wrap(s['fields']).filter_map do |f|
next if f['name'].blank?
{ 'name' => f['name'], 'default_value' => f['value'], 'readonly' => true }
end
attrs['fields'] = fields if fields.present?
attrs.with_indifferent_access
end
submissions = Submissions.create_from_submitters(
template:,
user: current_user,
source: :api,
source: :mcp,
submitters_order: 'random',
submissions_attrs: { submitters: submitters },
params: { 'send_email' => true, 'submitters' => submitters }
Loaded 100 of 120 files, more files were not shown because too many files have changed in this diff. Show more