Compare commits

..
62 Commits
Author SHA1 Message Date
Alex Turchyn 8f8f69f0bc Merge from docusealco/wip 2026-10-19 14:57:12 +03:00
Pete Matsyburka 04663d55eb update deps 2026-10-19 11:10:48 +03:00
Pete Matsyburka 93d0b935e2 fix dynamic document 2026-10-17 13:17:52 +03:00
Pete Matsyburka 829b2b2855 disable jemalloc 2026-10-17 10:42:20 +03:00
Alex Turchyn cf6de444f4 disable the review form for native apps 2026-10-16 23:22:32 +03:00
Pete Matsyburka 126f5d58a2 convert docx 2026-10-16 20:18:10 +03:00
Pete Matsyburka 09b93067f4 fix close modal 2026-10-16 19:56:36 +03:00
Pete Matsyburka f14458f238 fix generate 2026-10-16 19:56:36 +03:00
Pete Matsyburka 445139f05c adjust mobile nav 2026-10-16 19:56:36 +03:00
Pete Matsyburka e5e4965f05 adjust kba 2026-10-16 19:56:36 +03:00
Pete Matsyburka 1c8f7b0316 use jemalloc 2026-10-15 15:28:54 +03:00
Alex Turchyn 9fe3656652 fix icons shrinking 2026-10-14 08:11:45 +03:00
Pete Matsyburka 17ae46bd61 make remember signature permanent 2026-10-13 16:45:42 +03:00
Pete Matsyburka a036483938 tenants test flag 2026-10-13 09:23:38 +03:00
Alex Turchyn 3c65b0e4ba Merge from docusealco/wip 2026-10-12 15:29:17 +03:00
Pete Matsyburka ced5ab67ab adjust generate previews 2026-10-12 09:33:42 +03:00
Pete Matsyburka 28c4177128 adjsut detailed send button 2026-10-11 17:52:54 +03:00
Pete Matsyburka 5d86a08b5f update send modal 2026-10-10 15:57:08 +03:00
Pete Matsyburka a69fb81429 fix multiselect 2026-10-09 12:57:12 +03:00
Pete Matsyburka e6ac3380d4 dynamic editor image drag 2026-10-09 12:25:21 +03:00
Pete Matsyburka 3a535e7112 adjust date timezone 2026-10-08 16:06:41 +03:00
Pete Matsyburka 7464f9cfad download event log 2026-10-08 10:04:12 +03:00
Pete Matsyburka 58e24ef758 update submitter slug 2026-10-08 10:04:01 +03:00
Pete Matsyburka c1d809f989 update demo 2026-10-07 09:00:58 +03:00
Pete Matsyburka 80b24f0e2a show signature when completed 2026-10-06 12:12:17 +03:00
Pete Matsyburka 2e253904a3 invalid email error 2026-10-06 11:01:04 +03:00
Pete Matsyburka a7f381caf0 update gem 2026-10-05 12:31:08 +03:00
Alex Turchyn c6a7555f54 Merge from docusealco/wip 2026-10-05 12:27:31 +03:00
Pete Matsyburka 0772880bbc download dynamic document 2026-10-05 11:53:57 +03:00
Pete Matsyburka 73339d8cd9 fix editor undo 2026-10-05 11:45:16 +03:00
Pete Matsyburka 25504e8cb1 update routes 2026-10-04 11:04:14 +03:00
Pete Matsyburka 63a874528a improve text selection 2026-10-03 20:07:24 +03:00
Pete Matsyburka c1c99621ff sticky table tools 2026-10-03 18:35:29 +03:00
Pete Matsyburka 5c168f4a29 add page number style 2026-10-03 14:57:11 +03:00
Pete Matsyburka bef825c4e0 optimize add list style 2026-10-03 14:05:38 +03:00
Pete Matsyburka a990d22831 remove pinch to zoom 2026-10-03 14:05:38 +03:00
Pete Matsyburka 1687a372ae adjust editor 2026-10-03 14:05:38 +03:00
Alex Turchyn 9b267d4a07 upload inserted images as attachments 2026-10-03 14:05:38 +03:00
Pete Matsyburka 2189c2e9fe adjust fonts 2026-10-03 14:05:38 +03:00
Alex Turchyn 1e43fe8969 use Word line spacing units and join adjacent lists 2026-10-03 14:05:38 +03:00
Alex Turchyn f77a716658 remove spacing between list items like Word 2026-10-03 14:05:38 +03:00
Alex Turchyn a70ac571e3 simplify dynamic editor code 2026-10-03 14:05:38 +03:00
Alex Turchyn 4f5e72f983 fix table editing 2026-10-03 14:05:38 +03:00
Alex Turchyn 8f47ec6d4e add accessible names to table size counters 2026-10-03 14:05:38 +03:00
Alex Turchyn 9e1bbf2944 fix image resize handles 2026-10-03 14:05:38 +03:00
Alex Turchyn aa7d6e9aaa add pinch zoom and fix touch editing 2026-10-03 14:05:38 +03:00
Alex Turchyn 57c8be33ce adjust dynamic editor menus on mobile 2026-10-03 14:05:38 +03:00
Alex Turchyn ed7109bfc6 store list styles in dynamic document 2026-10-03 14:05:38 +03:00
Alex Turchyn 9e2590489b improve insert menu accessibility 2026-10-03 14:05:38 +03:00
Alex Turchyn d793f00b37 improve dynamic editor 2026-10-03 14:05:38 +03:00
Pete Matsyburka 5896c9a2aa strip edited image 2026-10-03 09:10:31 +03:00
Pete Matsyburka 78b0ce9828 add iban validation 2026-10-01 19:45:55 +03:00
Pete Matsyburka e187644716 refactor otp 2026-10-01 08:57:19 +03:00
Pete Matsyburka a847e7aa9c custom otp email 2026-09-30 19:56:12 +03:00
Pete Matsyburka 0905e7d215 reduce limit 2026-09-30 19:23:58 +03:00
Pete Matsyburka 91147d2f48 fix verify pdf 2026-09-30 19:22:41 +03:00
Pete Matsyburka acce6195b0 rescue validation errors 2026-09-30 13:38:47 +03:00
Pete Matsyburka ce9ab691ec fix document metadata 2026-09-30 12:57:48 +03:00
Pete Matsyburka 4190544edc use ivar 2026-09-28 23:28:13 +03:00
Pete Matsyburka db8e5efbd5 adjust i18n 2026-09-28 17:54:47 +03:00
Pete Matsyburka a5c6f4e40c update gem 2026-09-28 15:32:26 +03:00
Pete Matsyburka 7537d05d1f fix builder resize 2026-09-28 15:30:09 +03:00
92 changed files with 3721 additions and 575 deletions

No files matched your search

