Compare commits

...
86 Commits
Author SHA1 Message Date
Alex Turchyn 528a1216f8 Merge from docusealco/wip 2026-05-11 13:52:58 +03:00
Pete Matsyburka f2479bd259 adjust open revisions 2026-05-11 11:11:16 +03:00
Pete Matsyburka 135f0826d2 fix style 2026-05-09 21:17:18 +03:00
Pete Matsyburka 5710f0177b update gh stars 2026-05-09 19:25:42 +03:00
Pete Matsyburka 1a3a0528ba fix i18n 2026-05-09 10:34:20 +03:00
Pete Matsyburka 59793ff374 adjust builder 2026-05-08 19:35:46 +03:00
Pete Matsyburka 3c4ed42419 fix i18n 2026-05-08 18:08:35 +03:00
Pete Matsyburka 6a17b61550 update gem 2026-05-08 16:25:11 +03:00
Pete Matsyburka 76659497b7 typos 2026-05-08 16:12:09 +03:00
Pete Matsyburka ee50c957cb update gem 2026-05-08 15:43:34 +03:00
Pete Matsyburka a9dd200919 add pdfium rotate 2026-05-08 15:43:34 +03:00
Pete Matsyburka 1fa953104a update gh stars 2026-05-08 15:43:34 +03:00
Pete Matsyburka 50eb5b070e fix typo 2026-05-08 15:43:34 +03:00
Pete Matsyburka 10fd624bec add revisions 2026-05-08 15:43:34 +03:00
Alex Turchyn 04ec2f8260 allow permanent delete submissions in archived templates 2026-05-08 15:43:34 +03:00
Pete Matsyburka d828d79574 improve log 2026-05-08 15:43:34 +03:00
Pete Matsyburka 01dd3fefe5 retry webhooks in test mode 2026-05-08 15:43:34 +03:00
Pete Matsyburka 45ae954c0c add hmac webhook secret 2026-05-08 15:43:34 +03:00
Pete Matsyburka 1304849b55 update gh stars 2026-05-08 15:43:34 +03:00
Pete Matsyburka 0875faa079 fix date preview 2026-05-08 15:43:34 +03:00
Pete Matsyburka 3aa15d6ea6 update gem 2026-05-08 15:43:34 +03:00
Pete Matsyburka 6557329e97 gh 13k 2026-05-08 15:43:34 +03:00
Pete Matsyburka be27ce4161 Merge pull request #666 from aqilaziz/docs-fix-readme-typos
docs: fix README typos
2026-05-08 14:02:33 +03:00
aqilaziz a3391a970e docs: fix README typos 2026-05-08 05:23:28 +07:00
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
161 changed files with 4198 additions and 1031 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
+8 -8
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)
@@ -257,7 +257,7 @@ GEM
os (>= 0.9, < 2.0)
signet (>= 0.16, < 2.a)
hashdiff (1.2.1)
hexapdf (1.6.0)
hexapdf (1.7.0)
cmdparse (~> 3.0, >= 3.0.3)
geom2d (~> 0.4, >= 0.4.1)
openssl (>= 2.2.1)
@@ -318,7 +318,7 @@ GEM
multi_json (1.19.1)
net-http (0.9.1)
uri (>= 0.11.1)
net-imap (0.6.3)
net-imap (0.6.4)
date
net-protocol
net-pop (0.1.2)
@@ -328,15 +328,15 @@ GEM
net-smtp (0.5.1)
net-protocol
nio4r (2.7.5)
nokogiri (1.19.2-aarch64-linux-gnu)
nokogiri (1.19.3-aarch64-linux-gnu)
racc (~> 1.4)
nokogiri (1.19.2-aarch64-linux-musl)
nokogiri (1.19.3-aarch64-linux-musl)
racc (~> 1.4)
nokogiri (1.19.2-arm64-darwin)
nokogiri (1.19.3-arm64-darwin)
racc (~> 1.4)
nokogiri (1.19.2-x86_64-linux-gnu)
nokogiri (1.19.3-x86_64-linux-gnu)
racc (~> 1.4)
nokogiri (1.19.2-x86_64-linux-musl)
nokogiri (1.19.3-x86_64-linux-musl)
racc (~> 1.4)
numo-narray-alt (0.10.3)
oj (3.16.16)
+2 -2
View File
@@ -50,7 +50,7 @@ DocuSeal is an open source platform that provides secure and efficient digital d
- Company logo and white-label
- User roles
- Automated reminders
- Invitation and identify verification via SMS
- Invitation and identity verification via SMS
- Conditional fields and formulas
- Bulk send with CSV, XLSX spreadsheet import
- SSO / SAML
@@ -74,7 +74,7 @@ DocuSeal is an open source platform that provides secure and efficient digital d
docker run --name docuseal -p 3000:3000 -v.:/data docuseal/docuseal
```
By default DocuSeal docker container uses an SQLite database to store data and configurations. Alternatively, it is possible use PostgreSQL or MySQL databases by specifying the `DATABASE_URL` env variable.
By default DocuSeal docker container uses an SQLite database to store data and configurations. Alternatively, it is possible to use PostgreSQL or MySQL databases by specifying the `DATABASE_URL` env variable.
#### Docker Compose
@@ -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
@@ -8,10 +8,10 @@ module Api
COOKIE_STORE_LIMIT = 10
def create
submitter = Submitter.find_by!(slug: params[:submitter_slug])
@submitter = Submitter.find_by!(slug: params[:submitter_slug])
unless can_upload?(submitter)
Rollbar.error("Can't upload: #{submitter.id}") if defined?(Rollbar)
unless can_upload?(@submitter)
Rollbar.error("Can't upload: #{@submitter.id}") if defined?(Rollbar)
return render json: { error: I18n.t('form_has_been_archived') }, status: :unprocessable_content
end
@@ -20,23 +20,23 @@ module Api
image = Vips::Image.new_from_file(params[:file].path)
if ImageUtils.blank?(image)
Rollbar.error("Empty signature: #{submitter.id}") if defined?(Rollbar)
Rollbar.error("Empty signature: #{@submitter.id}") if defined?(Rollbar)
return render json: { error: "#{params[:type]} is empty" }, status: :unprocessable_content
end
if ImageUtils.error?(image)
Rollbar.error("Error signature: #{submitter.id}") if defined?(Rollbar)
Rollbar.error("Error signature: #{@submitter.id}") if defined?(Rollbar)
return render json: { error: "#{params[:type]} error, try to sign on another device" },
status: :unprocessable_content
end
end
attachment = Submitters.create_attachment!(submitter, params)
attachment = Submitters.create_attachment!(@submitter, params)
if params[:remember_signature] == 'true' && submitter.email.present?
cookies.encrypted[:signature_uuids] = build_new_cookie_signatures_json(submitter, attachment)
if params[:remember_signature] == 'true' && @submitter.email.present?
cookies.encrypted[:signature_uuids] = build_new_cookie_signatures_json(@submitter, attachment)
end
render json: attachment.as_json(only: %i[uuid created_at], methods: %i[url filename content_type])
+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
@@ -5,12 +5,12 @@ class SubmitFormInviteController < ApplicationController
skip_authorization_check
def create
submitter = Submitter.find_by!(slug: params[:submit_form_slug])
@submitter = Submitter.find_by!(slug: params[:submit_form_slug])
return head :unprocessable_content unless can_invite?(submitter)
return head :unprocessable_content unless can_invite?(@submitter)
invite_submitters = filter_invite_submitters(submitter, 'invite_by_uuid')
optional_invite_submitters = filter_invite_submitters(submitter, 'optional_invite_by_uuid')
invite_submitters = filter_invite_submitters(@submitter, 'invite_by_uuid')
optional_invite_submitters = filter_invite_submitters(@submitter, 'optional_invite_by_uuid')
ApplicationRecord.transaction do
(invite_submitters + optional_invite_submitters).each do |item|
@@ -21,18 +21,18 @@ class SubmitFormInviteController < ApplicationController
email = Submissions.normalize_email(attrs[:email])
submitter.submission.submitters.create!(uuid: attrs[:uuid], email:, account_id: submitter.account_id)
@submitter.submission.submitters.create!(uuid: attrs[:uuid], email:, account_id: @submitter.account_id)
SubmissionEvents.create_with_tracking_data(submitter, 'invite_party', request, { uuid: submitter.uuid })
SubmissionEvents.create_with_tracking_data(@submitter, 'invite_party', request, { uuid: @submitter.uuid })
end
submitter.submission.update!(submitters_order: :preserved)
@submitter.submission.update!(submitters_order: :preserved)
end
submitter.submission.submitters.reload
@submitter.submission.submitters.reload
if invite_submitters.all? { |s| submitter.submission.submitters.any? { |e| e.uuid == s['uuid'] } }
Submitters::SubmitValues.call(submitter, ActionController::Parameters.new(completed: 'true'), request)
if invite_submitters.all? { |s| @submitter.submission.submitters.any? { |e| e.uuid == s['uuid'] } }
Submitters::SubmitValues.call(@submitter, ActionController::Parameters.new(completed: 'true'), request)
head :ok
else
@@ -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
@@ -5,21 +5,21 @@ class SubmitFormValuesController < ApplicationController
skip_authorization_check
def index
submitter = Submitter.find_by!(slug: params[:submit_form_slug])
@submitter = Submitter.find_by!(slug: params[:submit_form_slug])
return render json: {} if submitter.completed_at? ||
submitter.declined_at? ||
submitter.submission.template&.archived_at? ||
submitter.submission.archived_at? ||
submitter.submission.expired? ||
!Submitters::AuthorizedForForm.call(submitter, current_user, request)
return render json: {} if @submitter.completed_at? ||
@submitter.declined_at? ||
@submitter.submission.template&.archived_at? ||
@submitter.submission.archived_at? ||
@submitter.submission.expired? ||
!Submitters::AuthorizedForForm.call(@submitter, current_user, request)
value = submitter.values[params['field_uuid']]
attachment = submitter.attachments.where(created_at: params[:after]..).find_by(uuid: value) if value.present?
value = @submitter.values[params['field_uuid']]
attachment = @submitter.attachments.where(created_at: params[:after]..).find_by(uuid: value) if value.present?
render json: {
value:,
attachment: attachment&.as_json(only: %i[uuid created_at], methods: %i[url filename content_type])
}, head: :ok
}
end
end
+9 -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
@@ -72,6 +77,8 @@ class TemplatesController < ApplicationController
WebhookUrls.enqueue_events(@template, 'template.updated')
TemplateVersions.find_or_create_for(@template, author: current_user) if params[:revision]
head :ok
end
@@ -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
@@ -0,0 +1,17 @@
# frozen_string_literal: true
class TemplatesVersionsController < ApplicationController
load_and_authorize_resource :template
def index
versions = @template.template_versions.order(id: :desc).preload(:author)
render json: versions.as_json(TemplateVersions::SERIALIZE_PARAMS)
end
def show
version = @template.template_versions.find(params[:id])
render json: TemplateVersions.serialize(version)
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)
@@ -0,0 +1,7 @@
# frozen_string_literal: true
class WebhookHmacController < ApplicationController
load_and_authorize_resource :webhook_url, parent: false
def show; end
end
@@ -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
+4
View File
@@ -169,6 +169,9 @@ 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',
withRevisions: true,
withRevisionsMenu: this.dataset.withRevisionsMenu === 'true',
editable: this.dataset.editable !== 'false',
authenticityToken: document.querySelector('meta[name="csrf-token"]')?.content,
withCustomFields: true,
@@ -180,6 +183,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()
}
})
}
}
}
+136 -52
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',
@@ -125,7 +136,7 @@ const es = {
verification_code_is_invalid: 'El código de verificación no es válido',
sign_on_the_touchscreen: 'Firmar en pantalla táctil',
scan_the_qr_code_with_the_camera_app_to_open_the_form_on_mobile_and_draw_your_signature: 'Escanea el código QR con la aplicación de la cámara para abrir el formulario en el móvil y dibujar tu firma',
by_clicking_you_agree_to_the: 'Al hacer clic en "{button}", usted acepta el',
by_clicking_you_agree_to_the: 'Al hacer clic en "{button}", usted acepta la',
electronic_signature_disclosure: 'Divulgación de Firma Electrónica',
esignature_disclosure: 'Divulgación de eFirma',
already_paid: 'Ya pagado',
@@ -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',
@@ -228,7 +245,7 @@ const it = {
verification_code_is_invalid: 'Il codice di verifica non è valido',
sign_on_the_touchscreen: 'Firma su schermo tattile',
scan_the_qr_code_with_the_camera_app_to_open_the_form_on_mobile_and_draw_your_signature: "Scansiona il codice QR con l'app della fotocamera per aprire il modulo sul cellulare e disegnare la tua firma",
by_clicking_you_agree_to_the: 'Cliccando su "{button}", accetti il',
by_clicking_you_agree_to_the: 'Cliccando su "{button}", accetti la',
electronic_signature_disclosure: 'Divulgazione della Firma Elettronica',
esignature_disclosure: 'Divulgazione della eFirma',
minimize: 'Minimizza',
@@ -241,7 +258,7 @@ const it = {
date: 'Data',
number: 'Numero',
image: 'Immagine',
pay: 'Pagamento',
pay: 'Paga',
file: 'File',
select: 'Seleziona',
checkbox: 'Checkbox',
@@ -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',
@@ -539,7 +574,7 @@ const pl = {
scan_the_qr_code_with_the_camera_app_to_open_the_form_on_mobile_and_draw_your_signature: 'Zeskanuj kod QR za pomocą aplikacji aparatu, aby otworzyć formularz na telefonie i narysować swój podpis',
by_clicking_you_agree_to_the: 'Klikając na "{button}", zgadzasz się na',
electronic_signature_disclosure: 'Ujawnienie Elektronicznej Sygnatury',
esignature_disclosure: 'Ujawnienie ePodpisu',
esignature_disclosure: 'informację o e-podpisie',
minimize: 'Zminimalizuj',
text: 'Tekst',
already_paid: 'Już zapłacono',
@@ -607,16 +642,22 @@ const pl = {
toggle_multiline_text: 'Przełącz Tekst Wielolinijkowy',
email_has_been_sent: 'E-mail został wysłany',
processing: 'Przetwarzanie',
pay_with_stripe: 'Płatność za pomocą Stripe',
pay_with_stripe: 'Zapłać za pomocą Stripe',
reupload: 'Ponowne przesłanie',
upload: 'Przesyłanie',
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} символів',
@@ -637,12 +678,12 @@ const uk = {
authored_by: 'Автор',
select_a_reason: 'Виберіть причину',
value_is_invalid: 'Значення є неправильним',
verification_code_is_invalid: 'Код підтвердження є неправильним',
verification_code_is_invalid: 'Невірний код підтвердження',
sign_on_the_touchscreen: 'Підписати на сенсорному екрані',
scan_the_qr_code_with_the_camera_app_to_open_the_form_on_mobile_and_draw_your_signature: 'Скануйте QR-код за допомогою програми камери, щоб відкрити форму на мобільному пристрої та намалювати свій підпис',
by_clicking_you_agree_to_the: 'Натиснувши на "{button}", ви погоджуєтеся з',
electronic_signature_disclosure: 'Розголошення Електронного Підпису',
esignature_disclosure: 'Розголошення еПідпису',
electronic_signature_disclosure: 'інформацією про е-підпис',
esignature_disclosure: 'інформацією про е-підпис',
minimize: 'Зменшити',
text: 'Текст',
already_paid: 'Вже оплачено',
@@ -710,16 +751,22 @@ const uk = {
toggle_multiline_text: 'Перемкнути Багаторядковий Текст',
email_has_been_sent: 'Електронний лист був відправлений',
processing: 'Обробка',
pay_with_strip: 'Сплатити за допомогою Stripe',
pay_with_stripe: 'Сплатити за допомогою Stripe',
reupload: 'Перезавантажити',
upload: 'Завантажити',
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ů',
@@ -727,7 +774,7 @@ const cs = {
verify_id: 'Ověřit ID',
identity_verification: 'Ověření identity',
complete: 'Dokončit',
fill_all_required_fields_to_complete: 'Please complete all mandatory fields',
fill_all_required_fields_to_complete: 'Vyplňte povinná pole pro dokončení',
sign_and_complete: 'Podepsat a dokončit',
invite: 'Pozvat',
email: 'E-mail',
@@ -735,8 +782,8 @@ const cs = {
reviewed: 'Zkontrolováno',
other: 'Jiné',
authored_by_me: 'Autorem jsem já',
approved_by: 'Schváleno kým',
reviewed_by: 'Zkontrolováno kým',
approved_by: 'Schválil(a)',
reviewed_by: 'Zkontroloval(a)',
authored_by: 'Autorem',
select_a_reason: 'Vyberte důvod',
value_is_invalid: 'Hodnota je neplatná',
@@ -745,7 +792,7 @@ const cs = {
scan_the_qr_code_with_the_camera_app_to_open_the_form_on_mobile_and_draw_your_signature: 'Naskenujte QR kód pomocí aplikace fotoaparátu, abyste otevřeli formulář na mobilním zařízení a nakreslili svůj podpis',
by_clicking_you_agree_to_the: 'Kliknutím na "{button}" souhlasíte s',
electronic_signature_disclosure: 'Zveřejněním Elektronického Podpisu',
esignature_disclosure: 'Zveřejnění ePodpisu',
esignature_disclosure: 'informací o e-podpisu',
already_paid: 'Už zaplaceno',
minimize: 'Minimalizovat',
text: 'Text',
@@ -813,16 +860,22 @@ const cs = {
toggle_multiline_text: 'Přepnout Víceřádkový Text',
email_has_been_sent: 'E-mail byl odeslán',
processing: 'Zpracování',
pay_with_stripe: 'Zaplacení přes Stripe',
pay_with_stripe: 'Zaplatit přes Stripe',
reupload: 'Znovu nahrát',
upload: 'Nahrát',
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',
@@ -837,18 +890,18 @@ const pt = {
approved: 'Aprovado',
reviewed: 'Revisado',
other: 'Outro',
authored_by_me: 'Autorizado por mim',
authored_by_me: 'Escrito por mim',
approved_by: 'Aprovado por',
reviewed_by: 'Revisado por',
authored_by: 'Autorizado por',
authored_by: 'Escrito por',
select_a_reason: 'Selecione um motivo',
value_is_invalid: 'Valor é inválido',
verification_code_is_invalid: 'Código de verificação é inválido',
sign_on_the_touchscreen: 'Assinar na tela sensível',
scan_the_qr_code_with_the_camera_app_to_open_the_form_on_mobile_and_draw_your_signature: 'Escaneie o código QR com o aplicativo da câmera para abrir o formulário no celular e desenhar sua assinatura',
by_clicking_you_agree_to_the: 'Ao clicar em "{button}", você concorda com o',
by_clicking_you_agree_to_the: 'Ao clicar em "{button}", você concorda com a',
electronic_signature_disclosure: 'Divulgação de Assinatura Eletrônica',
esignature_disclosure: 'Divulgação da eAssinatura',
esignature_disclosure: 'Informação sobre eAssinatura',
already_paid: 'Já pago',
minimize: 'Minimizar',
text: 'Texto',
@@ -917,15 +970,21 @@ const pt = {
email_has_been_sent: 'Email enviado',
processing: 'Processamento',
pay_with_stripe: 'Pagar com Stripe',
reupload: 'Reenviar',
reupload: 'Recarregar',
upload: 'Carregar',
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} תווים',
@@ -951,8 +1010,8 @@ const he = {
scan_the_qr_code_with_the_camera_app_to_open_the_form_on_mobile_and_draw_your_signature: 'סרוק את קוד ה-QR באמצעות אפליקציית המצלמה כדי לפתוח את הטופס במובייל ולצייר את החתימה שלך',
by_clicking_you_agree_to_the: 'על ידי לחיצה על "{button}", אתה מסכים ל',
electronic_signature_disclosure: 'חשיפת חתימה אלקטרונית',
esignature_disclosure: 'חשיפת ה-eחתימה',
minimize: 'לקטן',
esignature_disclosure: 'חשיפת חתימה אלקטרונית',
minimize: 'מזער',
text: 'טקסט',
already_paid: 'כבר שולם',
signature: 'חתימה',
@@ -963,9 +1022,9 @@ const he = {
number: 'מספר',
image: 'תמונה',
file: 'קובץ',
pay: 'תשלום',
pay: 'שלם',
select: 'בחר',
checkbox: 'תיק בחירה',
checkbox: 'סימון',
multiple: 'רב ערכים',
radio: 'רדיו',
cells: 'תאים',
@@ -992,9 +1051,9 @@ const he = {
download: 'הורדה',
clear: 'נקה',
redraw: 'צייר מחדש',
draw_initials: 'צייר ציוני ראשי תיבות',
draw_initials: 'צייר ראשי תיבות',
type_signature_here: 'הקלד חתימה כאן',
type_initial_here: 'הקלד ציוני ראשי תיבות כאן',
type_initial_here: 'הקלד ראשי תיבות כאן',
form_has_been_completed: 'הטופס הושלם',
document_has_been_signed: 'המסמך נחתם!',
documents_have_been_signed: 'המסמכים נחתמו!',
@@ -1004,7 +1063,7 @@ const he = {
open_source_documents_software: 'תוכנה פתוחה למסמכים',
verified_phone_number: 'אימות מספר טלפון',
use_international_format: 'השתמש בפורמט בינלאומי: +1xxx',
six_digits_code: 'קוד משתמש שש ספרות',
six_digits_code: 'קוד שש ספרות',
change_phone_number: 'שינוי מספר טלפון',
sending: 'שולח',
resend_code: 'שלח מחדש קוד',
@@ -1013,7 +1072,7 @@ const he = {
set_today: 'קבע היום',
toggle_multiline_text: 'שנה בין טקסט במספר שורות לטקסט בשורה אחת',
draw_signature: 'צייר חתימה',
type_initial: 'הקלד ציוני ראשי תיבות',
type_initial: 'הקלד ראשי תיבות',
draw: 'צייר',
type: 'הקלד',
type_text: 'הקלד טקסט',
@@ -1025,15 +1084,21 @@ 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',
complete_all_required_fields_to_proceed_with_identity_verification: 'Vul alle verplichte velden in om door te gaan met de identiteitsverificatie.',
verify_id: 'Verifiëren ID',
verify_id: 'ID verifiëren',
identity_verification: 'Identiteitsverificatie',
complete: 'Voltooien',
fill_all_required_fields_to_complete: 'Vul alle verplichte velden in om te voltooien',
@@ -1081,7 +1146,7 @@ const nl = {
continue: 'Doorgaan',
sign_now: 'Nu ondertekenen',
type_here_: 'Typ hier...',
optional: 'Optioneel',
optional: 'optioneel',
option: 'Optie',
appears_on: 'Verschijnt op',
page: 'Pagina',
@@ -1105,11 +1170,11 @@ const nl = {
powered_by: 'Aangedreven door',
please_check_the_box_to_continue: 'Vink het vakje aan om door te gaan.',
open_source_documents_software: 'Open source documenten software',
verified_phone_number: 'Geverifieerd telefoonnummer',
verified_phone_number: 'Telefoonnummer verifiëren',
use_international_format: 'Gebruik internationaal formaat: +1xxx',
six_digits_code: '6-cijferige code',
change_phone_number: 'Wijzig telefoonnummer',
sending: 'Voltooien...',
sending: 'Verzenden...',
resend_code: 'Code opnieuw verzenden',
verification_code_has_been_resent: 'Verificatiecode is opnieuw verzonden via SMS',
please_fill_all_required_fields: 'Vul alle verplichte velden in',
@@ -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} حرفًا',
@@ -1161,7 +1232,7 @@ const ar = {
esignature_disclosure: 'كشف التوقيع الإلكتروني',
text: 'نص',
signature: 'توقيع',
initials: 'الاختصارات',
initials: 'حروف أولى',
date: 'تاريخ',
number: 'رقم',
digitally_signed_by: 'تم التوقيع رقميًا بواسطة',
@@ -1198,9 +1269,9 @@ const ar = {
download: 'تحميل',
clear: 'مسح',
redraw: 'إعادة الرسم',
draw_initials: 'ارسم الاختصارات',
draw_initials: 'ارسم حروف أولى',
type_signature_here: 'اكتب التوقيع هنا',
type_initial_here: 'اكتب الاختصارات هنا',
type_initial_here: 'اكتب حروف أولى هنا',
form_has_been_completed: 'تم إكمال النموذج!',
document_has_been_signed: 'تم توقيع الوثيقة!',
documents_have_been_signed: 'تم توقيع الوثائق!',
@@ -1219,22 +1290,28 @@ const ar = {
set_today: 'تعيين اليوم',
toggle_multiline_text: 'تبديل النصوص متعددة الأسطر',
draw_signature: 'ارسم التوقيع',
type_initial: 'اكتب الاختصارات',
type_initial: 'اكتب حروف أولى',
draw: 'ارسم',
type: 'اكتب',
type_text: 'اكتب نصًا',
email_has_been_sent: 'تم إرسال البريد الإلكتروني',
processing: 'جارٍ المعالجة',
pay_with_strip: 'الدفع بواسطة Stripe',
pay_with_stripe: 'الدفع بواسطة Stripe',
reupload: 'إعادة التحميل',
upload: 'تحميل',
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}자여야 합니다',
@@ -1266,10 +1343,10 @@ const ko = {
signature: '서명',
initials: '이니셜',
date: '날짜',
digitally_signed_by: '디지털 서명이 완료되었습니다',
digitally_signed_by: '디지털 서명자:',
reason: '이유',
number: '숫자',
pay: '급여',
pay: '결제',
image: '이미지',
take_photo: '사진 찍기',
file: '파일',
@@ -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">
+50 -9
View File
@@ -50,6 +50,7 @@
@remove="$emit('remove')"
@scroll-to="$emit('scroll-to', $event)"
@add-custom-field="$emit('add-custom-field')"
@click-title="$emit('click-title')"
/>
<div
ref="touchValueTarget"
@@ -113,7 +114,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 +132,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 +184,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 +242,16 @@ export default {
required: false,
default: false
},
conditionalFieldIndex: {
type: Object,
required: false,
default: () => ({})
},
formulaValuesIndex: {
type: Object,
required: false,
default: () => ({})
},
isDraw: {
type: Boolean,
required: false,
@@ -307,7 +318,7 @@ export default {
default: false
}
},
emits: ['start-resize', 'stop-resize', 'start-drag', 'stop-drag', 'remove', 'scroll-to', 'add-custom-field'],
emits: ['start-resize', 'stop-resize', 'start-drag', 'stop-drag', 'remove', 'scroll-to', 'add-custom-field', 'click-title'],
data () {
return {
isContenteditable: false,
@@ -323,11 +334,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 +364,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 +419,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 +428,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 +542,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 +590,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 +659,12 @@ export default {
}
},
onDefaultValueBlur (e) {
if (this.field.preferences?.formula) {
this.isContenteditable = false
return
}
const text = this.$refs.defaultValue.innerText.trim()
this.isContenteditable = false
@@ -3,7 +3,7 @@
v-if="field?.type && (isSelected || isNameFocus) && !isInMultiSelection"
class="absolute bg-white rounded-t border overflow-visible whitespace-nowrap flex z-10 field-area-controls"
style="top: -25px; height: 25px"
@mousedown.stop
@mousedown.stop="$emit('click-title')"
@pointerdown.stop
>
<FieldSubmitter
@@ -279,7 +279,7 @@ export default {
default: false
}
},
emits: ['remove', 'scroll-to', 'add-custom-field', 'change'],
emits: ['remove', 'scroll-to', 'add-custom-field', 'change', 'click-title'],
data () {
return {
isShowFormulaModal: false,
+470 -53
View File
@@ -51,6 +51,30 @@
</div>
</div>
</div>
<div
v-if="beforeRevisionSnapshot"
class="top-1.5 sticky h-0 z-20 max-w-2xl mx-auto"
>
<div class="alert border-base-content/30 py-2 px-2.5">
<IconInfoCircle class="stroke-info shrink-0 w-6 h-6" />
<span>{{ t('viewing_revision_from').replace('{date}', formatRevisionTime(beforeRevisionSnapshot.revision.created_at)) }}</span>
<div>
<button
class="btn btn-sm"
@click.prevent="cancelRevision"
>
{{ t('cancel') }}
</button>
<button
v-if="editable"
class="btn btn-sm btn-neutral text-white"
@click.prevent="applyRevision"
>
{{ t('apply') }}
</button>
</div>
</div>
</div>
<div
v-if="$slots.buttons || withTitle"
id="title_container"
@@ -213,6 +237,18 @@
<span class="whitespace-nowrap">{{ t('preferences') }}</span>
</a>
</li>
<li v-if="withRevisionsMenu">
<button
class="flex space-x-2"
@click.prevent="openRevisionsModal"
@mouseenter="preloadRevisions"
>
<span class="w-6 h-6 flex-shrink-0 flex items-center justify-center">
<IconHistory class="w-5 h-5" />
</span>
<span class="whitespace-nowrap">{{ t('revisions') }}</span>
</button>
</li>
<li v-if="withDownload">
<button
class="flex space-x-2"
@@ -381,6 +417,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 +543,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"
@@ -594,7 +636,16 @@
<div
id="docuseal_modal_container"
class="modal-container"
/>
>
<RevisionsModal
v-if="isRevisionsModalOpen"
:template="template"
:revisions="revisions"
:locale="locale"
@close="isRevisionsModalOpen = false"
@apply="onRevisionApply"
/>
</div>
</div>
</template>
@@ -612,11 +663,22 @@ import DocumentPreview from './preview'
import DocumentControls from './controls'
import MobileFields from './mobile_fields'
import FieldSubmitter from './field_submitter'
import { IconPlus, IconUsersPlus, IconDeviceFloppy, IconChevronDown, IconEye, IconWritingSign, IconInnerShadowTop, IconInfoCircle, IconAdjustments, IconDownload } from '@tabler/icons-vue'
import RevisionsModal from './revisions_modal'
import { IconPlus, IconUsersPlus, IconDeviceFloppy, IconChevronDown, IconEye, IconWritingSign, IconInnerShadowTop, IconInfoCircle, IconAdjustments, IconDownload, IconHistory } from '@tabler/icons-vue'
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: {
@@ -642,7 +704,9 @@ export default {
IconDownload,
IconAdjustments,
IconEye,
IconDeviceFloppy
IconHistory,
IconDeviceFloppy,
RevisionsModal
},
provide () {
return {
@@ -654,6 +718,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 +803,11 @@ export default {
required: false,
default: false
},
withDetectExistingFields: {
type: Boolean,
required: false,
default: false
},
withCustomFields: {
type: Boolean,
required: false,
@@ -778,6 +848,11 @@ export default {
required: false,
default: () => []
},
withCustomFieldsTab: {
type: Boolean,
required: false,
default: false
},
withSelectedFieldType: {
type: Boolean,
required: false,
@@ -798,6 +873,11 @@ export default {
required: false,
default: () => []
},
dateFormats: {
type: Array,
required: false,
default: () => []
},
defaultSubmitters: {
type: Array,
required: false,
@@ -808,6 +888,11 @@ export default {
required: false,
default: () => []
},
pagePreviewFormat: {
type: String,
required: false,
default: '.jpg'
},
acceptFileTypes: {
type: String,
required: false,
@@ -943,6 +1028,16 @@ export default {
type: Boolean,
required: false,
default: false
},
withRevisions: {
type: Boolean,
required: false,
default: false
},
withRevisionsMenu: {
type: Boolean,
required: false,
default: false
}
},
data () {
@@ -953,6 +1048,7 @@ export default {
isLoadingBlankPage: false,
isSaving: false,
isDetectingPageFields: false,
detectFieldsQueue: [],
detectingAnalyzingProgress: null,
detectingFieldsAddedCount: null,
selectedSubmitter: null,
@@ -963,7 +1059,11 @@ export default {
drawCustomField: null,
drawOption: null,
dragField: null,
isDragFile: false
isDragFile: false,
isMathLoaded: false,
isRevisionsModalOpen: false,
revisions: [],
beforeRevisionSnapshot: null
}
},
computed: {
@@ -973,6 +1073,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 +1110,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 +1153,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 +1198,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 +1276,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 +1326,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 +1731,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 +1759,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,9 +1790,111 @@ 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()
},
preloadRevisions () {
this.loadRevisionsPromise ||= this.baseFetch(`/templates/${this.template.id}/versions`)
},
openRevisionsModal () {
this.closeDropdown()
this.loadRevisionsPromise ||= this.baseFetch(`/templates/${this.template.id}/versions`)
this.loadRevisionsPromise.then(async (resp) => {
this.revisions = await resp.json()
this.isRevisionsModalOpen = true
}).finally(() => {
this.loadRevisionsPromise = null
})
},
onRevisionApply (revision) {
this.beforeRevisionSnapshot = {
template: JSON.parse(JSON.stringify(this.template)),
dynamicDocuments: JSON.parse(JSON.stringify(this.dynamicDocuments)),
revision
}
const { dynamic_documents: nextDynamicDocs = [], ...nextTemplate } = revision.data
Object.assign(this.template, nextTemplate)
this.dynamicDocuments.splice(0, this.dynamicDocuments.length, ...nextDynamicDocs)
this.$nextTick(() => this.reloadDynamicDocumentContent())
this.isRevisionsModalOpen = false
},
cancelRevision () {
Object.assign(this.template, this.beforeRevisionSnapshot.template)
this.dynamicDocuments.splice(0, this.dynamicDocuments.length, ...this.beforeRevisionSnapshot.dynamicDocuments)
this.beforeRevisionSnapshot = null
this.$nextTick(() => this.reloadDynamicDocumentContent())
},
applyRevision () {
this.beforeRevisionSnapshot = null
const dynamicDocumentRefs = this.documentRefs.filter((ref) => ref.isDynamic)
dynamicDocumentRefs.forEach((ref) => ref.update())
this.rebuildVariablesSchema({ disable: false })
return Promise.all([this.save({ force: true }), ...dynamicDocumentRefs.map((ref) => ref.saveBody())])
},
reloadDynamicDocumentContent () {
this.documentRefs.forEach((ref) => {
if (ref.isDynamic) ref.reloadContent()
})
},
formatRevisionTime (string) {
return new Date(string).toLocaleString(this.locale || undefined, {
year: 'numeric',
month: 'numeric',
day: 'numeric',
hour: 'numeric',
minute: '2-digit'
})
},
t (key) {
return this.i18n[key] || i18n[this.language]?.[key] || i18n.en[key] || key
},
@@ -1978,7 +2388,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 +2501,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 +2642,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 +2712,7 @@ export default {
}
}
field.areas.push(fieldArea)
this.insertArea(field, fieldArea)
})
} else {
const fieldArea = {
@@ -2743,7 +3141,7 @@ export default {
const dynamicDocumentSaves = dynamicDocumentRefs.map((ref) => ref.saveBody())
Promise.all([this.save(), ...dynamicDocumentSaves]).then(() => {
Promise.all([this.save({ force: true, revision: this.withRevisions }), ...dynamicDocumentSaves]).then(() => {
window.Turbo.visit(`/templates/${this.template.id}`)
}).finally(() => {
this.isSaving = false
@@ -2773,6 +3171,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 +3215,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 +3248,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 +3277,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 +3315,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 +3333,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,11 +3366,19 @@ export default {
setTimeout(() => {
this.detectingFieldsAddedCount = null
}, 1000)
if (this.detectFieldsQueue.length) {
this.detectFieldsForPage(this.detectFieldsQueue.shift())
}
})
},
save ({ force } = { force: false }) {
save ({ force = false, revision = false } = {}) {
this.pendingFieldAttachmentUuids = []
if (this.beforeRevisionSnapshot) {
this.beforeRevisionSnapshot = null
}
if (this.onChange) {
this.onChange(this.template)
}
@@ -2988,7 +3404,8 @@ export default {
submitters: this.template.submitters,
fields: this.template.fields,
variables_schema: this.template.variables_schema
}
},
...(revision ? { revision: true } : {})
}),
headers: { 'Content-Type': 'application/json' }
}).then(() => {
@@ -6,7 +6,7 @@
class="absolute top-0 bottom-0 right-0 left-0"
@click.prevent="$emit('close')"
/>
<div class="modal-box pt-4 pb-6 px-6 mt-20 max-h-none w-full max-w-xl">
<div class="modal-box pt-4 pb-6 px-6 mt-20 w-full max-w-xl">
<div class="flex justify-between items-center border-b pb-2 mb-2 font-medium">
<span class="modal-title">
{{ t('condition') }} - {{ (defaultField ? (defaultField.title || item.title || item.name) : item.name) || buildDefaultName(item) }}
+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}`
})
})
},
@@ -183,6 +183,9 @@ export default {
this.sectionRefs.push(ref)
}
},
reloadContent () {
this.sectionRefs.forEach((ref) => ref.reloadContent())
},
onBeforeUnload (event) {
if (this.saveTimer) {
event.preventDefault()
@@ -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,
@@ -281,6 +281,9 @@ export default {
}
},
methods: {
reloadContent () {
this.editor.commands.setContent(this.section.innerHTML, { emitUpdate: false })
},
findAreaNodePos (areaUuid) {
const el = this.editor.view.dom.querySelector(`[data-area-uuid="${areaUuid}"]`)
@@ -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
})
}
}
}
@@ -147,7 +147,7 @@
>
<a
href="#"
class="flex px-2 group justify-between items-center"
class="flex px-2 text-sm group justify-between items-center"
:class="{ 'active': submitter === selectedSubmitter, 'py-0.5': submitters.length > 8 }"
@click.prevent="selectSubmitter(submitter)"
>
@@ -198,7 +198,7 @@
>
<a
href="#"
class="flex px-2"
class="flex px-2 text-sm"
@click.prevent="addSubmitter"
>
<IconUserPlus
+120 -9
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"
>
@@ -363,7 +383,7 @@
class="rounded py-2 px-4 w-full border border-dashed border-base-300"
>
<div class="text-center text-sm">
{{ t('start_a_quick_tour_to_learn_how_to_create_an_send_your_first_document') }}
{{ t('start_a_quick_tour_to_learn_how_to_create_and_send_your_first_document') }}
</div>
<div class="flex justify-center">
<label
@@ -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()
+71 -36
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',
@@ -199,7 +200,7 @@ const en = {
learn_more: 'Learn more',
and: 'and',
or: 'or',
start_a_quick_tour_to_learn_how_to_create_an_send_your_first_document: 'Start a quick tour to learn how to create and send your first document',
start_a_quick_tour_to_learn_how_to_create_and_send_your_first_document: 'Start a quick tour to learn how to create and send your first document',
start_tour: 'Start Tour',
or_add_from: 'Or add from',
sync: 'Sync',
@@ -214,7 +215,11 @@ const en = {
align_bottom: 'Align Bottom',
fields_selected: '{count} Fields Selected',
field_added: '{count} Field Added',
fields_added: '{count} Fields Added'
fields_added: '{count} Fields Added',
revisions: 'Revisions',
apply: 'Apply',
no_revisions_yet: 'No revisions yet',
viewing_revision_from: 'Viewing revision from {date}'
}
const es = {
@@ -238,7 +243,7 @@ const es = {
min: 'Mín',
max: 'Máx',
date_signed: 'Fecha actual',
fuente: 'Fuente',
font: 'Fuente',
party: 'Parte',
method: 'Método',
reorder_fields: 'Reordenar campos',
@@ -271,6 +276,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',
@@ -296,7 +302,7 @@ const es = {
set_signing_date: 'Establecer fecha de firma',
are_you_sure_: '¿Estás seguro?',
sign_yourself: 'Firma tú mismo',
checked: 'Seleccionado',
checked: 'Marcado',
current_date: 'Fecha actual',
send: 'Enviar',
remove: 'Eliminar',
@@ -334,16 +340,16 @@ const es = {
eighth_party: 'Octava Parte',
ninth_party: 'Novena Parte',
tenth_party: 'Décima Parte',
eleventh_party: 'Undécimo Partido',
twelfth_party: 'Duodécimo Partido',
thirteenth_party: 'Decimotercer Partido',
fourteenth_party: 'Catorceavo Partido',
fifteenth_party: 'Quinceavo Partido',
sixteenth_party: 'Dieciséisavo Partido',
seventeenth_party: 'Diecisieteavo Partido',
eighteenth_party: 'Dieciochoavo Partido',
nineteenth_party: 'Decimonovena Fiesta',
twentieth_party: 'Vigésima Fiesta',
eleventh_party: 'Undécima Parte',
twelfth_party: 'Duodécima Parte',
thirteenth_party: 'Decimotercera Parte',
fourteenth_party: 'Decimocuarta Parte',
fifteenth_party: 'Decimoquinta Parte',
sixteenth_party: 'Decimosexta Parte',
seventeenth_party: 'Decimoséptima Parte',
eighteenth_party: 'Decimoctava Parte',
nineteenth_party: 'Decimonovena Parte',
twentieth_party: 'Vigésima Parte',
draw: 'Dibujar',
add: 'Agregar',
text: 'Texto',
@@ -418,7 +424,7 @@ const es = {
learn_more: 'Aprende más',
and: 'y',
or: 'o',
start_a_quick_tour_to_learn_how_to_create_an_send_your_first_document: 'Inicia una guía rápida para aprender a crear y enviar tu primer documento.',
start_a_quick_tour_to_learn_how_to_create_and_send_your_first_document: 'Inicia una guía rápida para aprender a crear y enviar tu primer documento.',
start_tour: 'Iniciar guía',
or_add_from: 'O agregar desde',
sync: 'Sincronizar',
@@ -433,7 +439,11 @@ const es = {
align_bottom: 'Alinear abajo',
fields_selected: '{count} Campos Seleccionados',
field_added: '{count} Campo Añadido',
fields_added: '{count} Campos Añadidos'
fields_added: '{count} Campos Añadidos',
revisions: 'Revisiones',
apply: 'Aplicar',
no_revisions_yet: 'Aún no hay revisiones',
viewing_revision_from: 'Viendo revisión del {date}'
}
const it = {
@@ -456,15 +466,15 @@ const it = {
length: 'Lunghezza',
min: 'Min',
max: 'Max',
date_signed: 'Data attuale',
date_signed: 'Data corrente',
font: 'Carattere',
party: 'Parte',
method: 'Metodo',
reorder_fields: 'Riordina i campi',
verify_id: 'Verifica ID',
obtain_qualified_electronic_signature_with_the_trusted_provider_click_to_learn_more: 'Ottieni una firma elettronica qualificata (QeS) con il fornitore di fiducia. Clicca per saperne di più.',
ricorrente: 'Ricorrente',
una_volta: 'Una volta',
recurrent: 'Ricorrente',
one_off: 'Una tantum',
editable: 'Modificabile',
search_field: 'Campo di ricerca',
field_not_found: 'Campo non trovato',
@@ -508,6 +518,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',
@@ -522,7 +533,7 @@ const it = {
drawn: 'Disegnato',
drawn_or_typed: 'Disegnato o Digitato',
drawn_or_upload: 'Disegnato o Caricato',
upload: 'Caricare',
upload: 'Carica',
formula: 'Formula',
typed: 'Digitato',
draw_field_on_the_document: 'Disegnare un campo sul documento',
@@ -637,7 +648,7 @@ const it = {
learn_more: 'Scopri di più',
and: 'e',
or: 'o',
start_a_quick_tour_to_learn_how_to_create_an_send_your_first_document: 'Inizia un tour rapido per imparare a creare e inviare il tuo primo documento.',
start_a_quick_tour_to_learn_how_to_create_and_send_your_first_document: 'Inizia un tour rapido per imparare a creare e inviare il tuo primo documento.',
start_tour: 'Inizia il tour',
or_add_from: 'O aggiungi da',
sync: 'Sincronizza',
@@ -652,7 +663,11 @@ const it = {
align_bottom: 'Allinea in basso',
fields_selected: '{count} Campi Selezionati',
field_added: '{count} Campo Aggiunto',
fields_added: '{count} Campi Aggiunti'
fields_added: '{count} Campi Aggiunti',
revisions: 'Revisioni',
apply: 'Applica',
no_revisions_yet: 'Nessuna revisione ancora',
viewing_revision_from: 'Visualizzazione revisione del {date}'
}
const pt = {
@@ -676,13 +691,13 @@ const pt = {
min: 'Mín',
max: 'Máx',
date_signed: 'Data atual',
fonte: 'Fonte',
font: 'Fonte',
party: 'Parte',
method: 'Método',
reorder_fields: 'Reorganizar campos',
verify_id: 'Verificar ID',
obtain_qualified_electronic_signature_with_the_trusted_provider_click_to_learn_more: 'Obtenha a assinatura eletrônica qualificada (QeS) com o provedor confiável. Clique para saber mais.',
recurrent: 'Recurrente',
recurrent: 'Recorrente',
one_off: 'Único',
editable: 'Editável',
search_field: 'Campo de busca',
@@ -710,6 +725,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',
@@ -748,7 +764,7 @@ const pt = {
processing_: 'Processando...',
add_pdf_documents_or_images: 'Adicionar documentos PDF ou imagens',
add_documents_or_images: 'Adicionar documentos ou imagens',
add_a_new_document: 'Ajouter un nouveau document',
add_a_new_document: 'Adicionar um novo documento',
replace_existing_document: 'Substituir documento existente',
clone_and_replace_documents: 'Clonar e substituir documentos',
required: 'Obrigatório',
@@ -856,7 +872,7 @@ const pt = {
learn_more: 'Saiba mais',
and: 'e',
or: 'ou',
start_a_quick_tour_to_learn_how_to_create_an_send_your_first_document: 'Comece um tour rápido para aprender a criar e enviar seu primeiro documento.',
start_a_quick_tour_to_learn_how_to_create_and_send_your_first_document: 'Comece um tour rápido para aprender a criar e enviar seu primeiro documento.',
start_tour: 'Iniciar tour',
or_add_from: 'Ou adicionar de',
sync: 'Sincronizar',
@@ -871,7 +887,11 @@ const pt = {
align_bottom: 'Alinhar à parte inferior',
fields_selected: '{count} Campos Selecionados',
field_added: '{count} Campo Adicionado',
fields_added: '{count} Campos Adicionados'
fields_added: '{count} Campos Adicionados',
revisions: 'Revisões',
apply: 'Aplicar',
no_revisions_yet: 'Nenhuma revisão ainda',
viewing_revision_from: 'Visualizando revisão de {date}'
}
const fr = {
@@ -896,7 +916,7 @@ const fr = {
max: 'Max',
font: 'Police',
party: 'Partie',
date_signed: 'Date de signature',
date_signed: 'Date du jour',
method: 'Méthode',
reorder_fields: 'Réorganiser les champs',
verify_id: "Vérifier l'ID",
@@ -946,6 +966,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',
@@ -1075,7 +1096,7 @@ const fr = {
learn_more: 'En savoir plus',
and: 'et',
or: 'ou',
start_a_quick_tour_to_learn_how_to_create_an_send_your_first_document: 'Commencez une visite rapide pour apprendre à créer et à envoyer votre premier document',
start_a_quick_tour_to_learn_how_to_create_and_send_your_first_document: 'Commencez une visite rapide pour apprendre à créer et à envoyer votre premier document',
start_tour: 'Démarrer',
or_add_from: 'Ou ajouter depuis',
sync: 'Synchroniser',
@@ -1090,7 +1111,11 @@ const fr = {
align_bottom: 'Aligner en bas',
fields_selected: '{count} Champs Sélectionnés',
field_added: '{count} Champ Ajouté',
fields_added: '{count} Champs Ajoutés'
fields_added: '{count} Champs Ajoutés',
revisions: 'Révisions',
apply: 'Appliquer',
no_revisions_yet: 'Aucune révision pour le moment',
viewing_revision_from: 'Affichage de la révision du {date}'
}
const de = {
@@ -1115,7 +1140,7 @@ const de = {
max: 'Max.',
font: 'Schriftart',
party: 'Partei',
date_signed: 'Unterzeichnungsdatum',
date_signed: 'Aktuelles Datum',
method: 'Methode',
reorder_fields: 'Felder neu anordnen',
verify_id: 'ID überprüfen',
@@ -1165,6 +1190,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',
@@ -1294,7 +1320,7 @@ const de = {
learn_more: 'Mehr erfahren',
and: 'und',
or: 'oder',
start_a_quick_tour_to_learn_how_to_create_an_send_your_first_document: 'Starten Sie eine Kurztour, um zu lernen, wie Sie Ihr erstes Dokument erstellen und versenden.',
start_a_quick_tour_to_learn_how_to_create_and_send_your_first_document: 'Starten Sie eine Kurztour, um zu lernen, wie Sie Ihr erstes Dokument erstellen und versenden.',
start_tour: 'Tour starten',
or_add_from: 'Oder hinzufügen aus',
sync: 'Synchronisieren',
@@ -1309,7 +1335,11 @@ const de = {
align_bottom: 'Unten ausrichten',
fields_selected: '{count} Felder Ausgewählt',
field_added: '{count} Feld Hinzugefügt',
fields_added: '{count} Felder Hinzugefügt'
fields_added: '{count} Felder Hinzugefügt',
revisions: 'Revisionen',
apply: 'Anwenden',
no_revisions_yet: 'Noch keine Revisionen',
viewing_revision_from: 'Ansicht der Revision vom {date}'
}
const nl = {
@@ -1334,7 +1364,7 @@ const nl = {
max: 'Max',
font: 'Lettertype',
party: 'Partij',
date_signed: 'Datum ondertekend',
date_signed: 'Huidige datum',
method: 'Methode',
reorder_fields: 'Velden herschikken',
verify_id: 'ID verifiëren',
@@ -1384,6 +1414,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',
@@ -1513,7 +1544,7 @@ const nl = {
learn_more: 'Meer informatie',
and: 'en',
or: 'of',
start_a_quick_tour_to_learn_how_to_create_an_send_your_first_document: 'Start een korte rondleiding om te leren hoe u uw eerste document maakt en verzendt',
start_a_quick_tour_to_learn_how_to_create_and_send_your_first_document: 'Start een korte rondleiding om te leren hoe u uw eerste document maakt en verzendt',
start_tour: 'Rondleiding starten',
or_add_from: 'Of toevoegen van',
sync: 'Synchroniseren',
@@ -1528,7 +1559,11 @@ const nl = {
align_bottom: 'Onder uitlijnen',
fields_selected: '{count} Velden Geselecteerd',
field_added: '{count} Veld Toegevoegd',
fields_added: '{count} Velden Toegevoegd'
fields_added: '{count} Velden Toegevoegd',
revisions: 'Revisies',
apply: 'Toepassen',
no_revisions_yet: 'Nog geen revisies',
viewing_revision_from: 'Revisie van {date} bekijken'
}
export { en, es, it, pt, fr, de, nl }
+13
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"
@@ -55,6 +57,7 @@
@scroll-to="$emit('scroll-to', $event)"
@add-custom-field="$emit('add-custom-field', $event)"
@contextmenu="openAreaContextMenu($event, item.area, item.field)"
@click-title="closeContextMenu"
/>
<FieldArea
v-for="(area, index) in newAreas"
@@ -180,6 +183,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)
@@ -0,0 +1,104 @@
<template>
<div class="modal modal-open items-start !animate-none overflow-y-auto">
<div
class="absolute top-0 bottom-0 right-0 left-0"
@click.prevent="$emit('close')"
/>
<div class="modal-box pt-4 pb-6 mt-20 w-full">
<div class="flex justify-between items-center border-b pb-2 mb-3 font-medium">
<span>{{ t('revisions') }}</span>
<a
href="#"
class="text-xl"
@click.prevent="$emit('close')"
>&times;</a>
</div>
<ul class="space-y-1.5">
<li
v-for="revision in revisions"
:key="revision.id"
>
<button
type="button"
class="w-full text-left rounded-lg p-3 border border-dashed border-base-200 transition-colors disabled:cursor-default hover:bg-base-200 hover:border-base-200"
:disabled="loadingId !== null"
@click="viewRevision(revision.id)"
>
<div class="flex justify-between items-center gap-2">
<div class="flex flex-col">
<span>{{ formatDate(revision.created_at) }}</span>
<span class="-ml-0.5 flex items-center space-x-1 text-xs text-base-content/60 mt-0.5">
<IconUser class="w-3.5 h-3.5 flex-shrink-0" />
<span class="truncate">{{ revision.author.full_name || revision.author.email }}</span>
</span>
</div>
<span class="btn btn-sm btn-neutral text-white pointer-events-none flex-shrink-0">
<IconInnerShadowTop
v-if="loadingId === revision.id"
class="w-4 h-4 animate-spin"
/>
<span v-else>{{ t('view') }}</span>
</span>
</div>
</button>
</li>
<li
v-if="!revisions.length"
class="py-4 text-center text-base-content/60"
>
{{ t('no_revisions_yet') }}
</li>
</ul>
</div>
</div>
</template>
<script>
import { IconUser, IconInnerShadowTop } from '@tabler/icons-vue'
export default {
name: 'RevisionsModal',
components: { IconUser, IconInnerShadowTop },
inject: ['t', 'baseFetch'],
props: {
template: {
type: Object,
required: true
},
revisions: {
type: Array,
required: true
},
locale: {
type: String,
required: true
}
},
emits: ['close', 'apply'],
data () {
return {
loadingId: null
}
},
methods: {
viewRevision (id) {
if (this.loadingId !== null) return
this.loadingId = id
this.baseFetch(`/templates/${this.template.id}/versions/${id}`)
.then((r) => r.json())
.then((revision) => { this.$emit('apply', revision) })
.finally(() => { this.loadingId = null })
},
formatDate (string) {
return new Date(string).toLocaleString(this.locale || undefined, {
month: 'long',
day: 'numeric',
hour: 'numeric',
minute: '2-digit'
})
}
}
}
</script>
+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()
}
@@ -30,13 +30,12 @@ class SendFormCompletedWebhookRequestJob
attempt:,
data: Submitters::SerializeForWebhook.call(submitter))
if (resp.nil? || resp.status.to_i >= 400) && attempt <= MAX_ATTEMPTS &&
(!Docuseal.multitenant? || submitter.account.account_configs.exists?(key: :plan))
SendFormCompletedWebhookRequestJob.perform_in((2**attempt).minutes, {
**params,
'attempt' => attempt + 1,
'last_status' => resp&.status.to_i
})
end
return if attempt > MAX_ATTEMPTS || (resp && resp.status.to_i < 400)
SendFormCompletedWebhookRequestJob.perform_in((2**attempt).minutes, {
**params,
'attempt' => attempt + 1,
'last_status' => resp&.status.to_i
})
end
end
@@ -28,13 +28,12 @@ class SendFormDeclinedWebhookRequestJob
attempt:,
data: Submitters::SerializeForWebhook.call(submitter))
if (resp.nil? || resp.status.to_i >= 400) && attempt <= MAX_ATTEMPTS &&
(!Docuseal.multitenant? || submitter.account.account_configs.exists?(key: :plan))
SendFormDeclinedWebhookRequestJob.perform_in((2**attempt).minutes, {
**params,
'attempt' => attempt + 1,
'last_status' => resp&.status.to_i
})
end
return if attempt > MAX_ATTEMPTS || (resp && resp.status.to_i < 400)
SendFormDeclinedWebhookRequestJob.perform_in((2**attempt).minutes, {
**params,
'attempt' => attempt + 1,
'last_status' => resp&.status.to_i
})
end
end
@@ -28,13 +28,12 @@ class SendFormStartedWebhookRequestJob
attempt:,
data: Submitters::SerializeForWebhook.call(submitter))
if (resp.nil? || resp.status.to_i >= 400) && attempt <= MAX_ATTEMPTS &&
(!Docuseal.multitenant? || submitter.account.account_configs.exists?(key: :plan))
SendFormStartedWebhookRequestJob.perform_in((2**attempt).minutes, {
**params,
'attempt' => attempt + 1,
'last_status' => resp&.status.to_i
})
end
return if attempt > MAX_ATTEMPTS || (resp && resp.status.to_i < 400)
SendFormStartedWebhookRequestJob.perform_in((2**attempt).minutes, {
**params,
'attempt' => attempt + 1,
'last_status' => resp&.status.to_i
})
end
end
@@ -28,13 +28,12 @@ class SendFormViewedWebhookRequestJob
attempt:,
data: Submitters::SerializeForWebhook.call(submitter))
if (resp.nil? || resp.status.to_i >= 400) && attempt <= MAX_ATTEMPTS &&
(!Docuseal.multitenant? || submitter.account.account_configs.exists?(key: :plan))
SendFormViewedWebhookRequestJob.perform_in((2**attempt).minutes, {
**params,
'attempt' => attempt + 1,
'last_status' => resp&.status.to_i
})
end
return if attempt > MAX_ATTEMPTS || (resp && resp.status.to_i < 400)
SendFormViewedWebhookRequestJob.perform_in((2**attempt).minutes, {
**params,
'attempt' => attempt + 1,
'last_status' => resp&.status.to_i
})
end
end
@@ -26,13 +26,12 @@ class SendSubmissionArchivedWebhookRequestJob
attempt:,
data: submission.as_json(only: %i[id archived_at]))
if (resp.nil? || resp.status.to_i >= 400) && attempt <= MAX_ATTEMPTS &&
(!Docuseal.multitenant? || submission.account.account_configs.exists?(key: :plan))
SendSubmissionArchivedWebhookRequestJob.perform_in((2**attempt).minutes, {
**params,
'attempt' => attempt + 1,
'last_status' => resp&.status.to_i
})
end
return if attempt > MAX_ATTEMPTS || (resp && resp.status.to_i < 400)
SendSubmissionArchivedWebhookRequestJob.perform_in((2**attempt).minutes, {
**params,
'attempt' => attempt + 1,
'last_status' => resp&.status.to_i
})
end
end
@@ -26,13 +26,12 @@ class SendSubmissionCompletedWebhookRequestJob
attempt:,
data: Submissions::SerializeForApi.call(submission))
if (resp.nil? || resp.status.to_i >= 400) && attempt <= MAX_ATTEMPTS &&
(!Docuseal.multitenant? || submission.account.account_configs.exists?(key: :plan))
SendSubmissionCompletedWebhookRequestJob.perform_in((2**attempt).minutes, {
**params,
'attempt' => attempt + 1,
'last_status' => resp&.status.to_i
})
end
return if attempt > MAX_ATTEMPTS || (resp && resp.status.to_i < 400)
SendSubmissionCompletedWebhookRequestJob.perform_in((2**attempt).minutes, {
**params,
'attempt' => attempt + 1,
'last_status' => resp&.status.to_i
})
end
end
@@ -26,13 +26,12 @@ class SendSubmissionCreatedWebhookRequestJob
attempt:,
data: Submissions::SerializeForApi.call(submission))
if (resp.nil? || resp.status.to_i >= 400) && attempt <= MAX_ATTEMPTS &&
(!Docuseal.multitenant? || submission.account.account_configs.exists?(key: :plan))
SendSubmissionCreatedWebhookRequestJob.perform_in((2**attempt).minutes, {
**params,
'attempt' => attempt + 1,
'last_status' => resp&.status.to_i
})
end
return if attempt > MAX_ATTEMPTS || (resp && resp.status.to_i < 400)
SendSubmissionCreatedWebhookRequestJob.perform_in((2**attempt).minutes, {
**params,
'attempt' => attempt + 1,
'last_status' => resp&.status.to_i
})
end
end
@@ -26,13 +26,12 @@ class SendSubmissionExpiredWebhookRequestJob
attempt:,
data: Submissions::SerializeForApi.call(submission))
if (resp.nil? || resp.status.to_i >= 400) && attempt <= MAX_ATTEMPTS &&
(!Docuseal.multitenant? || submission.account.account_configs.exists?(key: :plan))
SendSubmissionExpiredWebhookRequestJob.perform_in((2**attempt).minutes, {
**params,
'attempt' => attempt + 1,
'last_status' => resp&.status.to_i
})
end
return if attempt > MAX_ATTEMPTS || (resp && resp.status.to_i < 400)
SendSubmissionExpiredWebhookRequestJob.perform_in((2**attempt).minutes, {
**params,
'attempt' => attempt + 1,
'last_status' => resp&.status.to_i
})
end
end
@@ -26,13 +26,12 @@ class SendTemplateArchivedWebhookRequestJob
attempt:,
data: template.as_json(only: %i[id archived_at]))
if (resp.nil? || resp.status.to_i >= 400) && attempt <= MAX_ATTEMPTS &&
(!Docuseal.multitenant? || template.account.account_configs.exists?(key: :plan))
SendTemplateArchivedWebhookRequestJob.perform_in((2**attempt).minutes, {
**params,
'attempt' => attempt + 1,
'last_status' => resp&.status.to_i
})
end
return if attempt > MAX_ATTEMPTS || (resp && resp.status.to_i < 400)
SendTemplateArchivedWebhookRequestJob.perform_in((2**attempt).minutes, {
**params,
'attempt' => attempt + 1,
'last_status' => resp&.status.to_i
})
end
end
@@ -26,13 +26,12 @@ class SendTemplateCreatedWebhookRequestJob
attempt:,
data: Templates::SerializeForApi.call(template))
if (resp.nil? || resp.status.to_i >= 400) && attempt <= MAX_ATTEMPTS &&
(!Docuseal.multitenant? || template.account.account_configs.exists?(key: :plan))
SendTemplateCreatedWebhookRequestJob.perform_in((2**attempt).minutes, {
**params,
'attempt' => attempt + 1,
'last_status' => resp&.status.to_i
})
end
return if attempt > MAX_ATTEMPTS || (resp && resp.status.to_i < 400)
SendTemplateCreatedWebhookRequestJob.perform_in((2**attempt).minutes, {
**params,
'attempt' => attempt + 1,
'last_status' => resp&.status.to_i
})
end
end
@@ -26,13 +26,12 @@ class SendTemplateUpdatedWebhookRequestJob
attempt:,
data: Templates::SerializeForApi.call(template))
if (resp.nil? || resp.status.to_i >= 400) && attempt <= MAX_ATTEMPTS &&
(!Docuseal.multitenant? || template.account.account_configs.exists?(key: :plan))
SendTemplateUpdatedWebhookRequestJob.perform_in((2**attempt).minutes, {
**params,
'attempt' => attempt + 1,
'last_status' => resp&.status.to_i
})
end
return if attempt > MAX_ATTEMPTS || (resp && resp.status.to_i < 400)
SendTemplateUpdatedWebhookRequestJob.perform_in((2**attempt).minutes, {
**params,
'attempt' => attempt + 1,
'last_status' => resp&.status.to_i
})
end
end
+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
+1
View File
@@ -70,6 +70,7 @@ class Template < ApplicationRecord
has_many :submissions, dependent: :destroy
has_many :template_sharings, dependent: :destroy
has_many :template_accesses, dependent: :destroy
has_many :template_versions, dependent: :destroy
has_many :dynamic_documents, dependent: :destroy
has_many :dynamic_document_versions, through: :dynamic_documents, source: :versions
+44
View File
@@ -0,0 +1,44 @@
# frozen_string_literal: true
# == Schema Information
#
# Table name: template_versions
#
# id :bigint not null, primary key
# data :text not null
# sha1 :string not null
# created_at :datetime not null
# updated_at :datetime not null
# account_id :bigint not null
# author_id :bigint not null
# template_id :bigint not null
#
# Indexes
#
# index_template_versions_on_account_id (account_id)
# index_template_versions_on_author_id (author_id)
# index_template_versions_on_template_id_and_sha1 (template_id,sha1) UNIQUE
#
# Foreign Keys
#
# fk_rails_... (account_id => accounts.id)
# fk_rails_... (author_id => users.id)
# fk_rails_... (template_id => templates.id)
#
class TemplateVersion < ApplicationRecord
belongs_to :template
belongs_to :account
belongs_to :author, class_name: 'User'
attribute :data, :string, default: -> { {} }
serialize :data, coder: JSON
before_validation :set_account, on: :create
private
def set_account
self.account ||= template.account
end
end
+15 -9
View File
@@ -4,14 +4,15 @@
#
# Table name: webhook_urls
#
# id :bigint not null, primary key
# events :text not null
# secret :text not null
# sha1 :string not null
# url :text not null
# created_at :datetime not null
# updated_at :datetime not null
# account_id :bigint not null
# id :bigint not null, primary key
# events :text not null
# hmac_secret :text not null
# secret :text not null
# sha1 :string not null
# url :text not null
# created_at :datetime not null
# updated_at :datetime not null
# account_id :bigint not null
#
# Indexes
#
@@ -47,10 +48,15 @@ class WebhookUrl < ApplicationRecord
serialize :secret, coder: JSON
before_validation :set_sha1
before_validation :set_hmac_secret
encrypts :url, :secret
encrypts :url, :secret, :hmac_secret
def set_sha1
self.sha1 = Digest::SHA1.hexdigest(url)
end
def set_hmac_secret
self.hmac_secret ||= WebhookUrls::Signatures.generate_secret
end
end
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>16k</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') %>
Loaded 100 of 161 files, more files were not shown because too many files have changed in this diff. Show more