Compare commits

...

109 Commits

Author SHA1 Message Date
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
DocuSeal ce39494f84 optimize for large files 2023-10-22 22:33:54 +03:00
DocuSeal c639a3f733 do not sign in after reset password if 2fa
https://github.com/devise-two-factor/devise-two-factor#disabling-automatic-login-after-password-resets
thanks Greg Molnar
2023-10-22 13:19:47 +03:00
DocuSeal 74bef133b3 add enqueries checkbox 2023-10-21 15:33:10 +03:00
DocuSeal 33142966dc allow to resubmit form via shared link 2023-10-21 13:06:59 +03:00
DocuSeal 42ec8d5fc9 do not allow to skip required fields 2023-10-21 12:09:50 +03:00
DocuSeal 01d752e811 fix webhook serializer 2023-10-21 12:09:37 +03:00
DocuSeal 99b33d4165 allow to resubmit form via shared link 2023-10-20 22:55:25 +03:00
DocuSeal 11a90d797a add user encrypted configs 2023-10-20 13:16:39 +03:00
DocuSeal 9da7463657 add submission status filters 2023-10-20 00:13:17 +03:00
Alex Turchyn b6d350ebbd fix no error messages when sign in with wrong credentials 2023-10-18 22:56:28 +03:00
DocuSeal 9e87a0373f add application key to submitters 2023-10-18 02:14:44 +03:00
DocuSeal aee55165c5 use start page 2023-10-16 21:21:04 +03:00
DocuSeal 9ddbcae153 fix plans link 2023-10-16 20:19:39 +03:00
DocuSeal 10cd3aa72a smtp ssl verify mode none 2023-10-16 20:19:28 +03:00
DocuSeal 6684a27e2f preserve textarea in the form 2023-10-16 02:04:52 +03:00
DocuSeal b6f7a7dd15 fix initials step 2023-10-16 01:40:17 +03:00
DocuSeal 309e43e18e fix set today date timezone 2023-10-16 01:28:04 +03:00
DocuSeal d5b8640a0e force redraw form steps 2023-10-16 00:51:08 +03:00
DocuSeal 49b9064d9f add ability to toggle textarea 2023-10-16 00:41:14 +03:00
DocuSeal 698031cfc0 add current date button 2023-10-15 22:59:32 +03:00
DocuSeal 6dc21e66e1 return to required fields 2023-10-15 21:41:47 +03:00
DocuSeal 60b32fec94 adjust sign up header buttons 2023-10-15 01:03:29 +03:00
DocuSeal c621166b45 use full name form company name if missing 2023-10-14 22:31:32 +03:00
DocuSeal 8e2da83f9e adjust demo 2023-10-14 21:19:21 +03:00
DocuSeal a39ba5b275 add plausible 2023-10-14 16:00:56 +03:00
DocuSeal edf466a6de add plans link 2023-10-14 15:42:50 +03:00
DocuSeal 3b8bb8c5bb fix api when fields without name 2023-10-13 14:17:28 +03:00
DocuSeal 79f1061353 improve signup form links 2023-10-12 22:59:15 +03:00
Alex Turchyn 745f2160c5 update the wording of emails 2023-10-12 22:42:14 +03:00
DocuSeal 2f0c79444b add fields configs param to submissions api 2023-10-12 22:13:46 +03:00
DocuSeal 69a3782903 add readonly fields via api 2023-10-12 17:23:59 +03:00
DocuSeal 3dd7c47558 refactor result generation 2023-10-11 21:05:42 +03:00
DocuSeal 761bb07ee6 fix migration for mysql 2023-10-11 21:05:29 +03:00
Alex Turchyn 92d7bc65f5 replace 'log in' with 'sign in' 2023-10-11 01:11:36 +03:00
151 changed files with 2650 additions and 687 deletions
+6
View File
@@ -67,8 +67,14 @@ RSpec/MultipleMemoizedHelpers:
Rails/I18nLocaleTexts:
Enabled: false
Rails/FindEach:
Enabled: false
Rails/SkipsModelValidations:
Enabled: false
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
+13 -15
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)
@@ -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)
@@ -345,7 +344,7 @@ GEM
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 +417,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 +454,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 +535,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)
+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?
+13 -5
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,8 @@ 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))
submissions = paginate(submissions.preload(:created_by_user, :template, :submitters,
audit_trail_attachment: :blob))
render json: {
data: submissions.as_json(serialize_params),
@@ -93,6 +94,7 @@ module Api
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
@@ -106,7 +108,9 @@ module Api
def submissions_params
params.permit(submission: [{
submitters: [[:uuid, :name, :email, :role, :completed, :phone, { values: {} }]]
submitters: [[:uuid, :name, :email, :role, :completed, :phone, :application_key,
{ values: {}, readonly_fields: [],
fields: [%i[name default_value readonly validation_pattern invalid_message]] }]]
}])
end
@@ -115,11 +119,15 @@ module Api
Array.wrap(submissions_params).each do |submission|
submission[:submitters].each_with_index do |submitter, index|
next if submitter[:values].blank?
default_values = submitter[:values] || {}
submitter[:fields]&.each { |f| default_values[f[:name]] = f[:default_value] if f[:default_value].present? }
next if default_values.blank?
values, new_attachments =
Submitters::NormalizeValues.call(template,
submitter[:values],
default_values,
submitter[:role] || template.submitters[index]['name'])
attachments.push(*new_attachments)
@@ -4,6 +4,27 @@ module Api
class SubmittersController < ApiBaseController
load_and_authorize_resource :submitter
def index
submitters = Submitters.search(@submitters, params[:q])
submitters = submitters.where(application_key: params[:application_key]) if params[:application_key].present?
submitters = submitters.where(submission_id: params[:submission_id]) if params[:submission_id].present?
submitters = paginate(
submitters.preload(:template, :submission, :submission_events,
documents_attachments: :blob, attachments_attachments: :blob)
)
render json: {
data: submitters.map { |s| Submitters::SerializeForApi.call(s, with_template: true, with_events: true) },
pagination: {
count: submitters.size,
next: submitters.last&.id,
prev: submitters.first&.id
}
}
end
def show
Submissions::EnsureResultGenerated.call(@submitter) if @submitter.completed_at?
+8 -5
View File
@@ -8,6 +8,7 @@ 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 = paginate(templates.preload(:author, documents_attachments: :blob))
@@ -51,11 +52,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
@@ -16,6 +16,7 @@ module Api
render json: {
schema:,
documents: documents.as_json(
methods: [:metadata],
include: {
preview_images: { methods: %i[url metadata filename] }
}
+1 -1
View File
@@ -33,7 +33,7 @@ class ApplicationController < ActionController::Base
private
def sign_in_for_demo
sign_in(User.order('random()').take) unless signed_in?
sign_in(User.active.order('random()').take) unless signed_in?
end
def current_account
+22
View File
@@ -0,0 +1,22 @@
# frozen_string_literal: true
class EnquiriesController < ApplicationController
skip_before_action :authenticate_user!
skip_authorization_check
def create
if params[:talk_to_sales] == 'on'
Faraday.post(Docuseal::ENQUIRIES_URL,
enquiry_params.merge(type: :talk_to_sales).to_json,
'Content-Type' => 'application/json')
end
head :ok
end
private
def enquiry_params
params.require(:user).permit(:email)
end
end
+2 -2
View File
@@ -10,7 +10,7 @@ class MfaSetupController < ApplicationController
current_user.save!
@provision_url = current_user.otp_provisioning_uri(current_user.email, issuer: Docuseal::PRODUCT_NAME)
@provision_url = current_user.otp_provisioning_uri(current_user.email, issuer: Docuseal.product_name)
end
def edit; end
@@ -22,7 +22,7 @@ class MfaSetupController < ApplicationController
redirect_to settings_profile_index_path, notice: '2FA has been configured'
else
@provision_url = current_user.otp_provisioning_uri(current_user.email, issuer: Docuseal::PRODUCT_NAME)
@provision_url = current_user.otp_provisioning_uri(current_user.email, issuer: Docuseal.product_name)
@error_message = 'Code is invalid'
+13
View File
@@ -0,0 +1,13 @@
# frozen_string_literal: true
class PasswordsController < Devise::PasswordsController
class Current < ActiveSupport::CurrentAttributes
attribute :user
end
def update
super do |resource|
Current.user = resource
end
end
end
@@ -0,0 +1,46 @@
# frozen_string_literal: true
class PreviewDocumentPageController < ActionController::API
include ActiveStorage::SetCurrent
FORMAT = Templates::ProcessDocument::FORMAT
def show
attachment = ActiveStorage::Attachment.find_by(uuid: params[:attachment_uuid])
return head :not_found unless attachment
preview_image = attachment.preview_images.joins(:blob).find_by(blob: { filename: "#{params[:id]}#{FORMAT}" })
return redirect_to preview_image.url, allow_other_host: true if preview_image
file_path =
if attachment.service.name == :disk
ActiveStorage::Blob.service.path_for(attachment.key)
else
find_or_create_document_tempfile_path(attachment)
end
io = Templates::ProcessDocument.generate_pdf_preview_from_file(attachment, file_path, params[:id].to_i)
render plain: io.tap(&:rewind).read, content_type: 'image/jpeg'
end
def find_or_create_document_tempfile_path(attachment)
file_path = "#{Dir.tmpdir}/#{attachment.uuid}"
File.open(file_path, File::RDWR | File::CREAT, 0o644) do |f|
f.flock(File::LOCK_EX)
# rubocop:disable Style/ZeroLengthPredicate
if f.size.zero?
f.binmode
f.write(attachment.download)
end
# rubocop:enable Style/ZeroLengthPredicate
end
file_path
end
end
+4 -4
View File
@@ -31,10 +31,10 @@ class RegistrationsController < Devise::RegistrationsController
redirect_to after_sign_up_path_for(current_user), allow_other_host: true
end
def require_no_authentication
super
def set_flash_message(key, kind, options = {})
return if key == :alert && kind == 'already_authenticated'
flash.clear
super
end
def build_resource(_hash = {})
@@ -43,7 +43,7 @@ class RegistrationsController < Devise::RegistrationsController
self.resource = account.users.new(user_params)
account.name ||= "#{resource.full_name}'s Company" if params[:action] == 'create'
account.name ||= resource.full_name if params[:action] == 'create'
end
def user_params
+3 -3
View File
@@ -32,9 +32,9 @@ class SessionsController < Devise::SessionsController
devise_parameter_sanitizer.permit(:sign_in, keys: [:otp_attempt])
end
def require_no_authentication
super
def set_flash_message(key, kind, options = {})
return if key == :alert && kind == 'already_authenticated'
flash.clear
super
end
end
@@ -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
+3 -2
View File
@@ -14,7 +14,8 @@ class StartFormController < ApplicationController
def update
@submitter = Submitter.where(submission: @template.submissions.where(deleted_at: nil))
.find_or_initialize_by(email: submitter_params[:email])
.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])
@@ -46,7 +47,7 @@ class StartFormController < ApplicationController
private
def submitter_params
params.require(:submitter).permit(:email).tap do |attrs|
params.require(:submitter).permit(:email, :phone, :name).tap do |attrs|
attrs[:email] = Submissions.normalize_email(attrs[:email])
end
end
+13 -1
View File
@@ -6,12 +6,24 @@ class SubmissionsController < ApplicationController
load_and_authorize_resource :submission, only: %i[show destroy]
PRELOAD_ALL_PAGES_AMOUNT = 200
def show
ActiveRecord::Associations::Preloader.new(
records: [@submission],
associations: [:template, { template_schema_documents: [:blob, { preview_images_attachments: :blob }] }]
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 :show, layout: 'plain'
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
+18 -5
View File
@@ -6,15 +6,28 @@ class SubmitFormController < ApplicationController
skip_before_action :authenticate_user!
skip_authorization_check
PRELOAD_ALL_PAGES_AMOUNT = 200
def show
@submitter =
Submitter.preload(submission: [
:template, { template_schema_documents: [:blob, { preview_images_attachments: :blob }] }
])
.find_by!(slug: params[:slug])
@submitter = Submitter.find_by!(slug: params[:slug])
return redirect_to submit_form_completed_path(@submitter.slug) if @submitter.completed_at?
ActiveRecord::Associations::Preloader.new(
records: [@submitter],
associations: [submission: [:template, { template_schema_documents: :blob }]]
).call
total_pages =
@submitter.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: @submitter.submission.template_schema_documents,
associations: [:blob, { preview_images_attachments: :blob }]
).call
end
Submitters::MaybeUpdateDefaultValues.call(@submitter, current_user)
cookies[:submitter_sid] = @submitter.signed_id
+24 -2
View File
@@ -10,6 +10,11 @@ class TemplatesController < ApplicationController
submissions = submissions.active if @template.deleted_at.blank?
submissions = Submissions.search(submissions, params[:q])
@base_submissions = submissions
submissions = submissions.pending if params[:status] == 'pending'
submissions = submissions.completed if params[:status] == 'completed'
@pagy, @submissions = pagy(submissions.preload(:submitters).order(id: :desc))
rescue ActiveRecord::RecordNotFound
redirect_to root_path
@@ -25,6 +30,14 @@ class TemplatesController < ApplicationController
associations: [schema_documents: { preview_images_attachments: :blob }]
).call
@template_data =
@template.as_json.merge(
documents: @template.schema_documents.as_json(
methods: [:metadata],
include: { preview_images: { methods: %i[url metadata filename] } }
)
).to_json
render :edit, layout: 'plain'
end
@@ -44,9 +57,18 @@ class TemplatesController < ApplicationController
end
def destroy
@template.update!(deleted_at: Time.current)
notice =
if !Docuseal.multitenant? && params[:permanently].present?
@template.destroy!
redirect_back(fallback_location: root_path, notice: 'Template has been archived.')
'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
@@ -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)
+2
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) => {
+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()
}
}
+4
View File
@@ -47,6 +47,10 @@ select:required:invalid {
@apply border-base-content/20;
}
.base-textarea {
@apply textarea textarea-bordered bg-white rounded-3xl;
}
.btn {
@apply no-animation;
}
+83 -7
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)">
@@ -110,9 +149,10 @@
<span v-else-if="field.type === 'date'">
{{ formattedDate }}
</span>
<span v-else>
{{ modelValue }}
</span>
<span
v-else
class="whitespace-pre-wrap"
>{{ modelValue }}</span>
</div>
</div>
</template>
@@ -172,6 +212,11 @@ export default {
}
},
emits: ['update:model-value'],
data () {
return {
textOverflowChars: 0
}
},
computed: {
fieldNames () {
return {
@@ -250,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)])
}
}
}
+1 -1
View File
@@ -78,7 +78,7 @@
>
{{ t('signed_with') }}
<a
href="https://www.docuseal.co"
href="https://www.docuseal.co/start"
target="_blank"
class="underline"
>DocuSeal</a> - {{ t('open_source_documents_software') }}
@@ -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>
+108 -67
View File
@@ -58,54 +58,20 @@
>
<div class="md:mt-4">
<div v-if="['cells', 'text'].includes(currentField.type)">
<label
v-if="currentField.name"
:for="currentField.uuid"
class="label text-2xl mb-2"
>{{ currentField.name }}
<template v-if="!currentField.required">({{ t('optional') }})</template>
</label>
<div
v-else
class="py-1"
<TextStep
:key="currentField.uuid"
v-model="values[currentField.uuid]"
:field="currentField"
@focus="$refs.areas.scrollIntoField(currentField)"
/>
<div>
<input
:id="currentField.uuid"
v-model="values[currentField.uuid]"
class="base-input !text-2xl w-full"
:required="currentField.required"
:placeholder="`${t('type_here')}...${currentField.required ? '' : ` (${t('optional')})`}`"
type="text"
:name="`values[${currentField.uuid}]`"
@focus="$refs.areas.scrollIntoField(currentField)"
>
</div>
</div>
<div v-else-if="currentField.type === 'date'">
<label
v-if="currentField.name"
:for="currentField.uuid"
class="label text-2xl mb-2"
>{{ currentField.name }}
<template v-if="!currentField.required">({{ t('optional') }})</template>
</label>
<div
v-else
class="py-1"
/>
<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"
@@ -133,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>
@@ -153,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>
@@ -179,6 +145,7 @@
</div>
<MultiSelectStep
v-else-if="currentField.type === 'multiple'"
:key="currentField.uuid"
v-model="values[currentField.uuid]"
:field="currentField"
/>
@@ -240,6 +207,7 @@
</div>
<ImageStep
v-else-if="currentField.type === 'image'"
:key="currentField.uuid"
v-model="values[currentField.uuid]"
:field="currentField"
:is-direct-upload="isDirectUpload"
@@ -250,8 +218,10 @@
<SignatureStep
v-else-if="currentField.type === 'signature'"
ref="currentStep"
:key="currentField.uuid"
v-model="values[currentField.uuid]"
:field="currentField"
:previous-value="previousSignatureValue"
:is-direct-upload="isDirectUpload"
:attachments-index="attachmentsIndex"
:submitter-slug="submitterSlug"
@@ -262,8 +232,10 @@
<InitialsStep
v-else-if="currentField.type === 'initials'"
ref="currentStep"
:key="currentField.uuid"
v-model="values[currentField.uuid]"
:field="currentField"
:previous-value="previousInitialsValue"
:is-direct-upload="isDirectUpload"
:attachments-index="attachmentsIndex"
:submitter-slug="submitterSlug"
@@ -274,6 +246,7 @@
/>
<AttachmentStep
v-else-if="currentField.type === 'file'"
:key="currentField.uuid"
v-model="values[currentField.uuid]"
:is-direct-upload="isDirectUpload"
:field="currentField"
@@ -284,6 +257,7 @@
<PhoneStep
v-else-if="currentField.type === 'phone'"
ref="currentStep"
:key="currentField.uuid"
v-model="values[currentField.uuid]"
:field="currentField"
:default-value="submitter.phone"
@@ -294,6 +268,7 @@
</div>
<div class="mt-6 md:mt-8">
<button
ref="submitButton"
type="submit"
class="base-button w-full flex justify-center"
:disabled="isButtonDisabled"
@@ -314,6 +289,12 @@
><span>...</span></span>
</span>
</button>
<div
v-if="showFillAllRequiredFields"
class="text-center mt-1"
>
{{ t('please_fill_all_required_fields') }}
</div>
</div>
</form>
<FormCompleted
@@ -332,7 +313,7 @@
:key="step[0].uuid"
href="#"
class="inline border border-base-300 h-3 w-3 rounded-full mx-1"
:class="{ 'bg-base-300': index === currentStep, 'bg-base-content': index < currentStep || isCompleted, 'bg-white': index > currentStep }"
:class="{ 'bg-base-300': index === currentStep, 'bg-base-content': (index < currentStep && stepFields[index].every((f) => !f.required || ![null, undefined, ''].includes(values[f.uuid]))) || isCompleted, 'bg-white': index > currentStep }"
@click.prevent="isCompleted ? '' : [saveStep(), goToStep(step, true)]"
/>
</div>
@@ -349,6 +330,8 @@ import InitialsStep from './initials_step'
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 } from '@tabler/icons-vue'
import { t } from './i18n'
@@ -363,7 +346,9 @@ export default {
InitialsStep,
MultiSelectStep,
IconInnerShadowTop,
DateStep,
IconArrowsDiagonal,
TextStep,
PhoneStep,
IconArrowsDiagonalMinimize2,
FormCompleted
@@ -389,6 +374,13 @@ export default {
required: false,
default: () => []
},
onComplete: {
type: Function,
required: false,
default () {
return () => {}
}
},
withConfetti: {
type: Boolean,
required: false,
@@ -422,7 +414,7 @@ export default {
allowToSkip: {
type: Boolean,
required: false,
default: true
default: false
},
goToLast: {
type: Boolean,
@@ -454,6 +446,7 @@ export default {
return {
isCompleted: false,
isFormVisible: true,
showFillAllRequiredFields: false,
currentStep: 0,
isSubmitting: false,
submittedValues: {},
@@ -467,6 +460,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
},
@@ -484,7 +487,7 @@ export default {
return this.currentStepFields[0]
},
stepFields () {
return this.fields.reduce((acc, f) => {
return this.fields.filter((f) => !f.readonly).reduce((acc, f) => {
const prevStep = acc[acc.length - 1]
if (f.type === 'checkbox' && Array.isArray(prevStep) && prevStep[0].type === 'checkbox') {
@@ -510,11 +513,27 @@ 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) {
this.currentStep = Math.min(
this.stepFields.indexOf([...this.stepFields].reverse().find((fields) => fields.some((f) => !!this.values[f.uuid]))) + 1,
this.stepFields.length - 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]
if (requiredEmptyStepIndex !== -1) {
indexesList.push(requiredEmptyStepIndex)
}
if (lastFilledStepIndex !== -1) {
indexesList.push(lastFilledStepIndex)
}
this.currentStep = Math.min(...indexesList)
}
if (/iPhone|iPad|iPod/i.test(navigator.userAgent)) {
@@ -605,6 +624,7 @@ export default {
},
goToStep (step, scrollToArea = false, clickUpload = false) {
this.currentStep = this.stepFields.indexOf(step)
this.showFillAllRequiredFields = false
this.$nextTick(() => {
this.recalculateButtonDisabledKey = Math.random()
@@ -621,12 +641,20 @@ export default {
})
},
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
})
}
},
@@ -643,8 +671,9 @@ export default {
})
const formData = new FormData(this.$refs.form)
const isLastStep = this.currentStep === this.stepFields.length - 1
if (this.currentStep === this.stepFields.length - 1 && !emptyRequiredField) {
if (isLastStep && !emptyRequiredField) {
formData.append('completed', 'true')
}
@@ -657,14 +686,26 @@ export default {
return Promise.reject(new Error(data.error))
}
this.submittedValues[this.currentField.uuid] = this.values[this.currentField.uuid]
if (isLastStep) {
this.isSecondWalkthrough = true
}
const nextStep = emptyRequiredField || this.stepFields[this.currentStep + 1]
const nextStep = (isLastStep && emptyRequiredField) || this.stepFields[this.currentStep + 1]
if (nextStep) {
this.goToStep(nextStep, true)
if (emptyRequiredField === nextStep) {
this.showFillAllRequiredFields = true
}
} else {
this.isCompleted = true
const respData = await response.text()
if (respData) {
this.onComplete(JSON.parse(respData))
}
}
}).catch(error => {
console.error('Error submitting form:', error)
+81
View File
@@ -29,6 +29,15 @@ const en = {
sending: 'Sending...',
resend_code: 'Re-send code',
verification_code_has_been_resent: 'Verification code has been re-sent via SMS',
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'
}
@@ -63,6 +72,15 @@ const es = {
sending: 'Enviando...',
resend_code: 'Reenviar código',
verification_code_has_been_resent: 'El código de verificación ha sido reenviado por SMS',
please_fill_all_required_fields: 'Por favor, complete todos los campos obligatorios',
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'
}
@@ -97,6 +115,15 @@ const it = {
sending: 'Invio in corso...',
resend_code: 'Rinvia codice',
verification_code_has_been_resent: 'Il codice di verifica è stato rinviato tramite SMS',
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"
}
@@ -131,6 +158,15 @@ const de = {
sending: 'Senden...',
resend_code: 'Code erneut senden',
verification_code_has_been_resent: 'Die Verifizierungscode wurde erneut per SMS gesendet',
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'
}
@@ -165,6 +201,15 @@ const fr = {
sending: 'Envoi en cours...',
resend_code: 'Renvoyer le code',
verification_code_has_been_resent: 'Le code de vérification a été renvoyé par SMS',
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é"
}
@@ -199,6 +244,15 @@ const pl = {
sending: 'Wysyłanie...',
resend_code: 'Ponownie wyślij kod',
verification_code_has_been_resent: 'Kod weryfikacyjny został ponownie wysłany',
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'
}
@@ -233,6 +287,15 @@ const uk = {
sending: 'Надсилаю...',
resend_code: 'Повторно відправити код',
verification_code_has_been_resent: 'Код підтвердження був повторно надісланий',
please_fill_all_required_fields: "Будь ласка, заповніть всі обов'язкові поля",
set_today: 'Задати Сьогодні',
date: 'Дата',
draw_signature: 'Намалюйте підпис',
type_initial: 'Введіть ініціали',
draw: 'Підпис',
type: 'Текст',
type_text: 'Введіть текст',
toggle_multiline_text: 'Перемкнути Багаторядковий Текст',
email_has_been_sent: 'Електронний лист був відправлений'
}
@@ -267,6 +330,15 @@ const cs = {
sending: 'Odesílání...',
resend_code: 'Znovu odeslat kód',
verification_code_has_been_resent: 'Ověřovací kód byl znovu odeslán',
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'
}
@@ -301,6 +373,15 @@ const pt = {
sending: 'Enviando...',
resend_code: 'Reenviar código',
verification_code_has_been_resent: 'O código de verificação foi reenviado via SMS',
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,36 +6,60 @@
>{{ 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')"
>
<button
<a
id="type_text_button"
class="btn btn-sm btn-circle"
:class="{ 'btn-neutral': isDrawInitials, 'btn-outline': !isDrawInitials }"
href="#"
class="btn btn-outline font-medium btn-sm"
@click.prevent="toggleTextInput"
>
<IconSignature :width="16" />
</button>
<span class="hidden sm:inline">
{{ t('draw') }}
</span>
</a>
</span>
<button
v-if="modelValue"
class="btn btn-outline btn-sm"
<a
v-if="modelValue || computedPreviousValue"
href="#"
class="btn font-medium btn-outline btn-sm"
@click.prevent="remove"
>
<IconReload :width="16" />
{{ t('clear') }}
</button>
<button
</a>
<a
v-else
class="btn btn-outline btn-sm"
href="#"
class="btn font-medium btn-outline btn-sm"
@click.prevent="clear"
>
<IconReload :width="16" />
{{ t('clear') }}
</button>
<button
</a>
<a
title="Minimize"
href="#"
class="py-1.5 inline md:hidden"
@click.prevent="$emit('minimize')"
>
@@ -43,26 +67,26 @@
: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"
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"
@@ -77,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
},
@@ -106,6 +132,11 @@ export default {
required: false,
default: () => ({})
},
previousValue: {
type: String,
required: false,
default: ''
},
modelValue: {
type: String,
required: false,
@@ -115,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) {
@@ -133,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)
@@ -148,6 +187,9 @@ export default {
methods: {
remove () {
this.$emit('update:model-value', '')
this.isUsePreviousValue = false
this.isInitialsStarted = false
},
clear () {
this.pad.clear()
@@ -189,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
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'],
@@ -122,6 +154,11 @@ export default {
required: false,
default: () => ({})
},
previousValue: {
type: String,
required: false,
default: ''
},
modelValue: {
type: String,
required: false,
@@ -131,10 +168,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 +194,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 +207,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 +239,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 +258,8 @@ export default {
this.$nextTick(() => {
this.$refs.textInput.focus()
this.loadFont()
this.$emit('start')
})
}
@@ -250,7 +313,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({})
}
@@ -0,0 +1,134 @@
<template>
<label
v-if="field.name"
:for="field.uuid"
class="label text-2xl mb-2"
>{{ field.name }}
<template v-if="!field.required">({{ t('optional') }})</template>
</label>
<div
v-else
class="py-1"
/>
<div class="items-center flex">
<input
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"
:oninvalid="field.validation?.message ? `this.setCustomValidity(${JSON.stringify(field.validation.message)})` : ''"
:oninput="field.validation?.message ? `this.setCustomValidity('')` : ''"
:placeholder="`${t('type_here')}...${field.required ? '' : ` (${t('optional')})`}`"
type="text"
:name="`values[${field.uuid}]`"
@focus="$emit('focus')"
>
<textarea
v-if="isTextArea"
:id="field.uuid"
ref="textarea"
v-model="text"
class="base-textarea !text-2xl w-full"
:placeholder="`${t('type_here')}...${field.required ? '' : ` (${t('optional')})`}`"
:required="field.required"
:name="`values[${field.uuid}]`"
@input="resizeTextarea"
@focus="$emit('focus')"
/>
<div
v-if="!isTextArea && field.type !== 'cells'"
class="tooltip"
:data-tip="t('toggle_multiline_text')"
>
<a
href="#"
class="btn btn-ghost btn-circle btn-sm"
@click.prevent="toggleTextArea"
>
<IconAlignBoxLeftTop />
</a>
</div>
</div>
</template>
<script>
import { IconAlignBoxLeftTop } from '@tabler/icons-vue'
export default {
name: 'TextStep',
components: {
IconAlignBoxLeftTop
},
inject: ['t'],
props: {
field: {
type: Object,
required: true
},
modelValue: {
type: String,
required: false,
default: ''
}
},
emits: ['update:model-value', 'focus'],
data () {
return {
isTextArea: false
}
},
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)
},
get () {
return this.modelValue
}
}
},
mounted () {
this.isTextArea = this.modelValue?.includes('\n')
if (this.isTextArea) {
this.$nextTick(() => {
this.resizeTextarea()
})
}
},
methods: {
resizeTextarea () {
const textarea = this.$refs.textarea
textarea.style.height = 'auto'
textarea.style.height = textarea.scrollHeight + 'px'
},
toggleTextArea () {
this.isTextArea = true
this.$nextTick(() => {
this.$refs.textarea.focus()
this.$refs.textarea.setSelectionRange(this.$refs.textarea.value.length, this.$refs.textarea.value.length)
this.resizeTextarea()
})
}
}
}
</script>
+78 -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"
: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"
: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"
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,11 @@ export default {
required: false,
default: false
},
editable: {
type: Boolean,
required: false,
default: true
},
field: {
type: Object,
required: false,
@@ -157,6 +177,7 @@ export default {
isResize: false,
isDragged: false,
isNameFocus: false,
textOverflowChars: 0,
dragFrom: { x: 0, y: 0 }
}
},
@@ -164,6 +185,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 +213,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 +253,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 +304,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 +357,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 }
+104 -55
View File
@@ -16,6 +16,7 @@
</a>
<Contenteditable
:model-value="template.name"
:editable="editable"
class="text-3xl font-semibold focus:text-clip"
:icon-stroke-width="2.3"
@update:model-value="updateName"
@@ -31,6 +32,7 @@
:href="`/templates/${template.id}/submissions/new`"
data-turbo-frame="modal"
class="btn btn-primary text-base"
@click="maybeShowEmptyTemplateAlert"
>
<IconUsersPlus
width="20"
@@ -75,6 +77,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)"
@@ -89,7 +93,7 @@
:class="{ 'bg-base-100': withStickySubmitters }"
>
<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 +107,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 +123,19 @@
:areas-index="fieldAreasIndex[document.uuid]"
:selected-submitter="selectedSubmitter"
:document="document"
:is-drag="!!dragFieldType"
:is-drag="!!dragField"
: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 +148,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 +160,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)"
@@ -216,7 +209,7 @@
<p>
<button
class="base-button"
@click="drawField = null"
@click="[drawField = null, drawOption = null]"
>
Cancel
</button>
@@ -229,11 +222,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,6 +241,7 @@
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'
@@ -262,6 +258,7 @@ export default {
Upload,
Document,
Fields,
MobileDrawField,
IconPlus,
FieldType,
IconX,
@@ -299,6 +296,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 +331,11 @@ export default {
required: false,
default: true
},
withUploadButton: {
type: Boolean,
required: false,
default: true
},
withPhone: {
type: Boolean,
required: false,
@@ -337,7 +354,8 @@ export default {
isSaving: false,
selectedSubmitter: null,
drawField: null,
dragFieldType: null
drawOption: null,
dragField: null
}
},
computed: {
@@ -369,6 +387,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 +430,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,6 +484,7 @@ export default {
onKeyUp (e) {
if (e.code === 'Escape') {
this.drawField = null
this.drawOption = null
this.selectedAreaRef.value = null
}
@@ -504,6 +531,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 +549,7 @@ export default {
}
this.drawField = null
this.drawOption = null
this.selectedAreaRef.value = area
@@ -561,14 +599,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 +620,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 +657,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 +729,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,7 +24,8 @@
*
</span>
<IconPencil
class="cursor-pointer flex-none opacity-0 group-hover/contenteditable:opacity-100 align-middle peer-focus:hidden"
v-if="editable"
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 }"
@@ -69,6 +70,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,
+37 -1
View File
@@ -5,6 +5,7 @@
:key="image.id"
:ref="setPageRefs"
:number="index"
:editable="editable"
:areas="areasIndex[index]"
:is-drag="isDrag"
:draw-field="drawField"
@@ -38,11 +39,21 @@ export default {
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,8 +67,33 @@ 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
},
sortedPreviewImages () {
return [...this.document.preview_images].sort((a, b) => parseInt(a.filename) - parseInt(b.filename))
const lazyloadMetadata = this.document.preview_images[this.document.preview_images.length - 1].metadata
return [...Array(this.numberOfPages).keys()].map((i) => {
return this.previewImagesIndex[i] || {
metadata: lazyloadMetadata,
id: Math.random().toString(),
url: this.basePreviewUrl + `/preview/${this.document.uuid}/${i}.jpg`
}
})
},
previewImagesIndex () {
return this.document.preview_images.reduce((acc, e) => {
acc[parseInt(e.filename)] = e
return acc
}, {})
}
},
beforeUpdate () {
+157 -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"
:button-width="20"
@update:model-value="[maybeUpdateOptions(), save()]"
@click="scrollToFirstArea"
@@ -20,6 +22,7 @@
<Contenteditable
ref="name"
:model-value="field.name || defaultName"
:editable="editable"
:icon-inline="true"
:icon-width="18"
:icon-stroke-width="1.6"
@@ -31,34 +34,46 @@
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"
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 +81,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 +151,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 +180,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 +189,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 +248,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 +260,33 @@
<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
},
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 +332,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 Submitter
</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="cursor-pointer group/contenteditable-container rounded-md p-2 border border-base-300 w-full flex justify-between"
>
<div class="flex items-center space-x-2">
<span
@@ -25,6 +111,7 @@
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)"
@@ -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"
@@ -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
@@ -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,
+91 -53
View File
@@ -5,26 +5,66 @@
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"
: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"
@@ -34,35 +74,12 @@
draggable="true"
class="flex items-center justify-center border border-dashed border-base-300 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>
<IconDrag class="cursor-grab" />
</div>
<div class="flex items-center flex-col px-2 py-2">
<component :is="icon" />
@@ -100,7 +117,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 +140,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 +157,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 +182,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.fields.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 +236,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,7 +252,7 @@ export default {
}
if (['select', 'multiple', 'radio'].includes(type)) {
field.options = ['']
field.options = [{ value: '', uuid: v4() }]
}
this.fields.push(field)
@@ -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>
+19 -4
View File
@@ -5,11 +5,12 @@
>
<img
ref="image"
loading="lazy"
:src="image.url"
:width="width"
class="border rounded mb-4"
:height="height"
loading="lazy"
class="border rounded mb-4"
@load="onImageLoad"
>
<div
class="top-0 bottom-0 left-0 right-0 absolute"
@@ -21,6 +22,7 @@
:ref="setAreaRefs"
:area="item.area"
:field="item.field"
:editable="editable"
@start-resize="resizeDirection = $event"
@stop-resize="resizeDirection = null"
@start-drag="isMove = true"
@@ -38,8 +40,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
@@ -76,6 +78,11 @@ export default {
required: false,
default: null
},
editable: {
type: Boolean,
required: false,
default: true
},
isDrag: {
type: Boolean,
required: false,
@@ -117,6 +124,10 @@ export default {
this.areaRefs = []
},
methods: {
onImageLoad (e) {
e.target.setAttribute('width', e.target.naturalWidth)
e.target.setAttribute('height', e.target.naturalHeight)
},
setAreaRefs (el) {
if (el) {
this.areaRefs.push(el)
@@ -136,6 +147,10 @@ export default {
return
}
if (!this.editable) {
return
}
this.showMask = true
this.$nextTick(() => {
+16 -2
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,
@@ -114,7 +128,7 @@ export default {
emits: ['scroll-to', 'change', 'remove', 'up', 'down', 'replace'],
computed: {
previewImage () {
return this.document.preview_images[0]
return [...this.document.preview_images].sort((a, b) => parseInt(a.filename) - parseInt(b.filename))[0]
}
},
mounted () {
+7 -8
View File
@@ -10,19 +10,18 @@ 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)
+21 -8
View File
@@ -1,18 +1,20 @@
# frozen_string_literal: true
class SubmitterMailer < ApplicationMailer
MAX_ATTACHMENTS_SIZE = 10.megabytes
def invitation_email(submitter, body: nil, subject: nil)
@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)
@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:)
else
'You have been invited to submit a form'
'You are invited to submit a form'
end
mail(to: @submitter.friendly_name,
@@ -29,7 +31,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)
@@ -56,13 +58,13 @@ 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
ReplaceEmailVariables.call(@email_config.value['subject'], submitter:)
else
'Your copy of documents'
'Your document copy'
end
mail(from: from_address_for_submitter(submitter),
@@ -75,13 +77,24 @@ class SubmitterMailer < ApplicationMailer
def add_completed_email_attachments!(submitter)
documents = Submitters.select_attachments_for_download(submitter)
total_size = 0
audit_trail_data = nil
if submitter.submission.audit_trail.present?
audit_trail_data = submitter.submission.audit_trail.download
total_size = audit_trail_data.size
end
documents.each do |attachment|
total_size += attachment.byte_size
break if total_size >= MAX_ATTACHMENTS_SIZE
attachments[attachment.filename.to_s] = attachment.download
end
if submitter.submission.audit_trail.present?
attachments[submitter.submission.audit_trail.filename.to_s] = submitter.submission.audit_trail.download
end
attachments[submitter.submission.audit_trail.filename.to_s] = audit_trail_data if audit_trail_data
documents
end
+3 -3
View File
@@ -1,12 +1,12 @@
# frozen_string_literal: true
class UserMailer < ApplicationMailer
def invitation_email(user)
@current_account = user.account
def invitation_email(user, invited_by: nil)
@current_account = invited_by&.account || user.account
@user = user
@token = @user.send(:set_reset_password_token)
mail(to: @user.friendly_name,
subject: 'You have been invited to Docuseal')
subject: 'You are invited to DocuSeal')
end
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
+5 -5
View File
@@ -30,9 +30,9 @@ class AccountConfig < ApplicationRecord
DEFAULT_VALUES = {
SUBMITTER_INVITATION_EMAIL_KEY => {
'subject' => 'You have been invited to submit a form',
'subject' => 'You are invited to submit a form',
'body' => "Hi there,\n\n" \
"You have been invited to submit the \"{{template.name}}\" form:\n\n" \
"You have been invited to submit the \"{{template.name}}\" form.\n\n" \
"{{submitter.link}}\n\n" \
"Please contact us by replying to this email if you didn't request this.\n\n" \
"Thanks,\n" \
@@ -45,11 +45,11 @@ class AccountConfig < ApplicationRecord
'{{submission.link}}'
},
SUBMITTER_DOCUMENTS_COPY_EMAIL_KEY => {
'subject' => 'Your copy of documents',
'subject' => 'Your document copy',
'body' => "Hi there,\n\n" \
"Please check the copy of your \"{{template.name}}\" submission in the email attachments.\n" \
"Alternatively, you can download the copy using:\n\n" \
"{{documents.links}}\n\n" \
"Alternatively, you can download your copy using:\n\n" \
"{{documents.link}}\n\n" \
"Thanks,\n" \
'{{account.name}}'
}
+29
View File
@@ -0,0 +1,29 @@
# frozen_string_literal: true
# == Schema Information
#
# Table name: encrypted_user_configs
#
# id :bigint not null, primary key
# key :string not null
# value :text not null
# created_at :datetime not null
# updated_at :datetime not null
# user_id :bigint not null
#
# Indexes
#
# index_encrypted_user_configs_on_user_id (user_id)
# index_encrypted_user_configs_on_user_id_and_key (user_id,key) UNIQUE
#
# Foreign Keys
#
# fk_rails_... (user_id => users.id)
#
class EncryptedUserConfig < ApplicationRecord
belongs_to :user
encrypts :value
serialize :value, JSON
end
+11
View File
@@ -6,6 +6,7 @@
#
# id :bigint not null, primary key
# deleted_at :datetime
# slug :string not null
# source :text not null
# submitters_order :string not null
# template_fields :text
@@ -19,6 +20,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
@@ -41,6 +43,8 @@ class Submission < ApplicationRecord
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,
@@ -48,6 +52,8 @@ class Submission < ApplicationRecord
through: :template, source: :documents_attachments
scope :active, -> { where(deleted_at: nil) }
scope :pending, -> { joins(:submitters).where(submitters: { completed_at: nil }).distinct }
scope :completed, -> { where.not(id: pending.select(:submission_id)) }
enum :source, {
invite: 'invite',
@@ -60,4 +66,9 @@ class Submission < ApplicationRecord
random: 'random',
preserved: 'preserved'
}, scope: false, prefix: true
def audit_trail_url
audit_trail&.url
end
alias audit_log_url audit_trail_url
end
+17 -16
View File
@@ -4,21 +4,22 @@
#
# Table name: submitters
#
# id :bigint not null, primary key
# completed_at :datetime
# email :string
# ip :string
# name :string
# opened_at :datetime
# phone :string
# sent_at :datetime
# slug :string not null
# ua :string
# uuid :string not null
# values :text not null
# created_at :datetime not null
# updated_at :datetime not null
# submission_id :bigint not null
# id :bigint not null, primary key
# application_key :string
# completed_at :datetime
# email :string
# ip :string
# name :string
# opened_at :datetime
# phone :string
# sent_at :datetime
# slug :string not null
# ua :string
# uuid :string not null
# values :text not null
# created_at :datetime not null
# updated_at :datetime not null
# submission_id :bigint not null
#
# Indexes
#
@@ -60,7 +61,7 @@ class Submitter < ApplicationRecord
def friendly_name
if name.present? && email.present? && email.exclude?(',')
"#{name} <#{email}>"
%("#{name.delete('"')}" <#{email}>)
else
email
end
+14 -13
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
#
+11 -1
View File
@@ -52,7 +52,9 @@ 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'
devise :two_factor_authenticatable, :recoverable, :rememberable, :validatable, :trackable
devise :registerable, :omniauthable, omniauth_providers: [:google_oauth2] if Docuseal.multitenant?
@@ -75,6 +77,14 @@ class User < ApplicationRecord
true
end
def self.sign_in_after_reset_password
if PasswordsController::Current.user.present?
!PasswordsController::Current.user.otp_required_for_login
else
true
end
end
def initials
[first_name&.first, last_name&.first].compact_blank.join.upcase
end
@@ -85,7 +95,7 @@ class User < ApplicationRecord
def friendly_name
if full_name.present?
"#{full_name} <#{email}>"
%("#{full_name.delete('"')}" <#{email}>)
else
email
end
+1 -1
View File
@@ -84,7 +84,7 @@
<div class="flex items-center h-full">
<div class="mx-auto">
<div class="max-w-xl mx-auto">
<h1 class="text-5xl font-bold text-base-content">👋 Welcome to DocuSeal</h1>
<h1 class="text-5xl font-bold text-base-content">👋 Welcome to <%= Docuseal.product_name %></h1>
</div>
<div class="max-w-lg mx-auto">
<p class="py-6 text-gray-600">Streamline document workflows, from creating customizable templates to filling and signing document forms</p>
-1
View File
@@ -21,6 +21,5 @@
<div class="form-control">
<%= f.button button_title(title: 'Change my password', disabled_with: 'Changing password'), class: 'base-button' %>
</div>
<%= render 'devise/shared/links' %>
<% end %>
</div>
-1
View File
@@ -12,5 +12,4 @@
<%= f.button button_title(title: 'Reset password', disabled_with: 'Resetting password'), class: 'base-button' %>
</div>
<% end %>
<%= render 'devise/shared/links' %>
</div>
@@ -41,6 +41,13 @@
</div>
<div class="form-control">
<%= f.button button_title(title: 'Sign up', disabled_with: 'Signing up'), class: 'base-button' %>
<% if Docuseal.multitenant? %>
<button id="talk_to_sales_button" class="hidden" type="submit" formaction="<%= enquiries_path %>" formnovalidate="true" formmethod="post"></button>
<label class="flex items-center cursor-pointer">
<input type="checkbox" name="talk_to_sales" class="base-checkbox" onchange="window.talk_to_sales_button.click()">
<span class="label">I would like to talk to sales</span>
</label>
<% end %>
</div>
<% end %>
<%= render 'devise/shared/links' %>
+5 -1
View File
@@ -16,7 +16,11 @@
</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 %>
<%= 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 %>
<% end %>
<%= render 'devise/shared/links' %>
</div>
+7 -3
View File
@@ -1,6 +1,6 @@
<div class="max-w-lg mx-auto px-2">
<%= render 'devise/shared/select_server' if Docuseal.multitenant? %>
<h1 class="text-4xl font-bold text-center mt-8">Log In</h1>
<h1 class="text-4xl font-bold text-center mt-8">Sign In</h1>
<%= form_for(resource, as: resource_name, html: { class: 'space-y-6' }, data: { turbo: params[:redir].blank? }, url: session_path(resource_name)) do |f| %>
<% if params[:redir].present? %>
<%= hidden_field_tag :redir, params[:redir] %>
@@ -16,11 +16,15 @@
</div>
</div>
<div class="form-control">
<%= f.button button_title(title: 'Log In', disabled_with: 'Logging In'), class: 'base-button' %>
<%= f.button button_title(title: 'Sign In', disabled_with: 'Signing In'), class: 'base-button' %>
</div>
<% end %>
<% if devise_mapping.omniauthable? %>
<%= button_to button_title(title: 'Log 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 %>
<%= 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 %>
<% end %>
<%= render 'devise/shared/links' %>
</div>
+2 -2
View File
@@ -1,5 +1,5 @@
<div class="max-w-lg mx-auto px-2">
<h1 class="text-4xl font-bold text-center mt-8">Log In</h1>
<h1 class="text-4xl font-bold text-center mt-8">Sign In</h1>
<%= form_for(resource, as: resource_name, html: { class: 'space-y-6' }, data: { turbo: params[:redir].blank? }, url: session_path(resource_name)) do |f| %>
<%= f.hidden_field :email %>
<%= f.hidden_field :password %>
@@ -13,7 +13,7 @@
</div>
</div>
<div class="form-control">
<%= f.button button_title(title: 'Log In', disabled_with: 'Logging In'), class: 'base-button' %>
<%= f.button button_title(title: 'Sign In', disabled_with: 'Signing In'), class: 'base-button' %>
</div>
<% end %>
</div>
+1 -7
View File
@@ -1,6 +1,6 @@
<div class="flex justify-between mt-4">
<%- if controller_name != 'sessions' %>
<%= link_to 'Log in', new_session_path(resource_name), class: 'link link-hover' %>
<%= link_to 'Already have an account?', new_session_path(resource_name), class: 'link link-hover mx-auto' %>
<% end %>
<%- if devise_mapping.registerable? && controller_name != 'registrations' %>
<%= link_to 'Create free account', registration_path({ redir: params[:redir] }.compact_blank), class: 'link link-hover' %>
@@ -8,10 +8,4 @@
<%- if devise_mapping.recoverable? && controller_name != 'passwords' && controller_name != 'registrations' %>
<%= link_to 'Forgot your password?', new_password_path(resource_name), class: 'link link-hover' %>
<% end %>
<%- if devise_mapping.confirmable? && controller_name != 'confirmations' %>
<%= link_to "Didn't receive confirmation instructions?", new_confirmation_path(resource_name), class: 'link link-hover' %>
<% end %>
<%- if devise_mapping.lockable? && resource_class.unlock_strategy_enabled?(:email) && controller_name != 'unlocks' %>
<%= link_to "Didn't receive unlock instructions?", new_unlock_path(resource_name), class: 'link link-hover' %>
<% end %>
</div>
+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" class="<%= local_assigns[:class] %>" width="44" height="44" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M3 12a9 9 0 1 0 18 0a9 9 0 0 0 -18 0" />
<path d="M12 7v5l3 3" />
</svg>

After

Width:  |  Height:  |  Size: 362 B

+9
View File
@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" class="<%= local_assigns[:class] %>" width="44" height="44" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M9 6l11 0" />
<path d="M9 12l11 0" />
<path d="M9 18l11 0" />
<path d="M5 6l0 .01" />
<path d="M5 12l0 .01" />
<path d="M5 18l0 .01" />
</svg>

After

Width:  |  Height:  |  Size: 440 B

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

After

Width:  |  Height:  |  Size: 409 B

+1 -1
View File
@@ -1,5 +1,5 @@
<div class="max-w-xl mx-auto px-2">
<h1 class="text-4xl font-bold text-center my-8">👋 Welcome to Docuseal</h1>
<h1 class="text-4xl font-bold text-center my-8">👋 Welcome to <%= Docuseal.product_name %></h1>
<%= form_for(resource, as: resource_name, url: password_path(resource_name), html: { method: :put, class: 'space-y-6' }) do |f| %>
<div class="space-y-2">
<%= render 'devise/shared/error_messages', resource: %>
+1
View File
@@ -8,6 +8,7 @@
<%= javascript_pack_tag 'application', defer: true %>
<%= stylesheet_pack_tag 'application', media: 'all' %>
<%= render 'shared/posthog' if ENV['POSTHOG_TOKEN'] %>
<%= render 'shared/plausible' if !signed_in? && ENV['PLAUSIBLE_DOMAIN'] %>
</head>
<body>
<turbo-frame id="modal"></turbo-frame>
+1
View File
@@ -21,6 +21,7 @@
<%= f.button button_title(title: 'Update', disabled_with: 'Updating'), class: 'base-button' %>
</div>
<% end %>
<%= render 'email_configs' %>
<p class="text-2xl font-bold mt-8 mb-4">Signature</p>
<% signature = UserConfigs.load_signature(current_user) %>
<% if signature %>
@@ -10,4 +10,4 @@
</div>
</div>
</div>
<%= render 'shared/attribution' %>
<%= render 'shared/attribution', link_path: '/start' %>
+1 -1
View File
@@ -1 +1 @@
<%= render 'shared/powered_by', with_counter: local_assigns[:with_counter] %>
<%= render 'shared/powered_by', with_counter: local_assigns[:with_counter], link_path: local_assigns[:link_path] %>
+1 -2
View File
@@ -3,8 +3,7 @@
<div class="w-full text-center">
<span class="font-bold">Demo Environment</span>
<br>
Feel free to
<a href="<%= new_template_path %>" data-turbo-frame="modal" class="inline underline font-medium">create a new template</a> document form or
<a href="<%= new_template_path %>" data-turbo-frame="modal" class="inline underline font-medium">Create a new template</a> document form or
<a href="<%= start_form_url(slug: ::Template.first&.slug) %>" target="_blank" class="inline underline font-medium">submit the existing one</a> 😊
</div>
</div>
+1 -1
View File
@@ -2,5 +2,5 @@
---
</p>
<p>
Sent using <a href="<%= Docuseal::PRODUCT_URL %>"><%= Docuseal::PRODUCT_NAME %></a> free document signing.
Sent using <a href="<%= Docuseal::PRODUCT_URL %>/start"><%= Docuseal.product_name %></a> free document signing.
</p>
+23 -20
View File
@@ -3,21 +3,22 @@
<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">
<% if Docuseal.demo? %>
<%= render 'shared/github_button' %>
<a href="https://docuseal.co/sign_up" class="btn btn-neutral btn-sm btn-outline inline-flex items-center justify-center" style="height: 37px">
Sign Up
</a>
<span class="hidden sm:inline">
<%= render 'shared/github_button' %>
</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 %>
@@ -58,18 +59,20 @@
</div>
</div>
<% else %>
<div class="flex space-x-6">
<%= link_to new_user_session_path, class: 'font-medium text-lg' do %>
<span class="flex items-center justify-center space-x-1">
<%= svg_icon('login', class: 'w-6 h-6') %>
<span>Sign in</span>
</span>
<% end %>
<% if Docuseal.multitenant? %>
<%= link_to registration_path, class: 'font-medium text-lg hidden md:inline' do %>
<div class="flex space-x-2">
<% if request.path != new_user_session_path %>
<%= link_to new_user_session_path, class: 'font-medium text-lg' do %>
<span class="flex items-center justify-center space-x-1">
<%= svg_icon('user_plus', class: 'w-6 h-6') %>
<span>Try for Free</span>
<%= svg_icon('login', class: 'w-6 h-6') %>
<span>Sign in</span>
</span>
<% end %>
<% end %>
<% 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 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>
+1 -1
View File
@@ -9,5 +9,5 @@
<% else %>
Powered by
<% end %>
<a href="<%= Docuseal::PRODUCT_URL %>" class="underline"><%= Docuseal::PRODUCT_NAME %></a> - open source documents software
<a href="<%= Docuseal::PRODUCT_URL %><%= local_assigns[:link_path] %>" class="underline"><%= Docuseal.product_name %></a> - open source documents software
</div>
+9 -6
View File
@@ -1,11 +1,14 @@
<form action="<%= request.path %>" method="get" class="items-center hidden md:flex">
<div class="relative">
<% if params[:q].present? %>
<a href="<%= request.path %>" title="Clear" class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-auto text-neutral text-2xl font-extralight">
<form action="<%= url_for %>" method="get" class="items-center hidden md:flex">
<% if params[:status].present? %>
<input name="status" value="<%= params[:status] %>" class="hidden">
<% end %>
<% if params[:q].present? %>
<div class="relative">
<a href="<%= url_for(params.to_unsafe_h.except(:q)) %>" title="Clear" class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-auto text-neutral text-2xl font-extralight">
&times;
</a>
<% end %>
</div>
</div>
<% end %>
<input id="search" name="q" value="<%= params[:q] %>" class="input input-ghost text-lg pr-10 -mr-12 <%= 'pl-8 input-outlined' if params[:q].present? %>">
<button type="submit" title="Search" class="btn btn-ghost btn-circle" onclick="window.search.value || document.activeElement === window.search ? null : [event.preventDefault(), window.search.focus()]">
<span class="enabled">
+25 -3
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,6 +42,13 @@
<%= link_to 'Team', settings_users_path, class: 'text-base hover:bg-base-300' %>
</li>
<% end %>
<% 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) %>
<li>
@@ -61,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>
+4 -1
View File
@@ -24,6 +24,9 @@
<%= 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>
</div>
</div>
<%= render 'shared/attribution' %>
<%= render 'shared/attribution', link_path: '/start' %>
+3 -3
View File
@@ -24,10 +24,10 @@
</div>
</div>
<% unless @template.deleted_at? %>
<%= form_for @submitter, url: start_form_path(@template.slug), data: { turbo_frame: :_top }, method: :put, html: { class: 'space-y-4' } do |f| %>
<%= 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], required: true, class: 'base-input', placeholder: 'Provide your email to start' %>
</div>
<div class="form-control">
<%= f.button button_title(title: 'Start', disabled_with: 'Starting'), class: 'base-button' %>
@@ -36,4 +36,4 @@
<% end %>
</div>
</div>
<%= render 'shared/attribution' %>
<%= render 'shared/attribution', link_path: '/start' %>
@@ -41,6 +41,7 @@
<%= f.text_area :body, value: config.value['body'], required: true, class: 'base-textarea w-full', rows: 10 %>
</autoresize-textarea>
</div>
<%= render 'message_fields' %>
</div>
</div>
</div>
+10 -5
View File
@@ -1,4 +1,4 @@
<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 %>%">
<div class="flex absolute <%= field['readonly'] ? 'text-[1.5vw] lg:text-xs' : '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' %>
@@ -11,9 +11,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">
@@ -28,8 +35,6 @@
<%= l(Date.parse(value), format: :long, locale: local_assigns[:locale]) %>
</div>
<% else %>
<div class="flex items-center px-0.5">
<%= Array.wrap(value).join(', ') %>
</div>
<div class="flex items-center px-0.5 whitespace-pre-wrap"><%= Array.wrap(value).join(', ') %></div>
<% end %>
</div>
+1
View File
@@ -23,4 +23,5 @@
<%= render 'detailed_form', template: @template %>
</div>
</div>
<%= content_for(:modal_extra) %>
<% end %>
+12 -8
View File
@@ -44,12 +44,16 @@
<% fields_index = Templates.build_field_areas_index(@submission.template_fields || @submission.template.fields) %>
<% values = @submission.submitters.reduce({}) { |acc, sub| acc.merge(sub.values) } %>
<% attachments_index = ActiveStorage::Attachment.where(record: @submission.submitters, name: :attachments).preload(:blob).index_by(&:uuid) %>
<% page_blob_struct = Struct.new(:url, :metadata, keyword_init: true) %>
<% (@submission.template_schema || @submission.template.schema).each do |item| %>
<% document = @submission.template_schema_documents.find { |e| e.uuid == item['attachment_uuid'] } %>
<% document_annots_index = document.metadata.dig('pdf', 'annotations')&.group_by { |e| e['page'] } || {} %>
<% document.preview_images.sort_by { |a| a.filename.base.to_i }.each_with_index do |page, index| %>
<% preview_images_index = document.preview_images.loaded? ? document.preview_images.index_by { |e| e.filename.base.to_i } : {} %>
<% lazyload_metadata = document.preview_images.first.metadata %>
<% (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 src="<%= page.url %>" width="<%= page.metadata['width'] %>" class="shadow-md mb-4" height="<%= page.metadata['height'] %>" loading="lazy">
<img loading="lazy" src="<%= page.url %>" width="<%= page.metadata['width'] %>" class="shadow-md 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: %>
@@ -57,7 +61,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>
@@ -105,18 +109,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
@@ -156,7 +160,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 %>
+3 -1
View File
@@ -1 +1,3 @@
<%= render 'docuseal_logo' %>
<div class="flex mt-4">
<%= render 'docuseal_logo' %>
</div>
@@ -1,4 +1,4 @@
<a href="<%= root_path %>" class="mx-auto text-2xl md:text-3xl font-bold items-center flex space-x-3">
<%= render 'shared/logo', class: 'w-9 h-9 md:w-12 md:h-12' %>
<span><%= Docuseal::PRODUCT_NAME %></span>
<span><%= Docuseal.product_name %></span>
</a>
+1 -1
View File
@@ -18,4 +18,4 @@
</div>
</div>
</div>
<%= render 'shared/attribution' %>
<%= render 'shared/attribution', link_path: '/start' %>
+12 -2
View File
@@ -19,7 +19,11 @@
<div>
<% if Accounts.can_send_emails?(@submitter.submission.template.account) && @submitter.email.present? %>
<%= 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: 'white-button w-full' %>
<div class="divider">OR</div>
<% if @submitter.submission.template.submitters.size != 1 %>
<div class="divider">OR</div>
<% else %>
<div class="py-2"></div>
<% end %>
<% end %>
<download-button data-src="<%= submitter_download_index_path(@submitter.slug) %>" class="base-button w-full">
<span class="flex items-center justify-center space-x-2" data-target="download-button.defaultButton">
@@ -32,6 +36,12 @@
</span>
</download-button>
</div>
<% if @submitter.submission.template.submitters.size == 1 %>
<div class="divider">OR</div>
<div>
<%= button_to button_title(title: 'Resubmit', disabled_with: 'Resubmit', icon: svg_icon('reload', class: 'w-6 h-6')), start_form_path(@submitter.submission.template.slug), params: { submitter: { email: @submitter.email, phone: @submitter.phone, name: @submitter.name }, resubmit: true }, method: :put, form: { onsubmit: 'event.submitter.disabled = true' }, class: 'white-button w-full' %>
</div>
<% end %>
</div>
</div>
<%= render 'shared/attribution' %>
<%= render 'shared/attribution', link_path: '/start' %>

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