+4 -2
View File
@@ -52,7 +52,7 @@ ENV OPENSSL_CONF=/etc/openssl_legacy.cnf
WORKDIR /app
RUN apk add --no-cache libpq vips redis onnxruntime leptonica && \
RUN apk add --no-cache libpq vips redis onnxruntime leptonica jemalloc font-liberation font-carlito && \
rm -f /usr/bin/onnx_test_runner /usr/bin/onnxruntime_test
RUN addgroup -g 2000 docuseal && adduser -u 2000 -G docuseal -s /bin/sh -D -h /home/docuseal docuseal
@@ -91,7 +91,7 @@ COPY --chown=docuseal:docuseal --from=download /model.onnx /app/tmp/model.onnx
COPY --chown=docuseal:docuseal --from=webpack /app/public/packs ./public/packs
RUN mkdir -p /app/public/fonts && ln -s /fonts/DancingScript-Regular.otf /app/public/fonts/ && \
mkdir -p /usr/share/fonts/noto && ln -s /fonts/GoNotoKurrent-Regular.ttf /usr/share/fonts/noto/ && ln -s /fonts/GoNotoKurrent-Bold.ttf /usr/share/fonts/noto/ && fc-cache -f && \
mkdir -p /usr/share/fonts/noto/bold && ln -s /fonts/GoNotoKurrent-Regular.ttf /usr/share/fonts/noto/ && ln -s /fonts/GoNotoKurrent-Bold.ttf /usr/share/fonts/noto/bold/ && fc-cache -f && \
bundle exec bootsnap precompile -j 1 --gemfile app/ lib/ && \
chown -R docuseal:docuseal /app/tmp/cache
@@ -100,6 +100,8 @@ ENV HOME=/home/docuseal
ENV WORKDIR=/data/docuseal
ENV VIPS_MAX_COORD=17000
ENV VIPS_BLOCK_UNTRUSTED=1
# ENV LD_PRELOAD=/usr/lib/libjemalloc.so.2
ENV MALLOC_CONF=dirty_decay_ms:1000,narenas:2,background_thread:true
EXPOSE 3000
CMD ["/app/bin/bundle", "exec", "puma", "-C", "/app/config/puma.rb", "--dir", "/app"]
+1
View File
@@ -45,6 +45,7 @@ gem 'trilogy', require: false
gem 'turbo-rails'
gem 'twitter_cldr', require: false
gem 'tzinfo-data'
gem 'wordcraft', require: false, group: :ce
gem 'xlsxtream', require: false
group :development, :test do
+17 -6
View File
@@ -497,7 +497,7 @@ GEM
ruby-vips (2.3.0)
ffi (~> 1.12)
logger
rubyzip (3.2.2)
rubyzip (3.7.0)
securerandom (0.4.1)
semantic_range (3.1.1)
shakapacker (9.7.0)
@@ -524,11 +524,11 @@ GEM
simplecov-html (0.13.2)
simplecov_json_formatter (0.1.4)
smart_properties (1.17.0)
sqlite3 (2.9.5-aarch64-linux-gnu)
sqlite3 (2.9.5-aarch64-linux-musl)
sqlite3 (2.9.5-arm64-darwin)
sqlite3 (2.9.5-x86_64-linux-gnu)
sqlite3 (2.9.5-x86_64-linux-musl)
sqlite3 (2.9.6-aarch64-linux-gnu)
sqlite3 (2.9.6-aarch64-linux-musl)
sqlite3 (2.9.6-arm64-darwin)
sqlite3 (2.9.6-x86_64-linux-gnu)
sqlite3 (2.9.6-x86_64-linux-musl)
strip_attributes (2.0.1)
activemodel (>= 3.0, < 9.0)
strscan (3.1.8)
@@ -572,6 +572,16 @@ GEM
base64
websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.5)
wordcraft (0.8.0-aarch64-linux-gnu)
ffi (>= 1.15)
wordcraft (0.8.0-aarch64-linux-musl)
ffi (>= 1.15)
wordcraft (0.8.0-arm64-darwin)
ffi (>= 1.15)
wordcraft (0.8.0-x86_64-linux-gnu)
ffi (>= 1.15)
wordcraft (0.8.0-x86_64-linux-musl)
ffi (>= 1.15)
xlsxtream (3.1.0)
zip_kit (>= 6.2, < 7)
xpath (3.2.0)
@@ -650,6 +660,7 @@ DEPENDENCIES
tzinfo-data
web-console
webmock
wordcraft
xlsxtream
RUBY VERSION
+1 -1
View File
@@ -40,7 +40,7 @@ DocuSeal is an open source platform that provides secure and efficient digital d
- Files storage on disk or AWS S3, Google Storage, Azure Cloud
- Automatic PDF eSignature
- PDF signature verification
- Users management
- User management
- Mobile-optimized
- 7 UI languages with signing available in 14 languages
- API and Webhooks for integrations
@@ -38,7 +38,7 @@ module Api
attachment = Submitters.create_attachment!(@submitter, file, metadata:)
if params[:remember_signature] == 'true' && @submitter.email.present?
cookies.encrypted[:signature_uuids] = build_new_cookie_signatures_json(@submitter, attachment)
cookies.permanent.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])
@@ -98,7 +98,7 @@ module Api
render json: build_create_json(submissions)
rescue Submitters::NormalizeValues::BaseError, Submissions::CreateFromSubmitters::BaseError,
DownloadUtils::UnableToDownload => e
Submitters::SubmitValues::ValidationError, DownloadUtils::UnableToDownload => e
Rollbar.warning(e) if defined?(Rollbar)
render json: { error: e.message }, status: :unprocessable_content
+2 -1
View File
@@ -78,7 +78,8 @@ module Api
render json: Submitters::SerializeForApi.call(@submitter, with_template: false, with_urls: true,
with_events: false, params:)
rescue Submitters::NormalizeValues::BaseError, DownloadUtils::UnableToDownload => e
rescue Submitters::NormalizeValues::BaseError, Submitters::SubmitValues::ValidationError,
DownloadUtils::UnableToDownload => e
Rollbar.warning(e) if defined?(Rollbar)
render json: { error: e.message }, status: :unprocessable_content
+6 -1
View File
@@ -118,7 +118,12 @@ class ApplicationController < ActionController::Base
end
def sign_in_for_demo
sign_in(User.active.order('random()').take) unless signed_in?
return if signed_in?
account = Account.create!(name: "Demo #{SecureRandom.hex(3)}")
sign_in(account.users.create!(first_name: 'Demo', last_name: 'User',
email: "#{SecureRandom.hex}@docuseal.com", password: SecureRandom.hex))
end
def current_account
@@ -66,6 +66,8 @@ class SubmissionsController < ApplicationController
create_submissions(@template, submissions_params, params)
end
raise Submissions::CreateFromSubmitters::BaseError, 'Invalid email address' if submissions.blank?
WebhookUrls.enqueue_events(submissions, 'submission.created')
Submissions.send_signature_requests(submissions)
@@ -10,12 +10,10 @@ class SubmitFormEmail2fasController < ApplicationController
def create
RateLimit.call("verify-2fa-code-#{@submitter.id}", limit: 2, ttl: 45.seconds, enabled: true)
RateLimit.call("verify-2fa-code-1h-#{@submitter.id}", limit: 10, ttl: 1.hour, enabled: true)
RateLimit.call("verify-2fa-code-1d-#{@submitter.id}", limit: 20, ttl: 1.day, enabled: true)
value = [@submitter.email.downcase.strip, @submitter.slug].join(':')
value = [@submitter.email.downcase.squish, @submitter.slug].join(':')
if EmailVerificationCodes.verify(params[:one_time_code].to_s.gsub(/\D/, ''), value)
if OtpChallenges.verify(params[:one_time_code].to_s.gsub(/\D/, ''), value, purpose: 'form_email_2fa')
SubmissionEvents.create_with_tracking_data(@submitter, 'email_verified', request, { email: @submitter.email })
cookies.encrypted[:email_2fa_slug] =
@@ -17,12 +17,12 @@ class SubmitFormMetadataController < ApplicationController
submission.schema_documents.find { |a| a.uuid == item['attachment_uuid'] }
end
ActiveRecord::Associations::Preloader.new(records: documents, associations: %i[blob record]).call
ActiveRecord::Associations::Preloader.new(records: documents, associations: :blob).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
DocumentMetadatas.find_or_create_for_document(document, account_id: submission.account_id).text_runs
]
end
@@ -4,19 +4,19 @@ class SubmitFormPrefillAttachmentsController < ActionController::API
include ActiveStorage::SetCurrent
def create
submitter = Submitter.find_by!(slug: params[:submit_form_slug])
@submitter = Submitter.find_by!(slug: params[:submit_form_slug])
unless can_upload?(submitter)
unless can_upload?(@submitter)
return render json: { error: I18n.t('form_has_been_archived') }, status: :unprocessable_content
end
uuid, slug = ApplicationRecord.signed_id_verifier.verify(params[:prefill_token], purpose: :prefill_attachment)
raise ActiveRecord::RecordNotFound if slug != submitter.slug
raise ActiveRecord::RecordNotFound if slug != @submitter.slug
source_attachment = ActiveStorage::Attachment.find_by!(uuid:)
attachment = submitter.attachments_attachments.find_or_create_by!(blob_id: source_attachment.blob_id)
attachment = @submitter.attachments_attachments.find_or_create_by!(blob_id: source_attachment.blob_id)
render json: attachment.as_json(only: %i[uuid created_at], methods: %i[url filename content_type])
end
@@ -34,7 +34,7 @@ class SubmitFormUploadController < ActionController::API
attachment = Submitters.create_attachment!(@submitter, file, metadata:)
if params[:remember_signature] == 'true' && @submitter.email.present?
cookies.encrypted[:signature_uuids] = build_new_cookie_signatures_json(@submitter, attachment)
cookies.permanent.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])
+17
View File
@@ -81,9 +81,26 @@ class SubmittersController < ApplicationController
submitter.name = attrs[:name] if attrs.key?(:name)
if submitter_changed?(submitter)
submitter.submitter_versions.new(slug: submitter.slug, email: submitter.email_was,
name: submitter.name_was, phone: submitter.phone_was)
submitter.slug = SecureRandom.base58(14)
submitter.values = Submitters.fetch_values_for_delegate(submitter)
end
submitter
end
def submitter_changed?(submitter)
if submitter.email_was.blank? && submitter.phone_was.blank?
submitter.name_was.present? && submitter.name_changed?
else
(submitter.email_was.present? && submitter.email_changed?) ||
(submitter.phone_was.present? && submitter.phone_changed?)
end
end
def submitter_params
params.require(:submitter).permit(:email, :name, :phone).transform_values(&:strip)
end
@@ -6,7 +6,17 @@ class TemplateDocumentsController < ApplicationController
FILES_TTL = 5.minutes
def index
render json: @template.schema_documents.map { |d| ActiveStorage::Blob.proxy_path(d.blob, expires_at: FILES_TTL.from_now.to_i) }
documents = @template.schema.map do |item|
if item['dynamic']
dynamic_document = @template.schema_dynamic_documents.find { |e| e.uuid == item['attachment_uuid'] }
DynamicDocuments::EnsureVersionGenerated.call(dynamic_document).document_attachment
else
@template.schema_documents.find { |e| e.uuid == item['attachment_uuid'] }
end
end
render json: documents.map { |d| ActiveStorage::Blob.proxy_path(d.blob, expires_at: FILES_TTL.from_now.to_i) }
end
def create
@@ -8,7 +8,9 @@ class TemplateSharingsTestingController < ApplicationController
end
def create
testing_account = Accounts.find_or_create_testing_user(true_user.account).account
account = can?(:manage, :tenants_test) ? current_account : true_user.account
testing_account = Accounts.find_or_create_testing_user(account).account
if params[:value] == '1'
TemplateSharing.create!(ability: :manage, account: testing_account, template: @template)
+6
View File
@@ -35,6 +35,9 @@ import PromptPassword from './elements/prompt_password'
import EmailsTextarea from './elements/emails_textarea'
import ToggleSubmit from './elements/toggle_submit'
import ToggleOnSubmit from './elements/toggle_on_submit'
import ToggleOnChecked from './elements/toggle_on_checked'
import ToggleSendTitle from './elements/toggle_send_title'
import AppendTemplate from './elements/append_template'
import CheckOnClick from './elements/check_on_click'
import PasswordInput from './elements/password_input'
import SearchInput from './elements/search_input'
@@ -159,6 +162,9 @@ safeRegisterElement('emails-textarea', EmailsTextarea)
safeRegisterElement('toggle-cookies', ToggleCookies)
safeRegisterElement('toggle-submit', ToggleSubmit)
safeRegisterElement('toggle-on-submit', ToggleOnSubmit)
safeRegisterElement('toggle-on-checked', ToggleOnChecked)
safeRegisterElement('toggle-send-title', ToggleSendTitle)
safeRegisterElement('append-template', AppendTemplate)
safeRegisterElement('password-input', PasswordInput)
safeRegisterElement('search-input', SearchInput)
safeRegisterElement('toggle-attribute', ToggleAttribute)
@@ -0,0 +1,29 @@
export default class extends HTMLElement {
connectedCallback () {
this.addEventListener('click', this.onClick)
}
disconnectedCallback () {
this.removeEventListener('click', this.onClick)
}
onClick = (e) => {
e.preventDefault()
const target = document.getElementById(this.dataset.targetId)
const item = this.querySelector('template').content.firstElementChild.cloneNode(true)
item.querySelector('a').addEventListener('click', (e) => {
e.preventDefault()
item.remove()
target.classList.toggle('hidden', !target.childElementCount)
})
target.append(item)
target.classList.remove('hidden')
item.querySelector('input').focus()
}
}
+18 -1
View File
@@ -2,7 +2,7 @@ import { actionable } from '@github/catalyst/lib/actionable'
import { targets, targetable } from '@github/catalyst/lib/targetable'
export default actionable(targetable(class extends HTMLElement {
static [targets.static] = ['items']
static [targets.static] = ['items', 'groups']
addItem (e) {
e.preventDefault()
@@ -24,11 +24,28 @@ export default actionable(targetable(class extends HTMLElement {
duplicateItem.querySelectorAll('a.hidden').forEach((button) => button.classList.toggle('hidden'))
originalItem.parentNode.append(duplicateItem)
this.toggleMultipleClasses()
duplicateItem.querySelector("input:not([type='hidden'])")?.focus()
}
removeItem (e) {
e.preventDefault()
this.items.find((item) => item.contains(e.target))?.remove()
this.toggleMultipleClasses()
}
toggleMultipleClasses () {
const isMultiple = this.items.length > 1
const elements = [...this.items, ...this.groups]
elements.forEach((element) => {
const classes = element.dataset.multipleClasses || this.dataset.multipleClasses
classes?.split(' ').forEach((cls) => element.classList.toggle(cls, isMultiple))
})
}
}))
+1 -1
View File
@@ -22,7 +22,7 @@ export default class extends HTMLElement {
}
async flashFrom (fetchResponse) {
const html = await fetchResponse?.responseHTML
const html = await fetchResponse?.responseHTML.catch(() => null)
const flash = html && new DOMParser().parseFromString(html, 'text/html').getElementById('native_flash')
if (flash?.dataset.alert) return { alert: flash.dataset.alert }
@@ -0,0 +1,23 @@
export default class extends HTMLElement {
connectedCallback () {
this.form.addEventListener('change', this.toggle)
this.toggle()
}
disconnectedCallback () {
this.form.removeEventListener('change', this.toggle)
}
toggle = () => {
const selector = this.dataset.names.split(' ').map((name) => `[name="${name}"]:checked:enabled`).join(', ')
const isChecked = !!this.form.querySelector(selector)
this.firstElementChild.classList.toggle('hidden', !isChecked)
this.lastElementChild.classList.toggle('hidden', isChecked)
}
get form () {
return this.closest('form')
}
}
@@ -0,0 +1,43 @@
export default class extends HTMLElement {
connectedCallback () {
this.events.forEach((event) => this.form.addEventListener(event, this.toggle))
this.toggle()
}
disconnectedCallback () {
this.events.forEach((event) => this.form.removeEventListener(event, this.toggle))
}
toggle = (event) => {
if (event?.type === 'input' && event.target.matches(this.fieldSelector('name'))) return
const isNameOnly = (field) => this.isFilled('name') && !this.isFilled(field)
const isSend = (this.isChecked('send_email') && !isNameOnly('email')) ||
(this.isChecked('send_sms') && !isNameOnly('phone'))
this.firstElementChild.classList.toggle('hidden', !isSend)
this.lastElementChild.classList.toggle('hidden', isSend)
}
isChecked (name) {
return !!this.form.querySelector(`[name="${name}"]:checked:enabled`)
}
isFilled (field) {
return [...this.form.querySelectorAll(this.fieldSelector(field))].some((input) => input.value.trim())
}
fieldSelector (field) {
return `[name$="[][${field}]"]`
}
get events () {
return ['change', 'input', 'linked-input.update']
}
get form () {
return this.closest('form')
}
}
+3
View File
@@ -24,6 +24,9 @@ const ICONS = {
user: 'person',
users: 'person.2',
users_plus: 'person.badge.plus',
user_plus: 'person.badge.plus',
eye_plus: 'eye',
mail_plus: 'envelope',
photo: 'photo',
settings: 'gearshape',
logout: 'rectangle.portrait.and.arrow.right',
+14
View File
@@ -13,6 +13,7 @@ const en = {
must_be_valid_email: 'Must be a valid email address',
must_be_valid_url: 'Must be a valid URL (starting with http:// or https://)',
must_be_valid_zip: 'Must be a valid ZIP code (XXXXX or XXXXX-XXXX)',
must_be_valid_iban: 'Must be a valid IBAN',
must_contain_numbers_only: 'Must contain numbers only',
must_contain_letters_only: 'Must contain letters only',
complete_all_required_fields_to_proceed_with_identity_verification: 'Complete all required fields to proceed with identity verification.',
@@ -132,6 +133,7 @@ const es = {
must_be_valid_email: 'Debe ser una dirección de correo electrónico válida',
must_be_valid_url: 'Debe ser una URL válida (que comience con http:// o https://)',
must_be_valid_zip: 'Debe ser un ZIP válido (XXXXX o XXXXX-XXXX)',
must_be_valid_iban: 'Debe ser un IBAN válido',
must_contain_numbers_only: 'Debe contener solo números',
must_contain_letters_only: 'Debe contener solo letras',
complete_all_required_fields_to_proceed_with_identity_verification: 'Complete todos los campos requeridos para continuar con la verificación de identidad.',
@@ -251,6 +253,7 @@ const it = {
must_be_valid_email: 'Deve essere un indirizzo email valido',
must_be_valid_url: 'Deve essere un URL valido (che inizia con http:// o https://)',
must_be_valid_zip: 'Deve essere uno ZIP valido (XXXXX o XXXXX-XXXX)',
must_be_valid_iban: 'Deve essere un IBAN valido',
must_contain_numbers_only: 'Deve contenere solo numeri',
must_contain_letters_only: 'Deve contenere solo lettere',
complete_all_required_fields_to_proceed_with_identity_verification: "Compila tutti i campi obbligatori per procedere con la verifica dell'identità.",
@@ -370,6 +373,7 @@ const de = {
must_be_valid_email: 'Muss eine gültige E-Mail-Adresse sein',
must_be_valid_url: 'Muss eine gültige URL sein (beginnend mit http:// oder https://)',
must_be_valid_zip: 'Muss eine gültige ZIP sein (XXXXX oder XXXXX-XXXX)',
must_be_valid_iban: 'Muss eine gültige IBAN sein',
must_contain_numbers_only: 'Darf nur Zahlen enthalten',
must_contain_letters_only: 'Darf nur Buchstaben enthalten',
complete_all_required_fields_to_proceed_with_identity_verification: 'Füllen Sie alle Pflichtfelder aus, um mit der Identitätsprüfung fortzufahren.',
@@ -489,6 +493,7 @@ const fr = {
must_be_valid_email: 'Doit être une adresse e-mail valide',
must_be_valid_url: 'Doit être une URL valide (commençant par http:// ou https://)',
must_be_valid_zip: 'Doit être un ZIP valide (XXXXX ou XXXXX-XXXX)',
must_be_valid_iban: 'Doit être un IBAN valide',
must_contain_numbers_only: 'Doit contenir uniquement des chiffres',
must_contain_letters_only: 'Doit contenir uniquement des lettres',
complete_all_required_fields_to_proceed_with_identity_verification: "Veuillez remplir tous les champs obligatoires pour poursuivre la vérification d'identité.",
@@ -608,6 +613,7 @@ const pl = {
must_be_valid_email: 'Musi być prawidłowym adresem e-mail',
must_be_valid_url: 'Musi być prawidłowym adresem URL (zaczynającym się od http:// lub https://)',
must_be_valid_zip: 'Musi być prawidłowym ZIP (XXXXX lub XXXXX-XXXX)',
must_be_valid_iban: 'Musi być prawidłowym numerem IBAN',
must_contain_numbers_only: 'Może zawierać tylko cyfry',
must_contain_letters_only: 'Może zawierać tylko litery',
complete_all_required_fields_to_proceed_with_identity_verification: 'Uzupełnij wszystkie wymagane pola, aby kontynuować weryfikację tożsamości.',
@@ -727,6 +733,7 @@ const uk = {
must_be_valid_email: 'Має бути дійсною адресою електронної пошти',
must_be_valid_url: 'Має бути дійсним URL (починаючи з http:// або https://)',
must_be_valid_zip: 'Має бути дійсним ZIP (XXXXX або XXXXX-XXXX)',
must_be_valid_iban: 'Має бути дійсним IBAN',
must_contain_numbers_only: 'Має містити лише цифри',
must_contain_letters_only: 'Має містити лише літери',
complete_all_required_fields_to_proceed_with_identity_verification: "Заповніть всі обов'язкові поля, щоб продовжити перевірку особи.",
@@ -846,6 +853,7 @@ const cs = {
must_be_valid_email: 'Musí být platná e-mailová adresa',
must_be_valid_url: 'Musí být platná URL (začínající http:// nebo https://)',
must_be_valid_zip: 'Musí být platný ZIP (XXXXX nebo XXXXX-XXXX)',
must_be_valid_iban: 'Musí být platný IBAN',
must_contain_numbers_only: 'Musí obsahovat pouze čísla',
must_contain_letters_only: 'Musí obsahovat pouze písmena',
complete_all_required_fields_to_proceed_with_identity_verification: 'Vyplňte všechna povinná pole, abyste mohli pokračovat v ověření identity.',
@@ -965,6 +973,7 @@ const pt = {
must_be_valid_email: 'Deve ser um endereço de e-mail válido',
must_be_valid_url: 'Deve ser uma URL válida (começando com http:// ou https://)',
must_be_valid_zip: 'Deve ser um ZIP válido (XXXXX ou XXXXX-XXXX)',
must_be_valid_iban: 'Deve ser um IBAN válido',
must_contain_numbers_only: 'Deve conter apenas números',
must_contain_letters_only: 'Deve conter apenas letras',
complete_all_required_fields_to_proceed_with_identity_verification: 'Preencha todos os campos obrigatórios para prosseguir com a verificação de identidade.',
@@ -1084,6 +1093,7 @@ const he = {
must_be_valid_email: 'חייבת להיות כתובת אימייל חוקית',
must_be_valid_url: 'חייבת להיות כתובת URL חוקית (המתחילה ב-http:// או https://)',
must_be_valid_zip: 'חייב להיות ZIP חוקי (XXXXX או XXXXX-XXXX)',
must_be_valid_iban: 'חייב להיות IBAN חוקי',
must_contain_numbers_only: 'חייב להכיל מספרים בלבד',
must_contain_letters_only: 'חייב להכיל אותיות בלבד',
complete_all_required_fields_to_proceed_with_identity_verification: 'מלא את כל השדות הנדרשים כדי להמשיך עם אימות זהות.',
@@ -1203,6 +1213,7 @@ const nl = {
must_be_valid_email: 'Moet een geldig e-mailadres zijn',
must_be_valid_url: 'Moet een geldige URL zijn (beginnend met http:// of https://)',
must_be_valid_zip: 'Moet een geldige ZIP zijn (XXXXX of XXXXX-XXXX)',
must_be_valid_iban: 'Moet een geldig IBAN zijn',
must_contain_numbers_only: 'Mag alleen cijfers bevatten',
must_contain_letters_only: 'Mag alleen letters bevatten',
complete_all_required_fields_to_proceed_with_identity_verification: 'Vul alle verplichte velden in om door te gaan met de identiteitsverificatie.',
@@ -1322,6 +1333,7 @@ const ar = {
must_be_valid_email: 'يجب أن يكون عنوان بريد إلكتروني صالحًا',
must_be_valid_url: 'يجب أن يكون عنوان URL صالحًا (يبدأ بـ http:// أو https://)',
must_be_valid_zip: 'يجب أن يكون ZIP صالحًا (XXXXX أو XXXXX-XXXX)',
must_be_valid_iban: 'يجب أن يكون IBAN صالحًا',
must_contain_numbers_only: 'يجب أن يحتوي على أرقام فقط',
must_contain_letters_only: 'يجب أن يحتوي على أحرف فقط',
complete_all_required_fields_to_proceed_with_identity_verification: 'أكمل جميع الحقول المطلوبة للمتابعة في التحقق من الهوية.',
@@ -1441,6 +1453,7 @@ const ko = {
must_be_valid_email: '유효한 이메일 주소여야 합니다',
must_be_valid_url: '유효한 URL이어야 합니다 (http:// 또는 https://로 시작)',
must_be_valid_zip: '유효한 ZIP이어야 합니다 (XXXXX 또는 XXXXX-XXXX)',
must_be_valid_iban: '유효한 IBAN이어야 합니다',
must_contain_numbers_only: '숫자만 포함해야 합니다',
must_contain_letters_only: '문자만 포함해야 합니다',
complete_all_required_fields_to_proceed_with_identity_verification: '신원 확인을 진행하려면 모든 필수 필드를 작성하십시오.',
@@ -1560,6 +1573,7 @@ const ja = {
must_be_valid_email: '有効なメールアドレスである必要があります',
must_be_valid_url: '有効なURLである必要があります (http://またはhttps://で始まる)',
must_be_valid_zip: '有効なZIPである必要があります (XXXXX または XXXXX-XXXX)',
must_be_valid_iban: '有効なIBANである必要があります',
must_contain_numbers_only: '数字のみを含む必要があります',
must_contain_letters_only: '文字のみを含む必要があります',
complete_all_required_fields_to_proceed_with_identity_verification: '本人確認を進めるには、すべての必須項目を入力してください。',
+20 -12
View File
@@ -3,11 +3,19 @@
v-if="!error"
class="label text-xl sm:text-2xl py-0 mb-2 sm:mb-3.5 field-name-label"
>
<MarkdownContent
v-if="field.title"
:string="field.title"
/>
<template v-else>{{ field.name || 'Knowledge Based Authentication' }}</template>
<span class="flex items-end">
<MarkdownContent
v-if="field.title"
:string="field.title"
/>
<template v-else>{{ field.name || 'Knowledge Based Authentication' }}</template>
<span
class="tooltip tooltip-bottom ml-2 mb-1"
data-tip="To verify your identity, the details you enter are checked against third-party records, which may include credit bureau data, and used to generate questions based on those records. Only these identity details are shared with the identity verification provider; this document and your tax return information are not shared with it or with any credit reporting company. If the check is recorded on your credit report, it appears as a soft inquiry, which does not affect your credit score and is not shown to lenders. This check does not give the sender of this document or the IRS access to your credit report."
>
<IconInfoCircle class="w-4 h-4 text-neutral-500" />
</span>
</span>
<span
v-if="questions"
class="float-right font-normal text-neutral-500 mt-1 whitespace-nowrap text-right text-sm"
@@ -282,13 +290,14 @@
<script>
import MarkdownContent from './markdown_content'
import { IconInnerShadowTop } from '@tabler/icons-vue'
import { IconInnerShadowTop, IconInfoCircle } from '@tabler/icons-vue'
export default {
name: 'KbaStep',
components: {
MarkdownContent,
IconInnerShadowTop
IconInnerShadowTop,
IconInfoCircle
},
inject: ['baseUrl', 't'],
props: {
@@ -483,7 +492,7 @@ export default {
this.clearCountdown()
try {
const payload = { ...this.form, submitter_slug: this.submitterSlug }
const payload = { ...this.form }
if (payload.dob) {
payload.dob = payload.dob.replace(/-/g, '')
@@ -497,7 +506,7 @@ export default {
payload.phone = payload.phone.replace(/^\+1/, '')
}
const resp = await fetch(this.baseUrl + '/api/kba', {
const resp = await fetch(this.baseUrl + `/s/${this.submitterSlug}/kba`, {
method: 'POST',
body: JSON.stringify(payload),
...this.fetchOptions,
@@ -548,13 +557,12 @@ export default {
}, {})
try {
const resp = await fetch(this.baseUrl + `/api/kba/${this.field.uuid}`, {
const resp = await fetch(this.baseUrl + `/s/${this.submitterSlug}/kba/${this.field.uuid}`, {
method: 'PUT',
body: JSON.stringify({
token: this.token,
answers: formattedAnswers,
reference: this.reference,
submitter_slug: this.submitterSlug
reference: this.reference
}),
...this.fetchOptions,
headers: { 'Content-Type': 'application/json', ...this.fetchOptions.headers }
@@ -156,7 +156,7 @@ export default {
return this.provider === 'paypal'
},
paymentEndpoint () {
return this.isPaypal ? '/api/paypal_payments' : '/api/stripe_payments'
return `/s/${this.submitterSlug}/${this.isPaypal ? 'paypal_payments' : 'stripe_payments'}`
},
sessionId () {
return this.queryParams.get('stripe_session_id') || this.queryParams.get('paypal_order_id') || (this.isPaypal && this.queryParams.get('token'))
@@ -255,9 +255,6 @@ export default {
if (this.sessionId) {
return fetch(this.baseUrl + this.paymentEndpoint + '/' + this.sessionId, {
method: 'PUT',
body: JSON.stringify({
submitter_slug: this.submitterSlug
}),
...this.fetchOptions,
headers: { 'Content-Type': 'application/json', ...this.fetchOptions.headers }
}).then(async (resp) => {
@@ -294,7 +291,6 @@ export default {
fetch(this.baseUrl + this.paymentEndpoint, {
method: 'POST',
body: JSON.stringify({
submitter_slug: this.submitterSlug,
field_uuid: this.field.uuid,
check_status: checkStatus,
referer: this.normalizedPaymentUrl()
@@ -285,7 +285,7 @@ export default {
}, 1000)
},
sendVerificationCode () {
return fetch(this.baseUrl + '/api/send_phone_verification_code', {
const options = {
method: 'POST',
body: JSON.stringify({
submitter_slug: this.submitterSlug,
@@ -293,6 +293,14 @@ export default {
phone: this.fullInternationalPhoneValue
}),
headers: { 'Content-Type': 'application/json' }
}
return fetch(this.baseUrl + `/s/${this.submitterSlug}/send_phone_verification_code`, options).then((resp) => {
if (resp.status === 404) {
return fetch(this.baseUrl + '/api/send_phone_verification_code', options)
} else {
return resp
}
}).then(async (resp) => {
if ([422, 429].includes(resp.status)) {
const data = await resp.json()
@@ -34,6 +34,7 @@
<input
v-if="!isTextArea"
:id="field.uuid"
ref="input"
v-model="text"
:maxlength="cellsMaxLegth"
dir="auto"
@@ -46,6 +47,7 @@
:placeholder="`${t('type_here_')}${field.required ? '' : ` (${t('optional')})`}`"
type="text"
:name="`values[${field.uuid}]`"
@input="validateValue($event.target)"
@focus="$emit('focus')"
>
<textarea
@@ -137,6 +139,9 @@ export default {
return null
}
},
isIbanValidation () {
return this.patternMessageKeys[this.field.validation?.pattern] === 'must_be_valid_iban'
},
patternMessageKeys () {
return {
'^[0-9]{3}-[0-9]{2}-[0-9]{4}$': 'must_be_valid_ssn',
@@ -144,6 +149,7 @@ export default {
'^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$': 'must_be_valid_email',
'^https?://.*': 'must_be_valid_url',
'^[0-9]{5}(?:-[0-9]{4})?$': 'must_be_valid_zip',
'^[A-Z]{2}[0-9]{2}(?: ?[A-Z0-9]){11,30}$': 'must_be_valid_iban',
'^[0-9]+$': 'must_contain_numbers_only',
'^[a-zA-Z]+$': 'must_contain_letters_only'
}
@@ -186,8 +192,21 @@ export default {
})
}
}
this.validateValue(this.$refs.input)
},
methods: {
validateValue (input) {
if (this.isIbanValidation) {
input.setCustomValidity(this.isValidIban(input.value) ? '' : this.t('must_be_valid_iban'))
}
},
isValidIban (value) {
const iban = value.replace(/\s/g, '')
const digits = (iban.slice(4) + iban.slice(0, 4)).replace(/[A-Z]/g, (char) => char.charCodeAt(0) - 55)
return !iban || digits.split('').reduce((remainder, digit) => (remainder * 10 + parseInt(digit)) % 97, 0) === 1
},
resizeTextarea () {
const textarea = this.$refs.textarea
@@ -162,10 +162,9 @@ export default {
},
methods: {
start () {
return fetch(this.baseUrl + `/api/identity_verification/${this.field.uuid}`, {
return fetch(this.baseUrl + `/s/${this.submitterSlug}/identity_verification/${this.field.uuid}`, {
method: 'PUT',
body: JSON.stringify({
submitter_slug: this.submitterSlug,
redirect_url: document.location.href
}),
...this.fetchOptions,
@@ -206,11 +205,8 @@ export default {
})
},
async submit () {
const resp = await fetch(this.baseUrl + '/api/identity_verification', {
const resp = await fetch(this.baseUrl + `/s/${this.submitterSlug}/identity_verification`, {
method: 'POST',
body: JSON.stringify({
submitter_slug: this.submitterSlug
}),
...this.fetchOptions,
headers: { 'Content-Type': 'application/json', ...this.fetchOptions.headers }
})
+1 -3
View File
@@ -319,7 +319,7 @@ export default {
default: false
}
},
emits: ['start-resize', 'stop-resize', 'start-drag', 'stop-drag', 'remove', 'scroll-to', 'add-custom-field', 'click-title', 'multi-select'],
emits: ['start-resize', 'stop-resize', 'start-drag', 'stop-drag', 'remove', 'scroll-to', 'add-custom-field', 'click-title'],
data () {
return {
isContenteditable: false,
@@ -798,8 +798,6 @@ export default {
this.selectedAreasRef.value.splice(this.selectedAreasRef.value.indexOf(this.area), 1)
}
this.$emit('multi-select', e)
return
}
@@ -272,7 +272,7 @@ export default {
FieldSubmitter,
IconX
},
inject: ['t', 'isMobile', 'nativePlatform'],
inject: ['t', 'nativePlatform'],
props: {
template: {
type: Object,
+135 -51
View File
@@ -3,8 +3,10 @@
ref="dragContainer"
style="max-width: 1600px"
:class="nativeView ? (isSheetView ? 'bg-base-100' : 'px-4') : 'mx-auto pl-3 md:pl-4 h-full'"
@dragstart="isInternalDrag = true"
@dragend="isInternalDrag = false"
@dragover="onDragover"
@drop="isDragFile = false"
@drop="[isDragFile = false, isInternalDrag = false]"
>
<HoverDropzone
v-if="sortedDocuments.length && withUploadButton && editable && !isSheetView"
@@ -906,7 +908,7 @@
<button
type="button"
class="join-item bg-base-content text-white h-9 pl-2 pr-3 inline-flex items-center justify-center cursor-pointer hover:opacity-90 border-l border-white/20"
@click="zoomLevel = 1"
@click="resetZoomLevel"
>
<IconX class="w-4 h-4 stroke-2" />
</button>
@@ -1007,6 +1009,9 @@ export default {
return {
template: this.template,
save: this.save,
pushUndo: this.pushUndo,
undo: this.undo,
redo: this.redo,
t: this.t,
assignDropAreaSize: this.assignDropAreaSize,
currencies: this.currencies,
@@ -1387,6 +1392,7 @@ export default {
drawOption: null,
dragField: null,
isDragFile: false,
isInternalDrag: false,
isMathLoaded: false,
isRevisionsModalOpen: false,
isDocumentsDrawerOpen: false,
@@ -1640,18 +1646,20 @@ export default {
this.selectedSubmitter = this.template.submitters[0]
},
mounted () {
this.undoStack = [JSON.stringify(this.template)]
this.undoStack = []
this.redoStack = []
this.undoStack.push(this.buildUndoData())
this.$nextTick(() => {
this.onWindowResize()
this.resizeObserver = new ResizeObserver(this.onResize)
this.resizeObserver.observe(this.$el.getRootNode().querySelector('div[data-v-app]'))
})
document.addEventListener('keyup', this.onKeyUp)
document.addEventListener('template-builder:apply-revision', this.onApplyRevisionEvent)
window.addEventListener('keydown', this.onKeyDown)
window.addEventListener('resize', this.onWindowResize)
window.addEventListener('dragleave', this.onWindowDragLeave)
this.$nextTick(() => {
@@ -1675,7 +1683,7 @@ export default {
document.removeEventListener('template-builder:apply-revision', this.onApplyRevisionEvent)
window.removeEventListener('keydown', this.onKeyDown)
window.removeEventListener('resize', this.onWindowResize)
this.resizeObserver.disconnect()
window.removeEventListener('dragleave', this.onWindowDragLeave)
},
beforeUpdate () {
@@ -2034,8 +2042,18 @@ export default {
ref.x = e.clientX - ref.offsetX
ref.y = e.clientY - ref.offsetY
} else if (e.dataTransfer?.types?.includes('Files') && !this.editModalDocumentUuid) {
this.isDragFile = true
} else if (e.dataTransfer?.types?.includes('Files') && !this.editModalDocumentUuid && !this.isInternalDrag) {
const items = Array.from(e.dataTransfer.items)
if (this.dynamicDocuments.length && items.length && items.every((item) => item.type.startsWith('image/'))) {
if (!e.defaultPrevented) {
e.preventDefault()
e.dataTransfer.dropEffect = 'none'
}
} else {
this.isDragFile = true
}
}
},
onWindowDragLeave (event) {
@@ -2287,6 +2305,8 @@ export default {
const dynamicDocumentRefs = this.documentRefs.filter((ref) => ref.isDynamic)
this.pushUndo()
dynamicDocumentRefs.forEach((ref) => ref.update())
this.rebuildVariablesSchema({ disable: false })
@@ -2414,42 +2434,85 @@ export default {
this.drawOption = null
},
undo () {
this.selectedAreasRef.value = []
if (this.undoStack.length > 1) {
this.undoStack.pop()
const stringData = this.undoStack[this.undoStack.length - 1]
const currentStringData = JSON.stringify(this.template)
const undoData = this.undoStack[this.undoStack.length - 1]
const currentUndoData = this.buildUndoData()
if (stringData && stringData !== currentStringData) {
this.redoStack.push(currentStringData)
if (!this.isSameUndoData(undoData, currentUndoData)) {
this.redoStack.push(currentUndoData)
Object.assign(this.template, JSON.parse(stringData))
this.save()
this.applyUndoData(undoData, currentUndoData)
}
}
},
redo () {
const stringData = this.redoStack.pop()
this.lastRedoData = stringData
const currentStringData = JSON.stringify(this.template)
this.selectedAreasRef.value = []
if (stringData && stringData !== currentStringData) {
if (this.undoStack[this.undoStack.length - 1] !== currentStringData) {
this.undoStack.push(currentStringData)
const undoData = this.redoStack.pop()
const currentUndoData = this.buildUndoData()
if (undoData && !this.isSameUndoData(undoData, currentUndoData)) {
if (!this.isSameUndoData(this.undoStack[this.undoStack.length - 1], currentUndoData)) {
this.undoStack.push(currentUndoData)
}
Object.assign(this.template, JSON.parse(stringData))
this.undoStack.push(undoData)
this.save()
this.applyUndoData(undoData, currentUndoData)
}
},
onWindowResize (e) {
buildUndoData () {
const template = JSON.stringify(toRaw(this.template))
const lastUndoData = this.undoStack[this.undoStack.length - 1]
return { template: template === lastUndoData?.template ? lastUndoData.template : template, sectionDocs: this.buildSectionDocs() }
},
buildSectionDocs () {
return new Map(this.documentRefs.flatMap((ref) => ref.isDynamic ? ref.getSectionDocs() : []))
},
isSameUndoData (undoData, currentUndoData) {
return undoData.template === currentUndoData.template && this.isSameSectionDocs(undoData, currentUndoData)
},
isSameSectionDocs (undoData, currentUndoData) {
return [...currentUndoData.sectionDocs].every(([sectionKey, doc]) => undoData.sectionDocs.get(sectionKey)?.eq(doc))
},
applyUndoData (undoData, currentUndoData) {
const isTemplateChanged = undoData.template !== currentUndoData.template
if (isTemplateChanged) {
Object.assign(this.template, JSON.parse(undoData.template))
}
this.documentRefs.forEach((ref) => {
if (ref.isDynamic) ref.restoreSectionDocs(undoData.sectionDocs)
})
this.buildSectionDocs().forEach((doc, sectionKey) => undoData.sectionDocs.set(sectionKey, doc))
if (isTemplateChanged) {
this.save()
this.$nextTick(this.syncUndoTemplate)
}
},
syncUndoTemplate () {
const lastUndoData = this.undoStack[this.undoStack.length - 1]
const template = JSON.stringify(toRaw(this.template))
if (template !== lastUndoData.template) lastUndoData.template = template
},
onResize () {
const breakpointLg = 1024
const breakpointMd = 768
const width = this.$el.getRootNode().querySelector('div[data-v-app]').offsetWidth
this.isBreakpointLg = width < breakpointLg
this.isBreakpointMd = width < breakpointMd
if (width) {
this.isBreakpointLg = width < breakpointLg
this.isBreakpointMd = width < breakpointMd
}
},
onPagesWheel (event) {
const isFastScroll = Math.abs(event.deltaY) > 1
@@ -2462,23 +2525,33 @@ export default {
event.preventDefault()
const oldZoom = this.zoomLevel
const nextZoom = Math.max(1, Math.min(3, oldZoom - event.deltaY * 0.006))
if (nextZoom === oldZoom) return
this.setZoomLevel(this.zoomLevel - event.deltaY * 0.006, event.clientX, event.clientY)
},
resetZoomLevel () {
const rect = this.$refs.pagesContainer.getBoundingClientRect()
const cursorX = event.clientX - rect.left
const cursorY = event.clientY - rect.top
const ratio = nextZoom / oldZoom
const nextScrollLeft = (this.$refs.pagesContainer.scrollLeft + cursorX) * ratio - cursorX
const nextScrollTop = (this.$refs.pagesContainer.scrollTop + cursorY) * ratio - cursorY
this.setZoomLevel(1, rect.left + rect.width / 2, (Math.max(rect.top, 0) + Math.min(rect.bottom, window.innerHeight)) / 2)
},
setZoomLevel (zoomLevel, clientX, clientY) {
const nextZoom = Math.max(1, Math.min(3, zoomLevel))
if (nextZoom === this.zoomLevel) return
const container = this.$refs.pagesContainer
const rect = this.$refs.documents.getBoundingClientRect()
this.zoomLevel = nextZoom
this.$nextTick(() => {
this.$refs.pagesContainer.scrollLeft = nextScrollLeft
this.$refs.pagesContainer.scrollTop = nextScrollTop
this.documentRefs.filter((ref) => ref.isDynamic).forEach((ref) => ref.updateContainerWidth())
this.$nextTick(() => {
const nextRect = this.$refs.documents.getBoundingClientRect()
const scrollContainer = container.scrollHeight > container.clientHeight ? container : document.scrollingElement
container.scrollLeft += nextRect.left - clientX + (clientX - rect.left) * nextRect.width / rect.width
scrollContainer.scrollTop += nextRect.top - clientY + (clientY - rect.top) * nextRect.height / rect.height
})
})
},
setDocumentRefs (el) {
@@ -2597,15 +2670,11 @@ export default {
event.stopImmediatePropagation()
event.preventDefault()
this.selectedAreasRef.value = []
this.redo()
} else if ((event.ctrlKey || event.metaKey) && event.key === 'z') {
event.stopImmediatePropagation()
event.preventDefault()
this.selectedAreasRef.value = []
this.undo()
} else if ((event.ctrlKey || event.metaKey) && event.key === 'c' && document.activeElement === document.body) {
if (this.selectedAreasRef.value.length > 1) {
@@ -2980,16 +3049,29 @@ export default {
return false
}
},
pushUndo () {
const stringData = JSON.stringify(this.template)
pushUndo ({ amend = false } = {}) {
const undoData = this.buildUndoData()
const lastUndoData = this.undoStack[this.undoStack.length - 1]
const isTemplateChanged = undoData.template !== lastUndoData.template
const isSectionDocsChanged = !this.isSameSectionDocs(lastUndoData, undoData)
if (this.undoStack[this.undoStack.length - 1] !== stringData) {
this.undoStack.push(stringData)
if (this.isUndoBatch || (amend && (!isSectionDocsChanged || (!this.redoStack.length && !isTemplateChanged)))) {
this.undoStack[this.undoStack.length - 1] = undoData
} else if (isTemplateChanged || isSectionDocsChanged) {
this.undoStack.push(undoData)
if (this.lastRedoData !== stringData) {
this.redoStack = []
}
this.redoStack = []
} else {
return
}
this.isUndoBatch = true
this.$nextTick(() => {
this.isUndoBatch = false
this.syncUndoTemplate()
})
},
setDefaultAreaSize (area, type) {
const documentRef = this.documentRefs.find((e) => e.document.uuid === area.attachment_uuid)
@@ -3959,7 +4041,7 @@ export default {
this.$nextTick(() => {
if (this.$el.closest('template-builder')) {
this.$el.closest('template-builder').dataset.template = JSON.stringify(this.template)
this.$el.closest('template-builder').dataset.template = JSON.stringify(toRaw(this.template))
}
})
@@ -3992,10 +4074,12 @@ export default {
this.$nextTick(() => {
if (this.$el.closest('template-builder')) {
this.$el.closest('template-builder').dataset.dynamicDocuments = JSON.stringify(this.dynamicDocuments)
this.$el.closest('template-builder').dataset.dynamicDocuments = JSON.stringify(toRaw(this.dynamicDocuments))
}
})
this.pushUndo({ amend: true })
this.save()
},
rebuildVariablesSchema ({ disable = true } = {}) {
@@ -0,0 +1,177 @@
<template>
<div
class="absolute z-[5] pointer-events-none select-none"
:style="{ top: coords.top + 'px', left: coords.left + 'px', width: coords.width + 'px', height: coords.height + 'px' }"
>
<div
class="absolute left-0 bottom-0"
:style="{ top: Math.max(coords.top - toolbarHeight - 2, 0) - coords.top + 'px' }"
>
<div
ref="toolbar"
class="sticky top-1.5 flex flex-col sm:flex-row gap-0.5 p-1 bg-white border border-neutral-200 rounded-lg shadow-lg pointer-events-auto"
@mousedown.prevent
>
<div
v-for="(row, rowIndex) in actionRows"
:key="rowIndex"
class="flex items-center gap-0.5"
>
<template
v-for="(group, groupIndex) in row"
:key="groupIndex"
>
<div
v-if="rowIndex > 0 || groupIndex > 0"
class="w-px h-5 bg-neutral-200 mx-1"
:class="{ 'hidden sm:block': rowIndex > 0 && groupIndex === 0 }"
/>
<button
v-for="action in group"
:key="action.title"
class="inline-flex items-center justify-center w-7 h-7 rounded-md"
:class="{ 'text-red-600': action.danger, 'bg-neutral-200': action.active, 'hover:bg-neutral-100': !action.active && !isDisabled(action), 'opacity-50 cursor-not-allowed': isDisabled(action) }"
:title="action.structural && !coords.isRegular ? t('irregular_table_structure') : t(action.title)"
:disabled="isDisabled(action)"
@click="$emit('command', action.command, action.arg)"
>
<component
:is="action.icon"
:width="16"
:height="16"
:stroke-width="1.6"
/>
</button>
</template>
</div>
</div>
</div>
<div
v-if="size"
class="absolute top-0 left-0 border border-dashed border-blue-500"
:style="{ width: size.width + 'px', height: size.height + 'px' }"
/>
<div
v-if="coords.type === 'table'"
class="absolute w-3 h-3 bg-white border-2 border-blue-500 rounded-sm pointer-events-auto cursor-nwse-resize touch-none before:absolute before:-inset-3"
:style="{ left: (size?.width ?? coords.width) - 6 + 'px', top: (size?.height ?? coords.height) - 6 + 'px' }"
:title="t('resize')"
@pointerdown.prevent.stop="startResize"
/>
</div>
</template>
<script>
import { h } from 'vue'
import { IconAlignLeft, IconAlignCenter, IconAlignRight, IconRowInsertTop, IconRowInsertBottom, IconColumnInsertLeft, IconColumnInsertRight, IconRowRemove, IconColumnRemove, IconTrash } from '@tabler/icons-vue'
const IconMergeCells = (props) => h('svg', { xmlns: 'http://www.w3.org/2000/svg', viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', 'stroke-linecap': 'round', 'stroke-linejoin': 'round', ...props }, [
h('path', { d: 'M5 4h14a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2v-12a2 2 0 0 1 2 -2z' }),
h('path', { d: 'M6 12h4m-2 -2l2 2l-2 2' }),
h('path', { d: 'M18 12h-4m2 -2l-2 2l2 2' })
])
const IconSplitCell = (props) => h('svg', { xmlns: 'http://www.w3.org/2000/svg', viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', 'stroke-linecap': 'round', 'stroke-linejoin': 'round', ...props }, [
h('path', { d: 'M5 4h14a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2v-12a2 2 0 0 1 2 -2z' }),
h('path', { d: 'M12 4v16' }),
h('path', { d: 'M10 12h-4m2 -2l-2 2l2 2' }),
h('path', { d: 'M14 12h4m-2 -2l2 2l-2 2' })
])
export default {
name: 'DynamicBlockMenu',
inject: ['t'],
props: {
coords: {
type: Object,
required: true
},
getResizeLimits: {
type: Function,
required: true
}
},
emits: ['command', 'resize'],
data () {
return {
size: null,
toolbarHeight: 0
}
},
computed: {
actionRows () {
const alignActions = [
{ command: 'setBlockAlign', arg: 'left', icon: IconAlignLeft, title: 'align_left', active: this.coords.align === 'left' },
{ command: 'setBlockAlign', arg: 'center', icon: IconAlignCenter, title: 'align_center', active: this.coords.align === 'center' },
{ command: 'setBlockAlign', arg: 'right', icon: IconAlignRight, title: 'align_right', active: this.coords.align === 'right' }
]
if (this.coords.type !== 'table') {
return [[alignActions, [{ command: 'deleteSelection', icon: IconTrash, title: 'delete_image', danger: true }]]]
}
return [
[
alignActions,
[
{ command: 'addTableRow', arg: false, icon: IconRowInsertTop, title: 'insert_row_above', structural: true },
{ command: 'addTableRow', arg: true, icon: IconRowInsertBottom, title: 'insert_row_below', structural: true },
{ command: 'addTableColumn', arg: false, icon: IconColumnInsertLeft, title: 'insert_column_left', structural: true },
{ command: 'addTableColumn', arg: true, icon: IconColumnInsertRight, title: 'insert_column_right', structural: true }
]
],
[
[
{ command: 'mergeTableCells', icon: IconMergeCells, title: 'merge_cells', structural: true, disabled: !this.coords.canMerge },
{ command: 'splitTableCell', icon: IconSplitCell, title: 'split_cell', structural: true, disabled: !this.coords.canSplit }
],
[
{ command: 'deleteTableRow', icon: IconRowRemove, title: 'delete_row', danger: true, structural: true },
{ command: 'deleteTableColumn', icon: IconColumnRemove, title: 'delete_column', danger: true, structural: true },
{ command: 'deleteTable', icon: IconTrash, title: 'delete_table', danger: true }
]
]
]
}
},
mounted () {
this.toolbarHeight = this.$refs.toolbar.offsetHeight
},
updated () {
this.toolbarHeight = this.$refs.toolbar.offsetHeight
},
beforeUnmount () {
window.removeEventListener('pointermove', this.onResizeMove)
window.removeEventListener('pointerup', this.onResizeEnd)
},
methods: {
isDisabled (action) {
return (action.structural && !this.coords.isRegular) || action.disabled
},
startResize (event) {
this.resizeStart = { x: event.clientX, y: event.clientY, width: this.coords.width, height: this.coords.height, ...this.getResizeLimits() }
this.size = { width: this.coords.width, height: this.coords.height }
window.addEventListener('pointermove', this.onResizeMove)
window.addEventListener('pointerup', this.onResizeEnd)
},
onResizeMove (event) {
const { x, y, width, height, minHeight, maxWidth } = this.resizeStart
this.size = {
width: Math.min(Math.max(width + event.clientX - x, 20), maxWidth),
height: Math.max(height + event.clientY - y, minHeight)
}
},
onResizeEnd () {
window.removeEventListener('pointermove', this.onResizeMove)
window.removeEventListener('pointerup', this.onResizeEnd)
if (this.size.width !== this.coords.width || this.size.height !== this.coords.height) {
this.$emit('resize', this.size)
}
this.size = null
}
}
}
</script>
@@ -22,6 +22,12 @@
v-if="shadow"
:to="shadow"
>
<component
:is="'style'"
v-if="listsStyle"
>
{{ listsStyle }}
</component>
<DynamicSection
v-for="section in sections"
:ref="setSectionRefs"
@@ -44,15 +50,17 @@
@draw="$emit('draw', $event)"
@add-custom-field="$emit('add-custom-field', $event)"
@set-draw="$emit('set-draw', $event)"
@add-attachment="document.attachments.push($event)"
@add-list="ensureListsStyle"
/>
</Teleport>
</div>
</template>
<script>
import { ref } from 'vue'
import { ref, computed } from 'vue'
import DynamicSection from './dynamic_section.vue'
import { dynamicStylesheet, tiptapStylesheet } from './dynamic_editor.js'
import { dynamicStylesheet, tiptapStylesheet, listsCss } from './dynamic_editor.js'
import { buildVariablesSchema, mergeSchemaProperties } from './dynamic_variables_schema.js'
export default {
@@ -61,6 +69,11 @@ export default {
DynamicSection
},
inject: ['baseFetch', 'template'],
provide () {
return {
documentFonts: computed(() => this.documentFonts)
}
},
props: {
document: {
type: Object,
@@ -107,6 +120,7 @@ export default {
return {
containerWidth: 1040,
isMounted: false,
listsStyle: null,
sectionRefs: []
}
},
@@ -134,6 +148,40 @@ export default {
styles () {
return this.headDom.querySelectorAll('style')
},
documentFonts () {
const fonts = []
const firstFamily = (value) => value.split(',')[0].trim().replace(/^["']|["']$/g, '')
const add = (label, value) => {
if (label && !fonts.some((font) => font.label === label)) fonts.push({ label, value })
}
this.styles.forEach((style) => {
const css = style.textContent
for (const type of ['minor', 'major']) {
for (const [, name, value] of css.matchAll(new RegExp(`(--[\\w-]+-${type}HAnsi-font):\\s*([^;}]+)`, 'g'))) {
add(firstFamily(value), `var(${name})`)
}
}
for (const [, value] of css.matchAll(/@font-face\s*\{[^}]*?font-family:\s*([^;}]+)/g)) {
add(firstFamily(value), value.trim())
}
})
const skipFamilies = ['symbol', 'wingdings', 'wingdings 2', 'wingdings 3', 'webdings', 'serif', 'sans-serif', 'monospace', 'inherit', 'initial']
const addUsed = (value) => {
if (value && !value.startsWith('var(') && !skipFamilies.includes(firstFamily(value).toLowerCase())) add(firstFamily(value), value.trim())
}
this.styles.forEach((style) => {
for (const [, value] of style.textContent.replace(/@font-face\s*\{[^}]*\}/g, '').matchAll(/font-family:\s*([^;}]+)/g)) addUsed(value)
})
this.bodyDom.querySelectorAll('[style*="font-family"]').forEach((el) => addUsed(el.style.fontFamily))
return fonts
},
shadow () {
if (this.isMounted) {
return this.$refs.shadow.attachShadow({ mode: 'open' })
@@ -142,16 +190,21 @@ export default {
}
}
},
created () {
this.updatedSectionEditors = new Map()
this.loadListsStyle()
},
mounted () {
this.isMounted = true
this.shadow.adoptedStyleSheets.push(dynamicStylesheet, tiptapStylesheet)
this.containerWidth = this.$refs.container.clientWidth
this.updateContainerWidth()
this.resizeObserver = new ResizeObserver(() => {
if (this.$refs.container) {
this.containerWidth = this.$refs.container.clientWidth
this.updateContainerWidth()
}
})
@@ -169,6 +222,9 @@ export default {
},
methods: {
mergeSchemaProperties,
updateContainerWidth () {
this.containerWidth = this.$refs.container.clientWidth
},
removeArea (area) {
this.sectionRefs.forEach((sectionRef) => {
const pos = sectionRef.findAreaNodePos(area.uuid)
@@ -184,8 +240,18 @@ export default {
}
},
reloadContent () {
this.loadListsStyle()
this.sectionRefs.forEach((ref) => ref.reloadContent())
},
getSectionDocs () {
return this.sectionRefs.map((ref) => [ref.sectionKey, ref.editor.state.doc])
},
restoreSectionDocs (sectionDocs) {
this.sectionRefs.forEach((ref) => {
if (sectionDocs.has(ref.sectionKey)) ref.restoreDoc(sectionDocs.get(ref.sectionKey))
})
},
onBeforeUnload (event) {
if (this.saveTimer) {
event.preventDefault()
@@ -211,21 +277,38 @@ export default {
onSectionUpdate (section, { editor }) {
clearTimeout(this.saveTimer)
this.saveTimer = setTimeout(async () => {
await this.updateSectionAndSave(section, editor)
this.updatedSectionEditors.set(section.id, editor)
delete this.saveTimer
}, 1000)
this.saveTimer = setTimeout(() => this.updateSectionsAndSave(), 1000)
},
loadListsStyle () {
this.listsStyle = this.bodyDom.querySelector('body > style[data-lists]')?.textContent || null
},
ensureListsStyle () {
if (this.listsStyle) return
const style = this.bodyDom.createElement('style')
style.dataset.lists = ''
style.textContent = listsCss
this.bodyDom.body.append(style)
this.listsStyle = listsCss
},
updateVariablesSchema () {
this.document.variables_schema = buildVariablesSchema(this.bodyDom.body)
},
updateSectionAndSave (section, editor) {
const target = this.bodyDom.getElementById(section.id)
updateSectionsAndSave () {
this.updatedSectionEditors.forEach((editor, sectionId) => {
const target = this.bodyDom.getElementById(sectionId)
if (target) {
target.innerHTML = this.getHtmlForSave(editor)
}
if (target) {
target.innerHTML = this.getHtmlForSave(editor)
}
})
this.updatedSectionEditors.clear()
this.document.body = this.bodyDom.body.innerHTML
@@ -245,6 +328,8 @@ export default {
delete this.saveTimer
this.updatedSectionEditors.clear()
this.sectionRefs.forEach(({ section, editor }) => {
const target = this.bodyDom.getElementById(section.id)
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,205 @@
<template>
<div class="absolute top-7 left-0 min-w-36 p-1 bg-white border border-neutral-200 rounded-lg shadow-lg">
<div
class="relative"
@pointerenter="$event.pointerType === 'mouse' ? submenu = 'field' : null"
>
<button
class="flex items-center gap-2 w-full px-2 py-1 rounded-md"
:class="{ 'bg-neutral-100': submenu === 'field' }"
@click="submenu = submenu === 'field' ? null : 'field'"
>
<IconForms
:width="16"
:height="16"
:stroke-width="1.6"
/>
{{ t('field') }}
<IconChevronRight
:width="14"
:height="14"
class="ml-auto"
/>
</button>
<div
v-if="submenu === 'field'"
class="absolute top-0 left-full min-w-40 p-1 bg-white border border-neutral-200 rounded-lg shadow-lg"
>
<button
v-for="(icon, type) in fieldTypesList"
:key="type"
class="flex items-center gap-2 w-full px-2 py-1 rounded-md hover:bg-neutral-100"
@click="[$emit('insert-field', type), $emit('close')]"
>
<component
:is="icon"
:width="16"
:height="16"
:stroke-width="1.6"
/>
{{ fieldNames[type] }}
</button>
</div>
</div>
<div
class="relative"
@pointerenter="$event.pointerType === 'mouse' ? submenu = 'table' : null"
>
<button
class="flex items-center gap-2 w-full px-2 py-1 rounded-md"
:class="{ 'bg-neutral-100': submenu === 'table' }"
@click="submenu = submenu === 'table' ? null : 'table'"
>
<IconTable
:width="16"
:height="16"
:stroke-width="1.6"
/>
{{ t('table') }}
<IconChevronRight
:width="14"
:height="14"
class="ml-auto"
/>
</button>
<div
v-if="submenu === 'table'"
class="absolute top-0 left-full w-max p-2 bg-white border border-neutral-200 rounded-lg shadow-lg"
>
<div
v-if="!isMobile"
class="grid grid-cols-8 gap-0.5 mb-2"
>
<template
v-for="row in 8"
:key="row"
>
<button
v-for="col in 8"
:key="col"
class="w-4 h-4 border rounded-sm"
:class="row <= tableSize.rows && col <= tableSize.cols ? 'bg-neutral-200 border-neutral-400' : 'bg-white border-neutral-200'"
:aria-label="`${row} × ${col}`"
@pointerenter="$event.pointerType === 'mouse' ? tableSize = { rows: row, cols: col } : null"
@click="[$emit('insert-table', { rows: row, cols: col }), $emit('close')]"
/>
</template>
</div>
<div class="flex items-center justify-center gap-1 text-xs">
<template
v-for="key in ['rows', 'cols']"
:key="key"
>
<span v-if="key === 'cols'">×</span>
<div class="flex items-center border border-neutral-200 rounded-md">
<button
class="inline-flex items-center justify-center w-6 h-6 rounded-md hover:bg-neutral-100"
:aria-label="t(`decrease_${key}`)"
@click="tableSize[key] = Math.max(tableSize[key] - 1, 1)"
>
<IconMinus
:width="12"
:height="12"
/>
</button>
<span class="w-5 text-center">{{ tableSize[key] }}</span>
<button
class="inline-flex items-center justify-center w-6 h-6 rounded-md hover:bg-neutral-100"
:aria-label="t(`increase_${key}`)"
@click="tableSize[key] = Math.min(tableSize[key] + 1, 20)"
>
<IconPlus
:width="12"
:height="12"
/>
</button>
</div>
</template>
</div>
<button
v-if="isMobile || tableSize.rows > 8 || tableSize.cols > 8"
class="w-full mt-2 px-2 py-1 rounded-md bg-neutral-100 hover:bg-neutral-200"
@click="[$emit('insert-table', { ...tableSize }), $emit('close')]"
>
{{ t('insert') }}
</button>
</div>
</div>
<button
class="flex items-center gap-2 w-full px-2 py-1 rounded-md hover:bg-neutral-100"
@mouseenter="submenu = null"
@click="[$emit('insert-variable'), $emit('close')]"
>
<IconBracketsContain
:width="16"
:height="16"
:stroke-width="1.6"
/>
{{ t('variable') }}
</button>
<button
class="flex items-center gap-2 w-full px-2 py-1 rounded-md hover:bg-neutral-100"
@mouseenter="submenu = null"
@click="[$emit('insert-image'), $emit('close')]"
>
<IconPhoto
:width="16"
:height="16"
:stroke-width="1.6"
/>
{{ t('image') }}
</button>
</div>
</template>
<script>
import { IconPlus, IconMinus, IconForms, IconTable, IconPhoto, IconChevronRight, IconBracketsContain } from '@tabler/icons-vue'
import FieldType from './field_type.vue'
export default {
name: 'DynamicInsertMenu',
components: {
IconPlus,
IconMinus,
IconForms,
IconTable,
IconPhoto,
IconChevronRight,
IconBracketsContain
},
inject: ['withPhone', 'withPayment', 'withVerification', 'withKba', 't', 'fieldTypes', 'isMobile'],
emits: ['insert-field', 'insert-table', 'insert-variable', 'insert-image', 'close'],
data () {
return {
submenu: null,
tableSize: { rows: 3, cols: 3 }
}
},
computed: {
fieldNames: FieldType.computed.fieldNames,
fieldIcons: FieldType.computed.fieldIcons,
skipTypes: FieldType.computed.skipTypes,
fieldIconsSorted: FieldType.computed.fieldIconsSorted,
fieldTypesList () {
return Object.fromEntries(Object.entries(this.fieldIconsSorted).filter(([type]) => {
return this.fieldTypes.includes(type) || ((this.withPhone || type !== 'phone') && (this.withPayment || type !== 'payment') && (this.withVerification || type !== 'verification') && (this.withKba || type !== 'kba'))
}))
}
},
mounted () {
document.addEventListener('mousedown', this.onDocumentMouseDown)
},
beforeUnmount () {
document.removeEventListener('mousedown', this.onDocumentMouseDown)
this.$emit('close')
},
methods: {
onDocumentMouseDown (event) {
if (!event.composedPath().includes(this.$el.parentElement)) {
this.$emit('close')
}
}
}
}
</script>
+575 -109
View File
@@ -1,107 +1,360 @@
<template>
<div
v-if="visible"
class="absolute z-10 flex items-center gap-0.5 px-1.5 py-1 bg-white border border-base-300 rounded-lg shadow select-none"
:style="{ top: (coords.top - 42) + 'px', left: coords.left + 'px', transform: 'translateX(-50%)' }"
ref="menu"
class="absolute z-10 flex flex-col gap-1 p-1 bg-white border border-neutral-200 rounded-lg shadow-lg select-none text-sm"
:style="{ top: coords.top + 'px', left: menuLeft + 'px' }"
@mousedown.prevent
>
<button
class="inline-flex items-center justify-center w-7 h-7 border-none rounded cursor-pointer text-gray-700"
:class="isBold ? 'bg-base-200' : 'bg-transparent'"
title="Bold"
@click="toggleBold"
>
<IconBold
:width="16"
:height="16"
/>
</button>
<button
class="inline-flex items-center justify-center w-7 h-7 border-none rounded cursor-pointer text-gray-700"
:class="isItalic ? 'bg-base-200' : 'bg-transparent'"
title="Italic"
@click="toggleItalic"
>
<IconItalic
:width="16"
:height="16"
/>
</button>
<button
class="inline-flex items-center justify-center w-7 h-7 border-none rounded cursor-pointer text-gray-700"
:class="isUnderline ? 'bg-base-200' : 'bg-transparent'"
title="Underline"
@click="toggleUnderline"
>
<IconUnderline
:width="16"
:height="16"
/>
</button>
<button
class="inline-flex items-center justify-center w-7 h-7 border-none rounded cursor-pointer text-gray-700"
:class="isStrike ? 'bg-base-200' : 'bg-transparent'"
title="Strikethrough"
@click="toggleStrike"
>
<IconStrikethrough
:width="16"
:height="16"
/>
</button>
<div class="w-px h-5 bg-base-300 mx-1" />
<button
class="inline-flex items-center justify-center text-xs h-7 border-none rounded cursor-pointer text-gray-700 bg-transparent"
title="Wrap in variable"
@click="wrapVariable"
>
<IconBracketsContain
:width="16"
:height="16"
:stroke-width="1.6"
/>
<span class="px-0.5">
Variable
</span>
</button>
<div class="w-px h-5 bg-base-300 mx-1" />
<button
class="inline-flex items-center justify-center text-xs h-7 border-none rounded cursor-pointer text-gray-700 bg-transparent"
title="Wrap in condition"
@click="wrapCondition"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.6"
stroke-linecap="round"
stroke-linejoin="round"
class="tabler-icon tabler-icon-brackets-contain"
><path d="M7 4h-4v16h4" /><path d="M17 4h4v16h-4" />
<text
x="12"
y="16.5"
text-anchor="middle"
fill="currentColor"
stroke="none"
font-size="14"
font-weight="600"
font-family="ui-sans-serif, system-ui, sans-serif"
>if</text>
</svg>
<span class="px-0.5">
Condition
</span>
</button>
<div class="flex items-center gap-0.5">
<div class="relative">
<button
class="flex items-center justify-between gap-1 w-24 sm:w-36 h-7 px-2 border border-neutral-200 rounded-md hover:bg-neutral-100"
:title="t('font')"
@click="toggleDropdown('font')"
>
<span
class="truncate"
:style="{ fontFamily }"
>{{ fontFamily }}</span>
<IconChevronDown
:width="14"
:height="14"
/>
</button>
<div
v-if="dropdown === 'font'"
ref="dropdown"
:class="isDropdownAlignedRight ? 'right-0' : 'left-0'"
class="absolute z-10 top-full mt-1 w-48 p-1 bg-white border border-neutral-200 rounded-lg shadow-lg max-h-64 overflow-y-auto"
>
<button
v-for="font in fonts"
:key="font.label"
class="block w-full text-left px-2 py-1 rounded-md truncate"
:class="font.label === fontFamily ? 'bg-neutral-200' : 'hover:bg-neutral-100'"
:style="{ fontFamily: font.value }"
@click="setSpanStyle('font-family', font.value)"
>
{{ font.label }}
</button>
</div>
</div>
<div class="relative">
<button
class="flex items-center justify-between gap-1 w-12 sm:w-16 h-7 px-2 border border-neutral-200 rounded-md hover:bg-neutral-100"
:title="t('font_size')"
@click="toggleDropdown('size')"
>
<span>{{ fontSize }}</span>
<IconChevronDown
:width="14"
:height="14"
/>
</button>
<div
v-if="dropdown === 'size'"
ref="dropdown"
:class="isDropdownAlignedRight ? 'right-0' : 'left-0'"
class="absolute z-10 top-full mt-1 w-16 p-1 bg-white border border-neutral-200 rounded-lg shadow-lg max-h-64 overflow-y-auto"
>
<button
v-for="size in fontSizes"
:key="size"
class="block w-full text-left px-2 py-1 rounded-md"
:class="size === fontSize ? 'bg-neutral-200' : 'hover:bg-neutral-100'"
@click="setFontSize(size)"
>
{{ size }}
</button>
</div>
</div>
<button
class="inline-flex items-center justify-center w-7 h-7 rounded-md hover:bg-neutral-100"
:title="t('increase_font_size')"
@click="setFontSize(fontSizes.find((size) => size > fontSize) || fontSize)"
>
<IconTextIncrease
:width="16"
:height="16"
/>
</button>
<button
class="inline-flex items-center justify-center w-7 h-7 rounded-md hover:bg-neutral-100"
:title="t('decrease_font_size')"
@click="setFontSize([...fontSizes].reverse().find((size) => size < fontSize) || fontSize)"
>
<IconTextDecrease
:width="16"
:height="16"
/>
</button>
<div class="w-px h-5 bg-neutral-200 mx-1" />
<div class="relative">
<button
class="inline-flex items-center justify-center h-7 px-1 rounded-md hover:bg-neutral-100"
:title="t('line_spacing')"
@click="toggleDropdown('lineHeight')"
>
<IconLineHeight
:width="16"
:height="16"
/>
<IconChevronDown
:width="12"
:height="12"
/>
</button>
<div
v-if="dropdown === 'lineHeight'"
ref="dropdown"
:class="isDropdownAlignedRight ? 'right-0' : 'left-0'"
class="absolute z-10 top-full mt-1 w-20 p-1 bg-white border border-neutral-200 rounded-lg shadow-lg"
>
<button
v-for="value in lineHeights"
:key="value"
class="block w-full text-left px-2 py-1 rounded-md"
:class="value === lineHeight ? 'bg-neutral-200' : 'hover:bg-neutral-100'"
@click="setBlockStyle('line-height', `calc(${value} * 1.15)`)"
>
{{ value }}
</button>
</div>
</div>
<div class="relative">
<button
class="inline-flex items-center justify-center h-7 px-1 rounded-md hover:bg-neutral-100"
:title="t('align')"
@click="toggleDropdown('align')"
>
<component
:is="alignIcons[textAlign]"
:width="16"
:height="16"
/>
<IconChevronDown
:width="12"
:height="12"
/>
</button>
<div
v-if="dropdown === 'align'"
ref="dropdown"
:class="isDropdownAlignedRight ? 'right-0' : 'left-0'"
class="absolute z-10 top-full mt-1 w-max flex gap-0.5 p-1 bg-white border border-neutral-200 rounded-lg shadow-lg"
>
<button
v-for="(icon, align) in alignIcons"
:key="align"
class="inline-flex items-center justify-center w-7 h-7 rounded-md"
:class="align === textAlign ? 'bg-neutral-200' : 'hover:bg-neutral-100'"
:title="t(align)"
@click="setBlockStyle('text-align', align)"
>
<component
:is="icon"
:width="16"
:height="16"
/>
</button>
</div>
</div>
<div class="relative">
<button
class="inline-flex items-center justify-center h-7 px-1 rounded-md"
:class="listStyle ? 'bg-neutral-200' : 'hover:bg-neutral-100'"
:title="t('bullet_list')"
@click="toggleDropdown('list')"
>
<IconList
:width="16"
:height="16"
/>
<IconChevronDown
:width="12"
:height="12"
/>
</button>
<div
v-if="dropdown === 'list'"
ref="dropdown"
:class="isDropdownAlignedRight ? 'right-0' : 'left-0'"
class="absolute z-10 top-full mt-1 w-28 p-1 bg-white border border-neutral-200 rounded-lg shadow-lg"
>
<button
v-for="({ label, marker }, style) in listStyles"
:key="style"
class="flex items-center gap-2 w-full px-2 py-1 rounded-md"
:class="style === listStyle ? 'bg-neutral-200' : 'hover:bg-neutral-100'"
@click="toggleList(style)"
>
<span class="w-4 text-center">{{ marker }}</span>
{{ label }}
</button>
</div>
</div>
</div>
<div class="flex items-center gap-0.5">
<button
class="inline-flex items-center justify-center w-7 h-7 rounded-md"
:class="isBold ? 'bg-neutral-200' : 'hover:bg-neutral-100'"
:title="t('bold')"
@click="toggleBold"
>
<IconBold
:width="16"
:height="16"
/>
</button>
<button
class="inline-flex items-center justify-center w-7 h-7 rounded-md"
:class="isItalic ? 'bg-neutral-200' : 'hover:bg-neutral-100'"
:title="t('italic')"
@click="toggleItalic"
>
<IconItalic
:width="16"
:height="16"
/>
</button>
<button
class="inline-flex items-center justify-center w-7 h-7 rounded-md"
:class="isUnderline ? 'bg-neutral-200' : 'hover:bg-neutral-100'"
:title="t('underline')"
@click="toggleUnderline"
>
<IconUnderline
:width="16"
:height="16"
/>
</button>
<button
class="inline-flex items-center justify-center w-7 h-7 rounded-md"
:class="isStrike ? 'bg-neutral-200' : 'hover:bg-neutral-100'"
:title="t('strikethrough')"
@click="toggleStrike"
>
<IconStrikethrough
:width="16"
:height="16"
/>
</button>
<div class="w-px h-5 bg-neutral-200 mx-1" />
<div
v-for="colorProperty in ['color', 'background-color']"
:key="colorProperty"
class="relative"
>
<button
class="inline-flex items-center justify-center h-7 px-1 rounded-md hover:bg-neutral-100"
:title="colorProperty === 'color' ? t('text_color') : t('background_color')"
@click="toggleDropdown(colorProperty)"
>
<span class="flex flex-col items-center">
<component
:is="colorProperty === 'color' ? 'IconLetterA' : 'IconHighlight'"
:width="16"
:height="14"
/>
<span
class="w-4 h-1 rounded-sm border border-neutral-200"
:style="{ backgroundColor: colorProperty === 'color' ? color : backgroundColor }"
/>
</span>
<IconChevronDown
:width="12"
:height="12"
/>
</button>
<div
v-if="dropdown === colorProperty"
ref="dropdown"
:class="isDropdownAlignedRight ? 'right-0' : 'left-0'"
class="absolute z-10 top-full mt-1 w-max p-2 bg-white border border-neutral-200 rounded-lg shadow-lg"
>
<button
class="flex items-center gap-1 w-full mb-2 px-2 py-1 rounded-md hover:bg-neutral-100"
@click="setSpanStyle(colorProperty, null)"
>
<IconX
:width="14"
:height="14"
/>
{{ colorProperty === 'color' ? t('default') : t('none') }}
</button>
<div class="grid grid-cols-10 gap-1">
<button
v-for="value in colors"
:key="value"
class="w-4 h-4 rounded-sm border border-neutral-200 hover:scale-125 transition-transform"
:style="{ backgroundColor: value }"
:title="value"
@click="setSpanStyle(colorProperty, value)"
/>
</div>
</div>
</div>
<div class="w-px h-5 bg-neutral-200 mx-1 sm:mx-0.5" />
<button
class="inline-flex items-center justify-center text-xs h-7 px-1 rounded-md"
:class="isCellSelection ? 'opacity-50 cursor-not-allowed' : 'hover:bg-neutral-100'"
:title="t('create_variable')"
:disabled="isCellSelection"
@click="wrapVariable"
>
<IconBracketsContain
:width="16"
:height="16"
:stroke-width="1.6"
/>
<span class="hidden sm:inline px-0.5 font-mono">
{{ t('variable') }}
</span>
</button>
<div class="w-px h-5 bg-neutral-200 mx-1 sm:mx-0.5" />
<button
class="inline-flex items-center justify-center text-xs h-7 px-1 rounded-md"
:class="isCellSelection ? 'opacity-50 cursor-not-allowed' : 'hover:bg-neutral-100'"
:title="t('create_condition')"
:disabled="isCellSelection"
@click="wrapCondition"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.6"
stroke-linecap="round"
stroke-linejoin="round"
class="tabler-icon tabler-icon-brackets-contain"
><path d="M7 4h-4v16h4" /><path d="M17 4h4v16h-4" />
<text
x="12"
y="16.5"
text-anchor="middle"
fill="currentColor"
stroke="none"
font-size="14"
font-weight="600"
font-family="ui-sans-serif, system-ui, sans-serif"
>if</text>
</svg>
<span class="hidden sm:inline px-0.5 font-mono">
{{ t('condition') }}
</span>
</button>
</div>
</div>
</template>
<script>
import { IconBold, IconItalic, IconUnderline, IconStrikethrough, IconBracketsContain } from '@tabler/icons-vue'
import { IconBold, IconItalic, IconUnderline, IconStrikethrough, IconBracketsContain, IconChevronDown, IconTextIncrease, IconTextDecrease, IconLineHeight, IconAlignLeft, IconAlignCenter, IconAlignRight, IconAlignJustified, IconLetterA, IconHighlight, IconList, IconX } from '@tabler/icons-vue'
import { CellSelection } from '@tiptap/pm/tables'
import { findNumbering, numberingListStyle } from './dynamic_editor.js'
export default {
name: 'DynamicMenu',
@@ -110,8 +363,17 @@ export default {
IconItalic,
IconUnderline,
IconStrikethrough,
IconBracketsContain
IconBracketsContain,
IconChevronDown,
IconTextIncrease,
IconTextDecrease,
IconLineHeight,
IconLetterA,
IconHighlight,
IconList,
IconX
},
inject: ['t', 'documentFonts'],
props: {
editor: {
type: Object,
@@ -123,23 +385,90 @@ export default {
default: null
}
},
emits: ['add-variable', 'add-condition'],
emits: ['add-variable', 'add-condition', 'add-list'],
data () {
return {
isMouseDown: false,
isBold: this.editor.isActive('bold'),
isItalic: this.editor.isActive('italic'),
isUnderline: this.editor.isActive('underline'),
isStrike: this.editor.isActive('strike')
dropdown: null,
isDropdownAlignedRight: false,
menuWidth: 0,
isBold: false,
isItalic: false,
isUnderline: false,
isStrike: false,
fontFamily: '',
fontSize: 12,
color: '',
backgroundColor: '',
lineHeight: '',
textAlign: 'left',
listStyle: null,
isCellSelection: false
}
},
computed: {
visible () {
return !!this.coords && !this.isMouseDown
},
menuLeft () {
return Math.max(4, Math.min(this.coords.left - this.menuWidth / 2, this.coords.width - this.menuWidth - 4))
},
standardFonts () {
return [
{ label: 'Arial', value: 'Arial' },
{ label: 'Times New Roman', value: "'Times New Roman'" },
{ label: 'Courier New', value: "'Courier New'" }
]
},
fonts () {
return [
...this.documentFonts.filter((font) => !this.standardFonts.some((standardFont) => standardFont.label === font.label)),
...this.standardFonts
]
},
fontSizes () {
return [8, 9, 10, 10.5, 11, 12, 14, 16, 18, 20, 24, 28, 32, 36, 48, 72]
},
lineHeights () {
return ['1', '1.15', '1.5', '2', '2.5', '3']
},
alignIcons () {
return {
left: IconAlignLeft,
center: IconAlignCenter,
right: IconAlignRight,
justify: IconAlignJustified
}
},
listStyles () {
return {
disc: { label: this.t('dots'), marker: '•' },
dash: { label: this.t('dash'), marker: '–' },
decimal: { label: this.t('numbers'), marker: '1.' }
}
},
colors () {
return [
'#000000', '#434343', '#666666', '#999999', '#b7b7b7', '#cccccc', '#d9d9d9', '#efefef', '#f3f3f3', '#ffffff',
'#980000', '#ff0000', '#ff9900', '#ffff00', '#00ff00', '#00ffff', '#4a86e8', '#0000ff', '#9900ff', '#ff00ff',
'#e6b8af', '#f4cccc', '#fce5cd', '#fff2cc', '#d9ead3', '#d0e0e3', '#c9daf8', '#cfe2f3', '#d9d2e9', '#ead1dc',
'#cc4125', '#e06666', '#f6b26b', '#ffd966', '#93c47d', '#76a5af', '#6d9eeb', '#6fa8dc', '#8e7cc3', '#c27ba0',
'#85200c', '#990000', '#b45f06', '#bf9000', '#38761d', '#134f5c', '#1155cc', '#0b5394', '#351c75', '#741b47'
]
}
},
watch: {
visible (value) {
this.dropdown = null
if (value) {
this.readState()
this.$nextTick(this.measure)
}
}
},
mounted () {
this.editor.view.dom.addEventListener('mousedown', this.onMouseDown)
this.editor.view.dom.parentElement.addEventListener('mousedown', this.onMouseDown)
document.addEventListener('mouseup', this.onMouseUp)
@@ -147,18 +476,79 @@ export default {
},
beforeUnmount () {
if (!this.editor.isDestroyed) {
this.editor.view.dom.removeEventListener('mousedown', this.onMouseDown)
this.editor.view.dom.parentElement.removeEventListener('mousedown', this.onMouseDown)
this.editor.off('transaction', this.onTransaction)
}
document.removeEventListener('mouseup', this.onMouseUp)
},
methods: {
measure () {
if (this.$refs.menu) {
this.menuWidth = this.$refs.menu.offsetWidth
}
},
toggleDropdown (name) {
this.dropdown = this.dropdown === name ? null : name
this.isDropdownAlignedRight = false
this.$nextTick(() => {
const dropdownEl = [this.$refs.dropdown].flat()[0]
if (!dropdownEl) return
const dropdownRect = dropdownEl.getBoundingClientRect()
const containerRect = this.$el.parentElement.getBoundingClientRect()
this.isDropdownAlignedRight = dropdownRect.right > containerRect.right
})
},
toggleBold () {
this.editor.chain().focus().toggleBold().run()
this.toggleStyledMark('bold', 'font-weight', this.isBold, (style) => parseInt(style.fontWeight) >= 600)
},
toggleItalic () {
this.editor.chain().focus().toggleItalic().run()
this.toggleStyledMark('italic', 'font-style', this.isItalic, (style) => style.fontStyle === 'italic')
},
toggleStyledMark (markName, property, isActive, isStyled) {
const isSegmentStyled = ({ from }) => isStyled(getComputedStyle(this.textElementAt(from)))
if (isActive) {
this.editor.chain().focus().unsetMark(markName).setListMarkerStyle(property, null).run()
if (this.selectedTextSegments().some(isSegmentStyled)) {
this.editor.chain().setSpanStyle(property, 'normal').run()
}
} else {
this.editor.chain().focus().setSpanStyle(property, null).run()
const { state, view } = this.editor
const tr = state.tr
this.selectedTextSegments().forEach((segment) => {
if (!isSegmentStyled(segment)) tr.addMark(segment.from, segment.to, state.schema.marks[markName].create())
})
if (tr.docChanged) view.dispatch(tr)
this.editor.commands.setListMarkerStyle(property, markName)
}
},
selectedTextSegments () {
const { state } = this.editor
const segments = []
state.selection.ranges.forEach(({ $from, $to }) => state.doc.nodesBetween($from.pos, $to.pos, (node, pos) => {
if (node.isText) segments.push({ from: Math.max(pos, $from.pos), to: Math.min(pos + node.nodeSize, $to.pos) })
}))
return segments
},
textElementAt (pos) {
const { node, offset } = this.editor.view.domAtPos(pos, 1)
if (node.nodeType === Node.TEXT_NODE) return node.parentElement
return node.childNodes[offset]?.nodeType === Node.ELEMENT_NODE ? node.childNodes[offset] : node
},
toggleUnderline () {
this.editor.chain().focus().toggleUnderline().run()
@@ -166,6 +556,30 @@ export default {
toggleStrike () {
this.editor.chain().focus().toggleStrike().run()
},
setSpanStyle (property, value) {
this.dropdown = null
const chain = this.editor.chain().focus().setSpanStyle(property, value)
if (property !== 'background-color') chain.setListMarkerStyle(property, value)
chain.run()
},
setFontSize (size) {
this.setSpanStyle('font-size', `${size}pt`)
},
setBlockStyle (property, value) {
this.dropdown = null
this.editor.chain().focus().setBlockStyle(property, value).run()
},
toggleList (style) {
this.dropdown = null
if (style !== this.listStyle) this.$emit('add-list')
this.editor.chain().focus().toggleList(style).run()
},
wrapVariable () {
const { from, to } = this.editor.state.selection
const replacement = '[[variable]]'
@@ -201,10 +615,62 @@ export default {
}, 1)
},
onTransaction () {
this.isBold = this.editor.isActive('bold')
this.isItalic = this.editor.isActive('italic')
this.isUnderline = this.editor.isActive('underline')
this.isStrike = this.editor.isActive('strike')
if (this.visible) {
this.readState()
}
},
readState () {
const { view, state } = this.editor
const { ranges } = state.selection
const selectionFrom = Math.min(...ranges.map((range) => range.$from.pos))
const selectionTo = Math.max(...ranges.map((range) => range.$to.pos))
let from = null
state.doc.nodesBetween(selectionFrom, selectionTo, (node, pos) => {
if (from === null && node.isText) from = Math.max(pos, selectionFrom)
})
if (from === null) from = selectionFrom
const $from = state.doc.resolve(from)
const el = this.textElementAt(from)
const style = getComputedStyle(el)
const blockDom = $from.parent.isTextblock ? view.nodeDOM($from.before()) : view.dom
const decorations = []
this.isCellSelection = state.selection instanceof CellSelection
this.isBold = parseInt(style.fontWeight) >= 600
this.isItalic = style.fontStyle === 'italic'
this.fontFamily = style.fontFamily.split(',')[0].replace(/["']/g, '').trim()
this.fontSize = Math.round(parseFloat(style.fontSize) * 0.75 * 2) / 2
this.color = style.color
this.backgroundColor = 'transparent'
for (let inlineEl = el; inlineEl && view.dom.contains(inlineEl); inlineEl = inlineEl.parentElement) {
const { textDecorationLine, backgroundColor } = getComputedStyle(inlineEl)
decorations.push(textDecorationLine)
if (inlineEl === blockDom) break
if (this.backgroundColor === 'transparent' && backgroundColor !== 'rgba(0, 0, 0, 0)' && backgroundColor !== 'transparent') {
this.backgroundColor = backgroundColor
}
}
this.isUnderline = this.editor.isActive('underline') || decorations.some((line) => line.includes('underline'))
this.isStrike = this.editor.isActive('strike') || decorations.some((line) => line.includes('line-through'))
if ($from.parent.isTextblock) {
const blockStyle = getComputedStyle(blockDom)
const align = blockStyle.textAlign.replace('start', 'left').replace('end', 'right')
this.textAlign = this.alignIcons[align] ? align : 'left'
this.lineHeight = String(Math.round(parseFloat(blockStyle.lineHeight) / parseFloat(blockStyle.fontSize) / 1.15 * 100) / 100)
}
this.listStyle = findNumbering($from.parent) ? numberingListStyle(view, $from.before()) : null
}
}
}
@@ -2,13 +2,17 @@
<div
class="relative bg-white select-none mb-4 before:border before:rounded before:top-0 before:bottom-0 before:left-0 before:right-0 before:absolute"
:class="{ 'cursor-crosshair': isDrawMode && editable }"
@mousedown="onMarginMouseDown"
>
<div
v-if="isDrawMode && editable && cursorHighlightCoords"
class="absolute pointer-events-none z-10 bg-black"
:style="{ width: '1px', height: cursorHighlightCoords.height + 'px', left: cursorHighlightCoords.x + 'px', top: cursorHighlightCoords.y + 'px' }"
/>
<div :style="{ zoom: containerWidth / sectionWidthPx }">
<div
:style="{ zoom, '--zoom': zoom }"
:data-zoomed-out="zoom < 0.75 || null"
>
<section
:id="section.id"
ref="editorElement"
@@ -17,6 +21,13 @@
:style="section.style.cssText"
/>
</div>
<input
ref="imageInput"
type="file"
accept="image/*"
class="hidden"
@change="onImageSelected"
>
<Teleport
v-if="editor"
:to="container"
@@ -30,6 +41,7 @@
:area="selectedArea"
:field="selectedField"
:editable="editable"
:is-mobile="isMobile"
:template="template"
:selected-areas-ref="selectedAreasRef"
:get-field-type-index="getFieldTypeIndex"
@@ -41,9 +53,50 @@
v-if="editable"
v-show="!selectedAreasRef.value.length"
:editor="editor"
:coords="dynamicMenuCoords"
:coords="isInsertMenuOpen ? null : dynamicMenuCoords"
@add-variable="dynamicMenuCoords = null"
@add-condition="dynamicMenuCoords = null"
@add-list="$emit('add-list')"
/>
<div
v-if="editable && insertMenuCoords && !isDrawMode"
class="absolute z-10 select-none text-sm"
:style="{ top: insertMenuCoords.top - 12 + 'px', left: insertMenuCoords.left - 12 + 'px' }"
@mousedown.prevent
>
<button
class="flex items-center justify-center w-6 h-6 rounded-md border border-neutral-200"
:class="isInsertMenuOpen ? 'bg-neutral-100 text-base-content' : 'bg-white text-base-content/40 hover:bg-neutral-100 hover:text-base-content'"
:title="t('insert')"
@click="isInsertMenuOpen = !isInsertMenuOpen"
>
<IconX
v-if="isInsertMenuOpen"
:width="14"
:height="14"
/>
<IconPlus
v-else
:width="14"
:height="14"
/>
</button>
<DynamicInsertMenu
v-if="isInsertMenuOpen"
:key="insertMenuCoords.top"
@close="isInsertMenuOpen = false"
@insert-field="onInsertField"
@insert-table="onInsertTable"
@insert-variable="editor.chain().focus().insertContent('[[variable]]').run()"
@insert-image="$refs.imageInput.click()"
/>
</div>
<DynamicBlockMenu
v-if="editable && blockMenuCoords && !isDrawMode"
:coords="blockMenuCoords"
:get-resize-limits="getBlockResizeLimits"
@command="onBlockCommand"
@resize="onBlockResize"
/>
<FieldContextMenu
v-if="contextMenu && contextMenuField"
@@ -64,20 +117,29 @@
<script>
import { shallowRef } from 'vue'
import { DOMSerializer, Fragment } from '@tiptap/pm/model'
import { Selection, TextSelection } from '@tiptap/pm/state'
import { findTable, CellSelection } from '@tiptap/pm/tables'
import { v4 } from 'uuid'
import { IconPlus, IconX } from '@tabler/icons-vue'
import FieldContextMenu from './field_context_menu.vue'
import AreaTitle from './area_title.vue'
import DynamicMenu from './dynamic_menu.vue'
import { buildEditor } from './dynamic_editor.js'
import DynamicInsertMenu from './dynamic_insert_menu.vue'
import DynamicBlockMenu from './dynamic_block_menu.vue'
import { buildEditor, isRegularTable, resizeTable, tableRowMinHeights } from './dynamic_editor.js'
export default {
name: 'DynamicSection',
components: {
DynamicMenu,
DynamicInsertMenu,
DynamicBlockMenu,
FieldContextMenu,
AreaTitle
AreaTitle,
IconPlus,
IconX
},
inject: ['template', 'save', 't', 'fieldsDragFieldRef', 'customDragFieldRef', 'selectedAreasRef', 'getFieldTypeIndex', 'fieldTypes', 'withPhone', 'withPayment', 'withVerification', 'withKba', 'backgroundColor'],
inject: ['template', 'save', 'pushUndo', 'undo', 'redo', 'baseFetch', 't', 'fieldsDragFieldRef', 'customDragFieldRef', 'selectedAreasRef', 'getFieldTypeIndex', 'fieldTypes', 'withPhone', 'withPayment', 'withVerification', 'withKba', 'backgroundColor', 'isMobile'],
props: {
section: {
type: Object,
@@ -147,12 +209,15 @@ export default {
default: null
}
},
emits: ['update', 'draw', 'set-draw', 'add-custom-field'],
emits: ['update', 'draw', 'set-draw', 'add-custom-field', 'add-attachment', 'add-list'],
data () {
return {
isAreaDrag: false,
areaToolbarCoords: null,
dynamicMenuCoords: null,
insertMenuCoords: null,
isInsertMenuOpen: false,
blockMenuCoords: null,
contextMenu: null,
cursorHighlightCoords: null
}
@@ -196,6 +261,9 @@ export default {
editor () {
return this.editorRef.value
},
sectionKey () {
return `${this.attachmentUuid}:${this.section.id}`
},
sectionWidthPx () {
const pt = parseFloat(this.section.style.width)
@@ -235,9 +303,19 @@ export default {
if (this.dynamicMenuCoords && this.editor && !this.editor.state.selection.empty) {
this.$nextTick(() => this.setDynamicMenuCoords(this.editor))
}
if (this.insertMenuCoords && this.editor) {
this.$nextTick(() => this.setInsertMenuCoords(this.editor))
}
if (this.blockMenuCoords && this.editor) {
this.$nextTick(() => this.setBlockMenuCoords(this.editor))
}
}
},
mounted () {
this.lastUpdateTime = 0
this.editorRef.value = buildEditor({
dynamicAreaProps: {
template: this.template,
@@ -250,17 +328,36 @@ export default {
onAreaResize: this.onAreaResize,
onAreaDragStart: this.onAreaDragStart
},
attachmentsIndex: this.attachmentsIndex,
getAttachmentsIndex: () => this.attachmentsIndex,
onFieldDrop: this.onFieldDrop,
onFieldDestroy: this.onFieldDestroy,
onUndo: this.undo,
onRedo: this.redo,
renderHtmlForSaveRef: this.renderHtmlForSaveRef,
editorOptions: {
element: this.$refs.editorElement,
editable: this.editable,
content: this.section.innerHTML,
onUpdate: (event) => this.$emit('update', event),
onUpdate: (event) => {
this.$emit('update', event)
if (event.transaction.getMeta('addToHistory') !== false) {
this.pushUndo({ amend: event.transaction.time - this.lastUpdateTime < 500 })
this.lastUpdateTime = event.transaction.time
}
if (this.blockMenuCoords) {
this.$nextTick(() => this.setBlockMenuCoords(this.editor))
}
},
onSelectionUpdate: this.onSelectionUpdate,
onBlur: () => { this.dynamicMenuCoords = null }
onFocus: this.onSelectionUpdate,
onBlur: () => {
this.dynamicMenuCoords = null
this.insertMenuCoords = null
this.blockMenuCoords = null
}
}
})
@@ -269,6 +366,8 @@ export default {
this.editor.view.dom.addEventListener('mousemove', this.onEditorMouseMove)
this.editor.view.dom.addEventListener('mouseleave', this.onEditorMouseLeave)
this.editor.view.dom.addEventListener('keydown', this.onEditorKeyDown)
this.$nextTick(() => this.pushUndo({ amend: true }))
},
beforeUnmount () {
if (this.editor) {
@@ -284,6 +383,19 @@ export default {
reloadContent () {
this.editor.commands.setContent(this.section.innerHTML, { emitUpdate: false, parseOptions: this.editor.options.parseOptions })
},
restoreDoc (doc) {
const { state, view } = this.editor
const { content } = doc.type.schema === state.schema ? doc : state.schema.nodeFromJSON(doc.toJSON())
const diffEnd = content.findDiffEnd(state.doc.content)
if (diffEnd) {
const tr = state.tr.replaceWith(0, state.doc.content.size, content).setMeta('addToHistory', false)
view.dispatch(tr.setSelection(Selection.near(tr.doc.resolve(diffEnd.a))).scrollIntoView())
this.lastUpdateTime = 0
}
},
findAreaNodePos (areaUuid) {
const el = this.editor.view.dom.querySelector(`[data-area-uuid="${areaUuid}"]`)
@@ -402,16 +514,163 @@ export default {
this.selectedAreasRef.value = [area]
}
}
this.blockMenuCoords = null
} else {
this.areaToolbarCoords = null
this.selectedAreasRef.value = []
if (editor.state.selection.empty) {
this.setBlockMenuCoords(editor)
if (selection.node || selection.empty || !editor.isFocused) {
this.dynamicMenuCoords = null
} else {
this.setDynamicMenuCoords(editor)
}
}
if (selection instanceof CellSelection) {
this.insertMenuCoords = null
} else if (editor.isFocused) {
this.setInsertMenuCoords(editor)
}
},
setInsertMenuCoords (editor) {
const coords = editor.view.coordsAtPos(editor.state.selection.head)
const containerRect = this.container.getBoundingClientRect()
const sectionEl = this.$refs.editorElement
const sectionRect = sectionEl.getBoundingClientRect()
const paddingLeft = parseFloat(getComputedStyle(sectionEl).paddingLeft) * this.zoom
this.insertMenuCoords = {
top: (coords.top + coords.bottom) / 2 - containerRect.top,
left: sectionRect.left - containerRect.left + Math.max(paddingLeft / 2, 14)
}
},
setBlockMenuCoords (editor) {
const { selection } = editor.state
const isImage = selection.node?.type.name === 'image'
const table = isImage ? null : findTable(selection.$from)
if (!isImage && !table) {
this.blockMenuCoords = null
return
}
const pos = isImage ? selection.from : table.pos
const dom = editor.view.nodeDOM(pos)
const rect = dom.getBoundingClientRect()
const containerRect = this.container.getBoundingClientRect()
const coords = {
type: isImage ? 'image' : 'table',
pos,
align: 'left',
top: rect.top - containerRect.top,
left: rect.left - containerRect.left,
width: rect.width,
height: rect.height
}
if (isImage) {
coords.align = getComputedStyle(dom.parentElement).textAlign.replace('start', 'left').replace('end', 'right')
} else {
const { marginLeft, marginRight, float } = dom.style
if (float === 'left' || float === 'right') {
coords.align = float
} else if (marginLeft === 'auto') {
coords.align = marginRight === 'auto' ? 'center' : 'right'
} else if (dom.getAttribute('align')) {
coords.align = dom.getAttribute('align')
}
coords.isRegular = isRegularTable(table.node)
coords.canMerge = editor.can().mergeTableCells()
coords.canSplit = editor.can().splitTableCell()
}
this.blockMenuCoords = coords
},
getBlockResizeLimits () {
const dom = this.editor.view.nodeDOM(this.blockMenuCoords.pos)
const rect = dom.getBoundingClientRect()
return {
minHeight: tableRowMinHeights(dom, this.zoom, false).reduce((acc, value) => acc + value, 0),
maxWidth: Math.max(this.$refs.editorElement.getBoundingClientRect().right - rect.left, rect.width)
}
},
onBlockResize ({ width, height }) {
resizeTable(this.editor.view, this.blockMenuCoords.pos, { width: width / this.zoom, height: height / this.zoom }, this.zoom)
this.editor.commands.focus()
this.$nextTick(() => this.setBlockMenuCoords(this.editor))
},
onBlockCommand (command, arg) {
this.editor.chain().focus()[command](arg).run()
this.$nextTick(() => this.setBlockMenuCoords(this.editor))
},
onInsertField (type) {
const { from, to } = this.editor.state.selection
this.insertFieldAtRange({ sourceField: { type }, from, to })
},
onInsertTable ({ rows, cols }) {
const $pos = this.editor.state.tr.deleteSelection().selection.$head
const cell = '<td style="border: 1px solid #000000; padding: 4pt; vertical-align: top"><p style="margin: 0"></p></td>'
const html = `<table style="width: 100%; border-collapse: collapse"><tbody>${`<tr>${cell.repeat(cols)}</tr>`.repeat(rows)}</tbody></table>`
const isEmpty = $pos.parent.isTextblock && !$pos.parent.content.size
const from = !$pos.parent.isTextblock ? $pos.pos : isEmpty ? $pos.before() : $pos.after()
const to = isEmpty ? $pos.after() : from
this.editor.chain().focus()
.deleteSelection()
.insertContentAt({ from, to }, html)
.setTextSelection(from + 4)
.run()
},
async onImageSelected (event) {
const file = event.target.files[0]
event.target.value = ''
if (!file) return
const image = await this.uploadImage(file)
if (image) {
this.editor.chain().focus().insertContent(image).run()
}
},
async insertImageFiles (files, pos) {
const images = (await Promise.all(files.map(this.uploadImage))).filter(Boolean)
if (images.length) {
this.editor.chain().focus().insertContentAt(pos, images).run()
}
},
async uploadImage (file) {
const formData = new FormData()
formData.append('file', file)
const response = await this.baseFetch(`/templates/${this.template.id}/dynamic_documents/${this.attachmentUuid}/attachments`, {
method: 'POST',
body: formData
})
const data = await response.json()
if (!response.ok) return alert(data.error)
this.$emit('add-attachment', data)
await this.$nextTick()
return { type: 'image', attrs: { htmlAttrs: { src: `blob:${data.uuid}`, style: 'max-width: 100%' } } }
},
setDynamicMenuCoords (editor) {
const { from, to } = editor.state.selection
@@ -419,11 +678,11 @@ export default {
const start = view.coordsAtPos(from)
const end = view.coordsAtPos(to)
const containerRect = this.container.getBoundingClientRect()
const left = (start.left + end.right) / 2 - containerRect.left
this.dynamicMenuCoords = {
top: Math.min(start.top, end.top) - containerRect.top,
left: Math.max(80, Math.min(left, containerRect.width - 80))
top: Math.max(start.bottom, end.bottom) - containerRect.top + 8,
left: (start.left + end.right) / 2 - containerRect.left,
width: containerRect.width
}
},
onFieldDestroy (node) {
@@ -493,6 +752,8 @@ export default {
if (event.key === 'Escape') {
this.editor.chain().setNodeSelection(0).blur().run()
this.deselectArea()
} else if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'z') {
event.stopPropagation()
}
},
onEditorMouseMove (event) {
@@ -608,25 +869,23 @@ export default {
}
}
const newField = {
const newField = payload.parentField || {
...field,
uuid: v4(),
submitter_uuid: this.selectedSubmitter.uuid,
areas: [area]
areas: []
}
newField.areas.push(area)
if (['radio', 'multiple'].includes(field.type) && field.options?.length) {
const oldOptionUuid = area.option_uuid
const optionsMap = {}
const oldOptionIndex = field.options.findIndex((opt) => opt.uuid === area.option_uuid)
newField.options = field.options.map((opt) => {
const newUuid = v4()
optionsMap[opt.uuid] = newUuid
if (!payload.parentField) {
newField.options = field.options.map((opt) => ({ ...opt, uuid: v4() }))
}
return { ...opt, uuid: newUuid }
})
area.option_uuid = optionsMap[oldOptionUuid] || newField.options[0]?.uuid
area.option_uuid = (newField.options[oldOptionIndex] || newField.options[0]).uuid
}
return { field: newField, area }
@@ -642,6 +901,8 @@ export default {
const text = clipboardData.getData('text/plain')
const clipboardHtml = html || (text.includes('<dynamic-field') ? text : '')
if (/class="[^"]*doc-list-/.test(html)) this.$emit('add-list')
if (!clipboardHtml || !clipboardHtml.includes('data-field=')) {
return
}
@@ -662,10 +923,13 @@ export default {
let lastArea = null
const parentFieldsIndex = {}
fieldNodes.forEach((fieldNode) => {
const fieldValue = fieldNode.dataset.field
const areaValue = fieldNode.dataset.area
const templateId = fieldNode.dataset.templateId
const sourceFieldUuid = fieldNode.getAttribute('uuid')
if (!fieldValue || !areaValue) {
return
@@ -674,11 +938,16 @@ export default {
const { field, area } = this.buildCopiedField({
field: JSON.parse(fieldValue),
area: JSON.parse(areaValue),
templateId: Number(templateId)
templateId: Number(templateId),
parentField: parentFieldsIndex[sourceFieldUuid]
})
this.insertFieldInTemplate(field, insertIndex)
insertIndex += 1
if (!parentFieldsIndex[sourceFieldUuid]) {
parentFieldsIndex[sourceFieldUuid] = field
this.insertFieldInTemplate(field, insertIndex)
insertIndex += 1
}
fieldNode.setAttribute('uuid', field.uuid)
fieldNode.setAttribute('area-uuid', area.uuid)
@@ -718,7 +987,7 @@ export default {
onSelectedAreaChange () {
this.save()
},
onFieldDrop (view, event, _slice, moved) {
onFieldDrop (view, event, slice, moved) {
this.isAreaDrag = false
if (moved) {
@@ -726,8 +995,9 @@ export default {
}
const draggedField = this.fieldsDragFieldRef?.value || this.customDragFieldRef?.value || this.dragField
const imageFiles = slice.size ? [] : Array.from(event.dataTransfer.files).filter((file) => file.type.startsWith('image/'))
if (!draggedField) return false
if (!draggedField && !imageFiles.length) return false
event.preventDefault()
@@ -735,17 +1005,63 @@ export default {
if (!pos) return false
this.insertFieldAtRange({
sourceField: draggedField,
existingField: this.fieldsDragFieldRef?.value,
from: pos.pos
})
if (draggedField) {
this.insertFieldAtRange({
sourceField: draggedField,
existingField: this.fieldsDragFieldRef?.value,
from: pos.pos
})
this.fieldsDragFieldRef.value = null
this.customDragFieldRef.value = null
this.fieldsDragFieldRef.value = null
this.customDragFieldRef.value = null
} else {
this.insertImageFiles(imageFiles, pos.pos)
}
return true
},
closestPosAtCoords ({ clientX, clientY }) {
const view = this.editor.view
const rect = view.dom.getBoundingClientRect()
const left = Math.min(Math.max(clientX, rect.left + 1), rect.right - 1)
const top = Math.min(Math.max(clientY, rect.top + 1), rect.bottom - 1)
const caret = document.caretPositionFromPoint?.(left, top, { shadowRoots: [view.root] })
if (caret && view.dom.contains(caret.offsetNode)) {
return view.posAtDOM(caret.offsetNode, caret.offset)
} else {
return view.posAtCoords({ left, top }).pos
}
},
onMarginMouseDown (event) {
const view = this.editor.view
if (event.button !== 0 || !this.editable || this.isDrawMode || view.dom.contains(event.target)) {
return
}
event.preventDefault()
const anchor = this.closestPosAtCoords(event)
const selectTo = (e) => {
const { doc, tr } = view.state
view.dispatch(tr.setSelection(TextSelection.between(doc.resolve(anchor), doc.resolve(this.closestPosAtCoords(e)))))
}
const onMouseUp = () => {
window.removeEventListener('mousemove', selectTo)
window.removeEventListener('mouseup', onMouseUp)
}
selectTo(event)
view.focus()
window.addEventListener('mousemove', selectTo)
window.addEventListener('mouseup', onMouseUp)
},
onEditorPointerDown (event) {
if (!this.isDrawMode || !this.editable || this.isDraggingField) {
return
@@ -19,4 +19,5 @@
:host {
all: initial;
-webkit-text-size-adjust: 100%;
}
@@ -745,6 +745,7 @@ export default {
'^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$': 'email',
'^https?://.*': 'url',
'^[0-9]{5}(?:-[0-9]{4})?$': 'zip',
'^[A-Z]{2}[0-9]{2}(?: ?[A-Z0-9]){11,30}$': 'iban',
'^[0-9]+$': 'numbers_only',
'^[a-zA-Z]+$': 'letters_only'
}
+273 -7
View File
@@ -210,6 +210,7 @@ const en = {
email: 'Email',
url: 'URL',
zip: 'ZIP',
iban: 'IBAN',
custom: 'Custom',
numbers_only: 'Numbers only',
letters_only: 'Letters only',
@@ -248,7 +249,44 @@ const en = {
no_revisions_yet: 'No revisions yet',
viewing_revision_from: 'Viewing revision from {date}',
connect_google_drive: 'Connect Google Drive',
submitting: 'Submitting'
submitting: 'Submitting',
font_size: 'Font size',
increase_font_size: 'Increase font size',
decrease_font_size: 'Decrease font size',
line_spacing: 'Line spacing',
justify: 'Justify',
bullet_list: 'List',
dots: 'Dots',
dash: 'Dash',
numbers: 'Numbers',
bold: 'Bold',
italic: 'Italic',
underline: 'Underline',
strikethrough: 'Strikethrough',
text_color: 'Text color',
background_color: 'Background color',
variable: 'Variable',
create_variable: 'Wrap in variable',
create_condition: 'Wrap in condition',
insert: 'Insert',
field: 'Field',
table: 'Table',
decrease_rows: 'Fewer rows',
increase_rows: 'More rows',
decrease_cols: 'Fewer columns',
increase_cols: 'More columns',
align_center: 'Align Center',
insert_row_above: 'Insert row above',
insert_row_below: 'Insert row below',
insert_column_left: 'Insert column left',
insert_column_right: 'Insert column right',
merge_cells: 'Merge cells',
split_cell: 'Split cell',
delete_row: 'Delete row',
delete_column: 'Delete column',
delete_table: 'Delete table',
delete_image: 'Delete image',
irregular_table_structure: 'Rows and columns can\'t be edited: irregular table layout'
}
const es = {
@@ -463,6 +501,7 @@ const es = {
email: 'Correo electrónico',
url: 'URL',
zip: 'ZIP',
iban: 'IBAN',
custom: 'Personalizado',
numbers_only: 'Solo números',
letters_only: 'Solo letras',
@@ -501,7 +540,44 @@ const es = {
no_revisions_yet: 'Aún no hay revisiones',
viewing_revision_from: 'Viendo revisión del {date}',
connect_google_drive: 'Conectar Google Drive',
submitting: 'Enviando'
submitting: 'Enviando',
font_size: 'Tamaño',
increase_font_size: 'Aumentar tamaño',
decrease_font_size: 'Reducir tamaño',
line_spacing: 'Interlineado',
justify: 'Justificar',
bullet_list: 'Lista',
dots: 'Puntos',
dash: 'Guiones',
numbers: 'Números',
bold: 'Negrita',
italic: 'Cursiva',
underline: 'Subrayado',
strikethrough: 'Tachado',
text_color: 'Color de texto',
background_color: 'Color de fondo',
variable: 'Variable',
create_variable: 'Crear variable',
create_condition: 'Crear condición',
insert: 'Insertar',
field: 'Campo',
table: 'Tabla',
decrease_rows: 'Menos filas',
increase_rows: 'Más filas',
decrease_cols: 'Menos columnas',
increase_cols: 'Más columnas',
align_center: 'Centrar',
insert_row_above: 'Fila arriba',
insert_row_below: 'Fila abajo',
insert_column_left: 'Columna izquierda',
insert_column_right: 'Columna derecha',
merge_cells: 'Combinar celdas',
split_cell: 'Dividir celda',
delete_row: 'Eliminar fila',
delete_column: 'Eliminar columna',
delete_table: 'Eliminar tabla',
delete_image: 'Eliminar imagen',
irregular_table_structure: 'No se pueden editar filas ni columnas: tabla irregular'
}
const it = {
@@ -716,6 +792,7 @@ const it = {
email: 'Email',
url: 'URL',
zip: 'CAP',
iban: 'IBAN',
custom: 'Personalizzato',
numbers_only: 'Solo numeri',
letters_only: 'Solo lettere',
@@ -754,7 +831,44 @@ const it = {
no_revisions_yet: 'Nessuna revisione ancora',
viewing_revision_from: 'Visualizzazione revisione del {date}',
connect_google_drive: 'Connetti Google Drive',
submitting: 'Invio'
submitting: 'Invio',
font_size: 'Dimensione',
increase_font_size: 'Aumenta dimensione',
decrease_font_size: 'Riduci dimensione',
line_spacing: 'Interlinea',
justify: 'Giustifica',
bullet_list: 'Elenco',
dots: 'Punti',
dash: 'Trattini',
numbers: 'Numeri',
bold: 'Grassetto',
italic: 'Corsivo',
underline: 'Sottolineato',
strikethrough: 'Barrato',
text_color: 'Colore testo',
background_color: 'Colore sfondo',
variable: 'Variabile',
create_variable: 'Crea variabile',
create_condition: 'Crea condizione',
insert: 'Inserisci',
field: 'Campo',
table: 'Tabella',
decrease_rows: 'Meno righe',
increase_rows: 'Più righe',
decrease_cols: 'Meno colonne',
increase_cols: 'Più colonne',
align_center: 'Centra',
insert_row_above: 'Riga sopra',
insert_row_below: 'Riga sotto',
insert_column_left: 'Colonna a sinistra',
insert_column_right: 'Colonna a destra',
merge_cells: 'Unisci celle',
split_cell: 'Dividi cella',
delete_row: 'Elimina riga',
delete_column: 'Elimina colonna',
delete_table: 'Elimina tabella',
delete_image: 'Elimina immagine',
irregular_table_structure: 'Righe e colonne non modificabili: tabella irregolare'
}
const pt = {
@@ -969,6 +1083,7 @@ const pt = {
email: 'Email',
url: 'URL',
zip: 'ZIP',
iban: 'IBAN',
custom: 'Personalizado',
numbers_only: 'Somente números',
letters_only: 'Somente letras',
@@ -1007,7 +1122,44 @@ const pt = {
no_revisions_yet: 'Nenhuma revisão ainda',
viewing_revision_from: 'Visualizando revisão de {date}',
connect_google_drive: 'Conectar Google Drive',
submitting: 'Enviando'
submitting: 'Enviando',
font_size: 'Tamanho',
increase_font_size: 'Aumentar tamanho',
decrease_font_size: 'Diminuir tamanho',
line_spacing: 'Espaçamento',
justify: 'Justificar',
bullet_list: 'Lista',
dots: 'Pontos',
dash: 'Traços',
numbers: 'Números',
bold: 'Negrito',
italic: 'Itálico',
underline: 'Sublinhado',
strikethrough: 'Tachado',
text_color: 'Cor do texto',
background_color: 'Cor de fundo',
variable: 'Variável',
create_variable: 'Criar variável',
create_condition: 'Criar condição',
insert: 'Inserir',
field: 'Campo',
table: 'Tabela',
decrease_rows: 'Menos linhas',
increase_rows: 'Mais linhas',
decrease_cols: 'Menos colunas',
increase_cols: 'Mais colunas',
align_center: 'Centralizar',
insert_row_above: 'Linha acima',
insert_row_below: 'Linha abaixo',
insert_column_left: 'Coluna à esquerda',
insert_column_right: 'Coluna à direita',
merge_cells: 'Mesclar células',
split_cell: 'Dividir célula',
delete_row: 'Excluir linha',
delete_column: 'Excluir coluna',
delete_table: 'Excluir tabela',
delete_image: 'Excluir imagem',
irregular_table_structure: 'Linhas e colunas não editáveis: tabela irregular'
}
const fr = {
@@ -1222,6 +1374,7 @@ const fr = {
email: 'E-mail',
url: 'URL',
zip: 'ZIP',
iban: 'IBAN',
custom: 'Personnalisé',
numbers_only: 'Chiffres uniquement',
letters_only: 'Lettres uniquement',
@@ -1260,7 +1413,44 @@ const fr = {
no_revisions_yet: 'Aucune révision pour le moment',
viewing_revision_from: 'Affichage de la révision du {date}',
connect_google_drive: 'Connecter Google Drive',
submitting: 'Soumission'
submitting: 'Soumission',
font_size: 'Taille',
increase_font_size: 'Augmenter la taille',
decrease_font_size: 'Réduire la taille',
line_spacing: 'Interligne',
justify: 'Justifier',
bullet_list: 'Liste',
dots: 'Points',
dash: 'Tirets',
numbers: 'Numéros',
bold: 'Gras',
italic: 'Italique',
underline: 'Souligné',
strikethrough: 'Barré',
text_color: 'Couleur du texte',
background_color: 'Couleur de fond',
variable: 'Variable',
create_variable: 'Créer une variable',
create_condition: 'Créer une condition',
insert: 'Insérer',
field: 'Champ',
table: 'Tableau',
decrease_rows: 'Moins de lignes',
increase_rows: 'Plus de lignes',
decrease_cols: 'Moins de colonnes',
increase_cols: 'Plus de colonnes',
align_center: 'Centrer',
insert_row_above: 'Ligne au-dessus',
insert_row_below: 'Ligne en dessous',
insert_column_left: 'Colonne à gauche',
insert_column_right: 'Colonne à droite',
merge_cells: 'Fusionner',
split_cell: 'Scinder',
delete_row: 'Supprimer la ligne',
delete_column: 'Supprimer la colonne',
delete_table: 'Supprimer le tableau',
delete_image: 'Supprimer l\'image',
irregular_table_structure: 'Lignes et colonnes non modifiables : tableau irrégulier'
}
const de = {
@@ -1475,6 +1665,7 @@ const de = {
email: 'E-Mail',
url: 'URL',
zip: 'ZIP',
iban: 'IBAN',
custom: 'Benutzerdefiniert',
numbers_only: 'Nur Zahlen',
letters_only: 'Nur Buchstaben',
@@ -1513,7 +1704,44 @@ const de = {
no_revisions_yet: 'Noch keine Revisionen',
viewing_revision_from: 'Ansicht der Revision vom {date}',
connect_google_drive: 'Google Drive verbinden',
submitting: 'Übermittlung'
submitting: 'Übermittlung',
font_size: 'Schriftgröße',
increase_font_size: 'Schrift vergrößern',
decrease_font_size: 'Schrift verkleinern',
line_spacing: 'Zeilenabstand',
justify: 'Blocksatz',
bullet_list: 'Liste',
dots: 'Punkte',
dash: 'Striche',
numbers: 'Zahlen',
bold: 'Fett',
italic: 'Kursiv',
underline: 'Unterstrichen',
strikethrough: 'Durchgestrichen',
text_color: 'Textfarbe',
background_color: 'Hintergrundfarbe',
variable: 'Variable',
create_variable: 'Variable erstellen',
create_condition: 'Bedingung erstellen',
insert: 'Einfügen',
field: 'Feld',
table: 'Tabelle',
decrease_rows: 'Weniger Zeilen',
increase_rows: 'Mehr Zeilen',
decrease_cols: 'Weniger Spalten',
increase_cols: 'Mehr Spalten',
align_center: 'Zentrieren',
insert_row_above: 'Zeile darüber',
insert_row_below: 'Zeile darunter',
insert_column_left: 'Spalte links',
insert_column_right: 'Spalte rechts',
merge_cells: 'Zellen verbinden',
split_cell: 'Zelle teilen',
delete_row: 'Zeile löschen',
delete_column: 'Spalte löschen',
delete_table: 'Tabelle löschen',
delete_image: 'Bild löschen',
irregular_table_structure: 'Zeilen und Spalten nicht bearbeitbar: unregelmäßige Tabelle'
}
const nl = {
@@ -1728,6 +1956,7 @@ const nl = {
email: 'E-mail',
url: 'URL',
zip: 'Postcode',
iban: 'IBAN',
custom: 'Aangepast',
numbers_only: 'Alleen cijfers',
letters_only: 'Alleen letters',
@@ -1766,7 +1995,44 @@ const nl = {
no_revisions_yet: 'Nog geen revisies',
viewing_revision_from: 'Revisie van {date} bekijken',
connect_google_drive: 'Verbind Google Drive',
submitting: 'Indienen'
submitting: 'Indienen',
font_size: 'Tekstgrootte',
increase_font_size: 'Tekst vergroten',
decrease_font_size: 'Tekst verkleinen',
line_spacing: 'Regelafstand',
justify: 'Uitvullen',
bullet_list: 'Lijst',
dots: 'Punten',
dash: 'Streepjes',
numbers: 'Nummers',
bold: 'Vet',
italic: 'Cursief',
underline: 'Onderstrepen',
strikethrough: 'Doorhalen',
text_color: 'Tekstkleur',
background_color: 'Achtergrondkleur',
variable: 'Variabele',
create_variable: 'Maak variabele',
create_condition: 'Maak voorwaarde',
insert: 'Invoegen',
field: 'Veld',
table: 'Tabel',
decrease_rows: 'Minder rijen',
increase_rows: 'Meer rijen',
decrease_cols: 'Minder kolommen',
increase_cols: 'Meer kolommen',
align_center: 'Centreren',
insert_row_above: 'Rij erboven',
insert_row_below: 'Rij eronder',
insert_column_left: 'Kolom links',
insert_column_right: 'Kolom rechts',
merge_cells: 'Cellen samenvoegen',
split_cell: 'Cel splitsen',
delete_row: 'Rij verwijderen',
delete_column: 'Kolom verwijderen',
delete_table: 'Tabel verwijderen',
delete_image: 'Afbeelding verwijderen',
irregular_table_structure: 'Rijen en kolommen niet bewerkbaar: onregelmatige tabel'
}
export { en, es, it, pt, fr, de, nl }
-8
View File
@@ -57,7 +57,6 @@
@scroll-to="$emit('scroll-to', $event)"
@add-custom-field="$emit('add-custom-field', $event)"
@contextmenu="openAreaContextMenu($event, item.area, item.field)"
@multi-select="openMultiSelectContextMenu"
@click-title="closeContextMenu"
/>
<FieldArea
@@ -450,13 +449,6 @@ export default {
areas: this.selectedAreasRef.value
}
},
openMultiSelectContextMenu (event) {
if (this.selectedAreasRef.value.length >= 2) {
this.openSelectionContextMenu(event)
} else {
this.closeContextMenu()
}
},
handleSelectionCopy () {
this.$emit('copy-selected-areas')
@@ -414,7 +414,7 @@ export default {
checkStripeStatus () {
this.isLoading = true
fetch('/api/stripe_connect').then(async (resp) => {
fetch('/stripe_connect').then(async (resp) => {
const { status } = await resp.json()
if (status === 'connected') {
@@ -427,7 +427,7 @@ export default {
})
},
checkPaypalStatus () {
fetch('/api/paypal_connect').then(async (resp) => {
fetch('/paypal_connect').then(async (resp) => {
const { status } = await resp.json()
if (status === 'connected') {
+11 -2
View File
@@ -181,12 +181,21 @@ class SubmitterMailer < ApplicationMailer
def otp_verification_email(submitter, locale: nil)
@current_account = submitter.account
@submitter = submitter
@otp_code = EmailVerificationCodes.generate([submitter.email.downcase.strip, submitter.slug].join(':'))
@otp_code = OtpChallenges.generate([submitter.email.downcase.squish, submitter.slug].join(':'),
purpose: 'form_email_2fa')
assign_message_metadata('otp_verification_email', submitter)
from =
if Docuseal.multitenant? &&
AccountConfig.exists?(account_id: submitter.account_id, key: 'custom_otp_email', value: true)
from_address_for_submitter(submitter)
else
default_params[:from]
end
I18n.with_locale(locale || submitter.account.locale) do
mail(to: submitter.email, subject: I18n.t('email_verification'))
mail(to: submitter.email, from:, subject: I18n.t('email_verification'))
end
end
+12 -2
View File
@@ -5,10 +5,20 @@ class TemplateMailer < ApplicationMailer
@current_account = template.account
@template = template
@otp_code = EmailVerificationCodes.generate([email.downcase.strip, template.slug].join(':'))
@otp_code = OtpChallenges.generate([email.downcase.squish, template.slug].join(':'), purpose: 'form_email_2fa')
assign_message_metadata('otp_verification_email', template)
mail(to: email, subject: I18n.t('email_verification'))
from =
if Docuseal.multitenant? &&
AccountConfig.exists?(account_id: template.account_id, key: 'custom_otp_email', value: true)
put_metadata('from_user_id' => template.author_id)
template.author.friendly_name
else
default_params[:from]
end
mail(to: email, from:, subject: I18n.t('email_verification'))
end
end
+21
View File
@@ -0,0 +1,21 @@
# frozen_string_literal: true
# == Schema Information
#
# Table name: otp_challenges
#
# id :bigint not null, primary key
# attempts :integer default(0), not null
# expire_at :datetime not null
# key :string not null
# nonce :string not null
# verified_at :datetime
# version :integer not null
# created_at :datetime not null
#
# Indexes
#
# index_otp_challenges_on_key_and_version (key,version) UNIQUE
#
class OtpChallenge < ApplicationRecord
end
+1 -1
View File
@@ -196,7 +196,7 @@
<% end %>
<% end %>
<% account_config = AccountConfig.find_or_initialize_by(account: current_account, key: AccountConfig::DOWNLOAD_LINKS_EXPIRE_KEY) %>
<% if can?(:manage, account_config) %>
<% if !Docuseal.demo? && can?(:manage, account_config) %>
<%= form_for account_config, url: account_configs_path, method: :post do |f| %>
<%= f.hidden_field :key %>
<div class="flex items-center justify-between gap-4 py-2.5">
+7
View File
@@ -0,0 +1,7 @@
<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">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M10 12a2 2 0 1 0 4 0a2 2 0 0 0 -4 0" />
<path d="M12 18c-3.6 0 -6.6 -2 -9 -6c2.4 -4 5.4 -6 9 -6c3.6 0 6.6 2 9 6" />
<path d="M16 19h6" />
<path d="M19 16v6" />
</svg>

After

Width:  |  Height:  |  Size: 460 B

+7
View File
@@ -0,0 +1,7 @@
<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">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M12 19h-7a2 2 0 0 1 -2 -2v-10a2 2 0 0 1 2 -2h14a2 2 0 0 1 2 2v5.5" />
<path d="M16 19h6" />
<path d="M19 16v6" />
<path d="M3 7l9 6l9 -6" />
</svg>

After

Width:  |  Height:  |  Size: 441 B

+2 -3
View File
@@ -1,8 +1,7 @@
<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">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M5 7a4 4 0 1 0 8 0a4 4 0 0 0 -8 0" />
<path d="M3 21v-2a4 4 0 0 1 4 -4h4c.96 0 1.84 .338 2.53 .901" />
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
<path d="M8 7a4 4 0 1 0 8 0a4 4 0 0 0 -8 0" />
<path d="M16 19h6" />
<path d="M19 16v6" />
<path d="M6 21v-2a4 4 0 0 1 4 -4h4" />
</svg>

Before

Width:  |  Height:  |  Size: 488 B

After

Width:  |  Height:  |  Size: 421 B

+1 -1
View File
@@ -3,7 +3,7 @@
<div class="w-full text-center">
<span class="font-bold"><%= t('demo_environment') %></span>
<br>
<%= t('create_a_new_template_document_form_or_submit_the_existing_one_html', new_template_link: new_template_path, start_form_link: start_form_url(slug: ::Template.first&.slug)) %> 😊
<%= t('create_a_new_template_document_form_html', new_template_link: new_template_path) %>
</div>
</div>
</div>
+2 -2
View File
@@ -22,13 +22,13 @@
<% end %>
<% if can?(:create, ::Template) && controller_name.in?(%w[templates_dashboard submissions_dashboard template_folders templates_archived submissions_archived]) %>
<% folder_name = @template_folder&.full_name %>
<native-action data-placement="create" data-icon="upload" data-native="upload" data-label="<%= t('upload') %>" data-url="<%= templates_upload_path(folder_name: folder_name) %>" data-accept="image/*, application/pdf, application/zip, application/json<%= ", #{Templates::CreateAttachments::DOCUMENT_EXTENSIONS.join(', ')}" if Docuseal.advanced_formats? %>"></native-action>
<native-action data-placement="create" data-icon="upload" data-native="upload" data-label="<%= t('upload') %>" data-url="<%= templates_upload_path(folder_name: folder_name) %>" data-accept="image/*, application/pdf, application/zip, application/json, <%= Templates::CreateAttachments.document_extensions.join(', ') %>"></native-action>
<native-action data-placement="create" data-icon="scan" data-native="scan" data-label="<%= t('scan') %>" data-url="<%= templates_upload_path(folder_name: folder_name) %>"></native-action>
<native-action data-placement="create" data-icon="pencil_plus">
<%= link_to t('create'), new_template_path(folder_name: folder_name), data: { turbo_frame: :modal, native_modal: :medium } %>
</native-action>
<% unless controller_name.in?(%w[templates_archived submissions_archived]) %>
<native-action data-placement="menu" data-menu="<%= t('new') %>" data-menu-icon="pencil_plus" data-icon="upload" data-native="upload" data-label="<%= t('upload') %>" data-url="<%= templates_upload_path(folder_name: folder_name) %>" data-accept="image/*, application/pdf, application/zip, application/json<%= ", #{Templates::CreateAttachments::DOCUMENT_EXTENSIONS.join(', ')}" if Docuseal.advanced_formats? %>"></native-action>
<native-action data-placement="menu" data-menu="<%= t('new') %>" data-menu-icon="pencil_plus" data-icon="upload" data-native="upload" data-label="<%= t('upload') %>" data-url="<%= templates_upload_path(folder_name: folder_name) %>" data-accept="image/*, application/pdf, application/zip, application/json, <%= Templates::CreateAttachments.document_extensions.join(', ') %>"></native-action>
<native-action data-placement="menu" data-menu="<%= t('new') %>" data-menu-icon="pencil_plus" data-icon="scan" data-native="scan" data-label="<%= t('scan') %>" data-url="<%= templates_upload_path(folder_name: folder_name) %>"></native-action>
<native-action data-placement="menu" data-menu="<%= t('new') %>" data-menu-icon="pencil_plus" data-icon="pencil_plus">
<%= link_to t('create'), new_template_path(folder_name: folder_name), data: { turbo_frame: :modal, native_modal: :medium } %>
+2 -2
View File
@@ -93,14 +93,14 @@
</li>
<% end %>
<% if !Docuseal.demo? && can?(:manage, EncryptedConfig) && (current_user == true_user || current_account.testing?) %>
<% if Docuseal.multitenant? || !turbo_native_ios? %>
<% unless turbo_native_app? %>
<li>
<%= link_to Docuseal.multitenant? ? console_redirect_index_path(redir: "#{Docuseal::CONSOLE_URL}#{'/test' if current_account.testing?}/api") : "#{Docuseal::CONSOLE_URL}/on_premises", class: 'text-base hover:bg-base-300', data: { turbo: false } do %>
<% if Docuseal.multitenant? %> API <% else %> <%= t('console') %> <% end %>
<% end %>
</li>
<% end %>
<% if Docuseal.multitenant? %>
<% if Docuseal.multitenant? && !turbo_native_app? %>
<li>
<%= link_to console_redirect_index_path(redir: "#{Docuseal::CONSOLE_URL}#{'/test' if current_account.testing?}/embedding/form"), class: 'text-base hover:bg-base-300', data: { turbo: false } do %>
<%= t('embedding') %>
+1 -1
View File
@@ -12,7 +12,7 @@
<div>
<p dir="auto" class="text-lg font-bold mb-1"><%= @submitter.submission.template&.name %></p>
<p dir="auto" class="text-sm">
<%= t(@submitter.with_signature_fields? ? 'signed_on_time' : 'completed_on_time', time: l(@submitter.completed_at.to_date, format: :long)) %>
<%= t(@submitter.with_signature_fields? ? 'signed_on_time' : 'completed_on_time', time: l(@submitter.completed_at.in_time_zone(@submitter.account.timezone).to_date, format: :long)) %>
</p>
</div>
</div>
+70
View File
@@ -0,0 +1,70 @@
<% fields_id = "add_new_fields_#{SecureRandom.hex(3)}" %>
<div id="<%= fields_id %>" class="space-y-3 mt-4 hidden" data-targets="dynamic-list.groups" data-multiple-classes="px-3 pt-3 pb-4 rounded-xl border border-dashed border-base-300"></div>
<native-menu class="<%= turbo_native_app? ? 'block' : 'dropdown block max-sm:hidden' %> mt-4 -mb-2">
<label tabindex="0" class="btn btn-ghost btn-sm normal-case font-normal w-full flex items-center justify-center border-dashed border-base-300 hover:border-base-content/20 !bg-transparent">
<%= svg_icon('users_plus', class: 'w-4 h-4 stroke-2') %>
<span><%= t('add_new') %></span>
</label>
<ul tabindex="0" class="<%= turbo_native_app? ? 'hidden' : 'z-10 dropdown-content p-2 mt-1 shadow menu bg-base-100 rounded-box' %>" style="left: 50%; --tw-translate-x: -50%">
<li>
<native-menu-action data-icon="user_plus" data-label="<%= t('recipients') %>" class="grid-cols-1 p-0">
<a href="#" class="flex items-center gap-2 px-4 py-2" data-action="click:dynamic-list#addItem">
<%= svg_icon('user_plus', class: 'w-4 h-4 stroke-2') %>
<span class="whitespace-nowrap"><%= t('recipients') %></span>
</a>
</native-menu-action>
</li>
<append-template class="contents" data-target-id="<%= fields_id %>">
<li>
<native-menu-action data-icon="eye_plus" data-label="<%= t('cc_view_only') %>" class="grid-cols-1 p-0">
<a href="#" class="flex items-center gap-2 px-4 py-2">
<%= svg_icon('eye_plus', class: 'w-4 h-4 stroke-2') %>
<span class="whitespace-nowrap"><%= t('cc_view_only') %></span>
</a>
</native-menu-action>
</li>
<template>
<submitter-item class="form-control">
<label class="label pt-0 pb-1">
<span class="label-text"><%= t('cc_view_only') %></span>
</label>
<div class="relative">
<submitters-autocomplete data-field="email">
<input type="email" name="cc_recipients[]" autocomplete="off" class="base-input !h-10 w-full pr-10" placeholder="<%= t('email') %>">
</submitters-autocomplete>
<a href="#" class="absolute right-2 top-2 block w-6 h-6 text-neutral-700 p-1 hover:text-base-content">
<%= svg_icon('trash', class: 'w-4 h-4') %>
</a>
</div>
</submitter-item>
</template>
</append-template>
<append-template class="contents" data-target-id="<%= fields_id %>">
<li>
<native-menu-action data-icon="mail_plus" data-label="<%= t('bcc_completed') %>" class="grid-cols-1 p-0">
<a href="#" class="flex items-center gap-2 px-4 py-2">
<%= svg_icon('mail_plus', class: 'w-4 h-4 stroke-2') %>
<span class="whitespace-nowrap"><%= t('bcc_completed') %></span>
</a>
</native-menu-action>
</li>
<template>
<div class="form-control">
<label class="label pt-0 pb-1">
<span class="label-text"><%= t('bcc_completed') %></span>
</label>
<div class="relative">
<input type="email" name="bcc_completed[]" autocomplete="off" class="base-input !h-10 w-full pr-10" placeholder="<%= t('email') %>">
<a href="#" class="absolute right-2 top-2 block w-6 h-6 text-neutral-700 p-1 hover:text-base-content">
<%= svg_icon('trash', class: 'w-4 h-4') %>
</a>
</div>
<% default_bcc_completed = template.preferences['bcc_completed'].presence || current_account.account_configs.find_by(key: AccountConfig::BCC_EMAILS)&.value %>
<% if default_bcc_completed.present? %>
<input type="hidden" name="bcc_completed[]" value="<%= default_bcc_completed %>">
<% end %>
</div>
</template>
</append-template>
</ul>
</native-menu>
+14 -12
View File
@@ -1,12 +1,12 @@
<% has_phone_field = false %>
<%= form_for '', url: template_submissions_path(template), html: { class: 'space-y-4', autocomplete: 'off' }, data: { turbo_frame: :_top } do |f| %>
<%= form_for '', url: template_submissions_path(template), html: { autocomplete: 'off' }, data: { turbo_frame: :_top } do |f| %>
<% submitters = template.submitters.reject { |e| e['invite_by_uuid'].present? || e['optional_invite_by_uuid'].present? || e['invite_via_field_uuid'].present? } %>
<dynamic-list class="space-y-4">
<dynamic-list class="block" data-multiple-classes="px-3 <%= submitters.size > 1 ? 'pt-3 pb-4' : 'py-4' %> rounded-xl border border-dashed border-base-300">
<div class="space-y-4">
<div class="relative <%= 'p-4 rounded-xl bg-base-300/40 max-sm:p-0 max-sm:bg-transparent' unless local_assigns[:variables_form] %>" data-targets="dynamic-list.items">
<div class="relative" data-targets="dynamic-list.items">
<div>
<div class="absolute right-4 top-5">
<a href="#" data-action="click:dynamic-list#removeItem" class="<%= submitters.size == 1 ? 'right-2 top-1' : '-top-3' %> relative block w-6 h-6 rounded-lg text-neutral-700 text-center bg-base-300 p-1 hidden hover:bg-neutral hover:text-white">
<div class="absolute top-5" style="right: 12px">
<a href="#" data-action="click:dynamic-list#removeItem" class="<%= submitters.size == 1 ? 'right-2 top-1' : '-top-3' %> relative block w-6 h-6 text-neutral-700 p-1 hidden hover:text-base-content">
<%= svg_icon('trash', class: 'w-4 h-4') %>
</a>
</div>
@@ -86,14 +86,11 @@
</div>
</div>
<% if params[:selfsign].blank? && local_assigns[:prefillable_fields].blank? && local_assigns[:recipient_form_fields].blank? && local_assigns[:variables_form].blank? %>
<a href="#" class="btn btn-primary btn-sm w-full flex items-center justify-center max-sm:hidden" data-action="click:dynamic-list#addItem">
<%= svg_icon('user_plus', class: 'w-4 h-4 stroke-2') %>
<span><%= t('add_new') %></span>
</a>
<%= render 'add_new', template: %>
<% end %>
</dynamic-list>
<%= local_assigns[:variables_form] %>
<div>
<div class="mt-4">
<%= render('submitters_order', f:, template:) if can_send_emails %>
<%= render 'send_email', f:, template:, can_send_emails:, viewer_submitter_uuids: local_assigns[:viewer_submitter_uuids] %>
<% if has_phone_field %>
@@ -101,7 +98,12 @@
<% end %>
<%= render 'extra_fields', f: %>
</div>
<div class="form-control">
<%= f.button button_title(title: t('add_recipients')), class: 'base-button' %>
<div class="form-control mt-4">
<%= f.button class: 'base-button' do %>
<toggle-send-title>
<span><%= button_title(title: t('send'), disabled_with: t('sending')) %></span>
<span class="hidden"><%= button_title(title: t('add_recipients')) %></span>
</toggle-send-title>
<% end %>
</div>
<% end %>
+15 -13
View File
@@ -1,4 +1,4 @@
<%= form_for '', url: template_submissions_path(template), html: { class: 'space-y-4', autocomplete: 'off' }, data: { turbo_frame: :_top } do |f| %>
<%= form_for '', url: template_submissions_path(template), html: { autocomplete: 'off' }, data: { turbo_frame: :_top } do |f| %>
<% submitters = template.submitters.reject { |e| e['invite_by_uuid'].present? || e['optional_invite_by_uuid'].present? || e['invite_via_field_uuid'].present? } %>
<% if submitters.size == 1 && local_assigns[:variables_form] %>
<% item = submitters.first %>
@@ -26,18 +26,18 @@
<emails-textarea data-bulk-enabled="<%= Docuseal.demo? || !Docuseal.multitenant? || can?(:manage, :bulk_send) %>" data-limit="<%= Docuseal.multitenant? ? (can?(:manage, :bulk_send) ? 40 : 1) : nil %>">
<submitters-autocomplete data-field="email" class="block relative">
<autoresize-textarea>
<%= f.text_area :emails, required: true, class: 'base-textarea w-full !text-lg', placeholder: "#{t('type_emails_here')}...", rows: 2 %>
<%= f.text_area :emails, required: true, class: 'base-textarea block w-full !text-lg', placeholder: "#{t('type_emails_here')}...", rows: 2 %>
</autoresize-textarea>
</submitters-autocomplete>
</emails-textarea>
</submitter-item>
<% else %>
<dynamic-list class="space-y-4">
<dynamic-list class="block" data-multiple-classes="px-3 pt-3 pb-4 rounded-xl border border-dashed border-base-300">
<div class="space-y-4">
<div class="relative <%= 'p-4 rounded-xl bg-base-300/40 max-sm:p-0 max-sm:bg-transparent' unless local_assigns[:variables_form] %>" data-targets="dynamic-list.items">
<div class="relative" data-targets="dynamic-list.items">
<div>
<div class="absolute right-4 top-5">
<a href="#" data-action="click:dynamic-list#removeItem" class="-top-3 relative block w-6 h-6 rounded-lg text-neutral-700 text-center bg-base-300 p-1 hidden hover:bg-neutral hover:text-white">
<div class="absolute top-5" style="right: 12px">
<a href="#" data-action="click:dynamic-list#removeItem" class="-top-3 relative block w-6 h-6 text-neutral-700 p-1 hidden hover:text-base-content">
<%= svg_icon('trash', class: 'w-4 h-4') %>
</a>
</div>
@@ -60,22 +60,24 @@
</div>
</div>
<% if params[:selfsign].blank? && local_assigns[:variables_form].blank? %>
<a href="#" class="btn btn-primary btn-sm w-full flex items-center justify-center max-sm:hidden" data-action="click:dynamic-list#addItem">
<%= svg_icon('user_plus', class: 'w-4 h-4 stroke-2') %>
<span><%= t('add_new') %></span>
</a>
<%= render 'add_new', template: %>
<% end %>
</dynamic-list>
<% end %>
<%= local_assigns[:variables_form] %>
<div>
<div class="mt-4">
<%= render('submitters_order', f:, template:) if can_send_emails %>
<%= render 'send_email', f:, template:, can_send_emails:, viewer_submitter_uuids: local_assigns[:viewer_submitter_uuids] %>
<%= render 'extra_fields', f: %>
</div>
<div class="form-control">
<div class="form-control mt-4">
<%= content_for(:submit_button) || capture do %>
<%= f.button button_title(title: t('add_recipients')), class: 'base-button' %>
<%= f.button class: 'base-button' do %>
<toggle-on-checked data-names="send_email send_sms">
<span><%= button_title(title: t('send'), disabled_with: t('sending')) %></span>
<span class="hidden"><%= button_title(title: t('add_recipients')) %></span>
</toggle-on-checked>
<% end %>
<% end %>
</div>
<% end %>
+14 -12
View File
@@ -1,11 +1,11 @@
<%= form_for '', url: template_submissions_path(template), html: { class: 'space-y-4', autocomplete: 'off' }, data: { turbo_frame: :_top } do |f| %>
<%= form_for '', url: template_submissions_path(template), html: { autocomplete: 'off' }, data: { turbo_frame: :_top } do |f| %>
<% submitters = template.submitters.reject { |e| e['invite_by_uuid'].present? || e['optional_invite_by_uuid'].present? || e['invite_via_field_uuid'].present? } %>
<dynamic-list class="space-y-4">
<dynamic-list class="block" data-multiple-classes="px-3 <%= submitters.size > 1 ? 'pt-3 pb-4' : 'py-4' %> rounded-xl border border-dashed border-base-300">
<div class="space-y-4">
<div class="relative <%= 'p-4 rounded-xl bg-base-300/40 max-sm:p-0 max-sm:bg-transparent' unless local_assigns[:variables_form] %>" data-targets="dynamic-list.items">
<div class="relative" data-targets="dynamic-list.items">
<div>
<div class="absolute right-4 top-5">
<a href="#" data-action="click:dynamic-list#removeItem" class="<%= submitters.size == 1 ? 'right-2 top-1' : '-top-3' %> relative block w-6 h-6 rounded-lg text-neutral-700 text-center bg-base-300 p-1 hidden hover:bg-neutral hover:text-white">
<div class="absolute top-5" style="right: 12px">
<a href="#" data-action="click:dynamic-list#removeItem" class="<%= submitters.size == 1 ? 'right-2 top-1' : '-top-3' %> relative block w-6 h-6 text-neutral-700 p-1 hidden hover:text-base-content">
<%= svg_icon('trash', class: 'w-4 h-4') %>
</a>
</div>
@@ -48,19 +48,21 @@
</div>
</div>
<% if params[:selfsign].blank? && local_assigns[:variables_form].blank? %>
<a href="#" class="btn btn-primary btn-sm w-full flex items-center justify-center max-sm:hidden" data-action="click:dynamic-list#addItem">
<%= svg_icon('user_plus', class: 'w-4 h-4 stroke-2') %>
<span><%= t('add_new') %></span>
</a>
<%= render 'add_new', template: %>
<% end %>
</dynamic-list>
<%= local_assigns[:variables_form] %>
<div>
<div class="mt-4">
<%= render 'submitters_order', f:, template: %>
<%= render 'send_sms', f:, checked: true %>
<%= render 'extra_phone_fields', f: %>
</div>
<div class="form-control">
<%= f.button button_title(title: t('add_recipients')), class: 'base-button' %>
<div class="form-control mt-4">
<%= f.button class: 'base-button' do %>
<toggle-on-checked data-names="send_email send_sms">
<span><%= button_title(title: t('send'), disabled_with: t('sending')) %></span>
<span class="hidden"><%= button_title(title: t('add_recipients')) %></span>
</toggle-on-checked>
<% end %>
</div>
<% end %>
@@ -14,7 +14,7 @@
<toggle-visible data-element-ids="<%= [message_field_id].to_json %>" class="flex">
<label>
<%= f.check_box :is_custom_message, checked: false, class: 'hidden peer', data: { action: 'change:toggle-visible#trigger', type: 'checkbox' } %>
<span class="link peer-checked:hidden"><%= t('edit_message') %></span>
<span class="link link-hover peer-checked:hidden"><%= t('edit_message') %></span>
</label>
</toggle-visible>
<% end %>
+5 -3
View File
@@ -1,5 +1,7 @@
<% unless turbo_native_ios? %>
<div class="form-control">
<%= render 'sms_settings/placeholder' %>
</div>
<toggle-on-checked data-names="is_custom_message" class="block">
<div class="form-control">
<%= render 'sms_settings/placeholder' %>
</div>
</toggle-on-checked>
<% end %>
+1 -1
View File
@@ -26,7 +26,7 @@
</div>
</toggle-visible>
<% end %>
<div class="px-5 mb-5 mt-4">
<div class="px-5 mb-5 mt-5">
<% unless only_detailed %>
<div id="email" class="<%= 'hidden' if default_tab != 'email' %>">
<%= render 'email_form', template: @template, variables_form:, can_send_emails:, viewer_submitter_uuids: %>
+18 -12
View File
@@ -54,7 +54,8 @@
<% if @submission.submitters.to_a.any?(&:completed_at?) %>
<% show_combined_download = @submission.completed_at? && !is_combined_enabled %>
<% show_unarchive = signed_in? && can?(:destroy, @submission) && @submission.archived_at? && @submission.completed_at? %>
<% show_download_dropdown = show_combined_download || show_unarchive %>
<% show_event_log_download = signed_in? && can?(:manage, :event_log_download) && can?(:read, @submission) %>
<% show_download_dropdown = show_combined_download || show_event_log_download || show_unarchive %>
<% if @submission.completed_at? || !is_combined_enabled %>
<div class="join relative">
<native-action data-label="<%= t('download') %>" data-icon="download">
@@ -95,6 +96,9 @@
</li>
</native-action>
<% end %>
<% if show_event_log_download %>
<%= render 'submissions/download_event_log' %>
<% end %>
<% if show_unarchive %>
<native-action data-label="<%= t('unarchive') %>" data-icon="rotate" data-section="archive">
<li>
@@ -163,6 +167,7 @@
<% value = values[field['uuid']].presence || (field['default_value'] != '{{date}}' && field['readonly'] == true && field['conditions'].blank? && field['default_value'].present? ? Submitters::SubmitValues.template_default_value_for_submitter(field['default_value'], @submission.submitters.find { |e| e.uuid == field['submitter_uuid'] }, with_time: false) : nil) %>
<% value ||= field['default_value'] if field['type'] == 'heading' %>
<% submitter = submitters_index[field['submitter_uuid']] %>
<% value = nil if field['type'] == 'signature' && submitter && !submitter.completed_at? %>
<% if value.present? %>
<% if (mask = field.dig('preferences', 'mask').presence) && signed_in? && can?(:read, @submission) %>
<span class="group">
@@ -222,39 +227,39 @@
</div>
<% if submitter&.name.present? %>
<div class="flex items-center space-x-1 mt-1">
<%= svg_icon('user', class: 'w-5 h-5') %>
<span>
<%= svg_icon('user', class: 'w-5 h-5 shrink-0') %>
<span class="truncate">
<%= submitter&.name %>
</span>
</div>
<% end %>
<% if submitter&.email.present? %>
<div class="flex items-center space-x-1 mt-1">
<%= svg_icon('mail', class: 'w-5 h-5') %>
<span>
<%= svg_icon('mail', class: 'w-5 h-5 shrink-0') %>
<span class="truncate">
<%= submitter.email || 'N/A' %>
</span>
</div>
<% end %>
<% if submitter&.phone.present? %>
<div class="flex items-center space-x-1 mt-1">
<%= svg_icon('phone', class: 'w-5 h-5') %>
<span>
<%= svg_icon('phone', class: 'w-5 h-5 shrink-0') %>
<span class="truncate">
<%= submitter.phone %>
</span>
</div>
<% end %>
<div class="flex items-center space-x-1 mt-1">
<% if submitter&.viewer? %>
<%= svg_icon(submitter.opened_at? ? 'file_check' : 'file_unknown', class: 'w-5 h-5') %>
<%= svg_icon(submitter.opened_at? ? 'file_check' : 'file_unknown', class: 'w-5 h-5 shrink-0') %>
<% elsif submitter && !submitter.completed_at? && @submission.completed_at? %>
<%= svg_icon('writing_off', class: 'w-5 h-5') %>
<%= svg_icon('writing_off', class: 'w-5 h-5 shrink-0') %>
<% elsif @submission.expire_at? && submitter && !submitter.completed_at? %>
<%= svg_icon('clock_exclamation', class: 'w-5 h-5') %>
<%= svg_icon('clock_exclamation', class: 'w-5 h-5 shrink-0') %>
<% else %>
<%= svg_icon('writing', class: 'w-5 h-5') %>
<%= svg_icon('writing', class: 'w-5 h-5 shrink-0') %>
<% end %>
<span>
<span class="truncate">
<% if submitter&.declined_at? %>
<%= t('declined_on_time', time: l(submitter.declined_at.in_time_zone(@submission.account.timezone), format: :short, locale: @submission.account.locale)) %>
<% elsif submitter&.viewer? %>
@@ -320,6 +325,7 @@
<% value = values[field['uuid']].presence || (field['default_value'] != '{{date}}' && field['readonly'] == true && field['default_value'].present? ? Submitters::SubmitValues.template_default_value_for_submitter(field['default_value'], @submission.submitters.find { |e| e.uuid == field['submitter_uuid'] }, with_time: false) : nil) %>
<% next if value.blank? %>
<% next if field['type'] == 'heading' || field['type'] == 'strikethrough' %>
<% next if field['type'] == 'signature' && submitter && !submitter.completed_at? %>
<div class="pt-2.5 border-b border-base-300">
<div class="text-xs font-medium uppercase mb-0.5" dir="auto">
<%= field['name'].presence || "#{t("#{field['type']}_field")} #{submitter_field_counters[field['type']]}" %>
@@ -15,7 +15,7 @@
<% last_submitter = @submission.submitters.completed.order(:completed_at).last %>
<% if last_submitter %>
<p dir="auto" class="text-sm">
<%= t(last_submitter.with_signature_fields? ? 'signed_on_time' : 'completed_on_time', time: l(last_submitter.completed_at.to_date, format: :long)) %>
<%= t(last_submitter.with_signature_fields? ? 'signed_on_time' : 'completed_on_time', time: l(last_submitter.completed_at.in_time_zone(@submission.account.timezone).to_date, format: :long)) %>
</p>
<% end %>
</div>
+1 -1
View File
@@ -13,7 +13,7 @@
<div>
<p dir="auto" class="text-lg font-bold mb-1"><%= @submitter.submission.name || @submitter.submission.template&.name %></p>
<p dir="auto" class="text-sm">
<%= t(@submitter.with_signature_fields? ? 'signed_on_time' : 'completed_on_time', time: l(completed_at.to_date, format: :long)) %>
<%= t(@submitter.with_signature_fields? ? 'signed_on_time' : 'completed_on_time', time: l(completed_at.in_time_zone(@submitter.account.timezone).to_date, format: :long)) %>
</p>
</div>
</div>
+1 -1
View File
@@ -11,7 +11,7 @@
</div>
<div dir="auto">
<p class="text-lg font-bold mb-1"><%= @submitter.submission.name || @submitter.submission.template&.name %></p>
<p class="text-sm"><%= t('form_has_been_declined_on_html', time: l(@submitter.declined_at, format: :long)) %></p>
<p class="text-sm"><%= t('form_has_been_declined_on_html', time: l(@submitter.declined_at.in_time_zone(@submitter.account.timezone).to_date, format: :long)) %></p>
</div>
</div>
</div>
+1 -1
View File
@@ -11,7 +11,7 @@
</div>
<div dir="auto">
<p class="text-lg font-bold mb-1"><%= @submitter.submission.name || @submitter.submission.template&.name %></p>
<p class="text-sm"><%= t('document_has_been_delegated_on_html', time: l(@submitter.submission_events.order(event_timestamp: :desc).find_by!(event_type: :delegate_form).event_timestamp, format: :long)) %></p>
<p class="text-sm"><%= t('document_has_been_delegated_on_html', time: l(@submitter.submission_events.order(event_timestamp: :desc).find_by!(event_type: :delegate_form).event_timestamp.in_time_zone(@submitter.account.timezone).to_date, format: :long)) %></p>
</div>
</div>
</div>
+1 -1
View File
@@ -19,7 +19,7 @@
<% last_submitter = @submitter.submission.submitters.completed.order(:completed_at).last %>
<% if last_submitter %>
<p dir="auto" class="text-sm">
<%= t(last_submitter.with_signature_fields? ? 'signed_on_time' : 'completed_on_time', time: l(last_submitter.completed_at.to_date, format: :long)) %>
<%= t(last_submitter.with_signature_fields? ? 'signed_on_time' : 'completed_on_time', time: l(last_submitter.completed_at.in_time_zone(@submitter.account.timezone).to_date, format: :long)) %>
</p>
<% end %>
</div>
+1 -1
View File
@@ -11,7 +11,7 @@
</div>
<div dir="auto">
<p class="text-lg font-bold mb-1"><%= @submitter.submission.name || @submitter.submission.template&.name %></p>
<p class="text-sm"><%= t('form_expired_at_html', time: l(@submitter.submission.expire_at, format: :long)) %></p>
<p class="text-sm"><%= t('form_expired_at_html', time: l(@submitter.submission.expire_at.in_time_zone(@submitter.account.timezone).to_date, format: :long)) %></p>
</div>
</div>
</div>
+1 -1
View File
@@ -82,7 +82,7 @@
</div>
<% templates_order_select_html = capture do %>
<% if params[:q].blank? && @pagy.pages > 1 && !can?(:manage, :countless) %>
<%= render 'shared/templates_order_select', with_recently_used: @pagy.count.present? && @pagy.count < 10_000, selected_order: %>
<%= render 'shared/templates_order_select', with_recently_used: @pagy.count.present? && @pagy.count < 1_000, selected_order: %>
<% end %>
<% end %>
<%= render 'shared/pagination', pagy: @pagy, items_name: @templates.present? ? 'templates' : 'template_folders', right_additional_html: templates_order_select_html %>
+1 -1
View File
@@ -31,7 +31,7 @@
</div>
</span>
</div>
<input id="file_dropzone_input" name="files[]" class="hidden" data-action="change:file-dropzone#onSelectFiles" data-target="file-dropzone.input" type="file" accept="image/*, application/pdf, application/zip, application/json<%= ", #{Templates::CreateAttachments::DOCUMENT_EXTENSIONS.join(', ')}" if Docuseal.advanced_formats? %>" multiple>
<input id="file_dropzone_input" name="files[]" class="hidden" data-action="change:file-dropzone#onSelectFiles" data-target="file-dropzone.input" type="file" accept="image/*, application/pdf, application/zip, application/json, <%= Templates::CreateAttachments.document_extensions.join(', ') %>" multiple>
</div>
</label>
</file-dropzone>
+1 -1
View File
@@ -16,7 +16,7 @@
</label>
<input type="hidden" name="form_id" value="<%= form_id %>">
<convert-upload>
<input id="upload_template" name="files[]" class="hidden" type="file" accept="image/*, application/pdf, application/zip, application/json<%= ", #{Templates::CreateAttachments::DOCUMENT_EXTENSIONS.join(', ')}" if Docuseal.advanced_formats? %>" multiple>
<input id="upload_template" name="files[]" class="hidden" type="file" accept="image/*, application/pdf, application/zip, application/json, <%= Templates::CreateAttachments.document_extensions.join(', ') %>" multiple>
</convert-upload>
<input hidden name="folder_name" value="<%= local_assigns[:folder_name] %>">
<% end %>
+1 -1
View File
@@ -10,4 +10,4 @@
<% account_configs = current_account.account_configs.where(key: [AccountConfig::TEMPLATE_CUSTOM_FIELDS_KEY, AccountConfig::TEMPLATE_DATE_FORMATS_KEY]).to_a %>
<% custom_fields = account_configs.find { |e| e.key == AccountConfig::TEMPLATE_CUSTOM_FIELDS_KEY }&.value || [] %>
<% date_formats = account_configs.find { |e| e.key == AccountConfig::TEMPLATE_DATE_FORMATS_KEY }&.value || [] %>
<template-builder data-native-platform="<%= turbo_native_ios? ? 'ios' : (turbo_native_android? ? 'android' : '') %>" class="grid" data-template="<%= @template_data.to_json %>" data-custom-fields="<%= custom_fields.to_json %>" data-date-formats="<%= date_formats.to_json %>" data-with-sign-yourself-button="<%= !@template.archived_at? %>" data-with-fields-detection="true" data-with-send-button="<%= !@template.archived_at? && can?(:create, @template.submissions.new(account: current_account)) %>" data-with-revisions-menu="<%= @template.template_versions.exists? %>" data-locale="<%= I18n.locale %>" data-show-tour-start-form="<%= @show_tour_start_form %>"></template-builder>
<template-builder data-native-platform="<%= turbo_native_ios? ? 'ios' : (turbo_native_android? ? 'android' : '') %>" data-accept-file-types="image/*, application/pdf, application/zip, application/json, <%= Templates::CreateAttachments.document_extensions.join(', ') %>" class="grid" data-template="<%= @template_data.to_json %>" data-custom-fields="<%= custom_fields.to_json %>" data-date-formats="<%= date_formats.to_json %>" data-with-sign-yourself-button="<%= !@template.archived_at? %>" data-with-fields-detection="true" data-with-send-button="<%= !@template.archived_at? && can?(:create, @template.submissions.new(account: current_account)) %>" data-with-revisions-menu="<%= @template.template_versions.exists? %>" data-locale="<%= I18n.locale %>" data-show-tour-start-form="<%= @show_tour_start_form %>"></template-builder>
+1 -1
View File
@@ -26,7 +26,7 @@
<%= t('share_template_with_test_mode') %>
</span>
<submit-form data-on="change">
<%= f.check_box :value, class: 'toggle', checked: @template.template_sharings.exists?(account_id: true_user.account.testing_accounts) %>
<%= f.check_box :value, class: 'toggle', checked: @template.template_sharings.exists?(account_id: (can?(:manage, :tenants_test) ? current_account : true_user.account).testing_accounts) %>
</submit-form>
</div>
<% end %>
+2 -2
View File
@@ -52,7 +52,7 @@
<% end %>
<% templates_order_select_html = capture do %>
<% if params[:q].blank? && @pagy.pages > 1 && !can?(:manage, :countless) %>
<%= render 'shared/templates_order_select', with_recently_used: @pagy.count.present? && @pagy.count < 10_000, selected_order: %>
<%= render 'shared/templates_order_select', with_recently_used: @pagy.count.present? && @pagy.count < 1_000, selected_order: %>
<% end %>
<% end %>
<% if @template_folders.present? || @show_default_folder || @show_shared_folder %>
@@ -127,4 +127,4 @@
<%= render 'shared/pagination', pagy: @related_submissions_pagy, items_name: 'submissions', next_page_path: submissions_path(q: params[:q]) %>
<% end %>
<% end %>
<%= render 'shared/review_form' %>
<%= render 'shared/review_form' unless turbo_native_app? %>
@@ -194,7 +194,7 @@
<%= t('share_template_with_test_mode') %>
</span>
<submit-form data-on="change" class="flex">
<%= f.check_box :value, class: 'toggle', checked: @template.template_sharings.exists?(account_id: true_user.account.testing_accounts) %>
<%= f.check_box :value, class: 'toggle', checked: @template.template_sharings.exists?(account_id: (can?(:manage, :tenants_test) ? current_account : true_user.account).testing_accounts) %>
</submit-form>
</div>
<% end %>
@@ -35,10 +35,12 @@
<%= svg_icon('user', class: 'w-5 h-5 inline') %>
<span><%= signature.reason %></span>
</div>
<div class="flex items-center space-x-1">
<%= svg_icon('calendar', class: 'w-5 h-5 inline') %>
<span><%= l(signature.signing_time.in_time_zone(current_account.timezone), format: :long, locale: current_account.locale) %></span>
</div>
<% if signature.signing_time %>
<div class="flex items-center space-x-1">
<%= svg_icon('calendar', class: 'w-5 h-5 inline') %>
<span><%= l(signature.signing_time.in_time_zone(current_account.timezone), format: :long, locale: current_account.locale) %></span>
</div>
<% end %>
<div class="flex items-center space-x-1">
<%= svg_icon('certificate', class: 'w-5 h-5 inline') %>
<span><%= signature.common_name %></span>
+1
View File
@@ -9,3 +9,4 @@ autoload :OnnxRuntime, 'onnxruntime'
autoload :RQRCode, 'rqrcode'
autoload :ArabicLetterConnector, 'arabic-letter-connector/logic'
autoload :JWT, 'jwt'
autoload :WordCraft, 'wordcraft'
+47 -19
View File
@@ -624,6 +624,7 @@ en: &en
form_has_been_declined: Form has been declined.
form_is_view_only: Form is view only.
file_is_missing: File is missing
file_is_not_an_image: File is not an image
folder_name_has_been_updated: Folder name has been updated.
unable_to_rename_folder: Unable to rename folder.
template_has_been_removed: Template has been removed.
@@ -683,7 +684,7 @@ en: &en
thirty_days: 30 days
free: Free
unlimited_documents_storage: Unlimited documents storage
users_management: Users management
user_management: User management
ten_field_types: 10 field types
multiple_signers: Multiple signers
automatic_esignature: Automatic eSignature
@@ -703,6 +704,7 @@ en: &en
developer_sandbox: Developer Sandbox.
upgrade_to_start_using_in_production_html: '<a href="%{url}" target="_blank" class="link font-semibold">Upgrade</a> to start using in Production.'
download_combined_pdf: Download combined PDF
download_event_log: Download Event Log
audit_log: Audit Log
signers: Signers
not_invited_yet: Not invited yet
@@ -714,7 +716,7 @@ en: &en
declined_on_time: 'Declined on %{time}'
expire_on_time: 'Expire on %{time}'
sign_in_person: Sign In-person
create_a_new_template_document_form_or_submit_the_existing_one_html: <a href="%{new_template_link}" data-turbo-frame="modal" class="inline underline font-medium">Create a new template</a> document form or <a href="%{start_form_link}" target="_blank" class="inline underline font-medium">submit the existing one</a>
create_a_new_template_document_form_html: <a href="%{new_template_link}" data-turbo-frame="modal" class="inline underline font-medium">Create a new template</a> document form
send_email_copy_with_completed_documents_to_a_specified_bcc_address: Send email copy with completed documents to a specified BCC address.
re_send_email: Re-send Email
re_send_emails: Re-send Emails
@@ -1026,6 +1028,8 @@ en: &en
failed_to_scan_the_document: Failed to scan the document
document_scanning_is_not_available_on_this_device: Document scanning is not available on this device
click_to_upload: Click to upload
cc_view_only: CC View-only
bcc_completed: BCC Completed
devise:
confirmations:
confirmed: Your email address has been successfully confirmed.
@@ -1781,6 +1785,7 @@ es: &es
form_has_been_declined: El formulario ha sido rechazado.
form_is_view_only: El formulario es de solo lectura.
file_is_missing: Falta el archivo
file_is_not_an_image: El archivo no es una imagen
folder_name_has_been_updated: El nombre de la carpeta ha sido actualizado.
unable_to_rename_folder: No se pudo renombrar la carpeta.
template_has_been_removed: La plantilla ha sido eliminada.
@@ -1840,7 +1845,7 @@ es: &es
thirty_days: 30 días
free: Gratis
unlimited_documents_storage: Almacenamiento ilimitado
users_management: Gestión de usuarios
user_management: Gestión de usuarios
ten_field_types: 10 tipos de campos
multiple_signers: Múltiples firmantes
automatic_esignature: Firma electrónica automática
@@ -1860,6 +1865,7 @@ es: &es
developer_sandbox: Sandbox para desarrolladores.
upgrade_to_start_using_in_production_html: '<a href="%{url}" target="_blank" class="link font-semibold">Actualiza</a> para empezar a usar en Producción.'
download_combined_pdf: Descargar PDF combinado
download_event_log: Descargar registro de eventos
audit_log: Registro de Auditoría
signers: Firmantes
not_invited_yet: Aún no invitado
@@ -1871,7 +1877,7 @@ es: &es
declined_on_time: 'Rechazado el %{time}'
expire_on_time: 'Expira el %{time}'
sign_in_person: Firma en persona
create_a_new_template_document_form_or_submit_the_existing_one_html: <a href="%{new_template_link}" data-turbo-frame="modal" class="inline underline font-medium">Crear una nueva plantilla</a> de documento o <a href="%{start_form_link}" target="_blank" class="inline underline font-medium">enviar el existente</a>
create_a_new_template_document_form_html: <a href="%{new_template_link}" data-turbo-frame="modal" class="inline underline font-medium">Crear una nueva plantilla</a> de documento
send_email_copy_with_completed_documents_to_a_specified_bcc_address: Enviar una copia del correo electrónico con los documentos completados a una dirección BCC especificada.
re_send_email: Reenviar email
re_send_emails: Reenviar emails
@@ -2172,6 +2178,8 @@ es: &es
failed_to_save_the_scan: No se pudo guardar el escaneo
failed_to_scan_the_document: No se pudo escanear el documento
document_scanning_is_not_available_on_this_device: El escaneo de documentos no está disponible en este dispositivo
cc_view_only: CC Solo lectura
bcc_completed: CCO Completado
devise:
confirmations:
confirmed: Tu dirección de correo electrónico ha sido confirmada correctamente.
@@ -2941,6 +2949,7 @@ it: &it
form_has_been_declined: Il modulo è stato rifiutato.
form_is_view_only: Il modulo è di sola lettura.
file_is_missing: File mancante
file_is_not_an_image: Il file non è un'immagine
folder_name_has_been_updated: Il nome della cartella è stato aggiornato.
unable_to_rename_folder: Impossibile rinominare la cartella.
template_has_been_removed: Il modello è stato rimosso.
@@ -3000,7 +3009,7 @@ it: &it
thirty_days: 30 giorni
free: Gratuito
unlimited_documents_storage: Archiviazione illimitata di documenti
users_management: Gestione utenti
user_management: Gestione utenti
ten_field_types: 10 tipi di campi
multiple_signers: Firmatari multipli
automatic_esignature: Firma elettronica automatica
@@ -3020,6 +3029,7 @@ it: &it
developer_sandbox: Sandbox per sviluppatori
upgrade_to_start_using_in_production_html: '<a href="%{url}" target="_blank" class="link font-semibold">Aggiorna</a> per iniziare a usare in produzione.'
download_combined_pdf: Scarica PDF combinato
download_event_log: Scarica registro degli eventi
audit_log: Registro di audit
signers: Firmatari
not_invited_yet: Non ancora invitato
@@ -3031,7 +3041,7 @@ it: &it
declined_on_time: 'Rifiutato il %{time}'
expire_on_time: 'Scade il %{time}'
sign_in_person: Firma di persona
create_a_new_template_document_form_or_submit_the_existing_one_html: <a href="%{new_template_link}" data-turbo-frame="modal" class="inline underline font-medium">Crea un nuovo modello</a> di documento o <a href="%{start_form_link}" target="_blank" class="inline underline font-medium">invia quello esistente</a>
create_a_new_template_document_form_html: <a href="%{new_template_link}" data-turbo-frame="modal" class="inline underline font-medium">Crea un nuovo modello</a> di documento
send_email_copy_with_completed_documents_to_a_specified_bcc_address: Invia una copia dell'email con i documenti completati a un indirizzo BCC specificato.
re_send_email: Reinvia email
re_send_emails: Reinvia le email
@@ -3332,6 +3342,8 @@ it: &it
failed_to_save_the_scan: Impossibile salvare la scansione
failed_to_scan_the_document: Impossibile scansionare il documento
document_scanning_is_not_available_on_this_device: La scansione dei documenti non è disponibile su questo dispositivo
cc_view_only: CC Sola lettura
bcc_completed: BCC Completato
devise:
confirmations:
confirmed: Il tuo indirizzo email è stato confermato con successo.
@@ -4087,6 +4099,7 @@ fr: &fr
form_has_been_declined: Le formulaire a été refusé.
form_is_view_only: Le formulaire est en lecture seule.
file_is_missing: Fichier manquant
file_is_not_an_image: Le fichier n'est pas une image
folder_name_has_been_updated: Le nom du dossier a été mis à jour.
unable_to_rename_folder: Impossible de renommer le dossier.
template_has_been_removed: Le modèle a été supprimé.
@@ -4146,7 +4159,7 @@ fr: &fr
thirty_days: 30 jours
free: Gratuit
unlimited_documents_storage: Stockage de documents illimité
users_management: Gestion des utilisateurs
user_management: Gestion des utilisateurs
ten_field_types: 10 types de champs
multiple_signers: Plusieurs signataires
automatic_esignature: Signature électronique automatique
@@ -4166,6 +4179,7 @@ fr: &fr
developer_sandbox: Bac à sable développeur.
upgrade_to_start_using_in_production_html: <a href="%{url}" target="_blank" class="link font-semibold">Mettez à niveau</a> pour commencer à utiliser en production.
download_combined_pdf: Télécharger le PDF combiné
download_event_log: Télécharger le journal des événements
audit_log: Journal d’audit
signers: Signataires
not_invited_yet: Pas encore invité
@@ -4177,7 +4191,7 @@ fr: &fr
declined_on_time: Refusé le %{time}
expire_on_time: Expire le %{time}
sign_in_person: Signer en personne
create_a_new_template_document_form_or_submit_the_existing_one_html: <a href="%{new_template_link}" data-turbo-frame="modal" class="inline underline font-medium">Créez un nouveau modèle</a> de document ou <a href="%{start_form_link}" target="_blank" class="inline underline font-medium">soumettez l’existant</a>
create_a_new_template_document_form_html: <a href="%{new_template_link}" data-turbo-frame="modal" class="inline underline font-medium">Créez un nouveau modèle</a> de document
send_email_copy_with_completed_documents_to_a_specified_bcc_address: Envoyer une copie e‑mail avec les documents complétés à une adresse Cci spécifiée.
re_send_email: Renvoyer l’e‑mail
re_send_emails: Renvoyer e‑mails
@@ -4475,6 +4489,8 @@ fr: &fr
failed_to_save_the_scan: Impossible d’enregistrer la numérisation
failed_to_scan_the_document: Impossible de numériser le document
document_scanning_is_not_available_on_this_device: La numérisation de documents n’est pas disponible sur cet appareil
cc_view_only: Cc Lecture seule
bcc_completed: Cci Terminé
devise:
confirmations:
confirmed: Votre adresse e-mail a été confirmée avec succès.
@@ -5243,6 +5259,7 @@ pt: &pt
form_has_been_declined: O formulário foi recusado.
form_is_view_only: O formulário é somente leitura.
file_is_missing: O arquivo está ausente
file_is_not_an_image: O arquivo não é uma imagem
folder_name_has_been_updated: O nome da pasta foi atualizado.
unable_to_rename_folder: Não foi possível renomear a pasta.
template_has_been_removed: O modelo foi removido.
@@ -5302,7 +5319,7 @@ pt: &pt
thirty_days: 30 dias
free: Gratuito
unlimited_documents_storage: Armazenamento ilimitado
users_management: Gerenciamento de usuários
user_management: Gerenciamento de usuários
ten_field_types: 10 tipos de campo
multiple_signers: Múltiplos signatários
automatic_esignature: Assinatura eletrônica automática
@@ -5322,6 +5339,7 @@ pt: &pt
developer_sandbox: Sandbox para desenvolvedores
upgrade_to_start_using_in_production_html: '<a href="%{url}" target="_blank" class="link font-semibold">Atualizar</a> para começar a usar em produção.'
download_combined_pdf: Baixar PDF combinado
download_event_log: Baixar log de eventos
audit_log: Log de auditoria
signers: Signatários
not_invited_yet: Ainda não convidado
@@ -5333,7 +5351,7 @@ pt: &pt
declined_on_time: 'Recusado em %{time}'
expire_on_time: 'Expira em %{time}'
sign_in_person: Assinar pessoalmente
create_a_new_template_document_form_or_submit_the_existing_one_html: '<a href="%{new_template_link}" data-turbo-frame="modal" class="inline underline font-medium">Criar um novo modelo</a> de documento ou <a href="%{start_form_link}" target="_blank" class="inline underline font-medium">submeter o existente</a>'
create_a_new_template_document_form_html: '<a href="%{new_template_link}" data-turbo-frame="modal" class="inline underline font-medium">Criar um novo modelo</a> de documento'
send_email_copy_with_completed_documents_to_a_specified_bcc_address: Envie uma cópia do e-mail com documentos concluídos para um endereço BCC especificado.
re_send_email: Reenviar e-mail
re_send_emails: Reenviar e-mails
@@ -5634,6 +5652,8 @@ pt: &pt
failed_to_save_the_scan: Não foi possível salvar a digitalização
failed_to_scan_the_document: Não foi possível digitalizar o documento
document_scanning_is_not_available_on_this_device: A digitalização de documentos não está disponível neste dispositivo
cc_view_only: CC Somente leitura
bcc_completed: BCC Concluído
devise:
confirmations:
confirmed: Seu endereço de e-mail foi confirmado com sucesso.
@@ -6402,6 +6422,7 @@ de: &de
form_has_been_declined: Das Formular wurde abgelehnt.
form_is_view_only: Das Formular ist schreibgeschützt.
file_is_missing: Datei fehlt
file_is_not_an_image: Datei ist kein Bild
folder_name_has_been_updated: Der Ordnername wurde aktualisiert.
unable_to_rename_folder: Der Ordner konnte nicht umbenannt werden.
template_has_been_removed: Die Vorlage wurde entfernt.
@@ -6461,7 +6482,7 @@ de: &de
thirty_days: 30 Tage
free: Kostenlos
unlimited_documents_storage: Unbegrenzter Dokumentenspeicher
users_management: Benutzerverwaltung
user_management: Benutzerverwaltung
ten_field_types: 10 Feldtypen
multiple_signers: Mehrere Unterzeichner
automatic_esignature: Automatische E‑Signatur
@@ -6481,6 +6502,7 @@ de: &de
developer_sandbox: Entwickler-Sandbox.
upgrade_to_start_using_in_production_html: '<a href="%{url}" target="_blank" class="link font-semibold">Upgrade</a> durchführen, um die Produktionsnutzung zu starten.'
download_combined_pdf: Kombiniertes PDF herunterladen
download_event_log: Ereignisprotokoll herunterladen
audit_log: Prüfprotokoll
signers: Unterzeichner
not_invited_yet: Noch nicht eingeladen
@@ -6492,7 +6514,7 @@ de: &de
declined_on_time: 'Abgelehnt am %{time}'
expire_on_time: 'Läuft ab am %{time}'
sign_in_person: Vor Ort unterschreiben
create_a_new_template_document_form_or_submit_the_existing_one_html: <a href="%{new_template_link}" data-turbo-frame="modal" class="inline underline font-medium">Neue Vorlage erstellen</a> oder <a href="%{start_form_link}" target="_blank" class="inline underline font-medium">bestehende einreichen</a>
create_a_new_template_document_form_html: <a href="%{new_template_link}" data-turbo-frame="modal" class="inline underline font-medium">Neue Vorlage erstellen</a>
send_email_copy_with_completed_documents_to_a_specified_bcc_address: Senden Sie eine E-Mail-Kopie mit abgeschlossenen Dokumenten an eine angegebene BCC-Adresse.
re_send_email: E-Mail erneut senden
re_send_emails: E-Mails senden
@@ -6793,6 +6815,8 @@ de: &de
failed_to_save_the_scan: Scan konnte nicht gespeichert werden
failed_to_scan_the_document: Dokument konnte nicht gescannt werden
document_scanning_is_not_available_on_this_device: Das Scannen von Dokumenten ist auf diesem Gerät nicht verfügbar
cc_view_only: CC Nur Ansicht
bcc_completed: BCC Abgeschlossen
devise:
confirmations:
confirmed: Ihre E-Mail-Adresse wurde erfolgreich bestätigt.
@@ -6975,14 +6999,14 @@ pl:
decline_reason: Powód odrzucenia
provide_a_reason: Podaj powód
notify_the_sender_with_the_reason_you_declined: Powiadom nadawcę o powodzie, dla którego odrzuciłeś
form_has_been_declined_on_html: 'Formularz został odrzucony o <span class="font-semibold">%{time}</span>'
document_has_been_delegated_on_html: 'Dokument został oddelegowany o <span class="font-semibold">%{time}</span>'
form_has_been_declined_on_html: 'Formularz został odrzucony <span class="font-semibold">%{time}</span>'
document_has_been_delegated_on_html: 'Dokument został oddelegowany <span class="font-semibold">%{time}</span>'
name_declined_by_submitter: '"%{name}" odrzucono przez %{submitter}'
name_declined_by_submitter_with_the_following_reason: '"%{name}" został odrzucony przez %{submitter} z następującym powodem:'
email: E-mail
verification_code_code: 'Kod weryfikacyjny: %{code}'
digitally_signed_by: Podpis cyfrowy przez
form_expired_at_html: 'Formularz wygasł o <span class="font-semibold">%{time}</span>'
form_expired_at_html: 'Formularz wygasł <span class="font-semibold">%{time}</span>'
role: Rola
provide_your_email_to_start: Podaj swój e-mail, aby rozpocząć
provide_your_email: Podaj swój e-mail
@@ -7117,8 +7141,8 @@ uk:
decline_reason: Причина відхилення
provide_a_reason: Надайте причину
notify_the_sender_with_the_reason_you_declined: Повідомте відправника про причину свого відхилення
form_has_been_declined_on_html: 'Форму було відхилено о <span class="font-semibold">%{time}</span>'
document_has_been_delegated_on_html: 'Документ було делеговано о <span class="font-semibold">%{time}</span>'
form_has_been_declined_on_html: 'Форму було відхилено <span class="font-semibold">%{time}</span>'
document_has_been_delegated_on_html: 'Документ було делеговано <span class="font-semibold">%{time}</span>'
name_declined_by_submitter: '"%{name}" відхилено %{submitter}'
name_declined_by_submitter_with_the_following_reason: '"%{name}" було відхилено %{submitter} з такою причиною:'
email: E-mail
@@ -8089,6 +8113,7 @@ nl: &nl
form_has_been_declined: Formulier is geweigerd.
form_is_view_only: Formulier is alleen-lezen.
file_is_missing: Bestand ontbreekt
file_is_not_an_image: Bestand is geen afbeelding
folder_name_has_been_updated: Mapnaam is bijgewerkt.
unable_to_rename_folder: Kan map niet hernoemen.
template_has_been_removed: Sjabloon is verwijderd.
@@ -8148,7 +8173,7 @@ nl: &nl
thirty_days: 30 dagen
free: Gratis
unlimited_documents_storage: Onbeperkte documentopslag
users_management: Gebruikersbeheer
user_management: Gebruikersbeheer
ten_field_types: 10 veldtypen
multiple_signers: Meerdere ondertekenaars
automatic_esignature: Automatische e-handtekening
@@ -8168,6 +8193,7 @@ nl: &nl
developer_sandbox: Ontwikkelaars-sandbox.
upgrade_to_start_using_in_production_html: <a href="%{url}" target="_blank" class="link font-semibold">Upgrade</a> om in productie te gaan gebruiken.
download_combined_pdf: Gecombineerde PDF downloaden
download_event_log: Gebeurtenislogboek downloaden
audit_log: Auditlogboek
signers: Ondertekenaars
not_invited_yet: Nog niet uitgenodigd
@@ -8179,7 +8205,7 @@ nl: &nl
declined_on_time: Geweigerd op %{time}
expire_on_time: Verloopt op %{time}
sign_in_person: In persoon ondertekenen
create_a_new_template_document_form_or_submit_the_existing_one_html: <a href="%{new_template_link}" data-turbo-frame="modal" class="inline underline font-medium">Maak een nieuw sjabloon</a> voor een documentformulier of <a href="%{start_form_link}" target="_blank" class="inline underline font-medium">dien het bestaande in</a>
create_a_new_template_document_form_html: <a href="%{new_template_link}" data-turbo-frame="modal" class="inline underline font-medium">Maak een nieuw sjabloon</a> voor een documentformulier
send_email_copy_with_completed_documents_to_a_specified_bcc_address: Stuur een e-mailkopie met voltooide documenten naar een opgegeven BCC-adres.
re_send_email: E-mail opnieuw verzenden
re_send_emails: E-mails verzenden
@@ -8476,6 +8502,8 @@ nl: &nl
failed_to_save_the_scan: Scan kon niet worden opgeslagen
failed_to_scan_the_document: Document kon niet worden gescand
document_scanning_is_not_available_on_this_device: Documenten scannen is niet beschikbaar op dit apparaat
cc_view_only: CC Alleen bekijken
bcc_completed: BCC Voltooid
devise:
confirmations:
confirmed: Uw e-mailadres is succesvol bevestigd.
@@ -0,0 +1,17 @@
# frozen_string_literal: true
class CreateOtpChallenges < ActiveRecord::Migration[8.1]
def change
create_table :otp_challenges do |t|
t.string :key, null: false
t.integer :version, null: false
t.string :nonce, null: false
t.integer :attempts, null: false, default: 0
t.datetime :expire_at, null: false
t.datetime :verified_at
t.datetime :created_at, null: false
t.index %i[key version], unique: true
end
end
end
+12 -1
View File
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.1].define(version: 2026_08_19_091500) do
ActiveRecord::Schema[8.1].define(version: 2026_09_30_061927) do
# These are extensions that must be enabled in order to support this database
enable_extension "btree_gin"
enable_extension "pg_catalog.plpgsql"
@@ -322,6 +322,17 @@ ActiveRecord::Schema[8.1].define(version: 2026_08_19_091500) do
t.index ["uid"], name: "index_oauth_applications_on_uid", unique: true
end
create_table "otp_challenges", force: :cascade do |t|
t.integer "attempts", default: 0, null: false
t.datetime "created_at", null: false
t.datetime "expire_at", null: false
t.string "key", null: false
t.string "nonce", null: false
t.datetime "verified_at"
t.integer "version", null: false
t.index ["key", "version"], name: "index_otp_challenges_on_key_and_version", unique: true
end
create_table "search_entries", force: :cascade do |t|
t.bigint "account_id", null: false
t.datetime "created_at", null: false
-28
View File
@@ -5,34 +5,6 @@ module Accounts
module_function
def create_duplicate(account)
new_account = account.dup
new_user = account.users.first.dup
new_user.uuid = SecureRandom.uuid
new_user.account = new_account
new_user.encrypted_password = SecureRandom.hex
new_user.email = "#{SecureRandom.hex}@docuseal.com"
account.templates.each do |template|
new_template = template.dup
new_template.account = new_account
new_template.slug = SecureRandom.base58(14)
new_template.archived_at = nil
new_template.save!
Templates::CloneAttachments.call(template: new_template, original_template: template)
end
new_user.save!(validate: false)
new_account.templates.update_all(folder_id: new_account.default_template_folder.id)
new_account
end
def users_count(account)
rel = User.where(account_id: account.id).or(
User.where(account_id: account.account_linked_accounts
-27
View File
@@ -1,27 +0,0 @@
# frozen_string_literal: true
module EmailVerificationCodes
DRIFT_BEHIND = 5.minutes
module_function
def generate(value)
totp = ROTP::TOTP.new(build_totp_secret(value))
totp.at(Time.current)
end
def verify(code, value)
totp = ROTP::TOTP.new(build_totp_secret(value))
totp.verify(code, drift_behind: DRIFT_BEHIND)
end
def build_totp_secret(value)
ROTP::Base32.encode(
Digest::SHA1.digest(
[Rails.application.secret_key_base, 'form_email_2fa', value].join(':')
)
)
end
end
+63
View File
@@ -0,0 +1,63 @@
# frozen_string_literal: true
module OtpChallenges
CODE_TTL = 10.minutes
REUSE_MIN_TTL = 5.minutes
MAX_ATTEMPTS = 20
MAX_TOTAL_ATTEMPTS = 100
ATTEMPTS_WINDOW = 23.hours
module_function
def generate(value, purpose:)
key = build_key(value, purpose)
latest_challenge = OtpChallenge.where(key:).order(version: :desc).first
otp_challenge =
if latest_challenge && !latest_challenge.verified_at? && latest_challenge.expire_at > REUSE_MIN_TTL.from_now
latest_challenge
else
version = latest_challenge ? latest_challenge.version + 1 : 1
OtpChallenge.create_with(nonce: SecureRandom.base58(16), expire_at: CODE_TTL.from_now)
.create_or_find_by!(key:, version:)
end
build_hotp(value, purpose, otp_challenge.nonce).at(0)
end
def verify(code, value, purpose:)
challenges = OtpChallenge.where(key: build_key(value, purpose))
recent_challenges = challenges.where(expire_at: ATTEMPTS_WINDOW.ago..).order(version: :desc).to_a
latest_challenge = recent_challenges.first
return false if latest_challenge.nil? || latest_challenge.verified_at? || latest_challenge.expire_at.past?
raise RateLimit::LimitApproached if recent_challenges.sum(&:attempts) >= MAX_ATTEMPTS
raise RateLimit::LimitApproached if challenges.sum(:attempts) >= MAX_TOTAL_ATTEMPTS
if build_hotp(value, purpose, latest_challenge.nonce).verify(code.to_s, 0)
OtpChallenge.where(id: latest_challenge.id, verified_at: nil).update_all(verified_at: Time.current) == 1
else
latest_challenge.increment!(:attempts)
OtpChallenge.current_transaction.after_rollback { OtpChallenge.update_counters(latest_challenge.id, attempts: 1) }
false
end
end
def build_key(value, purpose)
Digest::SHA256.hexdigest([purpose, value].join(':'))
end
def build_hotp(value, purpose, nonce)
ROTP::HOTP.new(
ROTP::Base32.encode(
Digest::SHA1.digest([Rails.application.secret_key_base, purpose, value, nonce].join(':'))
)
)
end
end
+23
View File
@@ -90,6 +90,10 @@ module Submissions
maybe_add_invite_submitters(submission, template, attrs[:submitters])
maybe_add_cc_submitters(submission, params[:cc_recipients],
submitters_order:, user:, params:,
preferences: preferences.merge(submission_preferences))
assign_submitters_is_viewer(submission)
submission.template = nil unless with_template
@@ -211,6 +215,25 @@ module Submissions
end
end
def maybe_add_cc_submitters(submission, recipients, submitters_order:, user:, params:, preferences:)
order = submission.template_submitters.filter_map { |s| s['order'] }.max
Array.wrap(recipients).each do |recipient|
attrs = recipient.is_a?(String) ? { email: recipient } : recipient.slice(:name, :email, :phone)
next if attrs.compact_blank.blank?
uuid = SecureRandom.uuid
submission.template_submitters <<
{ 'name' => 'CC', 'uuid' => uuid, 'is_viewer' => true, 'order' => order }.compact
build_submitter(submission:, attrs:, uuid:, user:, params:,
is_order_sent: submitters_order == 'random',
preferences: preferences.except('email_message_uuid'))
end
end
def submitter_message_preferences(uuid, params)
return {} if params[:request_email_per_submitter] != '1'
return {} if params[:is_custom_message] != '1'
+6 -3
View File
@@ -267,7 +267,10 @@ module Submissions
is_id_verified = submission_events.any?(&:complete_verification?)
is_kba_passed = submission_events.any?(&:complete_kba?)
complete_kba_event = submission_events.find(&:complete_kba?) || SubmissionEvent.new
kba_answers = complete_kba_event.data['answers']
kba_score = " (#{kba_answers['correct']}/#{kba_answers['asked']})" if kba_answers
info_rows = [
[
@@ -297,8 +300,8 @@ module Submissions
is_id_verified && {
text: "#{I18n.t('identity_verification')}: #{I18n.t('verified')}\n"
},
is_kba_passed && {
text: "#{I18n.t('knowledge_based_authentication')}: #{I18n.t('passed')}\n"
complete_kba_event.persisted? && {
text: "#{I18n.t('knowledge_based_authentication')}: #{I18n.t('passed')}#{kba_score}\n"
},
completed_event.data['ip'] && { text: "IP: #{completed_event.data['ip']}\n" },
completed_event.data['sid'] && { text: "#{I18n.t('session_id')}: #{completed_event.data['sid']}\n" },
@@ -602,6 +602,8 @@ module Submissions
cell_valign = field.dig('preferences', 'valign').to_s.presence_in(TEXT_VALIGNS) || 'center'
cell_layouter = cell_layouters[cell_valign]
value = value.to_s
if (mask = field.dig('preferences', 'mask').presence)
value = TextUtils.mask_value(value, mask)
end
+12 -4
View File
@@ -152,7 +152,7 @@ module Submitters
preferences['send_sms'] = params['send_sms'].in?(TRUE_VALUES) if params.key?('send_sms')
preferences['require_phone_2fa'] = params['require_phone_2fa'].in?(TRUE_VALUES) if params.key?('require_phone_2fa')
preferences['require_email_2fa'] = params['require_email_2fa'].in?(TRUE_VALUES) if params.key?('require_email_2fa')
preferences['bcc_completed'] = params['bcc_completed'] if params.key?('bcc_completed')
preferences['bcc_completed'] = normalize_bcc_completed(params['bcc_completed']) if params.key?('bcc_completed')
preferences['reply_to'] = params['reply_to'] if params.key?('reply_to')
preferences['go_to_last'] = params['go_to_last'] if params.key?('go_to_last')
preferences['completed_redirect_url'] = params['completed_redirect_url'] if params.key?('completed_redirect_url')
@@ -160,6 +160,14 @@ module Submitters
preferences
end
def normalize_bcc_completed(value)
if value.is_a?(Array)
value.compact_blank.uniq.join(', ')
else
value
end
end
def fetch_values_for_delegate(submitter)
fields = submitter.submission.template_fields || submitter.template.fields
default_values = submitter.preferences['default_values'] || {}
@@ -280,12 +288,12 @@ module Submitters
email_digest = Digest::MD5.base64digest(submitter.email.downcase.squish)
RateLimit.call("verify-2fa-code-#{email_digest}", limit: 2, ttl: 45.seconds, enabled: true)
RateLimit.call("verify-2fa-code-1h-#{email_digest}", limit: 10, ttl: 1.hour, enabled: true)
RateLimit.call("verify-2fa-code-1d-#{email_digest}", limit: 20, ttl: 1.day, enabled: true)
link_2fa_key = [submitter.email.downcase.squish, submitter.submission.template.slug].join(':')
raise InvalidOtp, I18n.t(:invalid_code) unless EmailVerificationCodes.verify(otp, link_2fa_key)
unless OtpChallenges.verify(otp.to_s.gsub(/\D/, ''), link_2fa_key, purpose: 'form_email_2fa')
raise InvalidOtp, I18n.t(:invalid_code)
end
true
end
+21
View File
@@ -6,6 +6,7 @@ module Templates
ZIP_CONTENT_TYPE = 'application/zip'
X_ZIP_CONTENT_TYPE = 'application/x-zip-compressed'
JSON_CONTENT_TYPE = 'application/json'
WORD_EXTENSIONS = %w[.docx .doc .rtf].freeze
DOCUMENT_EXTENSIONS = %w[.docx .doc .xlsx .xls .odt .rtf].freeze
DOCUMENT_CONTENT_TYPES = %w[
@@ -134,7 +135,27 @@ module Templates
return [handle_pdf_or_image(template, file, file.read, params, extract_fields:), []]
end
if File.extname(file.original_filename.to_s).downcase.in?(WORD_EXTENSIONS)
return [handle_word_document(template, file, params), []]
end
raise InvalidFileType, "#{file.content_type}/#{dynamic}"
end
def handle_word_document(template, file, params)
pdf = WordCraft.open_buffer(file, &:to_pdf)
pdf_file = ActionDispatch::Http::UploadedFile.new(
tempfile: StringIO.new(pdf),
filename: "#{File.basename(file.original_filename, '.*')}.pdf",
type: PDF_CONTENT_TYPE
)
handle_pdf_or_image(template, pdf_file, pdf, params)
end
def document_extensions
Docuseal.advanced_formats? ? DOCUMENT_EXTENSIONS : WORD_EXTENSIONS
end
end
end
+1 -1
View File
@@ -266,7 +266,7 @@ module Templates
['.png', 'image/png', {}]
end
data = image.write_to_buffer(extension, **format_args)
data = image.write_to_buffer(extension, strip: true, **format_args)
blob = ActiveStorage::Blob.create_and_upload!(
io: StringIO.new(data),
+16 -11
View File
@@ -90,23 +90,28 @@ module Templates
pool = Concurrent::FixedThreadPool.new(concurrency)
promises =
range.map do |page_number|
doc_page = doc.get_page(page_number)
blobs =
range.each_slice(concurrency).flat_map do |page_numbers|
promises =
page_numbers.map do |page_number|
doc_page = doc.get_page(page_number)
hide_placeholder_widgets(doc_page, hide_empty: !flatten_pages)
doc_page.flatten if flatten_pages
hide_placeholder_widgets(doc_page, hide_empty: !flatten_pages)
doc_page.flatten if flatten_pages
bytes, width, height = doc_page.render_to_bitmap(width: MAX_WIDTH)
bytes, width, height = doc_page.render_to_bitmap(width: MAX_WIDTH)
image = Vips::Image.new_from_memory_copy(bytes, width, height, 4, :uchar)
image = Vips::Image.new_from_memory_copy(bytes, width, height, 4, :uchar)
Concurrent::Promise.execute(executor: pool) { build_and_upload_blob(image, page_number) }
ensure
doc_page&.close
Concurrent::Promise.execute(executor: pool) { build_and_upload_blob(image, page_number) }
ensure
doc_page&.close
end
Concurrent::Promise.zip(*promises).value!
end
Concurrent::Promise.zip(*promises).value!.each do |blob|
blobs.each do |blob|
next unless blob
ApplicationRecord.no_touching do
-1
View File
@@ -20,7 +20,6 @@
"@tiptap/extension-dropcursor": "^3.19.0",
"@tiptap/extension-gapcursor": "^3.19.0",
"@tiptap/extension-hard-break": "^3.19.0",
"@tiptap/extension-history": "^3.19.0",
"@tiptap/extension-italic": "^3.19.0",
"@tiptap/extension-link": "^3.19.0",
"@tiptap/extension-paragraph": "^3.19.0",
-5
View File
@@ -1796,11 +1796,6 @@
resolved "https://registry.yarnpkg.com/@tiptap/extension-hard-break/-/extension-hard-break-3.19.0.tgz#7120524cec9ed4b957963693cb4c57cbecbaecf8"
integrity sha512-lAmQraYhPS5hafvCl74xDB5+bLuNwBKIEsVoim35I0sDJj5nTrfhaZgMJ91VamMvT+6FF5f1dvBlxBxAWa8jew==
"@tiptap/extension-history@^3.19.0":
version "3.19.0"
resolved "https://registry.yarnpkg.com/@tiptap/extension-history/-/extension-history-3.19.0.tgz#dc3a52f4eb16b7ac077c28284684b558c03adf00"
integrity sha512-hhN5nL7Pqcd9iomzeUUMKnmSQieKNlJ2FUgf2dHUqqvn4pWvcHA6P6UwdDNhuKFivSJNNMqtajkOvO4bYq1KPw==
"@tiptap/extension-italic@^3.19.0":
version "3.19.0"
resolved "https://registry.yarnpkg.com/@tiptap/extension-italic/-/extension-italic-3.19.0.tgz#af2a9c095ec846e379041f3e17e1dd101a5a4bf8"