Compare commits

..

125 Commits

Author SHA1 Message Date
Pete Matsyburka 8ef1c309e0 fix email messages user assoc 2023-12-10 21:59:50 +02:00
Pete Matsyburka a32dcc0dc5 fix image scale result generation 2023-12-07 22:46:15 +02:00
Pete Matsyburka 16a2fde4b7 fix result annots 2023-12-07 22:46:08 +02:00
Pete Matsyburka 7856f7a134 add template clone api endpoint 2023-12-06 11:29:55 +02:00
Pete Matsyburka c545924fe9 adjust errors controller 2023-12-06 10:50:20 +02:00
Pete Matsyburka b1510bb88c fix GCS credentials set via ENV var 2023-12-06 00:12:59 +02:00
Pete Matsyburka 31d2024ce5 add send_2fa_sms event type 2023-12-05 19:42:47 +02:00
Pete Matsyburka 23c49f8706 add sign up email placeholder 2023-12-05 18:30:07 +02:00
Pete Matsyburka 62eb0cda6b return 422 on form update values when completed 2023-12-05 18:18:32 +02:00
Alex Turchyn 9f48c6e625 add MS oauth 2023-12-05 17:38:56 +02:00
Pete Matsyburka 83ce401cf2 rescue timestamp response error 2023-12-04 12:57:22 +02:00
Pete Matsyburka 541b025ad0 fix timeserver config loading 2023-12-04 12:47:19 +02:00
Pete Matsyburka 5fd018df46 adjust audit trail verified message 2023-12-03 22:30:03 +02:00
Pete Matsyburka 2822b0086a fix spec 2023-12-03 20:24:43 +02:00
Pete Matsyburka e27606f443 use rails storage proxy for audit log url 2023-12-03 13:03:36 +02:00
Pete Matsyburka 6c95cd5945 use template archive icon 2023-12-03 03:27:56 +02:00
Pete Matsyburka d723146060 allow to invite fewer submitters than in template 2023-12-03 00:27:29 +02:00
Pete Matsyburka 3b03e61ae3 use template links with 2+ submitters 2023-12-03 00:06:03 +02:00
Pete Matsyburka 172513b96c fix submission email sending preferences 2023-12-02 23:16:38 +02:00
Pete Matsyburka 68be42a48d add form attachments to completed emails 2023-12-02 23:03:24 +02:00
Pete Matsyburka 0370e363bc make webhook URL removable 2023-12-02 22:19:24 +02:00
Pete Matsyburka ced88476cc add ability to use timestamp server 2023-12-02 18:14:33 +02:00
Pete Matsyburka c5d71505ef allow to disable text signature via account prefs 2023-12-02 16:26:37 +02:00
Pete Matsyburka 489f9859be add force 2fa toggle 2023-12-02 01:50:59 +02:00
Pete Matsyburka a87ca65839 adjust audit trail 2023-12-01 18:44:00 +02:00
Pete Matsyburka fa78d0976d fix spec 2023-12-01 16:59:28 +02:00
Pete Matsyburka 4aaab2563b configurable values normalize exception 2023-12-01 16:59:19 +02:00
Pete Matsyburka e073cf2c27 refactor 2023-12-01 00:38:11 +02:00
Pete Matsyburka 173d406b1f adjust api email sending 2023-11-30 20:14:09 +02:00
Pete Matsyburka 3bcbad6f04 preserve submitters order via api 2023-11-30 12:32:17 +02:00
Pete Matsyburka e67854fe56 better sign yourself flow 2023-11-30 11:56:11 +02:00
Pete Matsyburka 7510e0cb6b do not delete area in preview mode 2023-11-29 21:59:22 +02:00
Pete Matsyburka 30c913b54f add template api folder filters 2023-11-29 20:23:40 +02:00
Pete Matsyburka 2cda9cd736 make default fields non editable 2023-11-27 20:25:20 +02:00
Pete Matsyburka 342d29f272 allow to permanently delete template in the cloud 2023-11-27 20:25:11 +02:00
Pete Matsyburka 13f76c9944 adjust submission title link 2023-11-26 00:33:05 +02:00
Pete Matsyburka ce4e55f387 autoadjust field value font size 2023-11-26 00:33:02 +02:00
Pete Matsyburka 712107ba5a limit manual email sending in cloud 2023-11-25 13:36:37 +02:00
Pete Matsyburka 1aa867893e fix bcc address removal 2023-11-24 23:22:04 +02:00
Pete Matsyburka 4f6142f9c0 adjust form builder 2023-11-24 23:09:39 +02:00
Pete Matsyburka c303349666 fix default builder fields 2023-11-24 17:34:57 +02:00
Pete Matsyburka e85df170f2 fix connection to DB URL with underscores 2023-11-24 17:17:56 +02:00
Pete Matsyburka 01333e5b2e preserve submitter preferences 2023-11-23 20:03:52 +02:00
Pete Matsyburka 514987138a adjust readme 2023-11-23 16:00:08 +02:00
Pete Matsyburka 0814c6d065 adjust submissions logo 2023-11-22 22:15:10 +02:00
Pete Matsyburka 542a443fb8 add custom exceptions handling controller 2023-11-22 01:41:39 +02:00
Pete Matsyburka f2c6bb1c3d add submission order param 2023-11-22 01:04:47 +02:00
Alex Turchyn b85c9cfdb7 adjust form builder 2023-11-21 23:16:34 +02:00
Pete Matsyburka e07b50c22d fix undefined method 'field_value' 2023-11-21 13:08:40 +02:00
Pete Matsyburka 4dde9890e0 update deps 2023-11-20 17:28:31 +02:00
Pete Matsyburka 66d1ae2f64 link to submission preview page in completed emails 2023-11-20 01:00:00 +02:00
Pete Matsyburka d130835902 skip annots on error 2023-11-20 00:04:20 +02:00
Pete Matsyburka 52f66368a3 adjust submit for banner 2023-11-18 20:41:29 +02:00
DocuSeal 89d0810625 adjust signature step 2023-11-18 13:57:52 +02:00
Pete Matsyburka 06d3d1202b fix default form value 2023-11-18 12:28:51 +02:00
DocuSeal 0698aad8e1 adjust email configs loading 2023-11-17 18:47:08 +02:00
DocuSeal 3575a25935 fix default values assignment 2023-11-17 18:45:17 +02:00
DocuSeal cdd034e72d redirect with google oauth 2023-11-17 02:27:32 +02:00
Pete Matsyburka 26f7d5cf0d fix submissions destroy action 2023-11-16 01:55:57 +02:00
Pete Matsyburka 3c69430b94 fix email friendly name 2023-11-15 23:42:48 +02:00
Pete Matsyburka c0162bccc8 fix radio field type 2023-11-15 19:48:51 +02:00
Pete Matsyburka 1adb800f94 fix default values 2023-11-15 16:19:00 +02:00
Pete Matsyburka 333a5d6b7b add ability to draw areas for radio options 2023-11-14 01:30:00 +02:00
Pete Matsyburka 9aa68fe955 fix cell field length 2023-11-14 00:32:48 +02:00
Pete Matsyburka 46af87d89a mark field as submitter when navigating with areas 2023-11-13 23:13:49 +02:00
Pete Matsyburka 1066131143 use hash for field options 2023-11-13 01:56:50 +02:00
Pete Matsyburka a25022532e update default date format 2023-11-12 22:28:47 +02:00
Pete Matsyburka 5162f9a9dc remove redundant jwt rescue 2023-11-12 18:54:24 +02:00
Pete Matsyburka cc5694de43 set max length for cells form input 2023-11-12 00:24:58 +02:00
Pete Matsyburka f734ba280d fix cell resize 2023-11-12 00:24:58 +02:00
Pete Matsyburka 500d0da563 make phone field always required 2023-11-12 00:24:57 +02:00
Pete Matsyburka 6626a2b602 add default builder fields 2023-11-11 23:36:03 +02:00
Pete Matsyburka 07e66a6e53 adjust images preview loading in builder 2023-11-11 21:16:29 +02:00
Pete Matsyburka deef9c7d3b add FR time format locale 2023-11-11 15:28:43 +02:00
Pete Matsyburka 2ec8954dc8 add embedding to settings 2023-11-11 11:56:54 +02:00
Pete Matsyburka c1af803e5b adjust parse email 2023-11-11 02:43:39 +02:00
Pete Matsyburka 10abdbeba4 remove plausible from self-host 2023-11-11 02:43:39 +02:00
DocuSeal 1dbde7a6d8 add withUploadButton builder prop 2023-11-11 02:43:29 +02:00
Pete Matsyburka a27eb18670 add api link to settings 2023-11-09 01:20:28 +02:00
DocuSeal 44a49a4533 normalize file urls 2023-11-08 15:25:19 +02:00
DocuSeal fc11078cae adjust font size if long text 2023-11-07 11:47:26 +02:00
Pete Matsyburka a42ae0b39e fix file upload field 2023-11-07 02:28:06 +02:00
Pete Matsyburka 3163cdc761 fix builder tap on mobile 2023-11-06 22:08:49 +02:00
Pete Matsyburka 5439c63ce7 hide mobile field draw box 2023-11-06 21:23:46 +02:00
Pete Matsyburka b78287242b fix previous submitter values on the form 2023-11-06 20:52:59 +02:00
Alex Turchyn b869ae38b3 allow to add multiple submitters on mobile 2023-11-06 20:50:29 +02:00
DocuSeal 4a320e9ba6 pass name on resubmit 2023-11-06 13:45:15 +02:00
DocuSeal 0025928a35 fix replace default value variable 2023-11-06 13:40:40 +02:00
DocuSeal c227a1988a fix api pagination limit 2023-11-06 13:18:16 +02:00
Pete Matsyburka 3ade45f541 add field draw button if without areas 2023-11-05 16:31:32 +02:00
Pete Matsyburka 17da8bde80 add upgrade button 2023-11-05 16:31:13 +02:00
Pete Matsyburka 59d9a59769 add field default values 2023-11-05 01:41:54 +02:00
Pete Matsyburka 7210badde9 drag and drop fields to order 2023-11-04 14:06:59 +02:00
DocuSeal 21659a73f3 adjust new template form 2023-11-04 01:31:27 +02:00
DocuSeal c0e49ebeea add builder editable prop 2023-11-04 01:19:32 +02:00
DocuSeal 2d24eac4a3 fix completed submissions scope query 2023-11-02 20:32:51 +02:00
DocuSeal 14c3c8181b add template application key 2023-11-02 19:33:01 +02:00
DocuSeal 036f6049a2 Revert "add new signup page"
This reverts commit f7132445a1.
2023-11-02 13:37:57 +02:00
DocuSeal 236e4cb35f fix typo 2023-11-01 21:32:56 +02:00
DocuSeal ee77cb0982 use stronger api token 2023-11-01 15:20:39 +02:00
DocuSeal d474ad05e5 prefill shared link with email 2023-11-01 14:10:01 +02:00
DocuSeal dbf1fa37c5 adjust completed webhook sending 2023-11-01 13:11:42 +02:00
DocuSeal 7444bb8b68 add audit log url to api and webhooks 2023-11-01 12:17:35 +02:00
Alex Turchyn f7132445a1 add new signup page 2023-11-01 02:17:56 +02:00
Alex Turchyn 0127dd738e fix navbar mobile layout 2023-10-30 23:03:52 +02:00
DocuSeal df561a6211 update rubocop 2023-10-30 00:56:44 +02:00
DocuSeal 50d3404f3b force ssl only if host passed 2023-10-29 21:36:30 +02:00
DocuSeal 607fc8d338 allow to permanently remove templates 2023-10-29 21:36:30 +02:00
DocuSeal 8c366dfc38 use account timezone in audit trail 2023-10-29 21:36:30 +02:00
DocuSeal 5ed08fa9ab add sso settings placeholder 2023-10-29 21:36:30 +02:00
DocuSeal ddb557ebef upload template from URL 2023-10-29 21:36:30 +02:00
DocuSeal b48615df3f use handwritten font for typed signature 2023-10-29 21:36:29 +02:00
DocuSeal 86c7b92675 apply previous signature and initials on the next field 2023-10-28 01:56:22 +03:00
DocuSeal 041f362cc4 do not allow to edit phone if already specified 2023-10-28 00:53:16 +03:00
DocuSeal 65f491325e adjust configs loading 2023-10-28 00:42:33 +03:00
DocuSeal 57aef5f5e5 refactor date step 2023-10-26 21:26:08 +03:00
Alex Turchyn e704e6fa04 show edit icon on hover the outer box 2023-10-25 20:09:34 +03:00
DocuSeal 4c99bbb58a add onComplete form callback 2023-10-25 02:06:47 +03:00
DocuSeal c0647eef2c process 15 pages after pdf upload 2023-10-24 23:48:26 +03:00
DocuSeal c619b9be93 move plans link 2023-10-24 23:16:02 +03:00
DocuSeal abab3f9688 do not use libvips pdf on large files 2023-10-24 22:31:09 +03:00
DocuSeal 7b15111e8f use temp file for puma pid 2023-10-24 13:36:50 +03:00
DocuSeal 2c7f38c503 optimize mailer attachments 2023-10-24 12:41:12 +03:00
Alex Turchyn ed80df1372 fix audit trail images generation 2023-10-24 00:46:47 +03:00
DocuSeal 3614d1796c do not split signature pad js in form 2023-10-22 23:26:43 +03:00
161 changed files with 2882 additions and 789 deletions
+3
View File
@@ -75,3 +75,6 @@ Rails/SkipsModelValidations:
Rails/ApplicationController:
Enabled: false
Capybara/ClickLinkOrButtonStyle:
Enabled: false
+2 -1
View File
@@ -31,7 +31,7 @@ ENV BUNDLE_WITHOUT="development:test"
WORKDIR /app
RUN apk add --no-cache build-base sqlite-dev libpq-dev mariadb-dev vips-dev vips-poppler vips-heif libc6-compat ttf-freefont ttf-liberation && cp /usr/share/fonts/liberation/LiberationSans-Regular.ttf /usr/share/fonts/liberation/LiberationSans-Bold.ttf / && apk del ttf-liberation
RUN apk add --no-cache build-base sqlite-dev libpq-dev mariadb-dev vips-dev vips-poppler poppler-utils vips-heif libc6-compat ttf-freefont ttf-liberation && mkdir /fonts && cp /usr/share/fonts/liberation/LiberationSans-Regular.ttf /usr/share/fonts/liberation/LiberationSans-Bold.ttf /fonts && apk del ttf-liberation && wget -O /fonts/DancingScript.otf "https://github.com/impallari/DancingScript/raw/master/fonts/DancingScript-Regular.otf" && wget -O /fonts/DancingScript-License.txt https://github.com/impallari/DancingScript/blob/master/OFL.txt
COPY ./Gemfile ./Gemfile.lock ./
@@ -49,6 +49,7 @@ COPY LICENSE README.md Rakefile config.ru ./
COPY --from=webpack /app/public/packs ./public/packs
RUN ln -s /fonts /app/public/fonts
RUN bundle exec bootsnap precompile --gemfile app/ lib/
WORKDIR /data/docuseal
+2
View File
@@ -21,7 +21,9 @@ gem 'jwt'
gem 'lograge'
gem 'mysql2', require: false
gem 'oj'
gem 'omniauth-github'
gem 'omniauth-google-oauth2'
gem 'omniauth-microsoft-office365'
gem 'omniauth-rails_csrf_protection'
gem 'pagy'
gem 'pdf-reader'
+23 -17
View File
@@ -98,7 +98,6 @@ GEM
faraday_middleware (~> 1.0, >= 1.0.0.rc1)
net-http-persistent (~> 4.0)
nokogiri (~> 1, >= 1.10.8)
base64 (0.1.1)
bcrypt (3.1.19)
better_html (2.0.2)
actionview (>= 6.0)
@@ -247,7 +246,7 @@ GEM
hashdiff (1.0.1)
hashery (2.1.2)
hashie (5.0.0)
hexapdf (0.33.0)
hexapdf (0.34.1)
cmdparse (~> 3.0, >= 3.0.3)
geom2d (~> 0.4, >= 0.4.1)
openssl (>= 2.2.1)
@@ -294,7 +293,7 @@ GEM
mini_magick (4.12.0)
mini_mime (1.1.5)
mini_portile2 (2.8.4)
minitest (5.19.0)
minitest (5.20.0)
msgpack (1.7.2)
multi_json (1.15.0)
multi_xml (0.6.0)
@@ -329,23 +328,29 @@ GEM
hashie (>= 3.4.6)
rack (>= 2.2.3)
rack-protection
omniauth-github (2.0.1)
omniauth (~> 2.0)
omniauth-oauth2 (~> 1.8)
omniauth-google-oauth2 (1.1.1)
jwt (>= 2.0)
oauth2 (~> 2.0.6)
omniauth (~> 2.0)
omniauth-oauth2 (~> 1.8.0)
omniauth-microsoft-office365 (0.0.8)
omniauth
omniauth-oauth2
omniauth-oauth2 (1.8.0)
oauth2 (>= 1.4, < 3)
omniauth (~> 2.0)
omniauth-rails_csrf_protection (1.0.1)
actionpack (>= 4.2)
omniauth (~> 2.0)
openssl (3.1.0)
openssl (3.2.0)
orm_adapter (0.5.0)
os (1.1.4)
pagy (6.0.4)
parallel (1.23.0)
parser (3.2.2.3)
parser (3.2.2.4)
ast (~> 2.4.1)
racc
pdf-reader (2.11.0)
@@ -418,7 +423,7 @@ GEM
rake (13.0.6)
redis-client (0.16.0)
connection_pool
regexp_parser (2.8.1)
regexp_parser (2.8.2)
reline (0.3.8)
io-console (~> 0.5)
representable (3.2.0)
@@ -455,33 +460,32 @@ GEM
rspec-mocks (~> 3.12)
rspec-support (~> 3.12)
rspec-support (3.12.1)
rubocop (1.56.1)
base64 (~> 0.1.1)
rubocop (1.57.2)
json (~> 2.3)
language_server-protocol (>= 3.17.0)
parallel (~> 1.10)
parser (>= 3.2.2.3)
parser (>= 3.2.2.4)
rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 1.8, < 3.0)
rexml (>= 3.2.5, < 4.0)
rubocop-ast (>= 1.28.1, < 2.0)
ruby-progressbar (~> 1.7)
unicode-display_width (>= 2.4.0, < 3.0)
rubocop-ast (1.29.0)
rubocop-ast (1.30.0)
parser (>= 3.2.1.0)
rubocop-capybara (2.18.0)
rubocop-capybara (2.19.0)
rubocop (~> 1.41)
rubocop-factory_bot (2.23.1)
rubocop-factory_bot (2.24.0)
rubocop (~> 1.33)
rubocop-performance (1.19.0)
rubocop-performance (1.19.1)
rubocop (>= 1.7.0, < 2.0)
rubocop-ast (>= 0.4.0)
rubocop-rails (2.20.2)
rubocop-rails (2.22.1)
activesupport (>= 4.2.0)
rack (>= 1.1)
rubocop (>= 1.33.0, < 2.0)
rubocop-rspec (2.23.2)
rubocop (~> 1.33)
rubocop-rspec (2.25.0)
rubocop (~> 1.40)
rubocop-capybara (~> 2.17)
rubocop-factory_bot (~> 2.22)
ruby-progressbar (1.13.0)
@@ -537,7 +541,7 @@ GEM
tzinfo-data (1.2023.3)
tzinfo (>= 1.0.0)
uber (0.1.0)
unicode-display_width (2.4.2)
unicode-display_width (2.5.0)
uniform_notifier (1.16.0)
version_gem (1.1.3)
warden (1.2.9)
@@ -591,7 +595,9 @@ DEPENDENCIES
lograge
mysql2
oj
omniauth-github
omniauth-google-oauth2
omniauth-microsoft-office365
omniauth-rails_csrf_protection
pagy
pdf-reader
+3 -3
View File
@@ -34,10 +34,10 @@ DocuSeal is an open source platform that provides secure and efficient digital d
## Features
- [x] PDF form fields builder (WYSIWYG)
- [x] 10 field types available (Signature, Date, File, Checkbox etc.)
- [x] 11 field types available (Signature, Date, File, Checkbox etc.)
- [x] Multiple submitters per document
- [x] Automated emails via SMTP
- [x] Files storage on AWS S3, Google Storage, or Azure
- [x] Files storage on disk or AWS S3, Google Storage, Azure Cloud
- [x] Automatic PDF eSignature
- [x] PDF signature verification
- [x] Users management
@@ -76,7 +76,7 @@ Run the app under a custom domain over https using docker compose (make sure you
HOST=your-domain-name.com docker-compose up
```
## For Companies
## For Businesses
### Integrate seamless document signing into your web or mobile apps with DocuSeal!
At DocuSeal we have expertise and technologies to make documents creation, filling, signing and processing seamlessly integrated with your product. We specialize in working with various industries, including **Banking, Healthcare, Transport, Real Estate, eCommerce, KYC, CRM, and other software products** that require bulk document signing. By leveraging DocuSeal, we can assist in reducing the overall cost of developing and processing electronic documents while ensuring security and compliance with local electronic document laws.
@@ -0,0 +1,32 @@
# frozen_string_literal: true
class AccountConfigsController < ApplicationController
before_action :load_account_config
authorize_resource :account_config
ALLOWED_KEYS = [
AccountConfig::ALLOW_TYPED_SIGNATURE,
AccountConfig::FORCE_MFA
].freeze
def create
@account_config.update!(account_config_params)
head :ok
end
private
def load_account_config
return head :not_found unless ALLOWED_KEYS.include?(account_config_params[:key])
@account_config =
AccountConfig.find_or_initialize_by(account: current_account, key: account_config_params[:key])
end
def account_config_params
params.required(:account_config).permit!.tap do |attrs|
attrs[:value] = attrs[:value] == '1' if attrs[:value].in?(%w[1 0])
end
end
end
+1
View File
@@ -4,6 +4,7 @@ class AccountsController < ApplicationController
LOCALE_OPTIONS = {
'en-US' => 'English (United States)',
'en-GB' => 'English (United Kingdom)',
'fr-FR' => 'French (France)',
'es-ES' => 'Spanish (Spain)',
'pt-PT' => 'Portuguese (Portugal)',
'de-DE' => 'German (Germany)'
+1 -1
View File
@@ -25,7 +25,7 @@ module Api
def paginate(relation)
result = relation.order(id: :desc)
.limit([params[:limit] || DEFAULT_LIMIT, MAX_LIMIT].min)
.limit([params.fetch(:limit, DEFAULT_LIMIT).to_i, MAX_LIMIT].min)
result = result.where('id < ?', params[:after]) if params[:after].present?
result = result.where('id > ?', params[:before]) if params[:before].present?
+57 -34
View File
@@ -3,7 +3,7 @@
module Api
class SubmissionsController < ApiBaseController
load_and_authorize_resource :template, only: :create
load_and_authorize_resource :submission, only: %i[show index]
load_and_authorize_resource :submission, only: %i[show index destroy]
before_action only: :create do
authorize!(:create, Submission)
@@ -13,7 +13,12 @@ module Api
submissions = Submissions.search(@submissions, params[:q])
submissions = submissions.where(template_id: params[:template_id]) if params[:template_id].present?
submissions = paginate(submissions.preload(:created_by_user, :template, :submitters))
if params[:template_folder].present?
submissions = submissions.joins(template: :folder).where(folder: { name: params[:template_folder] })
end
submissions = paginate(submissions.preload(:created_by_user, :template, :submitters,
audit_trail_attachment: :blob))
render json: {
data: submissions.as_json(serialize_params),
@@ -49,35 +54,14 @@ module Api
end
def create
is_send_email = !params[:send_email].in?(['false', false])
params[:send_email] = true unless params.key?(:send_email)
params[:send_sms] = false unless params.key?(:send_sms)
submissions =
if (emails = (params[:emails] || params[:email]).presence) && params[:submission].blank?
Submissions.create_from_emails(template: @template,
user: current_user,
source: :api,
mark_as_sent: is_send_email,
emails:)
else
submissions_attrs, attachments = normalize_submissions_params!(submissions_params[:submission], @template)
submissions = create_submissions(@template, params)
Submissions.create_from_submitters(
template: @template,
user: current_user,
source: :api,
mark_as_sent: is_send_email,
submitters_order: params[:submitters_order] || 'preserved',
submissions_attrs:
)
end
Submissions.send_signature_requests(submissions)
Submissions.send_signature_requests(submissions, send_email: is_send_email)
submitters = submissions.flat_map(&:submitters)
save_default_value_attachments!(attachments, submitters)
render json: submitters
render json: submissions.flat_map(&:submitters)
rescue Submitters::NormalizeValues::UnknownFieldName, Submitters::NormalizeValues::UnknownSubmitterName => e
render json: { error: e.message }, status: :unprocessable_entity
end
@@ -90,9 +74,39 @@ module Api
private
def create_submissions(template, params)
is_send_email = !params[:send_email].in?(['false', false])
if (emails = (params[:emails] || params[:email]).presence) && params[:submission].blank?
Submissions.create_from_emails(template:,
user: current_user,
source: :api,
mark_as_sent: is_send_email,
emails:,
params:)
else
submissions_attrs, attachments = normalize_submissions_params!(submissions_params, template)
submissions = Submissions.create_from_submitters(
template:,
user: current_user,
source: :api,
mark_as_sent: is_send_email,
submitters_order: params[:submitters_order] || params[:order] || 'preserved',
submissions_attrs:,
params:
)
save_default_value_attachments!(attachments, submissions.flat_map(&:submitters))
submissions
end
end
def serialize_params
{
only: %i[id source submitters_order created_at updated_at],
methods: %i[audit_log_url],
include: {
submitters: { only: %i[id slug uuid name email phone
completed_at opened_at sent_at
@@ -105,11 +119,19 @@ module Api
end
def submissions_params
params.permit(submission: [{
submitters: [[:uuid, :name, :email, :role, :completed, :phone, :application_key,
{ values: {}, readonly_fields: [],
fields: [%i[name default_value readonly validation_pattern invalid_message]] }]]
}])
key = params.key?(:submission) ? :submission : :submissions
params.permit(
key => [
[:send_email, :send_sms, {
message: %i[subject body],
submitters: [[:send_email, :send_sms, :uuid, :name, :email, :role,
:completed, :phone, :application_key,
{ values: {}, readonly_fields: [], message: %i[subject body],
fields: [%i[name default_value readonly validation_pattern invalid_message]] }]]
}]
]
).fetch(key, [])
end
def normalize_submissions_params!(submissions_params, template)
@@ -126,7 +148,8 @@ module Api
values, new_attachments =
Submitters::NormalizeValues.call(template,
default_values,
submitter[:role] || template.submitters[index]['name'])
submitter[:role] || template.submitters[index]['name'],
throw_errors: true)
attachments.push(*new_attachments)
@@ -0,0 +1,38 @@
# frozen_string_literal: true
module Api
class TemplatesCloneController < ApiBaseController
load_and_authorize_resource :template
def create
authorize!(:manage, @template)
template = current_account.templates.new(source: :api)
template.application_key = params[:application_key]
template.name = params[:name] || "#{@template.name} (Clone)"
template.account = @template.account
template.author = current_user
template.assign_attributes(@template.slice(:folder_id, :fields, :schema, :submitters))
if params[:folder_name].present?
template.folder = TemplateFolders.find_or_create_by_name(current_user, params[:folder_name])
end
template.save!
Templates::CloneAttachments.call(template:, original_template: @template)
render json: template.as_json(serialize_params)
end
private
def serialize_params
{
include: { author: { only: %i[id email first_name last_name] },
documents: { only: %i[id uuid], methods: %i[url filename] } }
}
end
end
end
+9 -5
View File
@@ -8,6 +8,8 @@ module Api
templates = Templates.search(@templates, params[:q])
templates = params[:archived] ? templates.archived : templates.active
templates = templates.where(application_key: params[:application_key]) if params[:application_key].present?
templates = templates.joins(:folder).where(folder: { name: params[:folder] }) if params[:folder].present?
templates = paginate(templates.preload(:author, documents_attachments: :blob))
@@ -51,11 +53,13 @@ module Api
end
def template_params
params.require(:template).permit(:name,
schema: [%i[attachment_uuid name]],
submitters: [%i[name uuid]],
fields: [[:uuid, :submitter_uuid, :name, :type, :required,
{ options: [], areas: [%i[x y w h cell_w attachment_uuid page]] }]])
params.require(:template).permit(
:name,
schema: [%i[attachment_uuid name]],
submitters: [%i[name uuid]],
fields: [[:uuid, :submitter_uuid, :name, :type, :required, :readonly, :default_value,
{ options: [%i[value uuid]], areas: [%i[x y w h cell_w attachment_uuid option_uuid page]] }]]
)
end
end
end
+3 -2
View File
@@ -44,8 +44,9 @@ class ApplicationController < ActionController::Base
redirect_to setup_index_path unless User.exists?
end
def button_title(title: 'Submit', disabled_with: 'Submitting', icon: nil, icon_disabled: nil)
render_to_string(partial: 'shared/button_title', locals: { title:, disabled_with:, icon:, icon_disabled: })
def button_title(title: 'Submit', disabled_with: 'Submitting', title_class: '', icon: nil, icon_disabled: nil)
render_to_string(partial: 'shared/button_title',
locals: { title:, disabled_with:, title_class:, icon:, icon_disabled: })
end
def svg_icon(icon_name, class: '')
+12
View File
@@ -5,6 +5,7 @@ class DashboardController < ApplicationController
before_action :maybe_redirect_product_url
before_action :maybe_render_landing
before_action :maybe_redirect_mfa_setup
load_and_authorize_resource :template_folder, parent: false
load_and_authorize_resource :template, parent: false
@@ -62,6 +63,17 @@ class DashboardController < ApplicationController
redirect_to Docuseal::PRODUCT_URL, allow_other_host: true
end
def maybe_redirect_mfa_setup
return unless signed_in?
return if current_user.otp_required_for_login
return if !current_user.otp_required_for_login && !AccountConfig.exists?(value: true,
account_id: current_user.account_id,
key: AccountConfig::FORCE_MFA)
redirect_to mfa_setup_path, notice: 'Setup 2FA to continue'
end
def maybe_render_landing
return if signed_in?
+35
View File
@@ -0,0 +1,35 @@
# frozen_string_literal: true
class ErrorsController < ActionController::Base
ENTERPRISE_FEATURE_MESSAGE =
'This feature is available in Enterprise Edition: https://www.docuseal.co/pricing'
ENTERPRISE_PATHS = [
'/templates/html',
'/api/templates/html',
'/templates/pdf',
'/api/templates/pdf'
].freeze
def show
if request.original_fullpath.in?(ENTERPRISE_PATHS) && error_status_code == 404
return render json: { status: 404, message: ENTERPRISE_FEATURE_MESSAGE }, status: :not_found
end
respond_to do |f|
f.json do
render json: { status: error_status_code }, status: error_status_code
end
f.html { render error_status_code.to_s, status: error_status_code }
end
end
private
def error_status_code
@error_status_code ||=
ActionDispatch::ExceptionWrapper.new(request.env,
request.env['action_dispatch.exception']).status_code
end
end
+16 -6
View File
@@ -5,13 +5,11 @@ class MfaSetupController < ApplicationController
authorize!(:update, current_user)
end
def new
current_user.otp_secret ||= User.generate_otp_secret
before_action :set_provision_url, only: %i[show new]
current_user.save!
def show; end
@provision_url = current_user.otp_provisioning_uri(current_user.email, issuer: Docuseal.product_name)
end
def new; end
def edit; end
@@ -26,7 +24,7 @@ class MfaSetupController < ApplicationController
@error_message = 'Code is invalid'
render turbo_stream: turbo_stream.replace(:modal, template: 'mfa_setup/new'), status: :unprocessable_entity
render turbo_stream: turbo_stream.replace(:mfa_form, partial: 'mfa_setup/form'), status: :unprocessable_entity
end
end
@@ -41,4 +39,16 @@ class MfaSetupController < ApplicationController
render turbo_stream: turbo_stream.replace(:modal, template: 'mfa_setup/edit'), status: :unprocessable_entity
end
end
private
def set_provision_url
return redirect_to root_path, alert: '2FA has been set up already' if current_user.otp_required_for_login
current_user.otp_secret ||= User.generate_otp_secret
current_user.save!
@provision_url = current_user.otp_provisioning_uri(current_user.email, issuer: Docuseal.product_name)
end
end
@@ -12,7 +12,7 @@ class NotificationsSettingsController < ApplicationController
def index; end
def create
if @account_config.save
if @account_config.value.present? ? @account_config.save : @account_config.delete
redirect_back fallback_location: settings_notifications_path, notice: 'Changes have been saved'
else
redirect_back fallback_location: settings_notifications_path, alert: 'Unable to save'
@@ -6,12 +6,6 @@ class PreviewDocumentPageController < ActionController::API
FORMAT = Templates::ProcessDocument::FORMAT
def show
if Docuseal.multitenant?
Rollbar.warning('load page')
return head :not_found
end
attachment = ActiveStorage::Attachment.find_by(uuid: params[:attachment_uuid])
return head :not_found unless attachment
@@ -29,7 +23,7 @@ class PreviewDocumentPageController < ActionController::API
io = Templates::ProcessDocument.generate_pdf_preview_from_file(attachment, file_path, params[:id].to_i)
render plain: io.tap(&:rewind).read
render plain: io.tap(&:rewind).read, content_type: 'image/jpeg'
end
def find_or_create_document_tempfile_path(attachment)
@@ -0,0 +1,16 @@
# frozen_string_literal: true
class SsoSettingsController < ApplicationController
before_action :load_encrypted_config
authorize_resource :encrypted_config, only: :index
authorize_resource :encrypted_config, parent: false, except: :index
def index; end
private
def load_encrypted_config
@encrypted_config =
EncryptedConfig.find_or_initialize_by(account: current_account, key: 'saml_configs')
end
end
+23 -9
View File
@@ -14,21 +14,20 @@ class StartFormController < ApplicationController
def update
@submitter = Submitter.where(submission: @template.submissions.where(deleted_at: nil))
.order(id: :desc)
.then { |rel| params[:resubmit].present? ? rel.where(completed_at: nil) : rel }
.find_or_initialize_by(**submitter_params.compact_blank)
if @submitter.completed_at?
redirect_to start_form_completed_path(@template.slug, email: submitter_params[:email])
else
@submitter.assign_attributes(
uuid: @template.submitters.first['uuid'],
ip: request.remote_ip,
ua: request.user_agent
)
if @template.submitters.to_a.size > 1 && @submitter.new_record?
@error_message = 'Not found'
@submitter.submission ||= Submission.new(template: @template,
template_submitters: @template.submitters,
source: :link)
return render :show
end
assign_submission_attributes(@submitter, @template) if @submitter.new_record?
if @submitter.save
redirect_to submit_form_path(@submitter.slug)
@@ -46,8 +45,23 @@ class StartFormController < ApplicationController
private
def assign_submission_attributes(submitter, template)
submitter.assign_attributes(
uuid: template.submitters.first['uuid'],
ip: request.remote_ip,
ua: request.user_agent,
preferences: { 'send_email' => true }
)
submitter.submission ||= Submission.new(template:,
template_submitters: template.submitters,
source: :link)
submitter
end
def submitter_params
params.require(:submitter).permit(:email, :phone).tap do |attrs|
params.require(:submitter).permit(:email, :phone, :name).tap do |attrs|
attrs[:email] = Submissions.normalize_email(attrs[:email])
end
end
+10 -3
View File
@@ -34,23 +34,30 @@ class SubmissionsController < ApplicationController
def create
authorize!(:create, Submission)
if params[:is_custom_message] != '1'
params.delete(:subject)
params.delete(:body)
end
submissions =
if params[:emails].present?
Submissions.create_from_emails(template: @template,
user: current_user,
source: :invite,
mark_as_sent: params[:send_email] == '1',
emails: params[:emails])
emails: params[:emails],
params: params.merge('send_completed_email' => true))
else
Submissions.create_from_submitters(template: @template,
user: current_user,
source: :invite,
submitters_order: params[:preserve_order] == '1' ? 'preserved' : 'random',
mark_as_sent: params[:send_email] == '1',
submissions_attrs: submissions_params[:submission].to_h.values)
submissions_attrs: submissions_params[:submission].to_h.values,
params: params.merge('send_completed_email' => true))
end
Submissions.send_signature_requests(submissions, params)
Submissions.send_signature_requests(submissions)
redirect_to template_path(@template), notice: 'New recipients have been added'
end
@@ -0,0 +1,29 @@
# frozen_string_literal: true
class SubmissionsPreviewController < ApplicationController
skip_before_action :authenticate_user!
skip_authorization_check
PRELOAD_ALL_PAGES_AMOUNT = 200
def show
@submission = Submission.find_by!(slug: params[:slug])
ActiveRecord::Associations::Preloader.new(
records: [@submission],
associations: [:template, { template_schema_documents: :blob }]
).call
total_pages =
@submission.template_schema_documents.sum { |e| e.metadata.dig('pdf', 'number_of_pages').to_i }
if total_pages < PRELOAD_ALL_PAGES_AMOUNT
ActiveRecord::Associations::Preloader.new(
records: @submission.template_schema_documents,
associations: [:blob, { preview_images_attachments: :blob }]
).call
end
render 'submissions/show', layout: 'plain'
end
end
@@ -38,6 +38,10 @@ class SubmitFormController < ApplicationController
def update
submitter = Submitter.find_by!(slug: params[:slug])
if submitter.completed_at?
return render json: { error: 'Form has been completed already.' }, status: :unprocessable_entity
end
Submitters::SubmitValues.call(submitter, params, request)
head :ok
@@ -4,6 +4,13 @@ class SubmittersSendEmailController < ApplicationController
load_and_authorize_resource :submitter, id_param: :submitter_slug, find_by: :slug
def create
if Docuseal.multitenant? && SubmissionEvent.exists?(submitter: @submitter,
event_type: 'send_email',
created_at: 24.hours.ago..Time.current)
return redirect_back(fallback_location: submission_path(@submitter.submission),
alert: 'Email has been sent already.')
end
SubmitterMailer.invitation_email(@submitter).deliver_later!
SubmissionEvent.create!(submitter: @submitter, event_type: 'send_email')
+13 -2
View File
@@ -57,9 +57,20 @@ class TemplatesController < ApplicationController
end
def destroy
@template.update!(deleted_at: Time.current)
notice =
if params[:permanently].present?
@template.destroy!
redirect_back(fallback_location: root_path, notice: 'Template has been archived.')
Rollbar.info("Remove template: #{@template.id}") if defined?(Rollbar)
'Template has been removed.'
else
@template.update!(deleted_at: Time.current)
'Template has been archived.'
end
redirect_back(fallback_location: root_path, notice:)
end
private
@@ -3,20 +3,55 @@
class TemplatesUploadsController < ApplicationController
load_and_authorize_resource :template, parent: false
layout 'plain'
def show; end
def create
url_params = create_file_params_from_url if params[:url].present?
@template.account = current_account
@template.author = current_user
@template.folder = TemplateFolders.find_or_create_by_name(current_user, params[:folder_name])
@template.name = File.basename(params[:files].first.original_filename, '.*')
@template.name = File.basename((url_params || params)[:files].first.original_filename, '.*')
@template.save!
documents = Templates::CreateAttachments.call(@template, params)
documents = Templates::CreateAttachments.call(@template, url_params || params)
schema = documents.map { |doc| { attachment_uuid: doc.uuid, name: doc.filename.base } }
@template.update!(schema:)
redirect_to edit_template_path(@template)
rescue StandardError => e
Rollbar.error(e) if defined?(Rollbar)
redirect_to root_path, alert: 'Unable to upload file'
end
private
def create_file_params_from_url
tempfile = Tempfile.new
tempfile.binmode
tempfile.write(conn.get(Addressable::URI.parse(params[:url]).display_uri.to_s).body)
tempfile.rewind
file = ActionDispatch::Http::UploadedFile.new(
tempfile:,
filename: File.basename(
URI.decode_www_form_component(params[:filename].presence || params[:url])
),
type: Marcel::MimeType.for(tempfile)
)
{ files: [file] }
end
def conn
Faraday.new do |faraday|
faraday.response :follow_redirects
end
end
end
@@ -0,0 +1,52 @@
# frozen_string_literal: true
class TimestampServerController < ApplicationController
before_action :build_encrypted_config
authorize_resource :encrypted_config
def create
return head :not_found if Docuseal.multitenant?
test_timeserver_url(@encrypted_config.value) if @encrypted_config.value.present?
if @encrypted_config.value.present? ? @encrypted_config.save : @encrypted_config.delete
redirect_back fallback_location: settings_notifications_path, notice: 'Changes have been saved'
else
redirect_back fallback_location: settings_notifications_path, alert: 'Unable to save'
end
rescue HexaPDF::Error, SocketError, Submissions::TimestampHandler::TimestampError, OpenSSL::Timestamp::TimestampError
redirect_back fallback_location: settings_notifications_path, alert: 'Invalid Timeserver'
end
private
def test_timeserver_url(url)
pdf = HexaPDF::Document.new
pdf.pages.add
pkcs = Accounts.load_signing_pkcs(current_account)
pdf.sign(StringIO.new,
reason: 'Test',
certificate: pkcs.certificate,
key: pkcs.key,
certificate_chain: pkcs.ca_certs || [],
timestamp_handler: Submissions::TimestampHandler.new(tsa_url: url))
end
def load_encrypted_config
@encrypted_config
end
def build_encrypted_config
@encrypted_config =
EncryptedConfig.find_or_initialize_by(account: current_account,
key: EncryptedConfig::TIMESTAMP_SERVER_URL_KEY)
@encrypted_config.assign_attributes(encrypted_config_params)
end
def encrypted_config_params
params.require(:encrypted_config).permit(:value)
end
end
@@ -12,7 +12,7 @@ class VerifyPdfSignatureController < ApplicationController
cert_data = if Docuseal.multitenant?
Docuseal::CERTS
else
EncryptedConfig.find_by(account: current_account, key: EncryptedConfig::ESIGN_CERTS_KEY)&.value || {}
EncryptedConfig.find_by(key: EncryptedConfig::ESIGN_CERTS_KEY)&.value || {}
end
default_pkcs = GenerateCertificate.load_pkcs(cert_data)
@@ -7,7 +7,9 @@ class WebhookSettingsController < ApplicationController
def show; end
def create
@encrypted_config.update!(encrypted_config_params)
@encrypted_config.assign_attributes(encrypted_config_params)
@encrypted_config.value.present? ? @encrypted_config.save! : @encrypted_config.delete
redirect_back(fallback_location: settings_webhooks_path, notice: 'Webhook URL has been saved.')
end
+4
View File
@@ -18,6 +18,7 @@ import AutoresizeTextarea from './elements/autoresize_textarea'
import SubmittersAutocomplete from './elements/submitter_autocomplete'
import FolderAutocomplete from './elements/folder_autocomplete'
import SignatureForm from './elements/signature_form'
import SubmitForm from './elements/submit_form'
import * as TurboInstantClick from './lib/turbo_instant_click'
@@ -49,6 +50,7 @@ window.customElements.define('autoresize-textarea', AutoresizeTextarea)
window.customElements.define('submitters-autocomplete', SubmittersAutocomplete)
window.customElements.define('folder-autocomplete', FolderAutocomplete)
window.customElements.define('signature-form', SignatureForm)
window.customElements.define('submit-form', SubmitForm)
document.addEventListener('turbo:before-fetch-request', encodeMethodIntoRequestBody)
document.addEventListener('turbo:submit-end', async (event) => {
@@ -77,6 +79,8 @@ window.customElements.define('template-builder', class extends HTMLElement {
connectedCallback () {
this.appElem = document.createElement('div')
this.appElem.classList.add('md:h-screen')
this.app = createApp(TemplateBuilder, {
template: reactive(JSON.parse(this.dataset.template)),
backgroundColor: '#faf7f5',
+15 -1
View File
@@ -1,7 +1,21 @@
export default class extends HTMLElement {
connectedCallback () {
if (this.dataset.inputId) {
document.getElementById(this.dataset.inputId).value = Intl.DateTimeFormat().resolvedOptions().timeZone
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone
if (this.dataset.params === 'true') {
const params = new URLSearchParams(this.input.value)
params.set('timezone', timezone)
this.input.value = params.toString()
} else {
this.input.value = timezone
}
}
}
get input () {
return document.getElementById(this.dataset.inputId)
}
}
+5
View File
@@ -0,0 +1,5 @@
export default class extends HTMLElement {
connectedCallback () {
this.querySelector('form').requestSubmit()
}
}
+1
View File
@@ -17,6 +17,7 @@ window.customElements.define('submission-form', class extends HTMLElement {
isDemo: this.dataset.isDemo === 'true',
attribution: this.dataset.attribution !== 'false',
withConfetti: true,
withTypedSignature: this.dataset.withTypedSignature !== 'false',
values: reactive(JSON.parse(this.dataset.values)),
completedButton: JSON.parse(this.dataset.completedButton),
attachments: reactive(JSON.parse(this.dataset.attachments)),
+79 -4
View File
@@ -1,11 +1,11 @@
<template>
<div
class="flex absolute text-[1.5vw] lg:text-base"
class="flex absolute lg:text-base"
:style="computedStyle"
:class="{ 'cursor-default': !submittable, 'bg-red-100 border cursor-pointer ': submittable, 'border-red-100': !isActive && submittable, 'bg-opacity-70': !isActive && !isValueSet && submittable, 'border-red-500 border-dashed z-10': isActive && submittable, 'bg-opacity-30': (isActive || isValueSet) && submittable }"
:class="{ 'text-[1.5vw] lg:text-base': !textOverflowChars, 'text-[1.0vw] lg:text-xs': textOverflowChars, 'cursor-default': !submittable, 'bg-red-100 border cursor-pointer ': submittable, 'border-red-100': !isActive && submittable, 'bg-opacity-70': !isActive && !isValueSet && submittable, 'border-red-500 border-dashed z-10': isActive && submittable, 'bg-opacity-30': (isActive || isValueSet) && submittable }"
>
<div
v-if="!isActive && !isValueSet && field.type !== 'checkbox' && submittable"
v-if="!isActive && !isValueSet && field.type !== 'checkbox' && submittable && !area.option_uuid"
class="absolute top-0 bottom-0 right-0 left-0 items-center justify-center h-full w-full"
>
<span
@@ -21,7 +21,7 @@
</span>
</div>
<div
v-if="isActive && withLabel"
v-if="isActive && withLabel && !area.option_uuid"
class="absolute -top-7 rounded bg-base-content text-base-100 px-2 text-sm whitespace-nowrap"
>
{{ field.name || fieldNames[field.type] }}
@@ -87,6 +87,44 @@
:class="{ '!w-auto !h-full': area.w > area.h, '!w-full !h-auto': area.w <= area.h }"
/>
</div>
<div
v-else-if="field.type === 'radio' && area.option_uuid"
class="w-full p-[0.2vw] flex items-center justify-center"
>
<input
v-if="submittable"
type="radio"
:value="false"
class="aspect-square base-radio"
:class="{ '!w-auto !h-full': area.w > area.h, '!w-full !h-auto': area.w <= area.h }"
:checked="!!modelValue && modelValue === field.options.find((o) => o.uuid === area.option_uuid)?.value"
@click="$emit('update:model-value', field.options.find((o) => o.uuid === area.option_uuid)?.value)"
>
<IconCheck
v-else-if="!!modelValue && modelValue === field.options.find((o) => o.uuid === area.option_uuid)?.value"
class="aspect-square"
:class="{ '!w-auto !h-full': area.w > area.h, '!w-full !h-auto': area.w <= area.h }"
/>
</div>
<div
v-else-if="field.type === 'multiple' && area.option_uuid"
class="w-full p-[0.2vw] flex items-center justify-center"
>
<input
v-if="submittable"
type="checkbox"
:value="false"
class="aspect-square base-checkbox"
:class="{ '!w-auto !h-full': area.w > area.h, '!w-full !h-auto': area.w <= area.h }"
:checked="!!modelValue && modelValue.includes(field.options.find((o) => o.uuid === area.option_uuid)?.value)"
@change="updateMultipleSelectValue(field.options.find((o) => o.uuid === area.option_uuid)?.value)"
>
<IconCheck
v-else-if="!!modelValue && modelValue.includes(field.options.find((o) => o.uuid === area.option_uuid)?.value)"
class="aspect-square"
:class="{ '!w-auto !h-full': area.w > area.h, '!w-full !h-auto': area.w <= area.h }"
/>
</div>
<div
v-else-if="field.type === 'cells'"
class="w-full flex items-center"
@@ -102,6 +140,7 @@
</div>
<div
v-else
ref="textContainer"
class="flex items-center px-0.5"
>
<span v-if="Array.isArray(modelValue)">
@@ -173,6 +212,11 @@ export default {
}
},
emits: ['update:model-value'],
data () {
return {
textOverflowChars: 0
}
},
computed: {
fieldNames () {
return {
@@ -251,6 +295,37 @@ export default {
height: h * 100 + '%'
}
}
},
watch: {
modelValue () {
if (this.field.type === 'text' && this.$refs.textContainer && (this.textOverflowChars === 0 || (this.textOverflowChars - 4) > this.modelValue.length)) {
this.textOverflowChars = this.$refs.textContainer.scrollHeight > this.$refs.textContainer.clientHeight ? this.modelValue.length : 0
}
}
},
mounted () {
if (this.field.type === 'text' && this.$refs.textContainer) {
this.$nextTick(() => {
this.textOverflowChars = this.$refs.textContainer.scrollHeight > this.$refs.textContainer.clientHeight ? this.modelValue.length : 0
})
}
},
methods: {
updateMultipleSelectValue (value) {
if (this.modelValue?.includes(value)) {
const newValue = [...this.modelValue]
newValue.splice(newValue.indexOf(value), 1)
this.$emit('update:model-value', newValue)
} else {
const newValue = this.modelValue ? [...this.modelValue] : []
newValue.push(value)
this.$emit('update:model-value', newValue)
}
}
}
}
</script>
@@ -1,8 +1,8 @@
<template>
<div>
<div v-if="modelValue.length">
<div v-if="value.length">
<div
v-for="(val, index) in modelValue"
v-for="(val, index) in value"
:key="index"
class="flex mb-2"
>
@@ -26,10 +26,7 @@
{{ attachmentsIndex[val].filename }}
</span>
</a>
<button
v-if="modelValue"
@click.prevent="removeAttachment(val)"
>
<button @click.prevent="removeAttachment(val)">
<IconTrashX
:width="18"
:heigh="19"
@@ -91,18 +88,28 @@ export default {
}
},
emits: ['attached', 'update:model-value'],
computed: {
value: {
set (value) {
this.$emit('update:model-value', this.modelValue || [])
},
get () {
return this.modelValue || []
}
}
},
methods: {
removeAttachment (uuid) {
this.modelValue.splice(this.modelValue.indexOf(uuid), 1)
this.value.splice(this.value.indexOf(uuid), 1)
this.$emit('update:model-value', this.modelValue)
this.$emit('update:model-value', this.value)
},
onUpload (attachments) {
attachments.forEach((attachment) => {
this.$emit('attached', attachment)
})
this.$emit('update:model-value', [...this.modelValue, ...attachments.map(a => a.uuid)])
this.$emit('update:model-value', [...this.value, ...attachments.map(a => a.uuid)])
}
}
}
@@ -0,0 +1,73 @@
<template>
<div>
<div class="flex justify-between items-center w-full mb-2">
<label
:for="field.uuid"
class="label text-2xl"
>{{ field.name || t('date') }}
<template v-if="!field.required">({{ t('optional') }})</template>
</label>
<button
class="btn btn-outline btn-sm !normal-case font-normal"
@click.prevent="setCurrentDate"
>
<IconCalendarCheck :width="16" />
{{ t('set_today') }}
</button>
</div>
<div class="text-center">
<input
ref="input"
v-model="value"
class="base-input !text-2xl text-center w-full"
:required="field.required"
type="date"
:name="`values[${field.uuid}]`"
@focus="$emit('focus')"
>
</div>
</div>
</template>
<script>
import { IconCalendarCheck } from '@tabler/icons-vue'
export default {
name: 'DateStep',
components: {
IconCalendarCheck
},
inject: ['t'],
props: {
field: {
type: Object,
required: true
},
modelValue: {
type: String,
required: false,
default: ''
}
},
emits: ['update:model-value', 'focus'],
computed: {
value: {
set (value) {
this.$emit('update:model-value', value)
},
get () {
return this.modelValue
}
}
},
methods: {
setCurrentDate () {
const inputEl = this.$refs.input
inputEl.valueAsDate = new Date(new Date().getTime() - new Date().getTimezoneOffset() * 60000)
inputEl.dispatchEvent(new Event('input', { bubbles: true }))
}
}
}
</script>
+68 -52
View File
@@ -65,34 +65,13 @@
@focus="$refs.areas.scrollIntoField(currentField)"
/>
</div>
<div v-else-if="currentField.type === 'date'">
<div class="flex justify-between items-center w-full mb-2">
<label
:for="currentField.uuid"
class="label text-2xl"
>{{ currentField.name || t('date') }}
<template v-if="!currentField.required">({{ t('optional') }})</template>
</label>
<button
class="btn btn-outline btn-sm !normal-case font-normal"
@click.prevent="setCurrentDate"
>
<IconCalendarCheck :width="16" />
{{ t('set_today') }}
</button>
</div>
<div class="text-center">
<input
:id="currentField.uuid"
v-model="values[currentField.uuid]"
class="base-input !text-2xl text-center w-full"
:required="currentField.required"
type="date"
:name="`values[${currentField.uuid}]`"
@focus="$refs.areas.scrollIntoField(currentField)"
>
</div>
</div>
<DateStep
v-else-if="currentField.type === 'date'"
:key="currentField.uuid"
v-model="values[currentField.uuid]"
:field="currentField"
@focus="$refs.areas.scrollIntoField(currentField)"
/>
<div v-else-if="currentField.type === 'select'">
<label
v-if="currentField.name"
@@ -120,12 +99,12 @@
{{ t('select_your_option') }}
</option>
<option
v-for="(option, index) in currentField.options"
:key="index"
:selected="values[currentField.uuid] == option"
:value="option"
v-for="option in currentField.options"
:key="option.uuid"
:selected="values[currentField.uuid] == option.value"
:value="option.value"
>
{{ option }}
{{ option.value }}
</option>
</select>
</div>
@@ -140,24 +119,24 @@
<div class="flex w-full">
<div class="space-y-3.5 mx-auto">
<div
v-for="(option, index) in currentField.options"
:key="index"
v-for="option in currentField.options"
:key="option.uuid"
>
<label
:for="currentField.uuid + option"
:for="option.uuid"
class="flex items-center space-x-3"
>
<input
:id="currentField.uuid + option"
:id="option.uuid"
v-model="values[currentField.uuid]"
type="radio"
class="base-radio !h-7 !w-7"
:name="`values[${currentField.uuid}]`"
:value="option"
:value="option.value"
:required="currentField.required"
>
<span class="text-xl">
{{ option }}
{{ option.value }}
</span>
</label>
</div>
@@ -242,7 +221,9 @@
:key="currentField.uuid"
v-model="values[currentField.uuid]"
:field="currentField"
:previous-value="previousSignatureValue"
:is-direct-upload="isDirectUpload"
:with-typed-signature="withTypedSignature"
:attachments-index="attachmentsIndex"
:submitter-slug="submitterSlug"
@attached="attachments.push($event)"
@@ -255,6 +236,7 @@
:key="currentField.uuid"
v-model="values[currentField.uuid]"
:field="currentField"
:previous-value="previousInitialsValue"
:is-direct-upload="isDirectUpload"
:attachments-index="attachmentsIndex"
:submitter-slug="submitterSlug"
@@ -350,8 +332,9 @@ import AttachmentStep from './attachment_step'
import MultiSelectStep from './multi_select_step'
import PhoneStep from './phone_step'
import TextStep from './text_step'
import DateStep from './date_step'
import FormCompleted from './completed'
import { IconInnerShadowTop, IconArrowsDiagonal, IconArrowsDiagonalMinimize2, IconCalendarCheck } from '@tabler/icons-vue'
import { IconInnerShadowTop, IconArrowsDiagonal, IconArrowsDiagonalMinimize2 } from '@tabler/icons-vue'
import { t } from './i18n'
export default {
@@ -364,10 +347,10 @@ export default {
InitialsStep,
MultiSelectStep,
IconInnerShadowTop,
DateStep,
IconArrowsDiagonal,
TextStep,
PhoneStep,
IconCalendarCheck,
IconArrowsDiagonalMinimize2,
FormCompleted
},
@@ -392,11 +375,23 @@ export default {
required: false,
default: () => []
},
onComplete: {
type: Function,
required: false,
default () {
return () => {}
}
},
withConfetti: {
type: Boolean,
required: false,
default: false
},
withTypedSignature: {
type: Boolean,
required: false,
default: true
},
baseUrl: {
type: String,
required: false,
@@ -471,6 +466,16 @@ export default {
submitterSlug () {
return this.submitter.slug
},
previousSignatureValue () {
const signatureField = [...this.fields].reverse().find((field) => field.type === 'signature' && !!this.values[field.uuid])
return this.values[signatureField?.uuid]
},
previousInitialsValue () {
const initialsField = [...this.fields].reverse().find((field) => field.type === 'initials' && !!this.values[field.uuid])
return this.values[initialsField?.uuid]
},
isAnonymousChecboxes () {
return this.currentField.type === 'checkbox' && this.currentStepFields.every((e) => !e.name) && this.currentStepFields.length > 4
},
@@ -514,9 +519,15 @@ export default {
mounted () {
this.submittedValues = JSON.parse(JSON.stringify(this.values))
this.fields.forEach((field) => {
if (field.default_value && !field.readonly) {
this.values[field.uuid] = field.default_value
}
})
if (this.goToLast) {
const requiredEmptyStepIndex = this.stepFields.indexOf(this.stepFields.find((fields) => fields.some((f) => f.required && !this.values[f.uuid])))
const lastFilledStepIndex = this.stepFields.indexOf([...this.stepFields].reverse().find((fields) => fields.some((f) => !!this.values[f.uuid]))) + 1
const requiredEmptyStepIndex = this.stepFields.indexOf(this.stepFields.find((fields) => fields.some((f) => f.required && !this.submittedValues[f.uuid])))
const lastFilledStepIndex = this.stepFields.indexOf([...this.stepFields].reverse().find((fields) => fields.some((f) => !!this.submittedValues[f.uuid]))) + 1
const indexesList = [this.stepFields.length - 1]
@@ -635,20 +646,21 @@ export default {
}
})
},
setCurrentDate () {
const inputEl = document.getElementById(this.currentField.uuid)
inputEl.valueAsDate = new Date(new Date().getTime() - new Date().getTimezoneOffset() * 60000)
inputEl.dispatchEvent(new Event('input', { bubbles: true }))
},
saveStep (formData) {
const currentFieldUuid = this.currentField.uuid
if (this.isCompleted) {
return Promise.resolve({})
} else {
return fetch(this.baseUrl + this.submitPath, {
method: 'POST',
body: formData || new FormData(this.$refs.form)
}).then((response) => {
if (response.status === 200) {
this.submittedValues[currentFieldUuid] = this.values[currentFieldUuid]
}
return response
})
}
},
@@ -680,8 +692,6 @@ export default {
return Promise.reject(new Error(data.error))
}
this.submittedValues[this.currentField.uuid] = this.values[this.currentField.uuid]
if (isLastStep) {
this.isSecondWalkthrough = true
}
@@ -696,6 +706,12 @@ export default {
}
} else {
this.isCompleted = true
const respData = await response.text()
if (respData) {
this.onComplete(JSON.parse(respData))
}
}
}).catch(error => {
console.error('Error submitting form:', error)
+45
View File
@@ -32,6 +32,11 @@ const en = {
please_fill_all_required_fields: 'Please fill all required fields',
set_today: 'Set Today',
toggle_multiline_text: 'Toggle Multiline Text',
draw_signature: 'Draw signature',
type_initial: 'Type initials',
draw: 'Draw',
type: 'Type',
type_text: 'Type text',
date: 'Date',
email_has_been_sent: 'Email has been sent'
}
@@ -71,6 +76,11 @@ const es = {
set_today: 'Establecer Hoy',
date: 'Fecha',
toggle_multiline_text: 'Alternar Texto Multilínea',
draw_signature: 'Dibujar firma',
type_initial: 'Escribir iniciales',
draw: 'Dibujar',
type: 'Escribir',
type_text: 'Escribir texto',
email_has_been_sent: 'El correo electrónico ha sido enviado'
}
@@ -108,6 +118,11 @@ const it = {
please_fill_all_required_fields: 'Si prega di compilare tutti i campi obbligatori',
set_today: 'Imposta Oggi',
date: 'Data',
draw_signature: 'Disegna firma',
type_initial: 'Inserisci iniziali',
draw: 'Disegna',
type: 'Inserisci',
type_text: 'Inserisci testo',
toggle_multiline_text: 'Attiva Testo Multilinea',
email_has_been_sent: "L'email è stata inviata"
}
@@ -146,6 +161,11 @@ const de = {
please_fill_all_required_fields: 'Bitte füllen Sie alle erforderlichen Felder aus',
set_today: 'Heute einstellen',
date: 'Datum',
draw_signature: 'Unterschrift zeichnen',
type_initial: 'Initialen eingeben',
draw: 'Zeichnen',
type: 'Eingeben',
type_text: 'Text eingeben',
toggle_multiline_text: 'Mehrzeiligen Text umschalten',
email_has_been_sent: 'Die E-Mail wurde gesendet'
}
@@ -184,6 +204,11 @@ const fr = {
please_fill_all_required_fields: 'Veuillez remplir tous les champs obligatoires',
set_today: "Définir Aujourd'hui",
date: 'Date',
draw_signature: 'Dessiner une signature',
type_initial: 'Saisir les initiales',
draw: 'Dessiner',
type: 'Saisir',
type_text: 'Saisir du texte',
toggle_multiline_text: 'Basculer le Texte Multiligne',
email_has_been_sent: "L'email a été envoyé"
}
@@ -222,6 +247,11 @@ const pl = {
please_fill_all_required_fields: 'Proszę wypełnić wszystkie wymagane pola',
set_today: 'Ustaw Dziś',
date: 'Data',
draw_signature: 'Rysuj podpis',
type_initial: 'Wprowadź inicjały',
draw: 'Rysuj',
type: 'Wprowadź',
type_text: 'Wprowadź tekst',
toggle_multiline_text: 'Przełącz Tekst Wielolinijkowy',
email_has_been_sent: 'E-mail został wysłany'
}
@@ -260,6 +290,11 @@ const uk = {
please_fill_all_required_fields: "Будь ласка, заповніть всі обов'язкові поля",
set_today: 'Задати Сьогодні',
date: 'Дата',
draw_signature: 'Намалюйте підпис',
type_initial: 'Введіть ініціали',
draw: 'Підпис',
type: 'Текст',
type_text: 'Введіть текст',
toggle_multiline_text: 'Перемкнути Багаторядковий Текст',
email_has_been_sent: 'Електронний лист був відправлений'
}
@@ -298,6 +333,11 @@ const cs = {
please_fill_all_required_fields: 'Prosím vyplňte všechny povinné položky',
set_today: 'Nastavit Dnes',
date: 'Datum',
draw_signature: 'Nakreslit podpis',
type_initial: 'Zadat iniciály',
draw: 'Kreslit',
type: 'Zadat',
type_text: 'Zadat text',
toggle_multiline_text: 'Přepnout Víceřádkový Text',
email_has_been_sent: 'E-mail byl odeslán'
}
@@ -336,6 +376,11 @@ const pt = {
please_fill_all_required_fields: 'Por favor, preencha todos os campos obrigatórios',
set_today: 'Definir Hoje',
date: 'Data',
draw_signature: 'Desenhar assinatura',
type_initial: 'Inserir iniciais',
draw: 'Desenhar',
type: 'Inserir',
type_text: 'Inserir texto',
toggle_multiline_text: 'Alternar Texto Multilinha',
email_has_been_sent: 'Email enviado'
}
@@ -6,23 +6,43 @@
>{{ field.name || t('initials') }}</label>
<div class="space-x-2 flex">
<span
v-if="isDrawInitials"
class="tooltip"
:data-tip="t('type_initials')"
>
<a
id="type_text_button"
href="#"
class="btn btn-outline font-medium btn-sm"
@click.prevent="toggleTextInput"
>
<IconTextSize :width="16" />
<span class="hidden sm:inline">
{{ t('type') }}
</span>
</a>
</span>
<span
v-else
class="tooltip"
:data-tip="t('draw_initials')"
>
<a
id="type_text_button"
href="#"
class="btn btn-sm btn-circle"
:class="{ 'btn-neutral': isDrawInitials, 'btn-outline': !isDrawInitials }"
class="btn btn-outline font-medium btn-sm"
@click.prevent="toggleTextInput"
>
<IconSignature :width="16" />
<span class="hidden sm:inline">
{{ t('draw') }}
</span>
</a>
</span>
<a
v-if="modelValue"
v-if="modelValue || computedPreviousValue"
href="#"
class="btn btn-outline btn-sm"
class="btn font-medium btn-outline btn-sm"
@click.prevent="remove"
>
<IconReload :width="16" />
@@ -31,7 +51,7 @@
<a
v-else
href="#"
class="btn btn-outline btn-sm"
class="btn font-medium btn-outline btn-sm"
@click.prevent="clear"
>
<IconReload :width="16" />
@@ -51,26 +71,26 @@
</div>
</div>
<input
:value="modelValue"
:value="modelValue || computedPreviousValue"
type="hidden"
:name="`values[${field.uuid}]`"
>
<img
v-if="modelValue"
:src="attachmentsIndex[modelValue].url"
v-if="modelValue || computedPreviousValue"
:src="attachmentsIndex[modelValue || computedPreviousValue].url"
class="mx-auto bg-white border border-base-300 rounded max-h-72"
>
<canvas
v-show="!modelValue"
v-show="!modelValue && !computedPreviousValue"
ref="canvas"
class="bg-white border border-base-300 rounded"
class="bg-white border border-base-300 rounded-2xl"
/>
<input
v-if="!isDrawInitials"
v-if="!isDrawInitials && !modelValue && !computedPreviousValue"
id="initials_text_input"
ref="textInput"
class="base-input !text-2xl w-full mt-6 text-center"
:required="field.required && !isInitialsStarted && !modelValue"
:required="field.required && !isInitialsStarted"
:placeholder="`${t('type_initial_here')}...`"
type="text"
@focus="$emit('focus')"
@@ -81,12 +101,14 @@
<script>
import { cropCanvasAndExportToPNG } from './crop_canvas'
import { IconReload, IconSignature, IconArrowsDiagonalMinimize2 } from '@tabler/icons-vue'
import { IconReload, IconTextSize, IconSignature, IconArrowsDiagonalMinimize2 } from '@tabler/icons-vue'
import SignaturePad from 'signature_pad'
export default {
name: 'InitialsStep',
components: {
IconReload,
IconTextSize,
IconSignature,
IconArrowsDiagonalMinimize2
},
@@ -110,6 +132,11 @@ export default {
required: false,
default: () => ({})
},
previousValue: {
type: String,
required: false,
default: ''
},
modelValue: {
type: String,
required: false,
@@ -119,10 +146,20 @@ export default {
emits: ['attached', 'update:model-value', 'start', 'minimize', 'focus'],
data () {
return {
isInitialsStarted: false,
isInitialsStarted: !!this.previousValue,
isUsePreviousValue: true,
isDrawInitials: false
}
},
computed: {
computedPreviousValue () {
if (this.isUsePreviousValue) {
return this.previousValue
} else {
return null
}
}
},
async mounted () {
this.$nextTick(() => {
if (this.$refs.canvas) {
@@ -137,8 +174,6 @@ export default {
import('@rails/activestorage')
}
const { default: SignaturePad } = await import('signature_pad')
if (this.$refs.canvas) {
this.pad = new SignaturePad(this.$refs.canvas)
@@ -152,6 +187,9 @@ export default {
methods: {
remove () {
this.$emit('update:model-value', '')
this.isUsePreviousValue = false
this.isInitialsStarted = false
},
clear () {
this.pad.clear()
@@ -193,7 +231,11 @@ export default {
}
},
async submit () {
if (this.modelValue) {
if (this.modelValue || this.computedPreviousValue) {
if (this.computedPreviousValue) {
this.$emit('update:model-value', this.computedPreviousValue)
}
return Promise.resolve({})
}
@@ -7,25 +7,25 @@
<div class="flex w-full">
<div class="space-y-3.5 mx-auto">
<div
v-for="(option, index) in field.options"
:key="index"
v-for="option in field.options"
:key="option.uuid"
>
<label
:for="field.uuid + option"
:for="option.uuid"
class="flex items-center space-x-3"
>
<input
:id="field.uuid + option"
:id="option.uuid"
:ref="setInputRef"
type="checkbox"
:name="`values[${field.uuid}][]`"
:value="option"
:value="option.value"
class="base-checkbox !h-7 !w-7"
:checked="(modelValue || []).includes(option)"
:checked="(modelValue || []).includes(option.value)"
@change="onChange"
>
<span class="text-xl">
{{ option }}
{{ option.value }}
</span>
</label>
</div>
@@ -28,6 +28,7 @@
>
<div class="flex justify-between mt-2 -mb-2 md:-mb-4">
<a
v-if="!defaultValue"
href="#"
class="link"
@click.prevent="isCodeSent = false"
@@ -48,6 +49,7 @@
:id="field.uuid"
ref="phone"
:value="modelValue || defaultValue"
:readonly="!!defaultValue"
class="base-input !text-2xl w-full"
autocomplete="tel"
pattern="^\+[0-9\s\-]+$"
@@ -6,24 +6,45 @@
>{{ field.name || t('signature') }}</label>
<div class="space-x-2 flex">
<span
v-if="isTextSignature"
class="tooltip"
data-tip="Type text"
:data-tip="t('draw_signature')"
>
<button
<a
id="type_text_button"
class="btn btn-sm btn-circle"
:class="{ 'btn-neutral': isTextSignature, 'btn-outline': !isTextSignature }"
href="#"
class="btn btn-outline btn-sm font-medium"
@click.prevent="toggleTextInput"
>
<IconSignature :width="16" />
<span class="hidden sm:inline">
{{ t('draw') }}
</span>
</a>
</span>
<span
v-else-if="withTypedSignature"
class="tooltip"
:data-tip="t('type_text')"
>
<a
id="type_text_button"
href="#"
class="btn btn-outline btn-sm font-medium"
@click.prevent="toggleTextInput"
>
<IconTextSize :width="16" />
</button>
<span class="hidden sm:inline">
{{ t('type') }}
</span>
</a>
</span>
<span
class="tooltip"
data-tip="Take photo"
>
<label
class="btn btn-outline btn-sm btn-circle"
class="btn btn-outline btn-sm font-medium"
>
<IconCamera :width="16" />
<input
@@ -32,25 +53,31 @@
accept="image/*"
@change="drawImage"
>
<span class="hidden sm:inline">
{{ t('upload') }}
</span>
</label>
</span>
<button
v-if="modelValue"
class="btn btn-outline btn-sm"
<a
v-if="modelValue || computedPreviousValue"
href="#"
class="btn btn-outline btn-sm font-medium"
@click.prevent="remove"
>
<IconReload :width="16" />
{{ t('redraw') }}
</button>
<button
</a>
<a
v-else
class="btn btn-outline btn-sm"
href="#"
class="btn btn-outline btn-sm font-medium"
@click.prevent="clear"
>
<IconReload :width="16" />
{{ t('clear') }}
</button>
<button
</a>
<a
href="#"
title="Minimize"
class="py-1.5 inline md:hidden"
@click.prevent="$emit('minimize')"
@@ -59,23 +86,24 @@
:width="20"
:height="20"
/>
</button>
</a>
</div>
</div>
<input
:value="modelValue"
:value="modelValue || computedPreviousValue"
type="hidden"
:name="`values[${field.uuid}]`"
>
<img
v-if="modelValue"
:src="attachmentsIndex[modelValue].url"
v-if="modelValue || computedPreviousValue"
:src="attachmentsIndex[modelValue || computedPreviousValue].url"
class="mx-auto bg-white border border-base-300 rounded max-h-72"
>
<canvas
v-show="!modelValue"
v-show="!modelValue && !computedPreviousValue"
ref="canvas"
class="bg-white border border-base-300 rounded"
style="padding: 1px; 0"
class="bg-white border border-base-300 rounded-2xl"
/>
<input
v-if="isTextSignature"
@@ -91,8 +119,11 @@
</template>
<script>
import { IconReload, IconCamera, IconTextSize, IconArrowsDiagonalMinimize2 } from '@tabler/icons-vue'
import { IconReload, IconCamera, IconSignature, IconTextSize, IconArrowsDiagonalMinimize2 } from '@tabler/icons-vue'
import { cropCanvasAndExportToPNG } from './crop_canvas'
import SignaturePad from 'signature_pad'
let isFontLoaded = false
export default {
name: 'SignatureStep',
@@ -100,6 +131,7 @@ export default {
IconReload,
IconCamera,
IconTextSize,
IconSignature,
IconArrowsDiagonalMinimize2
},
inject: ['baseUrl', 't'],
@@ -117,11 +149,21 @@ export default {
required: true,
default: false
},
withTypedSignature: {
type: Boolean,
required: false,
default: true
},
attachmentsIndex: {
type: Object,
required: false,
default: () => ({})
},
previousValue: {
type: String,
required: false,
default: ''
},
modelValue: {
type: String,
required: false,
@@ -131,10 +173,20 @@ export default {
emits: ['attached', 'update:model-value', 'start', 'minimize'],
data () {
return {
isSignatureStarted: false,
isSignatureStarted: !!this.previousValue,
isUsePreviousValue: true,
isTextSignature: false
}
},
computed: {
computedPreviousValue () {
if (this.isUsePreviousValue) {
return this.previousValue
} else {
return null
}
}
},
async mounted () {
this.$nextTick(() => {
if (this.$refs.canvas) {
@@ -147,8 +199,6 @@ export default {
import('@rails/activestorage')
}
const { default: SignaturePad } = await import('signature_pad')
if (this.$refs.canvas) {
this.pad = new SignaturePad(this.$refs.canvas)
@@ -162,6 +212,22 @@ export default {
methods: {
remove () {
this.$emit('update:model-value', '')
this.isUsePreviousValue = false
this.isSignatureStarted = false
},
loadFont () {
if (!isFontLoaded) {
const font = new FontFace('Dancing Script', `url(${this.baseUrl}/fonts/DancingScript.otf) format("opentype")`)
font.load().then((loadedFont) => {
document.fonts.add(loadedFont)
isFontLoaded = true
}).catch((error) => {
console.error('Font loading failed:', error)
})
}
},
clear () {
this.pad.clear()
@@ -178,8 +244,8 @@ export default {
const canvas = this.$refs.canvas
const context = canvas.getContext('2d')
const fontFamily = 'Arial'
const fontSize = '44px'
const fontFamily = 'Dancing Script'
const fontSize = '38px'
const fontStyle = 'italic'
const fontWeight = ''
@@ -197,6 +263,8 @@ export default {
this.$nextTick(() => {
this.$refs.textInput.focus()
this.loadFont()
this.$emit('start')
})
}
@@ -250,7 +318,11 @@ export default {
}
},
async submit () {
if (this.modelValue) {
if (this.modelValue || this.computedPreviousValue) {
if (this.computedPreviousValue) {
this.$emit('update:model-value', this.computedPreviousValue)
}
return Promise.resolve({})
}
+15 -1
View File
@@ -15,6 +15,7 @@
v-if="!isTextArea"
:id="field.uuid"
v-model="text"
:maxlength="cellsMaxLegth"
class="base-input !text-2xl w-full !pr-11 -mr-10"
:required="field.required"
:pattern="field.validation?.pattern"
@@ -38,7 +39,7 @@
@focus="$emit('focus')"
/>
<div
v-if="!isTextArea"
v-if="!isTextArea && field.type !== 'cells'"
class="tooltip"
:data-tip="t('toggle_multiline_text')"
>
@@ -80,6 +81,19 @@ export default {
}
},
computed: {
cellsMaxLegth () {
if (this.field.type === 'cells') {
const area = this.field.areas?.[0]
if (area) {
return parseInt(area.w / area.cell_w) + 1
} else {
return null
}
} else {
return null
}
},
text: {
set (value) {
this.$emit('update:model-value', value)
+83 -30
View File
@@ -23,7 +23,7 @@
:style="{ left: (cellW / area.w * 100) + '%' }"
>
<span
v-if="index === 0"
v-if="index === 0 && editable"
class="h-2.5 w-2.5 rounded-full -bottom-1 border-gray-400 bg-white shadow-md border absolute cursor-ew-resize z-10"
style="left: -4px"
@mousedown.stop="startResizeCell"
@@ -42,6 +42,7 @@
v-model="field.submitter_uuid"
class="border-r"
:compact="true"
:editable="editable && !defaultField"
:menu-classes="'dropdown-content bg-white menu menu-xs p-2 shadow rounded-box w-52 rounded-t-none -left-[1px]'"
:submitters="template.submitters"
@update:model-value="save"
@@ -50,6 +51,7 @@
<FieldType
v-model="field.type"
:button-width="27"
:editable="editable && !defaultField"
:button-classes="'px-1'"
:menu-classes="'bg-white rounded-t-none'"
@update:model-value="[maybeUpdateOptions(), save()]"
@@ -58,15 +60,15 @@
<span
v-if="field.type !== 'checkbox' || field.name"
ref="name"
contenteditable
:contenteditable="editable && !defaultField"
class="pr-1 cursor-text outline-none block"
style="min-width: 2px"
@keydown.enter.prevent="onNameEnter"
@focus="onNameFocus"
@blur="onNameBlur"
>{{ field.name || defaultName }}</span>
>{{ optionIndexText }} {{ field.name || defaultName }}</span>
<div
v-if="isNameFocus && field.type !== 'checkbox'"
v-if="isNameFocus && !['checkbox', 'phone'].includes(field.type)"
class="flex items-center ml-1.5"
>
<input
@@ -84,7 +86,7 @@
>Required</label>
</div>
<button
v-else
v-else-if="editable"
class="pr-1"
title="Remove"
@click.prevent="$emit('remove')"
@@ -93,18 +95,30 @@
</button>
</div>
<div
class="opacity-50 flex items-center justify-center h-full w-full"
:class="bgColors[submitterIndex]"
class="flex items-center h-full w-full"
:class="[bgColors[submitterIndex], field?.default_value ? '' : 'justify-center']"
>
<span
v-if="field"
class="flex justify-center items-center space-x-1 h-full"
>
<div
v-if="field?.default_value"
:class="{ 'text-[1.5vw] lg:text-base': !textOverflowChars, 'text-[1.0vw] lg:text-xs': textOverflowChars }"
>
<div
ref="textContainer"
class="flex items-center px-0.5"
>
<span class="whitespace-pre-wrap">{{ field.default_value }}</span>
</div>
</div>
<component
:is="fieldIcons[field.type]"
v-else
width="100%"
height="100%"
class="max-h-10"
class="max-h-10 opacity-50"
/>
</span>
</div>
@@ -113,7 +127,7 @@
class="absolute top-0 bottom-0 right-0 left-0 cursor-pointer"
/>
<span
v-if="field?.type"
v-if="field?.type && editable"
class="h-4 w-4 md:h-2.5 md:w-2.5 -right-1 rounded-full -bottom-1 border-gray-400 bg-white shadow-md border absolute cursor-nwse-resize"
@mousedown.stop="startResize"
@touchstart="startTouchResize"
@@ -126,6 +140,7 @@ import FieldSubmitter from './field_submitter'
import FieldType from './field_type'
import Field from './field'
import { IconX } from '@tabler/icons-vue'
import { v4 } from 'uuid'
export default {
name: 'FieldArea',
@@ -145,6 +160,16 @@ export default {
required: false,
default: false
},
defaultField: {
type: Object,
required: false,
default: null
},
editable: {
type: Boolean,
required: false,
default: true
},
field: {
type: Object,
required: false,
@@ -157,6 +182,7 @@ export default {
isResize: false,
isDragged: false,
isNameFocus: false,
textOverflowChars: 0,
dragFrom: { x: 0, y: 0 }
}
},
@@ -164,6 +190,13 @@ export default {
defaultName: Field.computed.defaultName,
fieldNames: FieldType.computed.fieldNames,
fieldIcons: FieldType.computed.fieldIcons,
optionIndexText () {
if (this.area.option_uuid && this.field.options) {
return `${this.field.options.findIndex((o) => o.uuid === this.area.option_uuid) + 1}.`
} else {
return ''
}
},
cells () {
const cells = []
@@ -185,30 +218,30 @@ export default {
},
borderColors () {
return [
'border-red-500',
'border-sky-500',
'border-emerald-500',
'border-yellow-300',
'border-purple-600',
'border-pink-500',
'border-cyan-500',
'border-orange-500',
'border-lime-500',
'border-indigo-500'
'border-red-500/50',
'border-sky-500/50',
'border-emerald-500/50',
'border-yellow-300/50',
'border-purple-600/50',
'border-pink-500/50',
'border-cyan-500/50',
'border-orange-500/50',
'border-lime-500/50',
'border-indigo-500/50'
]
},
bgColors () {
return [
'bg-red-100',
'bg-sky-100',
'bg-emerald-100',
'bg-yellow-100',
'bg-purple-100',
'bg-pink-100',
'bg-cyan-100',
'bg-orange-100',
'bg-lime-100',
'bg-indigo-100'
'bg-red-100/50',
'bg-sky-100/50',
'bg-emerald-100/50',
'bg-yellow-100/50',
'bg-purple-100/50',
'bg-pink-100/50',
'bg-cyan-100/50',
'bg-orange-100/50',
'bg-lime-100/50',
'bg-indigo-100/50'
]
},
isSelected () {
@@ -225,6 +258,20 @@ export default {
}
}
},
watch: {
'field.default_value' () {
if (this.field.type === 'text' && this.field.default_value && this.$refs.textContainer && (this.textOverflowChars === 0 || (this.textOverflowChars - 4) > this.field.default_value.length)) {
this.textOverflowChars = this.$el.clientHeight < this.$refs.textContainer.clientHeight ? this.field.default_value.length : 0
}
}
},
mounted () {
if (this.field.type === 'text' && this.field.default_value && this.$refs.textContainer && (this.textOverflowChars === 0 || (this.textOverflowChars - 4) > this.field.default_value)) {
this.$nextTick(() => {
this.textOverflowChars = this.$el.clientHeight < this.$refs.textContainer.clientHeight ? this.field.default_value.length : 0
})
}
},
methods: {
onNameFocus (e) {
this.selectedAreaRef.value = this.area
@@ -262,12 +309,14 @@ export default {
}
},
maybeUpdateOptions () {
delete this.field.default_value
if (!['radio', 'multiple', 'select'].includes(this.field.type)) {
delete this.field.options
}
if (['select', 'multiple', 'radio'].includes(this.field.type)) {
this.field.options ||= ['']
this.field.options ||= [{ value: '', uuid: v4() }]
}
(this.field.areas || []).forEach((area) => {
@@ -313,6 +362,10 @@ export default {
startDrag (e) {
this.selectedAreaRef.value = this.area
if (!this.editable) {
return
}
const rect = e.target.getBoundingClientRect()
this.dragFrom = { x: e.clientX - rect.left, y: e.clientY - rect.top }
+141 -64
View File
@@ -4,10 +4,11 @@
class="mx-auto pl-3 md:pl-4 h-full"
>
<div
v-if="$slots.buttons || withTitle"
class="flex justify-between py-1.5 items-center pr-4 sticky top-0 z-10"
:style="{ backgroundColor }"
>
<div class="flex space-x-3">
<div class="flex items-center space-x-3">
<a
v-if="withLogo"
href="/"
@@ -15,8 +16,10 @@
<Logo />
</a>
<Contenteditable
v-if="withTitle"
:model-value="template.name"
class="text-3xl font-semibold focus:text-clip"
:editable="editable"
class="text-xl md:text-3xl font-semibold focus:text-clip"
:icon-stroke-width="2.3"
@update:model-value="updateName"
/>
@@ -27,17 +30,33 @@
name="buttons"
/>
<template v-else>
<a
:href="template.submitters.length > 1 ? `/templates/${template.id}/submissions/new?selfsign=true` : `/d/${template.slug}`"
class="btn btn-primary btn-ghost text-base hidden md:flex"
:target="template.submitters.length > 1 ? '' : '_blank'"
:data-turbo-frame="template.submitters.length > 1 ? 'modal' : ''"
@click="maybeShowEmptyTemplateAlert"
>
<IconWritingSign
width="20"
class="inline"
/>
<span class="hidden md:inline">
Sign Yourself
</span>
</a>
<a
:href="`/templates/${template.id}/submissions/new`"
data-turbo-frame="modal"
class="btn btn-primary text-base"
class="white-button md:!px-6"
@click="maybeShowEmptyTemplateAlert"
>
<IconUsersPlus
width="20"
class="inline"
/>
<span class="hidden md:inline">
Recipients
Send
</span>
</a>
<button
@@ -62,7 +81,10 @@
</template>
</div>
</div>
<div class="flex md:max-h-[calc(100vh-60px)]">
<div
class="flex"
:class="$slots.buttons || withTitle ? 'md:max-h-[calc(100%_-_60px)]' : 'md:max-h-[100%]'"
>
<div
ref="previews"
:style="{ 'display': isBreakpointLg ? 'none' : 'initial' }"
@@ -75,6 +97,8 @@
:item="item"
:document="sortedDocuments[index]"
:accept-file-types="acceptFileTypes"
:with-replace-button="withUploadButton"
:editable="editable"
:template="template"
:is-direct-upload="isDirectUpload"
@scroll-to="scrollIntoDocument(item)"
@@ -86,10 +110,10 @@
/>
<div
class="sticky bottom-0 py-2"
:class="{ 'bg-base-100': withStickySubmitters }"
:style="withStickySubmitters ? { backgroundColor } : {}"
>
<Upload
v-if="sortedDocuments.length"
v-if="sortedDocuments.length && editable && withUploadButton"
:accept-file-types="acceptFileTypes"
:template-id="template.id"
:is-direct-upload="isDirectUpload"
@@ -103,7 +127,7 @@
class="pr-3.5 pl-0.5"
>
<Dropzone
v-if="!sortedDocuments.length"
v-if="!sortedDocuments.length && withUploadButton"
:template-id="template.id"
:accept-file-types="acceptFileTypes"
:is-direct-upload="isDirectUpload"
@@ -119,16 +143,20 @@
:areas-index="fieldAreasIndex[document.uuid]"
:selected-submitter="selectedSubmitter"
:document="document"
:is-drag="!!dragFieldType"
:is-drag="!!dragField"
:default-fields="defaultFields"
:draw-field="drawField"
:editable="editable"
:base-url="baseUrl"
@draw="onDraw"
@drop-field="onDropfield"
@remove-area="removeArea"
/>
<DocumentControls
v-if="isBreakpointLg"
v-if="isBreakpointLg && editable"
:with-arrows="template.schema.length > 1"
:item="template.schema.find((item) => item.attachment_uuid === document.uuid)"
:with-replace-button="withUploadButton"
:document="document"
:template="template"
:is-direct-upload="isDirectUpload"
@@ -141,10 +169,11 @@
/>
</template>
<div
v-if="sortedDocuments.length && isBreakpointLg"
v-if="sortedDocuments.length && isBreakpointLg && editable"
class="pb-4"
>
<Upload
v-if="withUploadButton"
:template-id="template.id"
:is-direct-upload="isDirectUpload"
@success="updateFromUpload"
@@ -152,34 +181,19 @@
</div>
</template>
</div>
<span
v-if="drawField"
class="fixed text-center w-full left-1/2 bottom-0 transform -translate-x-1/2"
>
<span
class="rounded bg-base-200 px-4 py-2 rounded-full inline-flex space-x-2 mx-auto items-center mb-4 z-20 md:hidden"
>
<component
:is="fieldIcons[drawField.type]"
:width="20"
:height="20"
class="inline"
:stroke-width="1.6"
/>
<span>
Draw {{ fieldNames[drawField.type] }} Field
</span>
<a
href="#"
class="link block text-center"
@click.prevent="drawField = null"
>
Cancel
</a>
</span>
</span>
<MobileDrawField
v-if="drawField && isBreakpointLg"
:draw-field="drawField"
:fields="template.fields"
:submitters="template.submitters"
:selected-submitter="selectedSubmitter"
class="md:hidden"
:editable="editable"
@cancel="[drawField = null, drawOption = null]"
@change-submitter="[selectedSubmitter = $event, drawField.submitter_uuid = $event.uuid]"
/>
<FieldType
v-if="sortedDocuments.length && !drawField"
v-if="sortedDocuments.length && !drawField && editable"
class="dropdown-top dropdown-end fixed bottom-4 right-4 z-10 md:hidden"
:model-value="''"
@update:model-value="startFieldDraw($event)"
@@ -207,7 +221,8 @@
>
<div
v-if="drawField"
class="sticky inset-0 bg-base-100 h-full"
class="sticky inset-0 h-full"
:style="{ backgroundColor }"
>
<div class="bg-base-300 rounded-lg p-5 text-center space-y-4">
<p>
@@ -216,7 +231,7 @@
<p>
<button
class="base-button"
@click="drawField = null"
@click="[drawField = null, drawOption = null]"
>
Cancel
</button>
@@ -229,11 +244,13 @@
:fields="template.fields"
:submitters="template.submitters"
:selected-submitter="selectedSubmitter"
:default-fields="defaultFields"
:with-sticky-submitters="withStickySubmitters"
@set-draw="drawField = $event"
@set-drag="dragFieldType = $event"
:editable="editable"
@set-draw="[drawField = $event.field, drawOption = $event.option]"
@set-drag="dragField = $event"
@change-submitter="selectedSubmitter = $event"
@drag-end="dragFieldType = null"
@drag-end="dragField = null"
@scroll-to-area="scrollToArea"
/>
</div>
@@ -246,13 +263,14 @@
import Upload from './upload'
import Dropzone from './dropzone'
import Fields from './fields'
import MobileDrawField from './mobile_draw_field'
import Document from './document'
import Logo from './logo'
import Contenteditable from './contenteditable'
import DocumentPreview from './preview'
import DocumentControls from './controls'
import FieldType from './field_type'
import { IconUsersPlus, IconDeviceFloppy, IconInnerShadowTop, IconPlus, IconX } from '@tabler/icons-vue'
import { IconUsersPlus, IconDeviceFloppy, IconWritingSign, IconInnerShadowTop, IconPlus, IconX } from '@tabler/icons-vue'
import { v4 } from 'uuid'
import { ref, computed } from 'vue'
@@ -262,9 +280,11 @@ export default {
Upload,
Document,
Fields,
MobileDrawField,
IconPlus,
FieldType,
IconX,
IconWritingSign,
Logo,
Dropzone,
DocumentPreview,
@@ -299,6 +319,21 @@ export default {
required: false,
default: ''
},
editable: {
type: Boolean,
required: false,
default: true
},
defaultFields: {
type: Array,
required: false,
default: () => []
},
defaultSubmitters: {
type: Array,
required: false,
default: () => []
},
acceptFileTypes: {
type: String,
required: false,
@@ -319,6 +354,16 @@ export default {
required: false,
default: true
},
withUploadButton: {
type: Boolean,
required: false,
default: true
},
withTitle: {
type: Boolean,
required: false,
default: true
},
withPhone: {
type: Boolean,
required: false,
@@ -337,7 +382,8 @@ export default {
isSaving: false,
selectedSubmitter: null,
drawField: null,
dragFieldType: null
drawOption: null,
dragField: null
}
},
computed: {
@@ -369,6 +415,13 @@ export default {
}
},
created () {
this.defaultSubmitters.forEach((name, index) => {
const submitter = (this.template.submitters[index] ||= {})
submitter.name = name
submitter.uuid ||= v4()
})
this.selectedSubmitter = this.template.submitters[0]
},
mounted () {
@@ -405,10 +458,11 @@ export default {
}
if (['select', 'multiple', 'radio'].includes(type)) {
field.options = ['']
field.options = [{ value: '', uuid: v4() }]
}
this.drawField = field
this.drawOption = null
},
undo () {
if (this.undoStack.length > 1) {
@@ -458,10 +512,11 @@ export default {
onKeyUp (e) {
if (e.code === 'Escape') {
this.drawField = null
this.drawOption = null
this.selectedAreaRef.value = null
}
if (['Backspace', 'Delete'].includes(e.key) && this.selectedAreaRef.value && document.activeElement === document.body) {
if (this.editable && ['Backspace', 'Delete'].includes(e.key) && this.selectedAreaRef.value && document.activeElement === document.body) {
this.removeArea(this.selectedAreaRef.value)
this.selectedAreaRef.value = null
@@ -504,6 +559,16 @@ export default {
},
onDraw (area) {
if (this.drawField) {
if (this.drawOption) {
const areaWithoutOption = this.drawField.areas?.find((a) => !a.option_uuid)
if (areaWithoutOption && !this.drawField.areas.find((a) => a.option_uuid === this.drawField.options[0].uuid)) {
areaWithoutOption.option_uuid = this.drawField.options[0].uuid
}
area.option_uuid = this.drawOption.uuid
}
this.drawField.areas ||= []
this.drawField.areas.push(area)
@@ -512,6 +577,7 @@ export default {
}
this.drawField = null
this.drawOption = null
this.selectedAreaRef.value = area
@@ -561,14 +627,14 @@ export default {
onDropfield (area) {
const field = {
name: '',
type: this.dragFieldType,
uuid: v4(),
submitter_uuid: this.selectedSubmitter.uuid,
required: this.dragFieldType !== 'checkbox'
required: this.dragField.type !== 'checkbox',
...this.dragField
}
if (['select', 'multiple', 'radio'].includes(this.dragFieldType)) {
field.options = ['']
if (['select', 'multiple', 'radio'].includes(field.type)) {
field.options = [{ value: '', uuid: v4() }]
}
const fieldArea = {
@@ -582,27 +648,27 @@ export default {
let baseArea
if (this.selectedField?.type === this.dragFieldType) {
if (this.selectedField?.type === field.type) {
baseArea = this.selectedAreaRef.value
} else if (previousField?.areas?.length) {
baseArea = previousField.areas[previousField.areas.length - 1]
} else {
if (['checkbox'].includes(this.dragFieldType)) {
if (['checkbox'].includes(field.type)) {
baseArea = {
w: area.maskW / 30 / area.maskW,
h: area.maskW / 30 / area.maskW * (area.maskW / area.maskH)
}
} else if (this.dragFieldType === 'image') {
} else if (field.type === 'image') {
baseArea = {
w: area.maskW / 5 / area.maskW,
h: (area.maskW / 5 / area.maskW) * (area.maskW / area.maskH)
}
} else if (this.dragFieldType === 'signature') {
} else if (field.type === 'signature') {
baseArea = {
w: area.maskW / 5 / area.maskW,
h: (area.maskW / 5 / area.maskW) * (area.maskW / area.maskH) / 2
}
} else if (this.dragFieldType === 'initials') {
} else if (field.type === 'initials') {
baseArea = {
w: area.maskW / 10 / area.maskW,
h: area.maskW / 35 / area.maskW
@@ -619,7 +685,7 @@ export default {
fieldArea.h = baseArea.h
fieldArea.y = fieldArea.y - baseArea.h / 2
if (this.dragFieldType === 'cells') {
if (field.type === 'cells') {
fieldArea.cell_w = baseArea.cell_w || (baseArea.w / 5)
}
@@ -691,14 +757,25 @@ export default {
this.save()
},
onSaveClick () {
this.isSaving = true
maybeShowEmptyTemplateAlert (e) {
if (!this.template.fields.length) {
e.preventDefault()
this.save().then(() => {
window.Turbo.visit(`/templates/${this.template.id}`)
}).finally(() => {
this.isSaving = false
})
alert('Please draw fields to prepare the document.')
}
},
onSaveClick () {
if (this.template.fields.length) {
this.isSaving = true
this.save().then(() => {
window.Turbo.visit(`/templates/${this.template.id}`)
}).finally(() => {
this.isSaving = false
})
} else {
alert('Please draw fields to prepare the document.')
}
},
scrollToArea (area) {
const documentRef = this.documentRefs.find((a) => a.document.uuid === area.attachment_uuid)
@@ -5,7 +5,7 @@
>
<span
ref="contenteditable"
contenteditable
:contenteditable="editable"
style="min-width: 2px"
:class="iconInline ? 'inline' : 'block'"
class="peer outline-none focus:block"
@@ -24,10 +24,10 @@
*
</span>
<IconPencil
class="cursor-pointer flex-none opacity-0 group-hover/contenteditable:opacity-100 align-middle peer-focus:hidden"
class="cursor-pointer flex-none opacity-0 group-hover/contenteditable-container:opacity-100 group-hover/contenteditable:opacity-100 align-middle peer-focus:hidden"
:style="iconInline ? {} : { right: -(1.1 * iconWidth) + 'px' }"
title="Edit"
:class="{ 'ml-1': !withRequired, 'absolute': !iconInline, 'inline align-bottom': iconInline }"
:class="{ invisible: !editable, 'ml-1': !withRequired, 'absolute': !iconInline, 'inline align-bottom': iconInline }"
:width="iconWidth"
:stroke-width="iconStrokeWidth"
@click="[focusContenteditable(), selectOnEditClick && selectContent()]"
@@ -69,6 +69,11 @@ export default {
required: false,
default: false
},
editable: {
type: Boolean,
required: false,
default: true
},
iconStrokeWidth: {
type: Number,
required: false,
@@ -7,6 +7,7 @@
@update:model-value="onUpdateName"
/>
<ReplaceButton
v-if="withReplaceButton"
:is-direct-upload="isDirectUpload"
:template-id="template.id"
@click.stop
@@ -67,6 +68,11 @@ export default {
required: true,
default: false
},
withReplaceButton: {
type: Boolean,
required: true,
default: true
},
withArrows: {
type: Boolean,
required: false,
+25 -1
View File
@@ -5,8 +5,10 @@
:key="image.id"
:ref="setPageRefs"
:number="index"
:editable="editable"
:areas="areasIndex[index]"
:is-drag="isDrag"
:default-fields="defaultFields"
:draw-field="drawField"
:selected-submitter="selectedSubmitter"
:image="image"
@@ -34,15 +36,30 @@ export default {
required: false,
default: () => ({})
},
defaultFields: {
type: Array,
required: false,
default: () => []
},
selectedSubmitter: {
type: Object,
required: true
},
editable: {
type: Boolean,
required: false,
default: true
},
drawField: {
type: Object,
required: false,
default: null
},
baseUrl: {
type: String,
required: false,
default: ''
},
isDrag: {
type: Boolean,
required: false,
@@ -56,6 +73,13 @@ export default {
}
},
computed: {
basePreviewUrl () {
if (this.baseUrl) {
return new URL(this.baseUrl).origin
} else {
return ''
}
},
numberOfPages () {
return this.document.metadata?.pdf?.number_of_pages || this.document.preview_images.length
},
@@ -66,7 +90,7 @@ export default {
return this.previewImagesIndex[i] || {
metadata: lazyloadMetadata,
id: Math.random().toString(),
url: `/preview/${this.document.uuid}/${i}.jpg`
url: this.basePreviewUrl + `/preview/${this.document.uuid}/${i}.jpg`
}
})
},
+163 -58
View File
@@ -4,8 +4,9 @@
>
<div
class="border border-base-300 rounded rounded-tr-none relative group"
:style="{ backgroundColor: backgroundColor }"
>
<div class="flex items-center justify-between relative">
<div class="flex items-center justify-between relative group/contenteditable-container">
<div
class="absolute top-0 bottom-0 right-0 left-0 cursor-pointer"
@click="scrollToFirstArea"
@@ -13,6 +14,7 @@
<div class="flex items-center p-1 space-x-1">
<FieldType
v-model="field.type"
:editable="editable && !defaultField"
:button-width="20"
@update:model-value="[maybeUpdateOptions(), save()]"
@click="scrollToFirstArea"
@@ -20,6 +22,7 @@
<Contenteditable
ref="name"
:model-value="field.name || defaultName"
:editable="editable && !defaultField"
:icon-inline="true"
:icon-width="18"
:icon-stroke-width="1.6"
@@ -31,34 +34,47 @@
v-if="isNameFocus"
class="flex items-center relative"
>
<input
:id="`required-checkbox-${field.uuid}`"
v-model="field.required"
type="checkbox"
class="checkbox checkbox-xs no-animation rounded"
@mousedown.prevent
>
<label
:for="`required-checkbox-${field.uuid}`"
class="label text-xs"
@click.prevent="field.required = !field.required"
@mousedown.prevent
>Required</label>
<template v-if="field.type != 'phone'">
<input
:id="`required-checkbox-${field.uuid}`"
v-model="field.required"
type="checkbox"
class="checkbox checkbox-xs no-animation rounded"
@mousedown.prevent
>
<label
:for="`required-checkbox-${field.uuid}`"
class="label text-xs"
@click.prevent="field.required = !field.required"
@mousedown.prevent
>Required</label>
</template>
</div>
<div
v-else
v-else-if="editable"
class="flex items-center space-x-1"
>
<button
v-if="field && !field.areas.length"
title="Draw"
class="relative cursor-pointer text-transparent group-hover:text-base-content"
@click="$emit('set-draw', { field })"
>
<IconNewSection
:width="18"
:stroke-width="1.6"
/>
</button>
<span
v-if="field.areas?.length"
v-if="!defaultField"
class="dropdown dropdown-end"
>
<label
tabindex="0"
title="Areas"
title="Settings"
class="cursor-pointer text-transparent group-hover:text-base-content"
>
<IconShape
<IconSettings
:width="18"
:stroke-width="1.6"
/>
@@ -66,8 +82,60 @@
<ul
tabindex="0"
class="mt-1.5 dropdown-content menu menu-xs p-2 shadow bg-base-100 rounded-box w-52 z-10"
draggable="true"
@dragstart.prevent.stop
@click="closeDropdown"
>
<div
v-if="field.type === 'text'"
class="py-1.5 px-1 relative"
@click.stop
>
<input
v-model="field.default_value"
type="text"
placeholder="Default value"
class="input input-bordered input-xs w-full max-w-xs h-7 !outline-0"
@blur="save"
>
<label
v-if="field.default_value"
:style="{ backgroundColor: backgroundColor }"
class="absolute -top-1 left-2.5 px-1 h-4"
style="font-size: 8px"
>
Default value
</label>
</div>
<li
v-if="field.type != 'phone'"
@click.stop
>
<label class="cursor-pointer py-1.5">
<input
v-model="field.required"
type="checkbox"
class="toggle toggle-xs"
@update:model-value="save"
>
<span class="label-text">Required</span>
</label>
</li>
<li
v-if="field.type === 'text'"
@click.stop
>
<label class="cursor-pointer py-1.5">
<input
v-model="field.readonly"
type="checkbox"
class="toggle toggle-xs"
@update:model-value="save"
>
<span class="label-text">Read-only</span>
</label>
</li>
<hr class="pb-0.5 mt-0.5">
<li
v-for="(area, index) in field.areas || []"
:key="index"
@@ -84,11 +152,11 @@
Page {{ area.page + 1 }}
</a>
</li>
<li>
<li v-if="!field.areas?.length || !['radio', 'multiple'].includes(field.type)">
<a
href="#"
class="text-sm py-1 px-2"
@click.prevent="$emit('set-draw', field)"
@click.prevent="$emit('set-draw', { field })"
>
<IconNewSection
:width="20"
@@ -113,18 +181,7 @@
</ul>
</span>
<button
v-else
title="Areas"
class="relative cursor-pointer text-transparent group-hover:text-base-content"
@click="$emit('set-draw', field)"
>
<IconShape
:width="18"
:stroke-width="1.6"
/>
</button>
<button
class="relative text-transparent group-hover:text-base-content"
class="relative text-transparent group-hover:text-base-content pr-1"
title="Remove"
@click="$emit('remove', field)"
>
@@ -133,48 +190,58 @@
:stroke-width="1.6"
/>
</button>
<div class="flex flex-col pr-1 text-transparent group-hover:text-base-content">
<button
title="Up"
class="relative"
style="font-size: 10px; margin-bottom: -2px"
@click="$emit('move-up')"
>
</button>
<button
title="Down"
class="relative"
style="font-size: 10px; margin-top: -2px"
@click="$emit('move-down')"
>
</button>
</div>
</div>
</div>
<div
v-if="field.options"
ref="options"
class="border-t border-base-300 mx-2 pt-2 space-y-1.5"
draggable="true"
@dragstart.prevent.stop
>
<div
v-for="(option, index) in field.options"
:key="index"
:key="option.uuid"
class="flex space-x-1.5 items-center"
>
<span class="text-sm w-3.5">
{{ index + 1 }}.
</span>
<div
v-if="['radio', 'multiple'].includes(field.type) && (index > 0 || field.areas.find((a) => a.option_uuid) || !field.areas.length) && !field.areas.find((a) => a.option_uuid === option.uuid)"
class="items-center flex w-full"
>
<input
v-model="option.value"
class="w-full input input-primary input-xs text-sm bg-transparent !pr-7 -mr-6"
type="text"
required
@blur="save"
>
<button
title="Draw"
tabindex="-1"
@click.prevent="$emit('set-draw', { field, option })"
>
<IconNewSection
:width="18"
:stroke-width="1.6"
/>
</button>
</div>
<input
v-model="field.options[index]"
v-else
v-model="option.value"
class="w-full input input-primary input-xs text-sm bg-transparent"
type="text"
required
@focus="maybeFocusOnOptionArea(option)"
@blur="save"
>
<button
class="text-sm w-3.5"
@click="[field.options.splice(index, 1), save()]"
tabindex="-1"
@click="removeOption(option)"
>
&times;
</button>
@@ -182,7 +249,7 @@
<button
v-if="field.options"
class="text-center text-sm w-full pb-1"
@click="[field.options.push(''), save()]"
@click="addOption"
>
+ Add option
</button>
@@ -194,26 +261,38 @@
<script>
import Contenteditable from './contenteditable'
import FieldType from './field_type'
import { IconShape, IconNewSection, IconTrashX, IconCopy } from '@tabler/icons-vue'
import { IconShape, IconNewSection, IconTrashX, IconCopy, IconSettings } from '@tabler/icons-vue'
import { v4 } from 'uuid'
export default {
name: 'TemplateField',
components: {
Contenteditable,
IconSettings,
IconShape,
IconNewSection,
IconTrashX,
IconCopy,
FieldType
},
inject: ['template', 'save'],
inject: ['template', 'save', 'backgroundColor', 'selectedAreaRef'],
props: {
field: {
type: Object,
required: true
},
defaultField: {
type: Object,
required: false,
default: null
},
editable: {
type: Boolean,
required: false,
default: true
}
},
emits: ['set-draw', 'remove', 'move-up', 'move-down', 'scroll-to'],
emits: ['set-draw', 'remove', 'scroll-to'],
data () {
return {
isNameFocus: false
@@ -259,19 +338,45 @@ export default {
}, 1)
}
},
maybeFocusOnOptionArea (option) {
const area = this.field.areas.find((a) => a.option_uuid === option.uuid)
if (area) {
this.selectedAreaRef.value = area
}
},
scrollToFirstArea () {
return this.field.areas?.[0] && this.$emit('scroll-to', this.field.areas[0])
},
closeDropdown () {
document.activeElement.blur()
},
addOption () {
this.field.options.push({ value: '', uuid: v4() })
this.$nextTick(() => {
const inputs = this.$refs.options.querySelectorAll('input')
inputs[inputs.length - 1]?.focus()
})
this.save()
},
removeOption (option) {
this.field.options.splice(this.field.options.indexOf(option), 1)
this.field.areas.splice(this.field.areas.findIndex((a) => a.option_uuid === option.uuid), 1)
this.save()
},
maybeUpdateOptions () {
delete this.field.default_value
if (!['radio', 'multiple', 'select'].includes(this.field.type)) {
delete this.field.options
}
if (['radio', 'multiple', 'select'].includes(this.field.type)) {
this.field.options ||= ['']
this.field.options ||= [{ value: '', uuid: v4() }]
}
(this.field.areas || []).forEach((area) => {
@@ -1,5 +1,91 @@
<template>
<div class="dropdown">
<div v-if="mobileView">
<div class="flex space-x-2 items-end">
<div class="group/contenteditable-container bg-base-100 rounded-md p-2 border border-base-300 w-full flex justify-between items-end">
<div class="flex items-center space-x-2">
<span
class="w-3 h-3 flex-shrink-0 rounded-full"
:class="colors[submitters.indexOf(selectedSubmitter)]"
/>
<Contenteditable
v-model="selectedSubmitter.name"
class="cursor-text"
:icon-inline="true"
:editable="editable"
:select-on-edit-click="true"
:icon-width="18"
@update:model-value="$emit('name-change', selectedSubmitter)"
/>
</div>
</div>
<div class="dropdown dropdown-top dropdown-end">
<label
tabindex="0"
class="bg-base-100 cursor-pointer rounded-md p-2 border border-base-300 w-full flex justify-center"
>
<IconChevronUp
width="24"
height="24"
/>
</label>
<ul
v-if="editable"
tabindex="0"
class="rounded-md min-w-max mb-2"
:class="menuClasses"
@click="closeDropdown"
>
<li
v-for="(submitter, index) in submitters"
:key="submitter.uuid"
>
<a
href="#"
class="flex px-2 group justify-between items-center"
:class="{ 'active': submitter === selectedSubmitter }"
@click.prevent="selectSubmitter(submitter)"
>
<span class="py-1 flex items-center">
<span
class="rounded-full w-3 h-3 ml-1 mr-3"
:class="colors[index]"
/>
<span>
{{ submitter.name }}
</span>
</span>
<button
v-if="submitters.length > 1 && editable"
class="px-2"
@click.stop="remove(submitter)"
>
<IconTrashX :width="18" />
</button>
</a>
</li>
<li v-if="submitters.length < 10 && editable">
<a
href="#"
class="flex px-2"
@click.prevent="addSubmitter"
>
<IconUserPlus
:width="20"
:stroke-width="1.6"
/>
<span class="py-1">
Add {{ names[submitters.length] }}
</span>
</a>
</li>
</ul>
</div>
</div>
</div>
<div
v-else
class="dropdown"
>
<label
v-if="compact"
tabindex="0"
@@ -14,7 +100,7 @@
<label
v-else
tabindex="0"
class="cursor-pointer rounded-md p-2 border border-base-300 w-full flex justify-between"
class="group cursor-pointer group/contenteditable-container rounded-md p-2 border border-base-300 hover:border-content w-full flex justify-between"
>
<div class="flex items-center space-x-2">
<span
@@ -25,12 +111,13 @@
v-model="selectedSubmitter.name"
class="cursor-text"
:icon-inline="true"
:editable="editable"
:select-on-edit-click="true"
:icon-width="18"
@update:model-value="$emit('name-change', selectedSubmitter)"
/>
</div>
<span class="flex items-center">
<span class="flex items-center transition-all duration-75 group-hover:border border-base-content/20 border-dashed w-6 h-6 flex justify-center items-center rounded">
<IconPlus
width="18"
height="18"
@@ -38,6 +125,7 @@
</span>
</label>
<ul
v-if="editable || !compact"
tabindex="0"
:class="menuClasses"
@click="closeDropdown"
@@ -62,7 +150,7 @@
</span>
</span>
<button
v-if="!compact && submitters.length > 1"
v-if="!compact && submitters.length > 1 && editable"
class="hidden group-hover:block px-2"
@click.stop="remove(submitter)"
>
@@ -70,7 +158,7 @@
</button>
</a>
</li>
<li v-if="submitters.length < 10">
<li v-if="submitters.length < 10 && editable">
<a
href="#"
class="flex px-2"
@@ -81,7 +169,7 @@
:stroke-width="1.6"
/>
<span class="py-1">
Add Submitter
Add {{ names[submitters.length] }}
</span>
</a>
</li>
@@ -90,7 +178,7 @@
</template>
<script>
import { IconUserPlus, IconTrashX, IconPlus } from '@tabler/icons-vue'
import { IconUserPlus, IconTrashX, IconPlus, IconChevronUp } from '@tabler/icons-vue'
import Contenteditable from './contenteditable'
import { v4 } from 'uuid'
@@ -100,18 +188,29 @@ export default {
IconUserPlus,
Contenteditable,
IconPlus,
IconTrashX
IconTrashX,
IconChevronUp
},
props: {
submitters: {
type: Array,
required: true
},
editable: {
type: Boolean,
required: false,
default: true
},
compact: {
type: Boolean,
required: false,
default: false
},
mobileView: {
type: Boolean,
required: false,
default: false
},
modelValue: {
type: String,
required: true
@@ -140,16 +239,16 @@ export default {
},
names () {
return [
'First Submitter',
'Second Submitter',
'Third Submitter',
'Fourth Submitter',
'Fifth Submitter',
'Sixth Submitter',
'Seventh Submitter',
'Eighth Submitter',
'Ninth Submitter',
'Tenth Submitter'
'First Party',
'Second Party',
'Third Party',
'Fourth Party',
'Fifth Party',
'Sixth Party',
'Seventh Party',
'Eighth Party',
'Ninth Party',
'Tenth Party'
]
},
selectedSubmitter () {
@@ -15,6 +15,7 @@
</label>
</slot>
<ul
v-if="editable"
tabindex="0"
class="dropdown-content menu menu-xs p-2 shadow rounded-box w-52 z-10 mb-3"
:class="menuClasses"
@@ -65,6 +66,11 @@ export default {
required: false,
default: ''
},
editable: {
type: Boolean,
required: false,
default: true
},
buttonWidth: {
type: Number,
required: false,
+98 -55
View File
@@ -5,26 +5,67 @@
class="w-full rounded-lg"
:class="{ 'bg-base-100': withStickySubmitters }"
:submitters="submitters"
:editable="editable"
@new-submitter="save"
@remove="removeSubmitter"
@name-change="save"
@update:model-value="$emit('change-submitter', submitters.find((s) => s.uuid === $event))"
/>
</div>
<div class="mb-1 mt-2">
<div
class="mb-1 mt-2"
@dragover.prevent="onFieldDragover"
@drop="save"
>
<Field
v-for="field in submitterFields"
:key="field.uuid"
:data-uuid="field.uuid"
:field="field"
:type-index="fields.filter((f) => f.type === field.type).indexOf(field)"
:editable="editable && !dragField"
:default-field="defaultFields.find((f) => f.name === field.name)"
:draggable="editable"
@dragstart="dragField = field"
@dragend="dragField = null"
@remove="removeField"
@move-up="move(field, -1)"
@move-down="move(field, 1)"
@scroll-to="$emit('scroll-to-area', $event)"
@set-draw="$emit('set-draw', $event)"
/>
</div>
<div class="grid grid-cols-3 gap-1 pb-2">
<div v-if="submitterDefaultFields.length">
<hr class="mb-2">
<template
v-for="field in submitterDefaultFields"
:key="field.name"
>
<div
:style="{ backgroundColor: backgroundColor }"
draggable="true"
class="border border-base-300 rounded rounded-tr-none relative group mb-2"
@dragstart="onDragstart({ type: 'text', ...field })"
@dragend="$emit('drag-end')"
>
<div class="flex items-center justify-between relative cursor-grab">
<div class="flex items-center p-1 space-x-1">
<IconDrag />
<FieldType
:model-value="field.type || 'text'"
:editable="false"
:button-width="20"
/>
<span class="block pl-0.5">
{{ field.name }}
</span>
</div>
</div>
</div>
</template>
</div>
<div
v-if="editable"
class="grid grid-cols-3 gap-1 pb-2"
>
<template
v-for="(icon, type) in fieldIcons"
:key="type"
@@ -32,37 +73,14 @@
<button
v-if="withPhone || type != 'phone'"
draggable="true"
class="flex items-center justify-center border border-dashed border-base-300 w-full rounded relative"
class="group flex items-center justify-center border border-dashed border-base-300 hover:border-base-content/20 w-full rounded relative"
:style="{ backgroundColor: backgroundColor }"
@dragstart="onDragstart(type)"
@dragstart="onDragstart({ type: type })"
@dragend="$emit('drag-end')"
@click="addField(type)"
>
<div class="w-0 absolute left-0">
<svg
xmlns="http://www.w3.org/2000/svg"
class="cursor-grab"
width="18"
height="18"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
>
<path
stroke="none"
d="M0 0h24v24H0z"
fill="none"
/>
<path d="M9 5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
<path d="M9 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
<path d="M9 19m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
<path d="M15 5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
<path d="M15 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
<path d="M15 19m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
</svg>
<div class="flex items-console group-hover:bg-base-200/50 transition-all cursor-grab h-full absolute left-0">
<IconDrag class=" my-auto" />
</div>
<div class="flex items-center flex-col px-2 py-2">
<component :is="icon" />
@@ -100,7 +118,7 @@
</template>
</div>
<div
v-if="fields.length < 4"
v-if="fields.length < 4 && editable"
class="text-xs p-2 border border-base-200 rounded"
>
<ul class="list-disc list-outside ml-3">
@@ -123,12 +141,15 @@ import { v4 } from 'uuid'
import FieldType from './field_type'
import FieldSubmitter from './field_submitter'
import { IconLock } from '@tabler/icons-vue'
import IconDrag from './icon_drag'
export default {
name: 'TemplateFields',
components: {
Field,
FieldType,
FieldSubmitter,
IconDrag,
IconLock
},
inject: ['save', 'backgroundColor', 'withPhone'],
@@ -137,6 +158,16 @@ export default {
type: Array,
required: true
},
editable: {
type: Boolean,
required: false,
default: true
},
defaultFields: {
type: Array,
required: false,
default: () => []
},
withStickySubmitters: {
type: Boolean,
required: false,
@@ -152,16 +183,44 @@ export default {
}
},
emits: ['set-draw', 'set-drag', 'drag-end', 'scroll-to-area', 'change-submitter'],
data () {
return {
dragField: null
}
},
computed: {
fieldNames: FieldType.computed.fieldNames,
fieldIcons: FieldType.computed.fieldIcons,
submitterFields () {
return this.fields.filter((f) => f.submitter_uuid === this.selectedSubmitter.uuid)
},
submitterDefaultFields () {
return this.defaultFields.filter((f) => {
return !this.submitterFields.find((field) => field.name === f.name) && (!f.role || f.role === this.selectedSubmitter.name)
})
}
},
methods: {
onDragstart (fieldType) {
this.$emit('set-drag', fieldType)
onDragstart (field) {
this.$emit('set-drag', field)
},
onFieldDragover (e) {
const targetFieldUuid = e.target.closest('[data-uuid]')?.dataset?.uuid
if (this.dragField && targetFieldUuid && this.dragField.uuid !== targetFieldUuid) {
const field = this.fields.find((f) => f.uuid === targetFieldUuid)
const currentIndex = this.fields.indexOf(this.dragField)
const targetIndex = this.fields.indexOf(field)
if (currentIndex < targetIndex) {
this.fields.splice(targetIndex + 1, 0, this.dragField)
this.fields.splice(currentIndex, 1)
} else {
this.fields.splice(targetIndex, 0, this.dragField)
this.fields.splice(currentIndex + 1, 1)
}
}
},
removeSubmitter (submitter) {
[...this.fields].forEach((field) => {
@@ -178,26 +237,6 @@ export default {
this.save()
},
move (field, direction) {
const currentIndex = this.submitterFields.indexOf(field)
const fieldsIndex = this.fields.indexOf(field)
this.fields.splice(fieldsIndex, 1)
if (currentIndex + direction > this.submitterFields.length) {
const firstIndex = this.fields.indexOf(this.submitterFields[0])
this.fields.splice(firstIndex, 0, field)
} else if (currentIndex + direction < 0) {
const lastIndex = this.fields.indexOf(this.submitterFields[this.submitterFields.length - 1])
this.fields.splice(lastIndex + 1, 0, field)
} else {
this.fields.splice(fieldsIndex + direction, 0, field)
}
this.save()
},
removeField (field) {
this.fields.splice(this.fields.indexOf(field), 1)
@@ -214,11 +253,15 @@ export default {
}
if (['select', 'multiple', 'radio'].includes(type)) {
field.options = ['']
field.options = [{ value: '', uuid: v4() }]
}
this.fields.push(field)
if (['signature', 'initials', 'cells'].includes(type)) {
this.$emit('set-draw', { field })
}
this.save()
}
}
@@ -0,0 +1,31 @@
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
>
<path
stroke="none"
d="M0 0h24v24H0z"
fill="none"
/>
<path d="M9 5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
<path d="M9 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
<path d="M9 19m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
<path d="M15 5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
<path d="M15 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
<path d="M15 19m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
</svg>
</template>
<script>
export default {
name: 'DragIcon'
}
</script>
@@ -0,0 +1,98 @@
<template>
<div class="fixed text-center w-full bottom-0 pr-6 mb-4">
<span class="w-full bg-base-200 px-4 py-2 rounded-md inline-flex space-x-2 mx-auto items-center justify-between mb-2 z-20 md:hidden">
<div class="flex items-center space-x-2">
<component
:is="fieldIcons[drawField.type]"
:width="20"
:height="20"
class="inline"
:stroke-width="1.6"
/>
<span> Draw {{ fieldNames[drawField.type] }} Field </span>
</div>
<a
href="#"
class="link block text-center"
@click.prevent="$emit('cancel')"
>
Cancel
</a>
</span>
<FieldSubmitter
:model-value="selectedSubmitter.uuid"
:submitters="submitters"
:editable="editable"
:mobile-view="true"
@new-submitter="save"
@remove="removeSubmitter"
@name-change="save"
@update:model-value="$emit('change-submitter', submitters.find((s) => s.uuid === $event))"
/>
</div>
</template>
<script>
import Field from './field'
import FieldType from './field_type'
import FieldSubmitter from './field_submitter'
export default {
name: 'MobileDrawField',
components: {
Field,
FieldSubmitter
},
inject: ['save'],
props: {
drawField: {
type: Object,
required: true
},
editable: {
type: Boolean,
required: false,
default: true
},
submitters: {
type: Array,
required: true
},
fields: {
type: Array,
required: true
},
selectedSubmitter: {
type: Object,
required: true
}
},
emits: ['change-submitter', 'cancel'],
computed: {
fieldNames: FieldType.computed.fieldNames,
fieldIcons: FieldType.computed.fieldIcons
},
methods: {
removeSubmitter (submitter) {
[...this.fields].forEach((field) => {
if (field.submitter_uuid === submitter.uuid) {
this.removeField(field)
}
})
this.submitters.splice(this.submitters.indexOf(submitter), 1)
if (this.selectedSubmitter === submitter) {
this.$emit('change-submitter', this.submitters[0])
}
this.save()
},
removeField (field) {
this.fields.splice(this.fields.indexOf(field), 1)
this.save()
}
}
}
</script>
+18 -2
View File
@@ -22,6 +22,8 @@
:ref="setAreaRefs"
:area="item.area"
:field="item.field"
:editable="editable"
:default-field="defaultFields.find((f) => f.name === item.field.name)"
@start-resize="resizeDirection = $event"
@stop-resize="resizeDirection = null"
@start-drag="isMove = true"
@@ -39,8 +41,8 @@
v-show="resizeDirection || isMove || isDrag || showMask || (drawField && isMobile)"
id="mask"
ref="mask"
class="top-0 bottom-0 left-0 right-0 absolute z-10"
:class="{ 'cursor-grab': isDrag || isMove, 'cursor-nwse-resize': drawField, [resizeDirectionClasses[resizeDirection]]: !!resizeDirectionClasses }"
class="top-0 bottom-0 left-0 right-0 absolute"
:class="{ 'z-10': !isMobile, 'cursor-grab': isDrag || isMove, 'cursor-nwse-resize': drawField, [resizeDirectionClasses[resizeDirection]]: !!resizeDirectionClasses }"
@pointermove="onPointermove"
@pointerdown="onStartDraw"
@dragover.prevent
@@ -68,6 +70,11 @@ export default {
required: false,
default: () => []
},
defaultFields: {
type: Array,
required: false,
default: () => []
},
selectedSubmitter: {
type: Object,
required: true
@@ -77,6 +84,11 @@ export default {
required: false,
default: null
},
editable: {
type: Boolean,
required: false,
default: true
},
isDrag: {
type: Boolean,
required: false,
@@ -141,6 +153,10 @@ export default {
return
}
if (!this.editable) {
return
}
this.showMask = true
this.$nextTick(() => {
+15 -1
View File
@@ -12,10 +12,14 @@
class="group flex justify-end cursor-pointer top-0 bottom-0 left-0 right-0 absolute p-1"
@click="$emit('scroll-to', item)"
>
<div class="flex justify-between w-full">
<div
v-if="editable"
class="flex justify-between w-full"
>
<div style="width: 26px" />
<div class="">
<ReplaceButton
v-if="withReplaceButton"
:is-direct-upload="isDirectUpload"
:template-id="template.id"
:accept-file-types="acceptFileTypes"
@@ -95,11 +99,21 @@ export default {
type: Object,
required: true
},
editable: {
type: Boolean,
required: false,
default: true
},
acceptFileTypes: {
type: String,
required: false,
default: 'image/*, application/pdf'
},
withReplaceButton: {
type: Boolean,
required: true,
default: true
},
isDirectUpload: {
type: Boolean,
required: true,
+12 -11
View File
@@ -10,29 +10,30 @@ class ProcessSubmitterCompletionJob < ApplicationJob
Submissions::EnsureResultGenerated.call(submitter)
if submitter.account.encrypted_configs.exists?(key: EncryptedConfig::WEBHOOK_URL_KEY)
SendFormCompletedWebhookRequestJob.perform_later(submitter)
if is_all_completed && submitter.completed_at == submitter.submission.submitters.maximum(:completed_at)
Submissions::GenerateAuditTrail.call(submitter.submission)
enqueue_completed_emails(submitter)
end
return unless is_all_completed
return if submitter.completed_at != submitter.submission.submitters.maximum(:completed_at)
return if Accounts.load_webhook_configs(submitter.account).blank?
Submissions::GenerateAuditTrail.call(submitter.submission)
enqueue_emails(submitter)
SendFormCompletedWebhookRequestJob.perform_later(submitter)
end
def enqueue_emails(submitter)
def enqueue_completed_emails(submitter)
user = submitter.submission.created_by_user || submitter.template.author
if submitter.template.account.users.exists?(id: user.id)
if submitter.template.account.users.exists?(id: user.id) &&
submitter.submission.preferences['send_email'] != false
bcc = submitter.submission.template.account.account_configs
.find_by(key: AccountConfig::BCC_EMAILS)&.value
SubmitterMailer.completed_email(submitter, user, bcc:).deliver_later!
end
to = submitter.submission.submitters.sort_by(&:completed_at).select(&:email?).map(&:friendly_name).join(', ')
to = submitter.submission.submitters.reject { |e| e.preferences['send_email'] == false }
.sort_by(&:completed_at).select(&:email?).map(&:friendly_name).join(', ')
SubmitterMailer.documents_copy_email(submitter, to:).deliver_later! if to.present?
end
@@ -49,6 +50,6 @@ class ProcessSubmitterCompletionJob < ApplicationJob
next_submitter = submitter.submission.submitters.find { |s| s.uuid == next_submitter_item['uuid'] }
Submitters.send_signature_requests([next_submitter], send_email: true)
Submitters.send_signature_requests([next_submitter])
end
end
@@ -4,7 +4,7 @@ class SendSubmitterInvitationEmailJob < ApplicationJob
def perform(params = {})
submitter = Submitter.find(params['submitter_id'])
SubmitterMailer.invitation_email(submitter, subject: params['subject'], body: params['body']).deliver_now!
SubmitterMailer.invitation_email(submitter).deliver_now!
SubmissionEvent.create!(submitter:, event_type: 'send_email')
+40 -16
View File
@@ -3,18 +3,26 @@
class SubmitterMailer < ApplicationMailer
MAX_ATTACHMENTS_SIZE = 10.megabytes
def invitation_email(submitter, body: nil, subject: nil)
DEFAULT_INVITATION_SUBJECT = 'You are invited to submit a form'
def invitation_email(submitter)
@current_account = submitter.submission.template.account
@submitter = submitter
@body = body.presence
@email_config = @current_account.account_configs.find_by(key: AccountConfig::SUBMITTER_INVITATION_EMAIL_KEY)
if submitter.preferences['email_message_uuid']
@email_message = submitter.account.email_messages.find_by(uuid: submitter.preferences['email_message_uuid'])
end
@body = @email_message&.body.presence
@subject = @email_message&.subject.presence
@email_config = AccountConfigs.find_for_account(@current_account, AccountConfig::SUBMITTER_INVITATION_EMAIL_KEY)
subject =
if @email_config || subject.present?
ReplaceEmailVariables.call(subject.presence || @email_config.value['subject'], submitter:)
if @email_config || @subject
ReplaceEmailVariables.call(@subject || @email_config.value['subject'], submitter:)
else
'You are invited to submit a form'
DEFAULT_INVITATION_SUBJECT
end
mail(to: @submitter.friendly_name,
@@ -31,7 +39,7 @@ class SubmitterMailer < ApplicationMailer
Submissions::EnsureResultGenerated.call(submitter)
@email_config = @current_account.account_configs.find_by(key: AccountConfig::SUBMITTER_COMPLETED_EMAIL_KEY)
@email_config = AccountConfigs.find_for_account(@current_account, AccountConfig::SUBMITTER_COMPLETED_EMAIL_KEY)
add_completed_email_attachments!(submitter)
@@ -58,7 +66,7 @@ class SubmitterMailer < ApplicationMailer
@documents = add_completed_email_attachments!(submitter)
@email_config = @current_account.account_configs.find_by(key: AccountConfig::SUBMITTER_DOCUMENTS_COPY_EMAIL_KEY)
@email_config = AccountConfigs.find_for_account(@current_account, AccountConfig::SUBMITTER_DOCUMENTS_COPY_EMAIL_KEY)
subject =
if @email_config
@@ -86,20 +94,36 @@ class SubmitterMailer < ApplicationMailer
total_size = audit_trail_data.size
end
documents.each do |attachment|
data = attachment.download
total_size += data.size
break if total_size >= MAX_ATTACHMENTS_SIZE
attachments[attachment.filename.to_s] = data
end
total_size = add_attachments_with_size_limit(documents, total_size)
attachments[submitter.submission.audit_trail.filename.to_s] = audit_trail_data if audit_trail_data
file_fields = submitter.submission.template_fields.select { |e| e['type'] == 'file' }
if file_fields.pluck('submitter_uuid').uniq.size == 1
storage_attachments =
submitter.attachments.where(uuid: submitter.values.values_at(*file_fields.pluck('uuid')).flatten)
add_attachments_with_size_limit(storage_attachments, total_size)
end
documents
end
def add_attachments_with_size_limit(storage_attachments, current_size)
total_size = current_size
storage_attachments.each do |attachment|
total_size += attachment.byte_size
break if total_size >= MAX_ATTACHMENTS_SIZE
attachments[attachment.filename.to_s] = attachment.download
end
total_size
end
def from_address_for_submitter(submitter)
submitter.submission.created_by_user&.friendly_name || submitter.submission.template.author.friendly_name
end
+1 -1
View File
@@ -21,7 +21,7 @@
# fk_rails_... (user_id => users.id)
#
class AccessToken < ApplicationRecord
TOKEN_LENGTH = 22
TOKEN_LENGTH = 43
belongs_to :user
+1
View File
@@ -15,6 +15,7 @@ class Account < ApplicationRecord
has_many :users, dependent: :destroy
has_many :encrypted_configs, dependent: :destroy
has_many :account_configs, dependent: :destroy
has_many :email_messages, dependent: :destroy
has_many :templates, dependent: :destroy
has_many :template_folders, dependent: :destroy
has_one :default_template_folder, -> { where(name: TemplateFolder::DEFAULT_NAME) },
+3 -1
View File
@@ -25,6 +25,8 @@ class AccountConfig < ApplicationRecord
SUBMITTER_COMPLETED_EMAIL_KEY = 'submitter_completed_email'
SUBMITTER_DOCUMENTS_COPY_EMAIL_KEY = 'submitter_documents_copy_email'
BCC_EMAILS = 'bcc_emails'
FORCE_MFA = 'force_mfa'
ALLOW_TYPED_SIGNATURE = 'allow_typed_signature'
SUBMITTER_REMAILERS = 'submitter_reminders'
FORM_COMPLETED_BUTTON_KEY = 'form_completed_button'
@@ -49,7 +51,7 @@ class AccountConfig < ApplicationRecord
'body' => "Hi there,\n\n" \
"Please check the copy of your \"{{template.name}}\" submission in the email attachments.\n" \
"Alternatively, you can download your copy using:\n\n" \
"{{documents.links}}\n\n" \
"{{documents.link}}\n\n" \
"Thanks,\n" \
'{{account.name}}'
}
+39
View File
@@ -0,0 +1,39 @@
# frozen_string_literal: true
# == Schema Information
#
# Table name: email_messages
#
# id :bigint not null, primary key
# body :text not null
# sha1 :string not null
# subject :text not null
# uuid :string not null
# created_at :datetime not null
# updated_at :datetime not null
# account_id :bigint not null
# author_id :bigint not null
#
# Indexes
#
# index_email_messages_on_account_id (account_id)
# index_email_messages_on_sha1 (sha1)
# index_email_messages_on_uuid (uuid)
#
# Foreign Keys
#
# fk_rails_... (account_id => accounts.id)
# fk_rails_... (author_id => users.id)
#
class EmailMessage < ApplicationRecord
belongs_to :author, class_name: 'User'
belongs_to :account
attribute :uuid, :string, default: -> { SecureRandom.uuid }
before_validation :set_sha1, on: :create
def set_sha1
self.sha1 = Digest::SHA1.hexdigest({ subject:, body: }.to_json)
end
end
+1
View File
@@ -25,6 +25,7 @@ class EncryptedConfig < ApplicationRecord
FILES_STORAGE_KEY = 'active_storage',
EMAIL_SMTP_KEY = 'action_mailer_smtp',
ESIGN_CERTS_KEY = 'esign_certs',
TIMESTAMP_SERVER_URL_KEY = 'timestamp_server_url',
APP_URL_KEY = 'app_url',
WEBHOOK_URL_KEY = 'webhook_url'
].freeze
+16 -1
View File
@@ -6,6 +6,8 @@
#
# id :bigint not null, primary key
# deleted_at :datetime
# preferences :text not null
# slug :string not null
# source :text not null
# submitters_order :string not null
# template_fields :text
@@ -19,6 +21,7 @@
# Indexes
#
# index_submissions_on_created_by_user_id (created_by_user_id)
# index_submissions_on_slug (slug) UNIQUE
# index_submissions_on_template_id (template_id)
#
# Foreign Keys
@@ -34,13 +37,18 @@ class Submission < ApplicationRecord
has_many :submitters, dependent: :destroy
has_many :submission_events, dependent: :destroy
attribute :preferences, :string, default: -> { {} }
serialize :template_fields, JSON
serialize :template_schema, JSON
serialize :template_submitters, JSON
serialize :preferences, JSON
attribute :source, :string, default: 'link'
attribute :submitters_order, :string, default: 'random'
attribute :slug, :string, default: -> { SecureRandom.base58(14) }
has_one_attached :audit_trail
has_many :template_schema_documents,
@@ -49,7 +57,7 @@ class Submission < ApplicationRecord
scope :active, -> { where(deleted_at: nil) }
scope :pending, -> { joins(:submitters).where(submitters: { completed_at: nil }).distinct }
scope :completed, -> { left_joins(:submitters).where.not(submitters: { completed_at: nil }).distinct }
scope :completed, -> { where.not(id: pending.select(:submission_id)) }
enum :source, {
invite: 'invite',
@@ -62,4 +70,11 @@ class Submission < ApplicationRecord
random: 'random',
preserved: 'preserved'
}, scope: false, prefix: true
def audit_trail_url
return if audit_trail.blank?
Rails.application.routes.url_helpers.rails_storage_proxy_url(audit_trail, **Docuseal.default_url_options)
end
alias audit_log_url audit_trail_url
end
+1
View File
@@ -39,6 +39,7 @@ class SubmissionEvent < ApplicationRecord
send_email: 'send_email',
send_reminder_email: 'send_reminder_email',
send_sms: 'send_sms',
send_2fa_sms: 'send_2fa_sms',
open_email: 'open_email',
click_email: 'click_email',
click_sms: 'click_sms',
+4 -1
View File
@@ -12,6 +12,7 @@
# name :string
# opened_at :datetime
# phone :string
# preferences :text not null
# sent_at :datetime
# slug :string not null
# ua :string
@@ -37,9 +38,11 @@ class Submitter < ApplicationRecord
has_one :account, through: :template
attribute :values, :string, default: -> { {} }
attribute :preferences, :string, default: -> { {} }
attribute :slug, :string, default: -> { SecureRandom.base58(14) }
serialize :values, JSON
serialize :preferences, JSON
has_many_attached :documents
has_many_attached :attachments
@@ -61,7 +64,7 @@ class Submitter < ApplicationRecord
def friendly_name
if name.present? && email.present? && email.exclude?(',')
"#{name} <#{email}>"
%("#{name.delete('"')}" <#{email}>)
else
email
end
+15 -14
View File
@@ -4,19 +4,20 @@
#
# Table name: templates
#
# id :bigint not null, primary key
# deleted_at :datetime
# fields :text not null
# name :string not null
# schema :text not null
# slug :string not null
# source :text not null
# submitters :text not null
# created_at :datetime not null
# updated_at :datetime not null
# account_id :bigint not null
# author_id :bigint not null
# folder_id :bigint not null
# id :bigint not null, primary key
# application_key :string
# deleted_at :datetime
# fields :text not null
# name :string not null
# schema :text not null
# slug :string not null
# source :text not null
# submitters :text not null
# created_at :datetime not null
# updated_at :datetime not null
# account_id :bigint not null
# author_id :bigint not null
# folder_id :bigint not null
#
# Indexes
#
@@ -32,7 +33,7 @@
# fk_rails_... (folder_id => template_folders.id)
#
class Template < ApplicationRecord
DEFAULT_SUBMITTER_NAME = 'First Submitter'
DEFAULT_SUBMITTER_NAME = 'First Party'
belongs_to :author, class_name: 'User'
belongs_to :account
+7 -2
View File
@@ -52,11 +52,16 @@ class User < ApplicationRecord
belongs_to :account
has_one :access_token, dependent: :destroy
has_many :templates, dependent: :destroy, foreign_key: :author_id, inverse_of: :author
has_many :template_folders, dependent: :destroy, foreign_key: :author_id, inverse_of: :author
has_many :user_configs, dependent: :destroy
has_many :encrypted_configs, dependent: :destroy, class_name: 'EncryptedUserConfig'
has_many :email_messages, dependent: :destroy, foreign_key: :author_id, inverse_of: :author
devise :two_factor_authenticatable, :recoverable, :rememberable, :validatable, :trackable
devise :registerable, :omniauthable, omniauth_providers: [:google_oauth2] if Docuseal.multitenant?
if Docuseal.multitenant?
devise :registerable, :omniauthable, omniauth_providers: %i[google_oauth2 microsoft_office365 github]
end
attribute :role, :string, default: ADMIN_ROLE
attribute :uuid, :string, default: -> { SecureRandom.uuid }
@@ -94,7 +99,7 @@ class User < ApplicationRecord
def friendly_name
if full_name.present?
"#{full_name} <#{email}>"
%("#{full_name.delete('"')}" <#{email}>)
else
email
end
+29
View File
@@ -36,6 +36,35 @@
</div>
<% end %>
<% end %>
<% if can?(:manage, AccountConfig) %>
<div class="px-1 mt-8">
<h2 class="text-2xl font-bold mb-2">Preferences</h2>
<% account_config = AccountConfig.find_or_initialize_by(account: current_account, key: AccountConfig::FORCE_MFA) %>
<% if 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 py-2.5">
<span>
Force 2FA with Authenticator App
</span>
<%= f.check_box :value, class: 'toggle', checked: account_config.value, onchange: 'this.form.requestSubmit()' %>
</div>
<% end %>
<% end %>
<% account_config = AccountConfig.find_or_initialize_by(account: current_account, key: AccountConfig::ALLOW_TYPED_SIGNATURE) %>
<% if 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 py-2.5">
<span>
Allow typed text signatures
</span>
<%= f.check_box :value, class: 'toggle', checked: account_config.value != false, onchange: 'this.form.requestSubmit()' %>
</div>
<% end %>
<% end %>
</div>
<% end %>
</div>
<div class="w-0 md:w-52"></div>
</div>
+1 -1
View File
@@ -34,7 +34,7 @@
"submitters": [
{
"name": "John Doe",
"role": "<%= current_account.templates.last ? current_account.templates.last.submitters.first['name'] : 'First Submitter' %>",
"role": "<%= current_account.templates.last ? current_account.templates.last.submitters.first['name'] : 'First Party' %>",
"email": "<%= current_user.email.sub('@', '+test@') %>",
"values": {
"Form Text Field Name": "Default Value"
+1 -1
View File
@@ -8,7 +8,7 @@
<% end %>
<% if can?(:create, ::Template) %>
<%= render 'templates/upload_button' %>
<%= link_to new_template_path, class: 'btn btn-primary text-base btn-md gap-2', data: { turbo_frame: :modal } do %>
<%= link_to new_template_path, class: 'white-button !border gap-2', data: { turbo_frame: :modal } do %>
<%= svg_icon('plus', class: 'w-6 h-6 stroke-2') %>
<span class="hidden md:block">Create</span>
<% end %>
+13 -2
View File
@@ -8,7 +8,7 @@
<div class="space-y-2">
<div class="form-control">
<%= f.label :email, class: 'label' %>
<%= f.email_field :email, autofocus: true, autocomplete: 'email', class: 'base-input' %>
<%= f.email_field :email, autofocus: true, autocomplete: 'email', class: 'base-input', placeholder: 'Enter email to continue' %>
</div>
</div>
<div class="form-control">
@@ -16,7 +16,18 @@
</div>
<% end %>
<% if devise_mapping.omniauthable? %>
<%= button_to button_title(title: 'Sign up with Google', icon: svg_icon('brand_google', class: 'w-6 h-6')), omniauth_authorize_path(resource_name, :google_oauth2), class: 'white-button w-full mt-4', data: { turbo: false }, method: :post %>
<div class="space-y-4">
<%= form_for '', url: omniauth_authorize_path(resource_name, :google_oauth2), data: { turbo: false }, method: :post do |f| %>
<set-timezone data-input-id="state" data-params="true"></set-timezone>
<%= hidden_field_tag :state, { redir: params[:redir].to_s }.compact_blank.to_query %>
<%= f.button button_title(title: 'Sign up with Google', icon: svg_icon('brand_google', class: 'w-6 h-6')), class: 'white-button w-full mt-4' %>
<% end %>
<%= form_for '', url: omniauth_authorize_path(resource_name, :microsoft_office365), data: { turbo: false }, method: :post do |f| %>
<set-timezone data-input-id="state_microsoft" data-params="true"></set-timezone>
<%= hidden_field_tag :state, { redir: params[:redir].to_s }.compact_blank.to_query, id: 'state_microsoft' %>
<%= f.button button_title(title: 'Sign up with Microsoft', icon: svg_icon('brand_microsoft', class: 'w-6 h-6')), class: 'white-button w-full' %>
<% end %>
</div>
<% end %>
<%= render 'devise/shared/links' %>
</div>
+12 -1
View File
@@ -20,7 +20,18 @@
</div>
<% end %>
<% if devise_mapping.omniauthable? %>
<%= button_to button_title(title: 'Sign in with Google', icon: svg_icon('brand_google', class: 'w-6 h-6')), omniauth_authorize_path(resource_name, :google_oauth2), class: 'white-button w-full mt-4', data: { turbo: false }, method: :post %>
<div class="space-y-4">
<%= form_for '', url: omniauth_authorize_path(resource_name, :google_oauth2), data: { turbo: false }, method: :post do |f| %>
<set-timezone data-input-id="state" data-params="true"></set-timezone>
<%= hidden_field_tag :state, { redir: params[:redir].to_s }.compact_blank.to_query %>
<%= f.button button_title(title: 'Sign in with Google', icon: svg_icon('brand_google', class: 'w-6 h-6')), class: 'white-button w-full mt-4' %>
<% end %>
<%= form_for '', url: omniauth_authorize_path(resource_name, :microsoft_office365), data: { turbo: false }, method: :post do |f| %>
<set-timezone data-input-id="state_microsoft" data-params="true"></set-timezone>
<%= hidden_field_tag :state, { redir: params[:redir].to_s }.compact_blank.to_query, id: 'state_microsoft' %>
<%= f.button button_title(title: 'Sign in with Microsoft', icon: svg_icon('brand_microsoft', class: 'w-6 h-6')), class: 'white-button w-full' %>
<% end %>
</div>
<% end %>
<%= render 'devise/shared/links' %>
</div>
+26
View File
@@ -97,5 +97,31 @@
</tbody>
</table>
</div>
<% encrypted_config = EncryptedConfig.find_or_initialize_by(account: current_account, key: EncryptedConfig::TIMESTAMP_SERVER_URL_KEY) %>
<% if !Docuseal.multitenant? && can?(:manage, encrypted_config) %>
<div class="flex-grow max-w-xl">
<div class="flex justify-between items-end mb-4 mt-8">
<h2 class="text-3xl font-bold">Timestamp Server</h2>
</div>
<%= form_for encrypted_config, url: timestamp_server_index_path, method: :post, html: { autocomplete: 'off', class: 'space-y-4' } do |f| %>
<div class="form-control">
<%= f.label :value, class: 'label' do %>
<span class="flex items-center space-x-1">
<span>
Timeserver URL
</span>
<span class="tooltip" data-tip="URL of the trusted timeserver to be used to generate timestamp signatures.">
<%= svg_icon('info_circle', class: 'w-4 h-4') %>
</span>
</span>
<% end %>
<%= f.url_field :value, autocomplete: 'off', class: 'base-input', placeholder: 'URL (optional)' %>
</div>
<div class="form-control pt-2">
<%= f.button button_title(title: 'Save', disabled_with: 'Updating'), class: 'base-button' %>
</div>
<% end %>
<% end %>
</div>
</div>
</div>
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" class="<%= local_assigns[:class] %>" width="24" height="24" 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="M3 4m0 2a2 2 0 0 1 2 -2h14a2 2 0 0 1 2 2v0a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2z" /><path d="M5 8v10a2 2 0 0 0 2 2h10a2 2 0 0 0 2 -2v-10" /><path d="M10 12l4 0" /></svg>

After

Width:  |  Height:  |  Size: 449 B

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" class="<%= local_assigns[:class] %>" x="0px" y="0px" width="100" height="100" viewBox="0 0 48 48">
<path fill="#ff5722" d="M6 6H22V22H6z" transform="rotate(-180 14 14)"></path><path fill="#4caf50" d="M26 6H42V22H26z" transform="rotate(-180 34 14)"></path><path fill="#ffc107" d="M26 26H42V42H26z" transform="rotate(-180 34 34)"></path><path fill="#03a9f4" d="M6 26H22V42H6z" transform="rotate(-180 14 34)"></path>
</svg>

After

Width:  |  Height:  |  Size: 463 B

+17
View File
@@ -0,0 +1,17 @@
<%= form_for '', url: mfa_setup_path, data: { turbo_frame: :_top }, html: { id: 'mfa_form' } do |f| %>
<p class="text-center">
Use an authenticator mobile app like Google Authenticator or 1Password to scan the QR code below.
</p>
<div>
<%== RQRCode::QRCode.new(@provision_url).as_svg(viewbox: true, svg_attributes: { class: 'w-80 h-80 my-4 mx-auto' }) %>
</div>
<div class="form-control my-6 space-y-2">
<%= f.text_field :otp_attempt, required: true, placeholder: 'XXX-XXX', class: 'base-input text-center' %>
<span>
<%= @error_message %>
</span>
</div>
<div class="form-control mt-4">
<%= f.button button_title(title: 'Save'), class: 'base-button' %>
</div>
<% end %>
+1 -17
View File
@@ -1,19 +1,3 @@
<%= render 'shared/turbo_modal', title: 'Setup 2FA' do %>
<%= form_for '', url: mfa_setup_path, data: { turbo_frame: :_top } do |f| %>
<p class="text-center">
Use an authenticator mobile app like Google Authenticator or 1Password to scan the QR code below.
</p>
<div>
<%== RQRCode::QRCode.new(@provision_url).as_svg(viewbox: true, svg_attributes: { class: 'w-80 h-80 my-4 mx-auto' }) %>
</div>
<div class="form-control my-6 space-y-2">
<%= f.text_field :otp_attempt, required: true, placeholder: 'XXX-XXX', class: 'base-input text-center' %>
<span>
<%= @error_message %>
</span>
</div>
<div class="form-control mt-4">
<%= f.button button_title(title: 'Save'), class: 'base-button' %>
</div>
<% end %>
<%= render 'mfa_setup/form' %>
<% end %>
+4
View File
@@ -0,0 +1,4 @@
<div class="max-w-lg mx-auto px-2">
<h1 class="text-4xl font-bold text-center mt-8">Setup 2FA</h1>
<%= render 'mfa_setup/form' %>
</div>
@@ -0,0 +1,15 @@
<script>
if (!window.customElements.get('autosize-field')) {
window.customElements.define('autosize-field', class extends HTMLElement {
connectedCallback() {
if (this.field.scrollHeight > this.field.clientHeight) {
this.field.classList.remove('text-[1.5vw]', 'lg:text-base');
this.field.classList.add('text-[1.0vw]', 'lg:text-[0.70rem]');
}
}
get field() {
return this.closest('field-value');
}
});
}
</script>
+2 -2
View File
@@ -2,13 +2,13 @@
<span class="flex items-center justify-center space-x-2">
<%= icon %>
<% if title %>
<span><%= title %></span>
<span class="<%= local_assigns[:title_class] %>"><%= title %></span>
<% end %>
</span>
</span>
<span class="disabled">
<span class="flex items-center justify-center space-x-2">
<%= local_assigns[:icon_disabled] || svg_icon('loader', class: 'w-5 h-5 animate-spin') %>
<span><%= disabled_with %>...</span>
<span class="<%= local_assigns[:title_class] %>"><%= disabled_with %>...</span>
</span>
</span>
+6 -9
View File
@@ -3,7 +3,9 @@
<a href="<%= root_path %>" class="text-2xl font-bold items-center flex space-x-2">
<%= render 'shared/title' %>
</a>
<%= render 'shared/github' %>
<span class="hidden sm:inline">
<%= render 'shared/github' %>
</span>
</div>
<% if signed_in? %>
<div class="space-x-4 flex items-center">
@@ -16,13 +18,7 @@
</span>
<% else %>
<div class="flex items-center justify-center space-x-4 mr-1">
<% if can?(:manage, EncryptedConfig) && !can?(:manage, :tenants) %>
<%= link_to Docuseal.multitenant? ? console_redirect_index_path : Docuseal::CONSOLE_URL, class: 'hidden md:inline-flex items-center font-medium text-lg', data: { prefetch: false } do %>
Console
<span class="badge badge-warning ml-1">New</span>
<% end %>
<span class="hidden md:inline-flex h-3 border-r border-base-content"></span>
<% end %>
<%= render 'shared/navbar_buttons' %>
<%= link_to 'Settings', settings_profile_index_path, class: 'hidden md:inline-flex font-medium text-lg' %>
</div>
<% end %>
@@ -75,7 +71,8 @@
<% if Docuseal.multitenant? && !request.path.in?([registration_path, new_registration_path]) %>
<%= link_to registration_path, class: 'btn btn-neutral btn-sm btn-outline' do %>
<span class="flex items-center justify-center space-x-1">
<span>Create free Account</span>
<span class="hidden sm:block">Create free Account</span>
<span class="md:hidden">Sign Up</span>
</span>
<% end %>
<% end %>
@@ -0,0 +1,4 @@
<%= link_to Docuseal.multitenant? ? console_redirect_index_path(redir: "#{Docuseal::CONSOLE_URL}/plans") : "#{Docuseal::CONSOLE_URL}/on_premise", class: 'hidden md:inline-flex btn btn-warning btn-sm', data: { prefetch: false } do %>
Upgrade
<% end %>
<span class="hidden md:inline-flex h-3 border-r border-base-content"></span>
-9
View File
@@ -1,9 +0,0 @@
<script defer data-domain="<%= ENV.fetch('PLAUSIBLE_DOMAIN', nil) %>" src="https://plausible.io/js/script.manual.js"></script>
<script>window.plausible = window.plausible || function() { (window.plausible.q = window.plausible.q || []).push(arguments) }</script>
<script>
document.addEventListener("turbo:load", function (e) {
if (e.detail.url.match(/sign_in|sign_up|password/)) {
plausible('pageview')
}
})
</script>
+24 -9
View File
@@ -1,7 +1,8 @@
<div class="block w-full md:w-52 flex-none">
<menu-active>
<ul class="menu px-0">
<li class="menu-title py-0"><span>Settings</span></li>
<li class="menu-title py-0 !bg-transparent mb-3 -mt-5"><a href="<%= '/' %>" class="!bg-transparent !text-neutral font-medium">&larr; Back</a></li>
<li class="menu-title py-0 !bg-transparent"><span class="!bg-transparent">Settings</span></li>
<li></li>
<li>
<%= link_to 'Profile', settings_profile_index_path, class: 'text-base hover:bg-base-300' %>
@@ -41,12 +42,12 @@
<%= link_to 'Team', settings_users_path, class: 'text-base hover:bg-base-300' %>
</li>
<% end %>
<% if !Docuseal.demo? && can?(:manage, EncryptedConfig) %>
<li>
<%= link_to Docuseal.multitenant? ? console_redirect_index_path(redir: "#{Docuseal::CONSOLE_URL}/plans") : "#{Docuseal::CONSOLE_URL}/on_premise", class: 'text-base hover:bg-base-300', data: { prefetch: false } do %>
Plans
<% end %>
</li>
<% unless Docuseal.multitenant? %>
<% if can?(:read, EncryptedConfig.new(key: 'saml_configs', account: current_account)) %>
<li>
<%= link_to 'SSO', settings_sso_index_path, class: 'text-base hover:bg-base-300' %>
</li>
<% end %>
<% end %>
<% if Docuseal.demo? || !Docuseal.multitenant? %>
<% if can?(:read, AccessToken) %>
@@ -68,12 +69,26 @@
<%= render 'shared/settings_nav_extra' %>
<% if !Docuseal.demo? && can?(:manage, EncryptedConfig) %>
<li>
<%= link_to Docuseal.multitenant? ? console_redirect_index_path : Docuseal::CONSOLE_URL, class: 'text-base hover:bg-base-300', data: { prefetch: false } do %>
Console
<%= link_to Docuseal.multitenant? ? console_redirect_index_path(redir: "#{Docuseal::CONSOLE_URL}/plans") : "#{Docuseal::CONSOLE_URL}/on_premise", class: 'text-base hover:bg-base-300', data: { prefetch: false } do %>
Plans
<span class="badge badge-warning">New</span>
<% end %>
</li>
<% end %>
<% if !Docuseal.demo? && can?(:manage, EncryptedConfig) %>
<li>
<%= link_to Docuseal.multitenant? ? console_redirect_index_path(redir: "#{Docuseal::CONSOLE_URL}/api") : "#{Docuseal::CONSOLE_URL}/on_premise", class: 'text-base hover:bg-base-300', data: { prefetch: false } do %>
<% if Docuseal.multitenant? %> API <% else %> Console <% end %>
<% end %>
</li>
<% if Docuseal.multitenant? %>
<li>
<%= link_to console_redirect_index_path(redir: "#{Docuseal::CONSOLE_URL}/embedding/form"), class: 'text-base hover:bg-base-300', data: { prefetch: false } do %>
Embedding
<% end %>
</li>
<% end %>
<% end %>
</ul>
</menu-active>
<% if !can?(:manage, :tenants) %>
@@ -0,0 +1,11 @@
<div class="alert">
<%= svg_icon('info_circle', class: 'w-6 h-6') %>
<div>
<p class="font-bold">Single Sign On with SAML 2.0</p>
<p class="text-gray-700">
Unlock with DocuSeal Enterprise
<br>
<a class="link font-medium" target="_blank" href="<%= "#{Docuseal::CONSOLE_URL}/#{Docuseal.multitenant? ? 'plans' : 'on_premise'}" %>">Learn More</a>
</p>
</div>
</div>
+8
View File
@@ -0,0 +1,8 @@
<div class="flex flex-wrap space-y-4 md:flex-nowrap md:space-y-0">
<%= render 'shared/settings_nav' %>
<div class="flex-grow max-w-xl mx-auto">
<h1 class="text-4xl font-bold mb-4">SMS</h1>
<%= render 'placeholder' %>
</div>
<div class="w-0 md:w-52"></div>
</div>
+5 -3
View File
@@ -24,9 +24,11 @@
<%= button_to button_title(title: 'Send copy to Email', disabled_with: 'Sending', icon: svg_icon('mail_forward', class: 'w-6 h-6')), send_submission_email_index_path, params: { submitter_slug: @submitter.slug }, form: { onsubmit: 'event.submitter.disabled = true' }, class: 'base-button w-full' %>
</div>
<% end %>
<div>
<%= button_to button_title(title: 'Resubmit', disabled_with: 'Resubmit', icon: svg_icon('reload', class: 'w-6 h-6')), start_form_path(@template.slug), params: { submitter: { email: params[:email] }, resubmit: true }, method: :put, form: { onsubmit: 'event.submitter.disabled = true' }, class: 'white-button w-full' %>
</div>
<% if @template.submitters.to_a.size == 1 %>
<div>
<%= button_to button_title(title: 'Resubmit', disabled_with: 'Resubmit', icon: svg_icon('reload', class: 'w-6 h-6')), start_form_path(@template.slug), params: { submitter: { email: params[:email] }, resubmit: true }, method: :put, form: { onsubmit: 'event.submitter.disabled = true' }, class: 'white-button w-full' %>
</div>
<% end %>
</div>
</div>
<%= render 'shared/attribution', link_path: '/start' %>
+2 -1
View File
@@ -27,7 +27,8 @@
<%= form_for @submitter, url: start_form_path(@template.slug), data: { turbo_frame: :_top }, method: :put, html: { class: 'space-y-4', onsubmit: 'event.submitter.disabled = true' } do |f| %>
<div class="form-control !mt-0">
<%= f.label :email, class: 'label' %>
<%= f.email_field :email, value: current_user&.email, required: true, class: 'base-input', placeholder: 'Provide your email to start' %>
<%= f.email_field :email, value: current_user&.email || params[:email] || @submitter.email, required: true, class: 'base-input', placeholder: 'Provide your email to start' %>
<%= @error_message %>
</div>
<div class="form-control">
<%= f.button button_title(title: 'Start', disabled_with: 'Starting'), class: 'base-button' %>
@@ -9,7 +9,7 @@
</a>
</div>
<div class="grid <%= 'md:grid-cols-2' if template.submitters.size > 1 %> gap-4">
<% template.submitters.each do |item| %>
<% template.submitters.each_with_index do |item, index| %>
<submitter-item class="form-control">
<% if template.submitters.size > 1 %>
<label class="label pt-0 pb-1 text-xs">
@@ -18,11 +18,11 @@
<% end %>
<input type="hidden" name="submission[1][submitters][][uuid]" value="<%= item['uuid'] %>">
<submitters-autocomplete data-field="name">
<input type="text" name="submission[1][submitters][][name]" autocomplete="off" class="input input-sm input-bordered w-full" placeholder="Name" required>
<%= tag.input type: 'text', name: 'submission[1][submitters][][name]', autocomplete: 'off', class: 'input input-sm input-bordered w-full', placeholder: 'Name', required: index.zero?, value: params[:selfsign] && index.zero? ? current_user.full_name : '' %>
</submitters-autocomplete>
<div class="grid <%= 'md:grid-cols-2 gap-1' if template.submitters.size == 1 %>">
<submitters-autocomplete data-field="email">
<input type="email" multiple name="submission[1][submitters][][email]" autocomplete="off" class="input input-sm input-bordered mt-1.5 w-full" placeholder="Email (optional)">
<input type="email" multiple name="submission[1][submitters][][email]" autocomplete="off" class="input input-sm input-bordered mt-1.5 w-full" placeholder="Email (optional)" value="<%= params[:selfsign] && index.zero? ? current_user.email : '' %>">
</submitters-autocomplete>
<submitters-autocomplete data-field="phone">
<input type="tel" pattern="^\+[0-9\s\-]+$" oninvalid="this.value ? this.setCustomValidity('Use internatioanl format: +1xxx...') : ''" oninput="this.setCustomValidity('')" name="submission[1][submitters][][phone]" autocomplete="off" class="input input-sm input-bordered mt-1.5 w-full" placeholder="Phone (optional)">
@@ -34,10 +34,12 @@
</div>
</div>
</div>
<a href="#" class="btn btn-primary btn-sm w-full flex items-center justify-center" data-action="click:dynamic-list#addItem">
<%= svg_icon('user_plus', class: 'w-4 h-4 stroke-2') %>
<span>Add New</span>
</a>
<% if params[:selfsign].blank? %>
<a href="#" class="btn btn-primary btn-sm w-full flex items-center justify-center" data-action="click:dynamic-list#addItem">
<%= svg_icon('user_plus', class: 'w-4 h-4 stroke-2') %>
<span>Add New</span>
</a>
<% end %>
</dynamic-list>
<div>
<%= render('submitters_order', f:, template:) if Accounts.can_send_emails?(current_account) %>
+8 -6
View File
@@ -18,14 +18,14 @@
</a>
</div>
<div class="grid md:grid-cols-2 gap-4">
<% template.submitters.each do |item| %>
<% template.submitters.each_with_index do |item, index| %>
<submitter-item class="form-control">
<label class="label pt-0 pb-1 text-xs">
<span class="label-text"> <%= item['name'] %></span>
</label>
<input type="hidden" name="submission[1][submitters][][uuid]" value="<%= item['uuid'] %>">
<submitters-autocomplete data-field="email">
<input type="email" multiple name="submission[1][submitters][][email]" autocomplete="off" class="input input-sm input-bordered w-full" placeholder="Email" required>
<%= tag.input type: 'email', multiple: true, name: 'submission[1][submitters][][email]', autocomplete: 'off', class: 'input input-sm input-bordered w-full', placeholder: 'Email', required: index.zero?, value: params[:selfsign] && index.zero? ? current_user.email : '' %>
</submitters-autocomplete>
</submitter-item>
<% end %>
@@ -33,10 +33,12 @@
</div>
</div>
</div>
<a href="#" class="btn btn-primary btn-sm w-full flex items-center justify-center" data-action="click:dynamic-list#addItem">
<%= svg_icon('user_plus', class: 'w-4 h-4 stroke-2') %>
<span>Add New</span>
</a>
<% if params[:selfsign].blank? %>
<a href="#" class="btn btn-primary btn-sm w-full flex items-center justify-center" data-action="click:dynamic-list#addItem">
<%= svg_icon('user_plus', class: 'w-4 h-4 stroke-2') %>
<span>Add New</span>
</a>
<% end %>
</dynamic-list>
<% end %>
<div>
+1
View File
@@ -0,0 +1 @@
<%= render 'shared/logo', width: 40, height: 40 %>
+10 -8
View File
@@ -9,7 +9,7 @@
</a>
</div>
<div class="grid <%= template.submitters.size > 1 ? 'md:grid-cols-2 gap-4' : 'gap-1' %>">
<% template.submitters.each do |item| %>
<% template.submitters.each_with_index do |item, index| %>
<submitter-item class="grid <%= template.submitters.size > 1 ? 'gap-4' : 'md:grid-cols-2 gap-1' %>">
<div class="form-control">
<% if template.submitters.size > 1 %>
@@ -19,18 +19,18 @@
<% end %>
<input type="hidden" name="submission[1][submitters][][uuid]" value="<%= item['uuid'] %>">
<submitters-autocomplete data-field="phone">
<input type="tel" pattern="^\+[0-9\s\-]+$" oninvalid="this.value ? this.setCustomValidity('Use internatioanl format: +1xxx...') : ''" oninput="this.setCustomValidity('')" name="submission[1][submitters][][phone]" autocomplete="off" class="input input-sm input-bordered w-full" placeholder="Phone" required>
<%= tag.input type: 'tel', pattern: '^\+[0-9\s\-]+$', oninvalid: "this.value ? this.setCustomValidity('Use internatioanl format: +1xxx...') : ''", oninput: "this.setCustomValidity('')", name: 'submission[1][submitters][][phone]', autocomplete: 'off', class: 'input input-sm input-bordered w-full', placeholder: 'Phone', required: index.zero? %>
</submitters-autocomplete>
<% if template.submitters.size > 1 %>
<submitters-autocomplete data-field="name">
<input type="text" name="submission[1][submitters][][name]" autocomplete="off" class="input input-sm input-bordered mt-1.5 w-full" placeholder="Name (optional)">
<input type="text" name="submission[1][submitters][][name]" autocomplete="off" class="input input-sm input-bordered mt-1.5 w-full" placeholder="Name (optional)" value="<%= params[:selfsign] && index.zero? ? current_user.full_name : '' %>">
</submitters-autocomplete>
<% end %>
</div>
<% if template.submitters.size == 1 %>
<div class="form-control flex">
<submitters-autocomplete data-field="name">
<input type="text" name="submission[1][submitters][][name]" autocomplete="off" class="input input-sm input-bordered w-full" placeholder="Name (optional)">
<input type="text" name="submission[1][submitters][][name]" autocomplete="off" class="input input-sm input-bordered w-full" placeholder="Name (optional)" value="<%= params[:selfsign] && index.zero? ? current_user.full_name : '' %>">
</submitters-autocomplete>
</div>
<% end %>
@@ -40,10 +40,12 @@
</div>
</div>
</div>
<a href="#" class="btn btn-primary btn-sm w-full flex items-center justify-center" data-action="click:dynamic-list#addItem">
<%= svg_icon('user_plus', class: 'w-4 h-4 stroke-2') %>
<span>Add New</span>
</a>
<% if params[:selfsign].blank? %>
<a href="#" class="btn btn-primary btn-sm w-full flex items-center justify-center" data-action="click:dynamic-list#addItem">
<%= svg_icon('user_plus', class: 'w-4 h-4 stroke-2') %>
<span>Add New</span>
</a>
<% end %>
</dynamic-list>
<div>
<%= render('submitters_order', f:, template:) if Accounts.can_send_emails?(current_account) %>
@@ -2,8 +2,8 @@
<div class="form-control">
<%= f.label :preserve_order, for: uuid = SecureRandom.uuid, class: 'flex items-center cursor-pointer' do %>
<%= f.check_box :preserve_order, id: uuid, class: 'base-checkbox', checked: template.submissions.last&.submitters_order.in?(['preserved', nil]) %>
<span class="label">Preserve submitters order</span>
<span class="tooltip" data-tip="When checked, notifications will be sent to the second submitter once the form is completed by the previous submitter. Uncheck this option to send notifications to all submitters simultaneously right away.">
<span class="label">Preserve order</span>
<span class="tooltip" data-tip="When checked, notifications will be sent to the second party once the form is completed by the previous party. Uncheck this option to send notifications to all parties simultaneously right away.">
<%= svg_icon('info_circle', class: 'w-4 h-4') %>
</span>
<% end %>
+13 -3
View File
@@ -1,7 +1,8 @@
<div class="flex absolute text-[1.5vw] lg:text-base" style="width: <%= area['w'] * 100 %>%; height: <%= area['h'] * 100 %>%; left: <%= area['x'] * 100 %>%; top: <%= area['y'] * 100 %>%">
<field-value class="flex absolute text-[1.5vw] lg:text-base" style="width: <%= area['w'] * 100 %>%; height: <%= area['h'] * 100 %>%; left: <%= area['x'] * 100 %>%; top: <%= area['y'] * 100 %>%">
<% if field['type'].in?(['signature', 'image', 'initials']) %>
<img class="object-contain mx-auto" src="<%= attachments_index[value].url %>" loading="lazy">
<% elsif field['type'] == 'file' %>
<autosize-field></autosize-field>
<div class="px-0.5 flex flex-col justify-center">
<% Array.wrap(value).each do |val| %>
<a target="_blank" href="<%= attachments_index[val].url %>">
@@ -11,9 +12,16 @@
<% end %>
</div>
<% elsif field['type'] == 'checkbox' %>
<div class="w-full p-[0.2vw] flex items-center justify-center">
<div class="w-full flex items-center justify-center">
<%= svg_icon('check', class: "aspect-square #{area['w'] > area['h'] ? '!w-auto !h-full' : '!w-full !h-auto'}") %>
</div>
<% elsif field['type'].in?(%w[multiple radio]) && area['option_uuid'] %>
<% option = field['options']&.find { |o| o['uuid'] == area['option_uuid'] } %>
<% if option && Array.wrap(value).include?(option['value']) %>
<div class="w-full flex items-center justify-center">
<%= svg_icon('check', class: "aspect-square #{area['w'] > area['h'] ? '!w-auto !h-full' : '!w-full !h-auto'}") %>
</div>
<% end %>
<% elsif field['type'] == 'cells' %>
<% cell_width = area['cell_w'] / area['w'] * 100 %>
<div class="w-full flex items-center">
@@ -24,10 +32,12 @@
<% end %>
</div>
<% elsif field['type'] == 'date' %>
<autosize-field></autosize-field>
<div class="flex items-center px-0.5">
<%= l(Date.parse(value), format: :long, locale: local_assigns[:locale]) %>
</div>
<% else %>
<autosize-field></autosize-field>
<div class="flex items-center px-0.5 whitespace-pre-wrap"><%= Array.wrap(value).join(', ') %></div>
<% end %>
</div>
</field-value>
+1 -1
View File
@@ -1,4 +1,4 @@
<%= render 'shared/turbo_modal', title: 'Add New Recipients' do %>
<%= render 'shared/turbo_modal', title: params[:selfsign] ? 'Add Recipients' : 'Add New Recipients' do %>
<% options = [['via Email', 'email'], ['via Phone', 'phone'], ['Detailed', 'detailed']] %>
<toggle-visible data-element-ids="<%= options.map(&:last).to_json %>" class="relative text-center mt-4 block">
<div class="join">
+16 -13
View File
@@ -1,25 +1,27 @@
<div style="max-width: 1600px" class="mx-auto pl-4">
<div class="flex justify-between py-1.5 items-center pr-4 sticky top-0 md:relative z-10 bg-base-100">
<a href="<%= template_path(@submission.template) %>" class="flex space-x-3 py-1">
<span><%= render 'shared/logo', width: 40, height: 40 %></span>
<span class="text-3xl font-semibold focus:text-clip"><%= @submission.template.name %></span>
<a href="<%= signed_in? && @submission.template.account_id == current_account&.id ? template_path(@submission.template) : '/' %>" class="flex items-center space-x-3 py-1">
<span><%= render 'submissions/logo' %></span>
<span class="text-xl md:text-3xl font-semibold focus:text-clip" style="overflow: hidden; display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2;"><%= @submission.template.name %></span>
</a>
<div class="space-x-3 flex items-center">
<% if @submission.audit_trail.present? %>
<a href="<%= rails_blob_path(@submission.audit_trail) %>" class="white-button" target="_blank">
<%= svg_icon('external_link', class: 'w-6 h-6') %>
Audit Log
<span class="hidden md:inline">
Audit Log
</span>
</a>
<% end %>
<% if last_submitter = @submission.submitters.to_a.select(&:completed_at?).max_by(&:completed_at) %>
<download-button data-src="<%= submitter_download_index_path(last_submitter.slug) %>" class="base-button">
<span class="flex items-center justify-center space-x-2" data-target="download-button.defaultButton">
<%= svg_icon('download', class: 'w-6 h-6') %>
<span>Download</span>
<span class="hidden md:inline">Download</span>
</span>
<span class="flex items-center justify-center space-x-2 hidden" data-target="download-button.loadingButton">
<%= svg_icon('loader', class: 'w-6 h-6 animate-spin') %>
<span>Downloading</span>
<span class="hidden md:inline">Downloading</span>
</span>
</download-button>
<% elsif @submission.submitters.to_a.size == 1 %>
@@ -53,7 +55,7 @@
<% (document.metadata.dig('pdf', 'number_of_pages') || (document.preview_images.loaded? ? preview_images_index.size : document.preview_images.size)).times do |index| %>
<% page = preview_images_index[index] || page_blob_struct.new(metadata: lazyload_metadata, url: preview_document_page_path(document.uuid, "#{index}.jpg")) %>
<div id="<%= "page-#{document.uuid}-#{index}" %>" class="relative">
<img loading="lazy" src="<%= page.url %>" width="<%= page.metadata['width'] %>" class="shadow-md mb-4" height="<%= page.metadata['height'] %>">
<img loading="lazy" src="<%= page.url %>" width="<%= page.metadata['width'] %>" class="border rounded mb-4" height="<%= page.metadata['height'] %>">
<div class="top-0 bottom-0 left-0 right-0 absolute">
<% document_annots_index[index]&.each do |annot| %>
<%= render 'submissions/annotation', annot: %>
@@ -61,7 +63,7 @@
<% fields_index.dig(document.uuid, index)&.each do |(area, field)| %>
<% value = values[field['uuid']] %>
<% next if value.blank? %>
<%= render 'submissions/value', area:, field:, attachments_index:, value:, locale: current_account.locale %>
<%= render 'submissions/value', area:, field:, attachments_index:, value:, locale: @submission.template.account.locale %>
<% end %>
</div>
</div>
@@ -109,18 +111,18 @@
<div class="flex items-center space-x-1 mt-1">
<%= svg_icon('writing', class: 'w-5 h-5') %>
<span>
<%= submitter&.completed_at? ? l(submitter.completed_at.in_time_zone(current_account.timezone), format: :long, locale: current_account.locale) : 'Not completed yet' %>
<%= submitter&.completed_at? ? l(submitter.completed_at.in_time_zone(@submission.template.account.timezone), format: :long, locale: @submission.template.account.locale) : 'Not completed yet' %>
</span>
</div>
<% if submitter && submitter.email && !submitter.completed_at && can?(:update, submitter) %>
<% if signed_in? && submitter && submitter.email && !submitter.completed_at && can?(:update, submitter) %>
<div class="mt-2 mb-1">
<%= button_to button_title(title: submitter.sent_at? ? 'Re-send Email' : 'Send Email', disabled_with: 'Sending'), submitter_send_email_index_path(submitter_slug: submitter.slug), class: 'btn btn-sm btn-primary w-full' %>
</div>
<% end %>
<% if submitter && submitter.phone && !submitter.completed_at && can?(:update, submitter) %>
<% if signed_in? && submitter && submitter.phone && !submitter.completed_at && can?(:update, submitter) %>
<%= render 'send_sms_button', submitter: %>
<% end %>
<% if submitter && !submitter.completed_at? && can?(:create, submitter) %>
<% if signed_in? && submitter && !submitter.completed_at? && can?(:create, submitter) %>
<div class="mt-2 mb-1">
<a class="btn btn-sm btn-primary w-full" target="_blank" href="<%= submit_form_path(slug: submitter.slug) %>">
Submit Form
@@ -160,7 +162,7 @@
<% elsif field['type'] == 'checkbox' %>
<%= svg_icon('check', class: 'w-6 h-6') %>
<% elsif field['type'] == 'date' %>
<%= l(Date.parse(value), locale: current_account.locale, format: :long) %>
<%= l(Date.parse(value), locale: @submission.template.account.locale, format: :long) %>
<% else %>
<%= Array.wrap(value).join(', ') %>
<% end %>
@@ -172,3 +174,4 @@
</div>
</div>
</div>
<%= render 'scripts/autosize_field' %>

Some files were not shown because too many files have changed in this diff Show More