Compare commits

...
70 Commits
Author SHA1 Message Date
Pete Matsyburka 13f76c9944 adjust submission title link 2023-11-26 00:33:05 +02:00
Pete Matsyburka ce4e55f387 autoadjust field value font size 2023-11-26 00:33:02 +02:00
Pete Matsyburka 712107ba5a limit manual email sending in cloud 2023-11-25 13:36:37 +02:00
Pete Matsyburka 1aa867893e fix bcc address removal 2023-11-24 23:22:04 +02:00
Pete Matsyburka 4f6142f9c0 adjust form builder 2023-11-24 23:09:39 +02:00
Pete Matsyburka c303349666 fix default builder fields 2023-11-24 17:34:57 +02:00
Pete Matsyburka e85df170f2 fix connection to DB URL with underscores 2023-11-24 17:17:56 +02:00
Pete Matsyburka 01333e5b2e preserve submitter preferences 2023-11-23 20:03:52 +02:00
Pete Matsyburka 514987138a adjust readme 2023-11-23 16:00:08 +02:00
Pete Matsyburka 0814c6d065 adjust submissions logo 2023-11-22 22:15:10 +02:00
Pete Matsyburka 542a443fb8 add custom exceptions handling controller 2023-11-22 01:41:39 +02:00
Pete Matsyburka f2c6bb1c3d add submission order param 2023-11-22 01:04:47 +02:00
Alex Turchyn b85c9cfdb7 adjust form builder 2023-11-21 23:16:34 +02:00
Pete Matsyburka e07b50c22d fix undefined method 'field_value' 2023-11-21 13:08:40 +02:00
Pete Matsyburka 4dde9890e0 update deps 2023-11-20 17:28:31 +02:00
Pete Matsyburka 66d1ae2f64 link to submission preview page in completed emails 2023-11-20 01:00:00 +02:00
Pete Matsyburka d130835902 skip annots on error 2023-11-20 00:04:20 +02:00
Pete Matsyburka 52f66368a3 adjust submit for banner 2023-11-18 20:41:29 +02:00
DocuSeal 89d0810625 adjust signature step 2023-11-18 13:57:52 +02:00
Pete Matsyburka 06d3d1202b fix default form value 2023-11-18 12:28:51 +02:00
DocuSeal 0698aad8e1 adjust email configs loading 2023-11-17 18:47:08 +02:00
DocuSeal 3575a25935 fix default values assignment 2023-11-17 18:45:17 +02:00
DocuSeal cdd034e72d redirect with google oauth 2023-11-17 02:27:32 +02:00
Pete Matsyburka 26f7d5cf0d fix submissions destroy action 2023-11-16 01:55:57 +02:00
Pete Matsyburka 3c69430b94 fix email friendly name 2023-11-15 23:42:48 +02:00
Pete Matsyburka c0162bccc8 fix radio field type 2023-11-15 19:48:51 +02:00
Pete Matsyburka 1adb800f94 fix default values 2023-11-15 16:19:00 +02:00
Pete Matsyburka 333a5d6b7b add ability to draw areas for radio options 2023-11-14 01:30:00 +02:00
Pete Matsyburka 9aa68fe955 fix cell field length 2023-11-14 00:32:48 +02:00
Pete Matsyburka 46af87d89a mark field as submitter when navigating with areas 2023-11-13 23:13:49 +02:00
Pete Matsyburka 1066131143 use hash for field options 2023-11-13 01:56:50 +02:00
Pete Matsyburka a25022532e update default date format 2023-11-12 22:28:47 +02:00
Pete Matsyburka 5162f9a9dc remove redundant jwt rescue 2023-11-12 18:54:24 +02:00
Pete Matsyburka cc5694de43 set max length for cells form input 2023-11-12 00:24:58 +02:00
Pete Matsyburka f734ba280d fix cell resize 2023-11-12 00:24:58 +02:00
Pete Matsyburka 500d0da563 make phone field always required 2023-11-12 00:24:57 +02:00
Pete Matsyburka 6626a2b602 add default builder fields 2023-11-11 23:36:03 +02:00
Pete Matsyburka 07e66a6e53 adjust images preview loading in builder 2023-11-11 21:16:29 +02:00
Pete Matsyburka deef9c7d3b add FR time format locale 2023-11-11 15:28:43 +02:00
Pete Matsyburka 2ec8954dc8 add embedding to settings 2023-11-11 11:56:54 +02:00
Pete Matsyburka c1af803e5b adjust parse email 2023-11-11 02:43:39 +02:00
Pete Matsyburka 10abdbeba4 remove plausible from self-host 2023-11-11 02:43:39 +02:00
DocuSeal 1dbde7a6d8 add withUploadButton builder prop 2023-11-11 02:43:29 +02:00
Pete Matsyburka a27eb18670 add api link to settings 2023-11-09 01:20:28 +02:00
DocuSeal 44a49a4533 normalize file urls 2023-11-08 15:25:19 +02:00
DocuSeal fc11078cae adjust font size if long text 2023-11-07 11:47:26 +02:00
Pete Matsyburka a42ae0b39e fix file upload field 2023-11-07 02:28:06 +02:00
Pete Matsyburka 3163cdc761 fix builder tap on mobile 2023-11-06 22:08:49 +02:00
Pete Matsyburka 5439c63ce7 hide mobile field draw box 2023-11-06 21:23:46 +02:00
Pete Matsyburka b78287242b fix previous submitter values on the form 2023-11-06 20:52:59 +02:00
Alex Turchyn b869ae38b3 allow to add multiple submitters on mobile 2023-11-06 20:50:29 +02:00
DocuSeal 4a320e9ba6 pass name on resubmit 2023-11-06 13:45:15 +02:00
DocuSeal 0025928a35 fix replace default value variable 2023-11-06 13:40:40 +02:00
DocuSeal c227a1988a fix api pagination limit 2023-11-06 13:18:16 +02:00
Pete Matsyburka 3ade45f541 add field draw button if without areas 2023-11-05 16:31:32 +02:00
Pete Matsyburka 17da8bde80 add upgrade button 2023-11-05 16:31:13 +02:00
Pete Matsyburka 59d9a59769 add field default values 2023-11-05 01:41:54 +02:00
Pete Matsyburka 7210badde9 drag and drop fields to order 2023-11-04 14:06:59 +02:00
DocuSeal 21659a73f3 adjust new template form 2023-11-04 01:31:27 +02:00
DocuSeal c0e49ebeea add builder editable prop 2023-11-04 01:19:32 +02:00
DocuSeal 2d24eac4a3 fix completed submissions scope query 2023-11-02 20:32:51 +02:00
DocuSeal 14c3c8181b add template application key 2023-11-02 19:33:01 +02:00
DocuSeal 036f6049a2 Revert "add new signup page"
This reverts commit f7132445a1.
2023-11-02 13:37:57 +02:00
DocuSeal 236e4cb35f fix typo 2023-11-01 21:32:56 +02:00
DocuSeal ee77cb0982 use stronger api token 2023-11-01 15:20:39 +02:00
DocuSeal d474ad05e5 prefill shared link with email 2023-11-01 14:10:01 +02:00
DocuSeal dbf1fa37c5 adjust completed webhook sending 2023-11-01 13:11:42 +02:00
DocuSeal 7444bb8b68 add audit log url to api and webhooks 2023-11-01 12:17:35 +02:00
Alex Turchyn f7132445a1 add new signup page 2023-11-01 02:17:56 +02:00
Alex Turchyn 0127dd738e fix navbar mobile layout 2023-10-30 23:03:52 +02:00
102 changed files with 1772 additions and 479 deletions

No files matched your search

+2 -2
View File
@@ -246,7 +246,7 @@ GEM
hashdiff (1.0.1) hashdiff (1.0.1)
hashery (2.1.2) hashery (2.1.2)
hashie (5.0.0) hashie (5.0.0)
hexapdf (0.33.0) hexapdf (0.34.1)
cmdparse (~> 3.0, >= 3.0.3) cmdparse (~> 3.0, >= 3.0.3)
geom2d (~> 0.4, >= 0.4.1) geom2d (~> 0.4, >= 0.4.1)
openssl (>= 2.2.1) openssl (>= 2.2.1)
@@ -339,7 +339,7 @@ GEM
omniauth-rails_csrf_protection (1.0.1) omniauth-rails_csrf_protection (1.0.1)
actionpack (>= 4.2) actionpack (>= 4.2)
omniauth (~> 2.0) omniauth (~> 2.0)
openssl (3.1.0) openssl (3.2.0)
orm_adapter (0.5.0) orm_adapter (0.5.0)
os (1.1.4) os (1.1.4)
pagy (6.0.4) pagy (6.0.4)
+3 -3
View File
@@ -34,10 +34,10 @@ DocuSeal is an open source platform that provides secure and efficient digital d
## Features ## Features
- [x] PDF form fields builder (WYSIWYG) - [x] PDF form fields builder (WYSIWYG)
- [x] 10 field types available (Signature, Date, File, Checkbox etc.) - [x] 11 field types available (Signature, Date, File, Checkbox etc.)
- [x] Multiple submitters per document - [x] Multiple submitters per document
- [x] Automated emails via SMTP - [x] Automated emails via SMTP
- [x] Files storage on AWS S3, Google Storage, or Azure - [x] Files storage on disk or AWS S3, Google Storage, Azure Cloud
- [x] Automatic PDF eSignature - [x] Automatic PDF eSignature
- [x] PDF signature verification - [x] PDF signature verification
- [x] Users management - [x] Users management
@@ -76,7 +76,7 @@ Run the app under a custom domain over https using docker compose (make sure you
HOST=your-domain-name.com docker-compose up HOST=your-domain-name.com docker-compose up
``` ```
## For Companies ## For Businesses
### Integrate seamless document signing into your web or mobile apps with DocuSeal! ### Integrate seamless document signing into your web or mobile apps with DocuSeal!
At DocuSeal we have expertise and technologies to make documents creation, filling, signing and processing seamlessly integrated with your product. We specialize in working with various industries, including **Banking, Healthcare, Transport, Real Estate, eCommerce, KYC, CRM, and other software products** that require bulk document signing. By leveraging DocuSeal, we can assist in reducing the overall cost of developing and processing electronic documents while ensuring security and compliance with local electronic document laws. At DocuSeal we have expertise and technologies to make documents creation, filling, signing and processing seamlessly integrated with your product. We specialize in working with various industries, including **Banking, Healthcare, Transport, Real Estate, eCommerce, KYC, CRM, and other software products** that require bulk document signing. By leveraging DocuSeal, we can assist in reducing the overall cost of developing and processing electronic documents while ensuring security and compliance with local electronic document laws.
+1
View File
@@ -4,6 +4,7 @@ class AccountsController < ApplicationController
LOCALE_OPTIONS = { LOCALE_OPTIONS = {
'en-US' => 'English (United States)', 'en-US' => 'English (United States)',
'en-GB' => 'English (United Kingdom)', 'en-GB' => 'English (United Kingdom)',
'fr-FR' => 'French (France)',
'es-ES' => 'Spanish (Spain)', 'es-ES' => 'Spanish (Spain)',
'pt-PT' => 'Portuguese (Portugal)', 'pt-PT' => 'Portuguese (Portugal)',
'de-DE' => 'German (Germany)' 'de-DE' => 'German (Germany)'
+1 -1
View File
@@ -25,7 +25,7 @@ module Api
def paginate(relation) def paginate(relation)
result = relation.order(id: :desc) 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[:after]) if params[:after].present?
result = result.where('id > ?', params[:before]) if params[:before].present? result = result.where('id > ?', params[:before]) if params[:before].present?
+51 -33
View File
@@ -3,7 +3,7 @@
module Api module Api
class SubmissionsController < ApiBaseController class SubmissionsController < ApiBaseController
load_and_authorize_resource :template, only: :create 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 before_action only: :create do
authorize!(:create, Submission) authorize!(:create, Submission)
@@ -13,7 +13,8 @@ module Api
submissions = Submissions.search(@submissions, params[:q]) submissions = Submissions.search(@submissions, params[:q])
submissions = submissions.where(template_id: params[:template_id]) if params[:template_id].present? 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: { render json: {
data: submissions.as_json(serialize_params), data: submissions.as_json(serialize_params),
@@ -49,35 +50,14 @@ module Api
end end
def create def create
is_send_email = !params[:send_email].in?(['false', false]) params[:send_email] = true unless params.key?(:send_email)
params[:send_sms] = false unless params.key?(:send_sms)
submissions = submissions = create_submissions(@template, params)
if (emails = (params[:emails] || params[:email]).presence) && params[:submission].blank?
Submissions.create_from_emails(template: @template,
user: current_user,
source: :api,
mark_as_sent: is_send_email,
emails:)
else
submissions_attrs, attachments = normalize_submissions_params!(submissions_params[:submission], @template)
Submissions.create_from_submitters( Submissions.send_signature_requests(submissions)
template: @template,
user: current_user,
source: :api,
mark_as_sent: is_send_email,
submitters_order: params[:submitters_order] || 'preserved',
submissions_attrs:
)
end
Submissions.send_signature_requests(submissions, send_email: is_send_email) render json: submissions.flat_map(&:submitters)
submitters = submissions.flat_map(&:submitters)
save_default_value_attachments!(attachments, submitters)
render json: submitters
rescue Submitters::NormalizeValues::UnknownFieldName, Submitters::NormalizeValues::UnknownSubmitterName => e rescue Submitters::NormalizeValues::UnknownFieldName, Submitters::NormalizeValues::UnknownSubmitterName => e
render json: { error: e.message }, status: :unprocessable_entity render json: { error: e.message }, status: :unprocessable_entity
end end
@@ -90,9 +70,39 @@ module Api
private private
def create_submissions(template, params)
is_send_email = !params[:send_email].in?(['false', false])
if (emails = (params[:emails] || params[:email]).presence) && params[:submission].blank?
Submissions.create_from_emails(template:,
user: current_user,
source: :api,
mark_as_sent: is_send_email,
emails:,
params:)
else
submissions_attrs, attachments = normalize_submissions_params!(submissions_params, template)
submissions = Submissions.create_from_submitters(
template:,
user: current_user,
source: :api,
mark_as_sent: is_send_email,
submitters_order: params[:submitters_order] || params[:order] || 'preserved',
submissions_attrs:,
params:
)
save_default_value_attachments!(attachments, submissions.flat_map(&:submitters))
submissions
end
end
def serialize_params def serialize_params
{ {
only: %i[id source submitters_order created_at updated_at], only: %i[id source submitters_order created_at updated_at],
methods: %i[audit_log_url],
include: { include: {
submitters: { only: %i[id slug uuid name email phone submitters: { only: %i[id slug uuid name email phone
completed_at opened_at sent_at completed_at opened_at sent_at
@@ -105,11 +115,19 @@ module Api
end end
def submissions_params def submissions_params
params.permit(submission: [{ key = params.key?(:submission) ? :submission : :submissions
submitters: [[:uuid, :name, :email, :role, :completed, :phone, :application_key,
{ values: {}, readonly_fields: [], params.permit(
fields: [%i[name default_value readonly validation_pattern invalid_message]] }]] key => [
}]) [:send_email, :send_sms, {
message: %i[subject body],
submitters: [[:send_email, :send_sms, :uuid, :name, :email, :role,
:completed, :phone, :application_key,
{ values: {}, readonly_fields: [], message: %i[subject body],
fields: [%i[name default_value readonly validation_pattern invalid_message]] }]]
}]
]
).fetch(key, [])
end end
def normalize_submissions_params!(submissions_params, template) def normalize_submissions_params!(submissions_params, template)
+8 -5
View File
@@ -8,6 +8,7 @@ module Api
templates = Templates.search(@templates, params[:q]) templates = Templates.search(@templates, params[:q])
templates = params[:archived] ? templates.archived : templates.active 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)) templates = paginate(templates.preload(:author, documents_attachments: :blob))
@@ -51,11 +52,13 @@ module Api
end end
def template_params def template_params
params.require(:template).permit(:name, params.require(:template).permit(
schema: [%i[attachment_uuid name]], :name,
submitters: [%i[name uuid]], schema: [%i[attachment_uuid name]],
fields: [[:uuid, :submitter_uuid, :name, :type, :required, submitters: [%i[name uuid]],
{ options: [], areas: [%i[x y w h cell_w attachment_uuid page]] }]]) 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 end
end end
+35
View File
@@ -0,0 +1,35 @@
# frozen_string_literal: true
class ErrorsController < ActionController::Base
ENTERPRISE_FEATURE_MESSAGE =
'This feature is available in Enterprise Edition: https://www.docuseal.co/pricing'
ENTERPRISE_PATHS = [
'/templates/html',
'/api/templates/html',
'/templates/pdf',
'/api/templates/pdf'
].freeze
def show
if request.original_fullpath.in?(ENTERPRISE_PATHS)
return render json: { status: 404, message: ENTERPRISE_FEATURE_MESSAGE }, status: :not_found
end
respond_to do |f|
f.json do
render json: { status: error_status_code }, status: error_status_code
end
f.html { render error_status_code.to_s, status: error_status_code }
end
end
private
def error_status_code
@error_status_code ||=
ActionDispatch::ExceptionWrapper.new(request.env,
request.env['action_dispatch.exception']).status_code
end
end
@@ -12,7 +12,7 @@ class NotificationsSettingsController < ApplicationController
def index; end def index; end
def create def create
if @account_config.save if @account_config.value.present? ? @account_config.save : @account_config.delete
redirect_back fallback_location: settings_notifications_path, notice: 'Changes have been saved' redirect_back fallback_location: settings_notifications_path, notice: 'Changes have been saved'
else else
redirect_back fallback_location: settings_notifications_path, alert: 'Unable to save' redirect_back fallback_location: settings_notifications_path, alert: 'Unable to save'
@@ -23,7 +23,7 @@ class PreviewDocumentPageController < ActionController::API
io = Templates::ProcessDocument.generate_pdf_preview_from_file(attachment, file_path, params[:id].to_i) io = Templates::ProcessDocument.generate_pdf_preview_from_file(attachment, file_path, params[:id].to_i)
render plain: io.tap(&:rewind).read render plain: io.tap(&:rewind).read, content_type: 'image/jpeg'
end end
def find_or_create_document_tempfile_path(attachment) def find_or_create_document_tempfile_path(attachment)
+3 -2
View File
@@ -23,7 +23,8 @@ class StartFormController < ApplicationController
@submitter.assign_attributes( @submitter.assign_attributes(
uuid: @template.submitters.first['uuid'], uuid: @template.submitters.first['uuid'],
ip: request.remote_ip, ip: request.remote_ip,
ua: request.user_agent ua: request.user_agent,
preferences: { 'send_email' => true }
) )
@submitter.submission ||= Submission.new(template: @template, @submitter.submission ||= Submission.new(template: @template,
@@ -47,7 +48,7 @@ class StartFormController < ApplicationController
private private
def submitter_params def submitter_params
params.require(:submitter).permit(:email, :phone).tap do |attrs| params.require(:submitter).permit(:email, :phone, :name).tap do |attrs|
attrs[:email] = Submissions.normalize_email(attrs[:email]) attrs[:email] = Submissions.normalize_email(attrs[:email])
end end
end end
+10 -3
View File
@@ -34,23 +34,30 @@ class SubmissionsController < ApplicationController
def create def create
authorize!(:create, Submission) authorize!(:create, Submission)
if params[:is_custom_message] != '1'
params.delete(:subject)
params.delete(:body)
end
submissions = submissions =
if params[:emails].present? if params[:emails].present?
Submissions.create_from_emails(template: @template, Submissions.create_from_emails(template: @template,
user: current_user, user: current_user,
source: :invite, source: :invite,
mark_as_sent: params[:send_email] == '1', mark_as_sent: params[:send_email] == '1',
emails: params[:emails]) emails: params[:emails],
params:)
else else
Submissions.create_from_submitters(template: @template, Submissions.create_from_submitters(template: @template,
user: current_user, user: current_user,
source: :invite, source: :invite,
submitters_order: params[:preserve_order] == '1' ? 'preserved' : 'random', submitters_order: params[:preserve_order] == '1' ? 'preserved' : 'random',
mark_as_sent: params[:send_email] == '1', mark_as_sent: params[:send_email] == '1',
submissions_attrs: submissions_params[:submission].to_h.values) submissions_attrs: submissions_params[:submission].to_h.values,
params:)
end end
Submissions.send_signature_requests(submissions, params) Submissions.send_signature_requests(submissions)
redirect_to template_path(@template), notice: 'New recipients have been added' redirect_to template_path(@template), notice: 'New recipients have been added'
end 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
@@ -4,6 +4,13 @@ class SubmittersSendEmailController < ApplicationController
load_and_authorize_resource :submitter, id_param: :submitter_slug, find_by: :slug load_and_authorize_resource :submitter, id_param: :submitter_slug, find_by: :slug
def create def create
if Docuseal.multitenant? && SubmissionEvent.exists?(submitter: @submitter,
event_type: 'send_email',
created_at: 24.hours.ago..Time.current)
return redirect_back(fallback_location: submission_path(@submitter.submission),
alert: 'Email has been sent already.')
end
SubmitterMailer.invitation_email(@submitter).deliver_later! SubmitterMailer.invitation_email(@submitter).deliver_later!
SubmissionEvent.create!(submitter: @submitter, event_type: 'send_email') SubmissionEvent.create!(submitter: @submitter, event_type: 'send_email')
@@ -35,7 +35,7 @@ class TemplatesUploadsController < ApplicationController
def create_file_params_from_url def create_file_params_from_url
tempfile = Tempfile.new tempfile = Tempfile.new
tempfile.binmode tempfile.binmode
tempfile.write(conn.get(params[:url]).body) tempfile.write(conn.get(Addressable::URI.parse(params[:url]).display_uri.to_s).body)
tempfile.rewind tempfile.rewind
file = ActionDispatch::Http::UploadedFile.new( file = ActionDispatch::Http::UploadedFile.new(
+2
View File
@@ -79,6 +79,8 @@ window.customElements.define('template-builder', class extends HTMLElement {
connectedCallback () { connectedCallback () {
this.appElem = document.createElement('div') this.appElem = document.createElement('div')
this.appElem.classList.add('md:h-screen')
this.app = createApp(TemplateBuilder, { this.app = createApp(TemplateBuilder, {
template: reactive(JSON.parse(this.dataset.template)), template: reactive(JSON.parse(this.dataset.template)),
backgroundColor: '#faf7f5', backgroundColor: '#faf7f5',
+15 -1
View File
@@ -1,7 +1,21 @@
export default class extends HTMLElement { export default class extends HTMLElement {
connectedCallback () { connectedCallback () {
if (this.dataset.inputId) { 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)
}
} }
+79 -4
View File
@@ -1,11 +1,11 @@
<template> <template>
<div <div
class="flex absolute text-[1.5vw] lg:text-base" class="flex absolute lg:text-base"
:style="computedStyle" :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 <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" class="absolute top-0 bottom-0 right-0 left-0 items-center justify-center h-full w-full"
> >
<span <span
@@ -21,7 +21,7 @@
</span> </span>
</div> </div>
<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" class="absolute -top-7 rounded bg-base-content text-base-100 px-2 text-sm whitespace-nowrap"
> >
{{ field.name || fieldNames[field.type] }} {{ 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 }" :class="{ '!w-auto !h-full': area.w > area.h, '!w-full !h-auto': area.w <= area.h }"
/> />
</div> </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 <div
v-else-if="field.type === 'cells'" v-else-if="field.type === 'cells'"
class="w-full flex items-center" class="w-full flex items-center"
@@ -102,6 +140,7 @@
</div> </div>
<div <div
v-else v-else
ref="textContainer"
class="flex items-center px-0.5" class="flex items-center px-0.5"
> >
<span v-if="Array.isArray(modelValue)"> <span v-if="Array.isArray(modelValue)">
@@ -173,6 +212,11 @@ export default {
} }
}, },
emits: ['update:model-value'], emits: ['update:model-value'],
data () {
return {
textOverflowChars: 0
}
},
computed: { computed: {
fieldNames () { fieldNames () {
return { return {
@@ -251,6 +295,37 @@ export default {
height: h * 100 + '%' 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> </script>
@@ -1,8 +1,8 @@
<template> <template>
<div> <div>
<div v-if="modelValue.length"> <div v-if="value.length">
<div <div
v-for="(val, index) in modelValue" v-for="(val, index) in value"
:key="index" :key="index"
class="flex mb-2" class="flex mb-2"
> >
@@ -26,10 +26,7 @@
{{ attachmentsIndex[val].filename }} {{ attachmentsIndex[val].filename }}
</span> </span>
</a> </a>
<button <button @click.prevent="removeAttachment(val)">
v-if="modelValue"
@click.prevent="removeAttachment(val)"
>
<IconTrashX <IconTrashX
:width="18" :width="18"
:heigh="19" :heigh="19"
@@ -91,18 +88,28 @@ export default {
} }
}, },
emits: ['attached', 'update:model-value'], emits: ['attached', 'update:model-value'],
computed: {
value: {
set (value) {
this.$emit('update:model-value', this.modelValue || [])
},
get () {
return this.modelValue || []
}
}
},
methods: { methods: {
removeAttachment (uuid) { 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) { onUpload (attachments) {
attachments.forEach((attachment) => { attachments.forEach((attachment) => {
this.$emit('attached', 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)])
} }
} }
} }
+27 -15
View File
@@ -99,12 +99,12 @@
{{ t('select_your_option') }} {{ t('select_your_option') }}
</option> </option>
<option <option
v-for="(option, index) in currentField.options" v-for="option in currentField.options"
:key="index" :key="option.uuid"
:selected="values[currentField.uuid] == option" :selected="values[currentField.uuid] == option.value"
:value="option" :value="option.value"
> >
{{ option }} {{ option.value }}
</option> </option>
</select> </select>
</div> </div>
@@ -119,24 +119,24 @@
<div class="flex w-full"> <div class="flex w-full">
<div class="space-y-3.5 mx-auto"> <div class="space-y-3.5 mx-auto">
<div <div
v-for="(option, index) in currentField.options" v-for="option in currentField.options"
:key="index" :key="option.uuid"
> >
<label <label
:for="currentField.uuid + option" :for="option.uuid"
class="flex items-center space-x-3" class="flex items-center space-x-3"
> >
<input <input
:id="currentField.uuid + option" :id="option.uuid"
v-model="values[currentField.uuid]" v-model="values[currentField.uuid]"
type="radio" type="radio"
class="base-radio !h-7 !w-7" class="base-radio !h-7 !w-7"
:name="`values[${currentField.uuid}]`" :name="`values[${currentField.uuid}]`"
:value="option" :value="option.value"
:required="currentField.required" :required="currentField.required"
> >
<span class="text-xl"> <span class="text-xl">
{{ option }} {{ option.value }}
</span> </span>
</label> </label>
</div> </div>
@@ -513,9 +513,15 @@ export default {
mounted () { mounted () {
this.submittedValues = JSON.parse(JSON.stringify(this.values)) 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) { if (this.goToLast) {
const requiredEmptyStepIndex = this.stepFields.indexOf(this.stepFields.find((fields) => fields.some((f) => f.required && !this.values[f.uuid]))) 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.values[f.uuid]))) + 1 const lastFilledStepIndex = this.stepFields.indexOf([...this.stepFields].reverse().find((fields) => fields.some((f) => !!this.submittedValues[f.uuid]))) + 1
const indexesList = [this.stepFields.length - 1] const indexesList = [this.stepFields.length - 1]
@@ -635,12 +641,20 @@ export default {
}) })
}, },
saveStep (formData) { saveStep (formData) {
const currentFieldUuid = this.currentField.uuid
if (this.isCompleted) { if (this.isCompleted) {
return Promise.resolve({}) return Promise.resolve({})
} else { } else {
return fetch(this.baseUrl + this.submitPath, { return fetch(this.baseUrl + this.submitPath, {
method: 'POST', method: 'POST',
body: formData || new FormData(this.$refs.form) body: formData || new FormData(this.$refs.form)
}).then((response) => {
if (response.status === 200) {
this.submittedValues[currentFieldUuid] = this.values[currentFieldUuid]
}
return response
}) })
} }
}, },
@@ -672,8 +686,6 @@ export default {
return Promise.reject(new Error(data.error)) return Promise.reject(new Error(data.error))
} }
this.submittedValues[this.currentField.uuid] = this.values[this.currentField.uuid]
if (isLastStep) { if (isLastStep) {
this.isSecondWalkthrough = true this.isSecondWalkthrough = true
} }
+45
View File
@@ -32,6 +32,11 @@ const en = {
please_fill_all_required_fields: 'Please fill all required fields', please_fill_all_required_fields: 'Please fill all required fields',
set_today: 'Set Today', set_today: 'Set Today',
toggle_multiline_text: 'Toggle Multiline Text', toggle_multiline_text: 'Toggle Multiline Text',
draw_signature: 'Draw signature',
type_initial: 'Type initials',
draw: 'Draw',
type: 'Type',
type_text: 'Type text',
date: 'Date', date: 'Date',
email_has_been_sent: 'Email has been sent' email_has_been_sent: 'Email has been sent'
} }
@@ -71,6 +76,11 @@ const es = {
set_today: 'Establecer Hoy', set_today: 'Establecer Hoy',
date: 'Fecha', date: 'Fecha',
toggle_multiline_text: 'Alternar Texto Multilínea', 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' email_has_been_sent: 'El correo electrónico ha sido enviado'
} }
@@ -108,6 +118,11 @@ const it = {
please_fill_all_required_fields: 'Si prega di compilare tutti i campi obbligatori', please_fill_all_required_fields: 'Si prega di compilare tutti i campi obbligatori',
set_today: 'Imposta Oggi', set_today: 'Imposta Oggi',
date: 'Data', date: 'Data',
draw_signature: 'Disegna firma',
type_initial: 'Inserisci iniziali',
draw: 'Disegna',
type: 'Inserisci',
type_text: 'Inserisci testo',
toggle_multiline_text: 'Attiva Testo Multilinea', toggle_multiline_text: 'Attiva Testo Multilinea',
email_has_been_sent: "L'email è stata inviata" email_has_been_sent: "L'email è stata inviata"
} }
@@ -146,6 +161,11 @@ const de = {
please_fill_all_required_fields: 'Bitte füllen Sie alle erforderlichen Felder aus', please_fill_all_required_fields: 'Bitte füllen Sie alle erforderlichen Felder aus',
set_today: 'Heute einstellen', set_today: 'Heute einstellen',
date: 'Datum', date: 'Datum',
draw_signature: 'Unterschrift zeichnen',
type_initial: 'Initialen eingeben',
draw: 'Zeichnen',
type: 'Eingeben',
type_text: 'Text eingeben',
toggle_multiline_text: 'Mehrzeiligen Text umschalten', toggle_multiline_text: 'Mehrzeiligen Text umschalten',
email_has_been_sent: 'Die E-Mail wurde gesendet' email_has_been_sent: 'Die E-Mail wurde gesendet'
} }
@@ -184,6 +204,11 @@ const fr = {
please_fill_all_required_fields: 'Veuillez remplir tous les champs obligatoires', please_fill_all_required_fields: 'Veuillez remplir tous les champs obligatoires',
set_today: "Définir Aujourd'hui", set_today: "Définir Aujourd'hui",
date: 'Date', 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', toggle_multiline_text: 'Basculer le Texte Multiligne',
email_has_been_sent: "L'email a été envoyé" email_has_been_sent: "L'email a été envoyé"
} }
@@ -222,6 +247,11 @@ const pl = {
please_fill_all_required_fields: 'Proszę wypełnić wszystkie wymagane pola', please_fill_all_required_fields: 'Proszę wypełnić wszystkie wymagane pola',
set_today: 'Ustaw Dziś', set_today: 'Ustaw Dziś',
date: 'Data', 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', toggle_multiline_text: 'Przełącz Tekst Wielolinijkowy',
email_has_been_sent: 'E-mail został wysłany' email_has_been_sent: 'E-mail został wysłany'
} }
@@ -260,6 +290,11 @@ const uk = {
please_fill_all_required_fields: "Будь ласка, заповніть всі обов'язкові поля", please_fill_all_required_fields: "Будь ласка, заповніть всі обов'язкові поля",
set_today: 'Задати Сьогодні', set_today: 'Задати Сьогодні',
date: 'Дата', date: 'Дата',
draw_signature: 'Намалюйте підпис',
type_initial: 'Введіть ініціали',
draw: 'Підпис',
type: 'Текст',
type_text: 'Введіть текст',
toggle_multiline_text: 'Перемкнути Багаторядковий Текст', toggle_multiline_text: 'Перемкнути Багаторядковий Текст',
email_has_been_sent: 'Електронний лист був відправлений' email_has_been_sent: 'Електронний лист був відправлений'
} }
@@ -298,6 +333,11 @@ const cs = {
please_fill_all_required_fields: 'Prosím vyplňte všechny povinné položky', please_fill_all_required_fields: 'Prosím vyplňte všechny povinné položky',
set_today: 'Nastavit Dnes', set_today: 'Nastavit Dnes',
date: 'Datum', 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', toggle_multiline_text: 'Přepnout Víceřádkový Text',
email_has_been_sent: 'E-mail byl odeslán' email_has_been_sent: 'E-mail byl odeslán'
} }
@@ -336,6 +376,11 @@ const pt = {
please_fill_all_required_fields: 'Por favor, preencha todos os campos obrigatórios', please_fill_all_required_fields: 'Por favor, preencha todos os campos obrigatórios',
set_today: 'Definir Hoje', set_today: 'Definir Hoje',
date: 'Data', date: 'Data',
draw_signature: 'Desenhar assinatura',
type_initial: 'Inserir iniciais',
draw: 'Desenhar',
type: 'Inserir',
type_text: 'Inserir texto',
toggle_multiline_text: 'Alternar Texto Multilinha', toggle_multiline_text: 'Alternar Texto Multilinha',
email_has_been_sent: 'Email enviado' email_has_been_sent: 'Email enviado'
} }
@@ -6,23 +6,43 @@
>{{ field.name || t('initials') }}</label> >{{ field.name || t('initials') }}</label>
<div class="space-x-2 flex"> <div class="space-x-2 flex">
<span <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" class="tooltip"
:data-tip="t('draw_initials')" :data-tip="t('draw_initials')"
> >
<a <a
id="type_text_button" id="type_text_button"
href="#" href="#"
class="btn btn-sm btn-circle" class="btn btn-outline font-medium btn-sm"
:class="{ 'btn-neutral': isDrawInitials, 'btn-outline': !isDrawInitials }"
@click.prevent="toggleTextInput" @click.prevent="toggleTextInput"
> >
<IconSignature :width="16" /> <IconSignature :width="16" />
<span class="hidden sm:inline">
{{ t('draw') }}
</span>
</a> </a>
</span> </span>
<a <a
v-if="modelValue || computedPreviousValue" v-if="modelValue || computedPreviousValue"
href="#" href="#"
class="btn btn-outline btn-sm" class="btn font-medium btn-outline btn-sm"
@click.prevent="remove" @click.prevent="remove"
> >
<IconReload :width="16" /> <IconReload :width="16" />
@@ -31,7 +51,7 @@
<a <a
v-else v-else
href="#" href="#"
class="btn btn-outline btn-sm" class="btn font-medium btn-outline btn-sm"
@click.prevent="clear" @click.prevent="clear"
> >
<IconReload :width="16" /> <IconReload :width="16" />
@@ -63,7 +83,7 @@
<canvas <canvas
v-show="!modelValue && !computedPreviousValue" v-show="!modelValue && !computedPreviousValue"
ref="canvas" ref="canvas"
class="bg-white border border-base-300 rounded" class="bg-white border border-base-300 rounded-2xl"
/> />
<input <input
v-if="!isDrawInitials && !modelValue && !computedPreviousValue" v-if="!isDrawInitials && !modelValue && !computedPreviousValue"
@@ -81,13 +101,14 @@
<script> <script>
import { cropCanvasAndExportToPNG } from './crop_canvas' 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' import SignaturePad from 'signature_pad'
export default { export default {
name: 'InitialsStep', name: 'InitialsStep',
components: { components: {
IconReload, IconReload,
IconTextSize,
IconSignature, IconSignature,
IconArrowsDiagonalMinimize2 IconArrowsDiagonalMinimize2
}, },
@@ -7,25 +7,25 @@
<div class="flex w-full"> <div class="flex w-full">
<div class="space-y-3.5 mx-auto"> <div class="space-y-3.5 mx-auto">
<div <div
v-for="(option, index) in field.options" v-for="option in field.options"
:key="index" :key="option.uuid"
> >
<label <label
:for="field.uuid + option" :for="option.uuid"
class="flex items-center space-x-3" class="flex items-center space-x-3"
> >
<input <input
:id="field.uuid + option" :id="option.uuid"
:ref="setInputRef" :ref="setInputRef"
type="checkbox" type="checkbox"
:name="`values[${field.uuid}][]`" :name="`values[${field.uuid}][]`"
:value="option" :value="option.value"
class="base-checkbox !h-7 !w-7" class="base-checkbox !h-7 !w-7"
:checked="(modelValue || []).includes(option)" :checked="(modelValue || []).includes(option.value)"
@change="onChange" @change="onChange"
> >
<span class="text-xl"> <span class="text-xl">
{{ option }} {{ option.value }}
</span> </span>
</label> </label>
</div> </div>
@@ -6,17 +6,37 @@
>{{ field.name || t('signature') }}</label> >{{ field.name || t('signature') }}</label>
<div class="space-x-2 flex"> <div class="space-x-2 flex">
<span <span
v-if="isTextSignature"
class="tooltip" class="tooltip"
data-tip="Type text" :data-tip="t('draw_signature')"
> >
<a <a
id="type_text_button" id="type_text_button"
href="#" href="#"
class="btn btn-sm btn-circle" class="btn btn-outline btn-sm font-medium"
:class="{ 'btn-neutral': isTextSignature, 'btn-outline': !isTextSignature }" @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" @click.prevent="toggleTextInput"
> >
<IconTextSize :width="16" /> <IconTextSize :width="16" />
<span class="hidden sm:inline">
{{ t('type') }}
</span>
</a> </a>
</span> </span>
<span <span
@@ -24,7 +44,7 @@
data-tip="Take photo" data-tip="Take photo"
> >
<label <label
class="btn btn-outline btn-sm btn-circle" class="btn btn-outline btn-sm font-medium"
> >
<IconCamera :width="16" /> <IconCamera :width="16" />
<input <input
@@ -33,12 +53,15 @@
accept="image/*" accept="image/*"
@change="drawImage" @change="drawImage"
> >
<span class="hidden sm:inline">
{{ t('upload') }}
</span>
</label> </label>
</span> </span>
<a <a
v-if="modelValue || computedPreviousValue" v-if="modelValue || computedPreviousValue"
href="#" href="#"
class="btn btn-outline btn-sm" class="btn btn-outline btn-sm font-medium"
@click.prevent="remove" @click.prevent="remove"
> >
<IconReload :width="16" /> <IconReload :width="16" />
@@ -47,7 +70,7 @@
<a <a
v-else v-else
href="#" href="#"
class="btn btn-outline btn-sm" class="btn btn-outline btn-sm font-medium"
@click.prevent="clear" @click.prevent="clear"
> >
<IconReload :width="16" /> <IconReload :width="16" />
@@ -79,7 +102,8 @@
<canvas <canvas
v-show="!modelValue && !computedPreviousValue" v-show="!modelValue && !computedPreviousValue"
ref="canvas" ref="canvas"
class="bg-white border border-base-300 rounded" style="padding: 1px; 0"
class="bg-white border border-base-300 rounded-2xl"
/> />
<input <input
v-if="isTextSignature" v-if="isTextSignature"
@@ -95,7 +119,7 @@
</template> </template>
<script> <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 { cropCanvasAndExportToPNG } from './crop_canvas'
import SignaturePad from 'signature_pad' import SignaturePad from 'signature_pad'
@@ -107,6 +131,7 @@ export default {
IconReload, IconReload,
IconCamera, IconCamera,
IconTextSize, IconTextSize,
IconSignature,
IconArrowsDiagonalMinimize2 IconArrowsDiagonalMinimize2
}, },
inject: ['baseUrl', 't'], inject: ['baseUrl', 't'],
+15 -1
View File
@@ -15,6 +15,7 @@
v-if="!isTextArea" v-if="!isTextArea"
:id="field.uuid" :id="field.uuid"
v-model="text" v-model="text"
:maxlength="cellsMaxLegth"
class="base-input !text-2xl w-full !pr-11 -mr-10" class="base-input !text-2xl w-full !pr-11 -mr-10"
:required="field.required" :required="field.required"
:pattern="field.validation?.pattern" :pattern="field.validation?.pattern"
@@ -38,7 +39,7 @@
@focus="$emit('focus')" @focus="$emit('focus')"
/> />
<div <div
v-if="!isTextArea" v-if="!isTextArea && field.type !== 'cells'"
class="tooltip" class="tooltip"
:data-tip="t('toggle_multiline_text')" :data-tip="t('toggle_multiline_text')"
> >
@@ -80,6 +81,19 @@ export default {
} }
}, },
computed: { 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: { text: {
set (value) { set (value) {
this.$emit('update:model-value', value) this.$emit('update:model-value', value)
+78 -30
View File
@@ -23,7 +23,7 @@
:style="{ left: (cellW / area.w * 100) + '%' }" :style="{ left: (cellW / area.w * 100) + '%' }"
> >
<span <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" 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" style="left: -4px"
@mousedown.stop="startResizeCell" @mousedown.stop="startResizeCell"
@@ -42,6 +42,7 @@
v-model="field.submitter_uuid" v-model="field.submitter_uuid"
class="border-r" class="border-r"
:compact="true" :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]'" :menu-classes="'dropdown-content bg-white menu menu-xs p-2 shadow rounded-box w-52 rounded-t-none -left-[1px]'"
:submitters="template.submitters" :submitters="template.submitters"
@update:model-value="save" @update:model-value="save"
@@ -50,6 +51,7 @@
<FieldType <FieldType
v-model="field.type" v-model="field.type"
:button-width="27" :button-width="27"
:editable="editable"
:button-classes="'px-1'" :button-classes="'px-1'"
:menu-classes="'bg-white rounded-t-none'" :menu-classes="'bg-white rounded-t-none'"
@update:model-value="[maybeUpdateOptions(), save()]" @update:model-value="[maybeUpdateOptions(), save()]"
@@ -58,15 +60,15 @@
<span <span
v-if="field.type !== 'checkbox' || field.name" v-if="field.type !== 'checkbox' || field.name"
ref="name" ref="name"
contenteditable :contenteditable="editable"
class="pr-1 cursor-text outline-none block" class="pr-1 cursor-text outline-none block"
style="min-width: 2px" style="min-width: 2px"
@keydown.enter.prevent="onNameEnter" @keydown.enter.prevent="onNameEnter"
@focus="onNameFocus" @focus="onNameFocus"
@blur="onNameBlur" @blur="onNameBlur"
>{{ field.name || defaultName }}</span> >{{ optionIndexText }} {{ field.name || defaultName }}</span>
<div <div
v-if="isNameFocus && field.type !== 'checkbox'" v-if="isNameFocus && !['checkbox', 'phone'].includes(field.type)"
class="flex items-center ml-1.5" class="flex items-center ml-1.5"
> >
<input <input
@@ -84,7 +86,7 @@
>Required</label> >Required</label>
</div> </div>
<button <button
v-else v-else-if="editable"
class="pr-1" class="pr-1"
title="Remove" title="Remove"
@click.prevent="$emit('remove')" @click.prevent="$emit('remove')"
@@ -93,18 +95,30 @@
</button> </button>
</div> </div>
<div <div
class="opacity-50 flex items-center justify-center h-full w-full" class="flex items-center h-full w-full"
:class="bgColors[submitterIndex]" :class="[bgColors[submitterIndex], field?.default_value ? '' : 'justify-center']"
> >
<span <span
v-if="field" v-if="field"
class="flex justify-center items-center space-x-1 h-full" 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 <component
:is="fieldIcons[field.type]" :is="fieldIcons[field.type]"
v-else
width="100%" width="100%"
height="100%" height="100%"
class="max-h-10" class="max-h-10 opacity-50"
/> />
</span> </span>
</div> </div>
@@ -113,7 +127,7 @@
class="absolute top-0 bottom-0 right-0 left-0 cursor-pointer" class="absolute top-0 bottom-0 right-0 left-0 cursor-pointer"
/> />
<span <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" 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" @mousedown.stop="startResize"
@touchstart="startTouchResize" @touchstart="startTouchResize"
@@ -126,6 +140,7 @@ import FieldSubmitter from './field_submitter'
import FieldType from './field_type' import FieldType from './field_type'
import Field from './field' import Field from './field'
import { IconX } from '@tabler/icons-vue' import { IconX } from '@tabler/icons-vue'
import { v4 } from 'uuid'
export default { export default {
name: 'FieldArea', name: 'FieldArea',
@@ -145,6 +160,11 @@ export default {
required: false, required: false,
default: false default: false
}, },
editable: {
type: Boolean,
required: false,
default: true
},
field: { field: {
type: Object, type: Object,
required: false, required: false,
@@ -157,6 +177,7 @@ export default {
isResize: false, isResize: false,
isDragged: false, isDragged: false,
isNameFocus: false, isNameFocus: false,
textOverflowChars: 0,
dragFrom: { x: 0, y: 0 } dragFrom: { x: 0, y: 0 }
} }
}, },
@@ -164,6 +185,13 @@ export default {
defaultName: Field.computed.defaultName, defaultName: Field.computed.defaultName,
fieldNames: FieldType.computed.fieldNames, fieldNames: FieldType.computed.fieldNames,
fieldIcons: FieldType.computed.fieldIcons, 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 () { cells () {
const cells = [] const cells = []
@@ -185,30 +213,30 @@ export default {
}, },
borderColors () { borderColors () {
return [ return [
'border-red-500', 'border-red-500/50',
'border-sky-500', 'border-sky-500/50',
'border-emerald-500', 'border-emerald-500/50',
'border-yellow-300', 'border-yellow-300/50',
'border-purple-600', 'border-purple-600/50',
'border-pink-500', 'border-pink-500/50',
'border-cyan-500', 'border-cyan-500/50',
'border-orange-500', 'border-orange-500/50',
'border-lime-500', 'border-lime-500/50',
'border-indigo-500' 'border-indigo-500/50'
] ]
}, },
bgColors () { bgColors () {
return [ return [
'bg-red-100', 'bg-red-100/50',
'bg-sky-100', 'bg-sky-100/50',
'bg-emerald-100', 'bg-emerald-100/50',
'bg-yellow-100', 'bg-yellow-100/50',
'bg-purple-100', 'bg-purple-100/50',
'bg-pink-100', 'bg-pink-100/50',
'bg-cyan-100', 'bg-cyan-100/50',
'bg-orange-100', 'bg-orange-100/50',
'bg-lime-100', 'bg-lime-100/50',
'bg-indigo-100' 'bg-indigo-100/50'
] ]
}, },
isSelected () { 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: { methods: {
onNameFocus (e) { onNameFocus (e) {
this.selectedAreaRef.value = this.area this.selectedAreaRef.value = this.area
@@ -262,12 +304,14 @@ export default {
} }
}, },
maybeUpdateOptions () { maybeUpdateOptions () {
delete this.field.default_value
if (!['radio', 'multiple', 'select'].includes(this.field.type)) { if (!['radio', 'multiple', 'select'].includes(this.field.type)) {
delete this.field.options delete this.field.options
} }
if (['select', 'multiple', 'radio'].includes(this.field.type)) { if (['select', 'multiple', 'radio'].includes(this.field.type)) {
this.field.options ||= [''] this.field.options ||= [{ value: '', uuid: v4() }]
} }
(this.field.areas || []).forEach((area) => { (this.field.areas || []).forEach((area) => {
@@ -313,6 +357,10 @@ export default {
startDrag (e) { startDrag (e) {
this.selectedAreaRef.value = this.area this.selectedAreaRef.value = this.area
if (!this.editable) {
return
}
const rect = e.target.getBoundingClientRect() const rect = e.target.getBoundingClientRect()
this.dragFrom = { x: e.clientX - rect.left, y: e.clientY - rect.top } this.dragFrom = { x: e.clientX - rect.left, y: e.clientY - rect.top }
+99 -51
View File
@@ -4,6 +4,7 @@
class="mx-auto pl-3 md:pl-4 h-full" class="mx-auto pl-3 md:pl-4 h-full"
> >
<div <div
v-if="$slots.buttons || withTitle"
class="flex justify-between py-1.5 items-center pr-4 sticky top-0 z-10" class="flex justify-between py-1.5 items-center pr-4 sticky top-0 z-10"
:style="{ backgroundColor }" :style="{ backgroundColor }"
> >
@@ -15,7 +16,9 @@
<Logo /> <Logo />
</a> </a>
<Contenteditable <Contenteditable
v-if="withTitle"
:model-value="template.name" :model-value="template.name"
:editable="editable"
class="text-3xl font-semibold focus:text-clip" class="text-3xl font-semibold focus:text-clip"
:icon-stroke-width="2.3" :icon-stroke-width="2.3"
@update:model-value="updateName" @update:model-value="updateName"
@@ -63,7 +66,10 @@
</template> </template>
</div> </div>
</div> </div>
<div class="flex md:max-h-[calc(100vh-60px)]"> <div
class="flex"
:class="$slots.buttons || withTitle ? 'md:max-h-[calc(100%_-_60px)]' : 'md:max-h-[100%]'"
>
<div <div
ref="previews" ref="previews"
:style="{ 'display': isBreakpointLg ? 'none' : 'initial' }" :style="{ 'display': isBreakpointLg ? 'none' : 'initial' }"
@@ -76,6 +82,8 @@
:item="item" :item="item"
:document="sortedDocuments[index]" :document="sortedDocuments[index]"
:accept-file-types="acceptFileTypes" :accept-file-types="acceptFileTypes"
:with-replace-button="withUploadButton"
:editable="editable"
:template="template" :template="template"
:is-direct-upload="isDirectUpload" :is-direct-upload="isDirectUpload"
@scroll-to="scrollIntoDocument(item)" @scroll-to="scrollIntoDocument(item)"
@@ -87,10 +95,10 @@
/> />
<div <div
class="sticky bottom-0 py-2" class="sticky bottom-0 py-2"
:class="{ 'bg-base-100': withStickySubmitters }" :style="withStickySubmitters ? { backgroundColor } : {}"
> >
<Upload <Upload
v-if="sortedDocuments.length" v-if="sortedDocuments.length && editable && withUploadButton"
:accept-file-types="acceptFileTypes" :accept-file-types="acceptFileTypes"
:template-id="template.id" :template-id="template.id"
:is-direct-upload="isDirectUpload" :is-direct-upload="isDirectUpload"
@@ -104,7 +112,7 @@
class="pr-3.5 pl-0.5" class="pr-3.5 pl-0.5"
> >
<Dropzone <Dropzone
v-if="!sortedDocuments.length" v-if="!sortedDocuments.length && withUploadButton"
:template-id="template.id" :template-id="template.id"
:accept-file-types="acceptFileTypes" :accept-file-types="acceptFileTypes"
:is-direct-upload="isDirectUpload" :is-direct-upload="isDirectUpload"
@@ -120,16 +128,19 @@
:areas-index="fieldAreasIndex[document.uuid]" :areas-index="fieldAreasIndex[document.uuid]"
:selected-submitter="selectedSubmitter" :selected-submitter="selectedSubmitter"
:document="document" :document="document"
:is-drag="!!dragFieldType" :is-drag="!!dragField"
:draw-field="drawField" :draw-field="drawField"
:editable="editable"
:base-url="baseUrl"
@draw="onDraw" @draw="onDraw"
@drop-field="onDropfield" @drop-field="onDropfield"
@remove-area="removeArea" @remove-area="removeArea"
/> />
<DocumentControls <DocumentControls
v-if="isBreakpointLg" v-if="isBreakpointLg && editable"
:with-arrows="template.schema.length > 1" :with-arrows="template.schema.length > 1"
:item="template.schema.find((item) => item.attachment_uuid === document.uuid)" :item="template.schema.find((item) => item.attachment_uuid === document.uuid)"
:with-replace-button="withUploadButton"
:document="document" :document="document"
:template="template" :template="template"
:is-direct-upload="isDirectUpload" :is-direct-upload="isDirectUpload"
@@ -142,10 +153,11 @@
/> />
</template> </template>
<div <div
v-if="sortedDocuments.length && isBreakpointLg" v-if="sortedDocuments.length && isBreakpointLg && editable"
class="pb-4" class="pb-4"
> >
<Upload <Upload
v-if="withUploadButton"
:template-id="template.id" :template-id="template.id"
:is-direct-upload="isDirectUpload" :is-direct-upload="isDirectUpload"
@success="updateFromUpload" @success="updateFromUpload"
@@ -153,34 +165,19 @@
</div> </div>
</template> </template>
</div> </div>
<span <MobileDrawField
v-if="drawField" v-if="drawField && isBreakpointLg"
class="fixed text-center w-full left-1/2 bottom-0 transform -translate-x-1/2" :draw-field="drawField"
> :fields="template.fields"
<span :submitters="template.submitters"
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" :selected-submitter="selectedSubmitter"
> class="md:hidden"
<component :editable="editable"
:is="fieldIcons[drawField.type]" @cancel="[drawField = null, drawOption = null]"
:width="20" @change-submitter="[selectedSubmitter = $event, drawField.submitter_uuid = $event.uuid]"
: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>
<FieldType <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" class="dropdown-top dropdown-end fixed bottom-4 right-4 z-10 md:hidden"
:model-value="''" :model-value="''"
@update:model-value="startFieldDraw($event)" @update:model-value="startFieldDraw($event)"
@@ -208,7 +205,8 @@
> >
<div <div
v-if="drawField" v-if="drawField"
class="sticky inset-0 bg-base-100 h-full" class="sticky inset-0 h-full"
:style="{ backgroundColor }"
> >
<div class="bg-base-300 rounded-lg p-5 text-center space-y-4"> <div class="bg-base-300 rounded-lg p-5 text-center space-y-4">
<p> <p>
@@ -217,7 +215,7 @@
<p> <p>
<button <button
class="base-button" class="base-button"
@click="drawField = null" @click="[drawField = null, drawOption = null]"
> >
Cancel Cancel
</button> </button>
@@ -230,11 +228,13 @@
:fields="template.fields" :fields="template.fields"
:submitters="template.submitters" :submitters="template.submitters"
:selected-submitter="selectedSubmitter" :selected-submitter="selectedSubmitter"
:default-fields="defaultFields"
:with-sticky-submitters="withStickySubmitters" :with-sticky-submitters="withStickySubmitters"
@set-draw="drawField = $event" :editable="editable"
@set-drag="dragFieldType = $event" @set-draw="[drawField = $event.field, drawOption = $event.option]"
@set-drag="dragField = $event"
@change-submitter="selectedSubmitter = $event" @change-submitter="selectedSubmitter = $event"
@drag-end="dragFieldType = null" @drag-end="dragField = null"
@scroll-to-area="scrollToArea" @scroll-to-area="scrollToArea"
/> />
</div> </div>
@@ -247,6 +247,7 @@
import Upload from './upload' import Upload from './upload'
import Dropzone from './dropzone' import Dropzone from './dropzone'
import Fields from './fields' import Fields from './fields'
import MobileDrawField from './mobile_draw_field'
import Document from './document' import Document from './document'
import Logo from './logo' import Logo from './logo'
import Contenteditable from './contenteditable' import Contenteditable from './contenteditable'
@@ -263,6 +264,7 @@ export default {
Upload, Upload,
Document, Document,
Fields, Fields,
MobileDrawField,
IconPlus, IconPlus,
FieldType, FieldType,
IconX, IconX,
@@ -300,6 +302,21 @@ export default {
required: false, required: false,
default: '' default: ''
}, },
editable: {
type: Boolean,
required: false,
default: true
},
defaultFields: {
type: Array,
required: false,
default: () => []
},
defaultSubmitters: {
type: Array,
required: false,
default: () => []
},
acceptFileTypes: { acceptFileTypes: {
type: String, type: String,
required: false, required: false,
@@ -320,6 +337,16 @@ export default {
required: false, required: false,
default: true default: true
}, },
withUploadButton: {
type: Boolean,
required: false,
default: true
},
withTitle: {
type: Boolean,
required: false,
default: true
},
withPhone: { withPhone: {
type: Boolean, type: Boolean,
required: false, required: false,
@@ -338,7 +365,8 @@ export default {
isSaving: false, isSaving: false,
selectedSubmitter: null, selectedSubmitter: null,
drawField: null, drawField: null,
dragFieldType: null drawOption: null,
dragField: null
} }
}, },
computed: { computed: {
@@ -370,6 +398,13 @@ export default {
} }
}, },
created () { created () {
this.defaultSubmitters.forEach((name, index) => {
const submitter = (this.template.submitters[index] ||= {})
submitter.name = name
submitter.uuid ||= v4()
})
this.selectedSubmitter = this.template.submitters[0] this.selectedSubmitter = this.template.submitters[0]
}, },
mounted () { mounted () {
@@ -406,10 +441,11 @@ export default {
} }
if (['select', 'multiple', 'radio'].includes(type)) { if (['select', 'multiple', 'radio'].includes(type)) {
field.options = [''] field.options = [{ value: '', uuid: v4() }]
} }
this.drawField = field this.drawField = field
this.drawOption = null
}, },
undo () { undo () {
if (this.undoStack.length > 1) { if (this.undoStack.length > 1) {
@@ -459,6 +495,7 @@ export default {
onKeyUp (e) { onKeyUp (e) {
if (e.code === 'Escape') { if (e.code === 'Escape') {
this.drawField = null this.drawField = null
this.drawOption = null
this.selectedAreaRef.value = null this.selectedAreaRef.value = null
} }
@@ -505,6 +542,16 @@ export default {
}, },
onDraw (area) { onDraw (area) {
if (this.drawField) { 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 ||= []
this.drawField.areas.push(area) this.drawField.areas.push(area)
@@ -513,6 +560,7 @@ export default {
} }
this.drawField = null this.drawField = null
this.drawOption = null
this.selectedAreaRef.value = area this.selectedAreaRef.value = area
@@ -562,14 +610,14 @@ export default {
onDropfield (area) { onDropfield (area) {
const field = { const field = {
name: '', name: '',
type: this.dragFieldType,
uuid: v4(), uuid: v4(),
submitter_uuid: this.selectedSubmitter.uuid, submitter_uuid: this.selectedSubmitter.uuid,
required: this.dragFieldType !== 'checkbox' required: this.dragField.type !== 'checkbox',
...this.dragField
} }
if (['select', 'multiple', 'radio'].includes(this.dragFieldType)) { if (['select', 'multiple', 'radio'].includes(field.type)) {
field.options = [''] field.options = [{ value: '', uuid: v4() }]
} }
const fieldArea = { const fieldArea = {
@@ -583,27 +631,27 @@ export default {
let baseArea let baseArea
if (this.selectedField?.type === this.dragFieldType) { if (this.selectedField?.type === field.type) {
baseArea = this.selectedAreaRef.value baseArea = this.selectedAreaRef.value
} else if (previousField?.areas?.length) { } else if (previousField?.areas?.length) {
baseArea = previousField.areas[previousField.areas.length - 1] baseArea = previousField.areas[previousField.areas.length - 1]
} else { } else {
if (['checkbox'].includes(this.dragFieldType)) { if (['checkbox'].includes(field.type)) {
baseArea = { baseArea = {
w: area.maskW / 30 / area.maskW, w: area.maskW / 30 / area.maskW,
h: area.maskW / 30 / area.maskW * (area.maskW / area.maskH) h: area.maskW / 30 / area.maskW * (area.maskW / area.maskH)
} }
} else if (this.dragFieldType === 'image') { } else if (field.type === 'image') {
baseArea = { baseArea = {
w: area.maskW / 5 / area.maskW, w: area.maskW / 5 / area.maskW,
h: (area.maskW / 5 / area.maskW) * (area.maskW / area.maskH) h: (area.maskW / 5 / area.maskW) * (area.maskW / area.maskH)
} }
} else if (this.dragFieldType === 'signature') { } else if (field.type === 'signature') {
baseArea = { baseArea = {
w: area.maskW / 5 / area.maskW, w: area.maskW / 5 / area.maskW,
h: (area.maskW / 5 / area.maskW) * (area.maskW / area.maskH) / 2 h: (area.maskW / 5 / area.maskW) * (area.maskW / area.maskH) / 2
} }
} else if (this.dragFieldType === 'initials') { } else if (field.type === 'initials') {
baseArea = { baseArea = {
w: area.maskW / 10 / area.maskW, w: area.maskW / 10 / area.maskW,
h: area.maskW / 35 / area.maskW h: area.maskW / 35 / area.maskW
@@ -620,7 +668,7 @@ export default {
fieldArea.h = baseArea.h fieldArea.h = baseArea.h
fieldArea.y = fieldArea.y - baseArea.h / 2 fieldArea.y = fieldArea.y - baseArea.h / 2
if (this.dragFieldType === 'cells') { if (field.type === 'cells') {
fieldArea.cell_w = baseArea.cell_w || (baseArea.w / 5) fieldArea.cell_w = baseArea.cell_w || (baseArea.w / 5)
} }
@@ -5,7 +5,7 @@
> >
<span <span
ref="contenteditable" ref="contenteditable"
contenteditable :contenteditable="editable"
style="min-width: 2px" style="min-width: 2px"
:class="iconInline ? 'inline' : 'block'" :class="iconInline ? 'inline' : 'block'"
class="peer outline-none focus:block" class="peer outline-none focus:block"
@@ -27,7 +27,7 @@
class="cursor-pointer flex-none opacity-0 group-hover/contenteditable-container:opacity-100 group-hover/contenteditable:opacity-100 align-middle peer-focus:hidden" class="cursor-pointer flex-none opacity-0 group-hover/contenteditable-container:opacity-100 group-hover/contenteditable:opacity-100 align-middle peer-focus:hidden"
:style="iconInline ? {} : { right: -(1.1 * iconWidth) + 'px' }" :style="iconInline ? {} : { right: -(1.1 * iconWidth) + 'px' }"
title="Edit" title="Edit"
:class="{ 'ml-1': !withRequired, 'absolute': !iconInline, 'inline align-bottom': iconInline }" :class="{ invisible: !editable, 'ml-1': !withRequired, 'absolute': !iconInline, 'inline align-bottom': iconInline }"
:width="iconWidth" :width="iconWidth"
:stroke-width="iconStrokeWidth" :stroke-width="iconStrokeWidth"
@click="[focusContenteditable(), selectOnEditClick && selectContent()]" @click="[focusContenteditable(), selectOnEditClick && selectContent()]"
@@ -69,6 +69,11 @@ export default {
required: false, required: false,
default: false default: false
}, },
editable: {
type: Boolean,
required: false,
default: true
},
iconStrokeWidth: { iconStrokeWidth: {
type: Number, type: Number,
required: false, required: false,
@@ -7,6 +7,7 @@
@update:model-value="onUpdateName" @update:model-value="onUpdateName"
/> />
<ReplaceButton <ReplaceButton
v-if="withReplaceButton"
:is-direct-upload="isDirectUpload" :is-direct-upload="isDirectUpload"
:template-id="template.id" :template-id="template.id"
@click.stop @click.stop
@@ -67,6 +68,11 @@ export default {
required: true, required: true,
default: false default: false
}, },
withReplaceButton: {
type: Boolean,
required: true,
default: true
},
withArrows: { withArrows: {
type: Boolean, type: Boolean,
required: false, required: false,
+19 -1
View File
@@ -5,6 +5,7 @@
:key="image.id" :key="image.id"
:ref="setPageRefs" :ref="setPageRefs"
:number="index" :number="index"
:editable="editable"
:areas="areasIndex[index]" :areas="areasIndex[index]"
:is-drag="isDrag" :is-drag="isDrag"
:draw-field="drawField" :draw-field="drawField"
@@ -38,11 +39,21 @@ export default {
type: Object, type: Object,
required: true required: true
}, },
editable: {
type: Boolean,
required: false,
default: true
},
drawField: { drawField: {
type: Object, type: Object,
required: false, required: false,
default: null default: null
}, },
baseUrl: {
type: String,
required: false,
default: ''
},
isDrag: { isDrag: {
type: Boolean, type: Boolean,
required: false, required: false,
@@ -56,6 +67,13 @@ export default {
} }
}, },
computed: { computed: {
basePreviewUrl () {
if (this.baseUrl) {
return new URL(this.baseUrl).origin
} else {
return ''
}
},
numberOfPages () { numberOfPages () {
return this.document.metadata?.pdf?.number_of_pages || this.document.preview_images.length return this.document.metadata?.pdf?.number_of_pages || this.document.preview_images.length
}, },
@@ -66,7 +84,7 @@ export default {
return this.previewImagesIndex[i] || { return this.previewImagesIndex[i] || {
metadata: lazyloadMetadata, metadata: lazyloadMetadata,
id: Math.random().toString(), id: Math.random().toString(),
url: `/preview/${this.document.uuid}/${i}.jpg` url: this.basePreviewUrl + `/preview/${this.document.uuid}/${i}.jpg`
} }
}) })
}, },
+156 -57
View File
@@ -4,6 +4,7 @@
> >
<div <div
class="border border-base-300 rounded rounded-tr-none relative group" class="border border-base-300 rounded rounded-tr-none relative group"
:style="{ backgroundColor: backgroundColor }"
> >
<div class="flex items-center justify-between relative group/contenteditable-container"> <div class="flex items-center justify-between relative group/contenteditable-container">
<div <div
@@ -13,6 +14,7 @@
<div class="flex items-center p-1 space-x-1"> <div class="flex items-center p-1 space-x-1">
<FieldType <FieldType
v-model="field.type" v-model="field.type"
:editable="editable"
:button-width="20" :button-width="20"
@update:model-value="[maybeUpdateOptions(), save()]" @update:model-value="[maybeUpdateOptions(), save()]"
@click="scrollToFirstArea" @click="scrollToFirstArea"
@@ -20,6 +22,7 @@
<Contenteditable <Contenteditable
ref="name" ref="name"
:model-value="field.name || defaultName" :model-value="field.name || defaultName"
:editable="editable"
:icon-inline="true" :icon-inline="true"
:icon-width="18" :icon-width="18"
:icon-stroke-width="1.6" :icon-stroke-width="1.6"
@@ -31,34 +34,46 @@
v-if="isNameFocus" v-if="isNameFocus"
class="flex items-center relative" class="flex items-center relative"
> >
<input <template v-if="field.type != 'phone'">
:id="`required-checkbox-${field.uuid}`" <input
v-model="field.required" :id="`required-checkbox-${field.uuid}`"
type="checkbox" v-model="field.required"
class="checkbox checkbox-xs no-animation rounded" type="checkbox"
@mousedown.prevent class="checkbox checkbox-xs no-animation rounded"
> @mousedown.prevent
<label >
:for="`required-checkbox-${field.uuid}`" <label
class="label text-xs" :for="`required-checkbox-${field.uuid}`"
@click.prevent="field.required = !field.required" class="label text-xs"
@mousedown.prevent @click.prevent="field.required = !field.required"
>Required</label> @mousedown.prevent
>Required</label>
</template>
</div> </div>
<div <div
v-else v-else-if="editable"
class="flex items-center space-x-1" 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 <span
v-if="field.areas?.length"
class="dropdown dropdown-end" class="dropdown dropdown-end"
> >
<label <label
tabindex="0" tabindex="0"
title="Areas" title="Settings"
class="cursor-pointer text-transparent group-hover:text-base-content" class="cursor-pointer text-transparent group-hover:text-base-content"
> >
<IconShape <IconSettings
:width="18" :width="18"
:stroke-width="1.6" :stroke-width="1.6"
/> />
@@ -66,8 +81,60 @@
<ul <ul
tabindex="0" tabindex="0"
class="mt-1.5 dropdown-content menu menu-xs p-2 shadow bg-base-100 rounded-box w-52 z-10" 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" @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 <li
v-for="(area, index) in field.areas || []" v-for="(area, index) in field.areas || []"
:key="index" :key="index"
@@ -84,11 +151,11 @@
Page {{ area.page + 1 }} Page {{ area.page + 1 }}
</a> </a>
</li> </li>
<li> <li v-if="!field.areas?.length || !['radio', 'multiple'].includes(field.type)">
<a <a
href="#" href="#"
class="text-sm py-1 px-2" class="text-sm py-1 px-2"
@click.prevent="$emit('set-draw', field)" @click.prevent="$emit('set-draw', { field })"
> >
<IconNewSection <IconNewSection
:width="20" :width="20"
@@ -113,18 +180,7 @@
</ul> </ul>
</span> </span>
<button <button
v-else class="relative text-transparent group-hover:text-base-content pr-1"
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"
title="Remove" title="Remove"
@click="$emit('remove', field)" @click="$emit('remove', field)"
> >
@@ -133,48 +189,58 @@
:stroke-width="1.6" :stroke-width="1.6"
/> />
</button> </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> </div>
<div <div
v-if="field.options" v-if="field.options"
ref="options"
class="border-t border-base-300 mx-2 pt-2 space-y-1.5" class="border-t border-base-300 mx-2 pt-2 space-y-1.5"
draggable="true"
@dragstart.prevent.stop
> >
<div <div
v-for="(option, index) in field.options" v-for="(option, index) in field.options"
:key="index" :key="option.uuid"
class="flex space-x-1.5 items-center" class="flex space-x-1.5 items-center"
> >
<span class="text-sm w-3.5"> <span class="text-sm w-3.5">
{{ index + 1 }}. {{ index + 1 }}.
</span> </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 <input
v-model="field.options[index]" v-else
v-model="option.value"
class="w-full input input-primary input-xs text-sm bg-transparent" class="w-full input input-primary input-xs text-sm bg-transparent"
type="text" type="text"
required required
@focus="maybeFocusOnOptionArea(option)"
@blur="save" @blur="save"
> >
<button <button
class="text-sm w-3.5" class="text-sm w-3.5"
@click="[field.options.splice(index, 1), save()]" tabindex="-1"
@click="removeOption(option)"
> >
&times; &times;
</button> </button>
@@ -182,7 +248,7 @@
<button <button
v-if="field.options" v-if="field.options"
class="text-center text-sm w-full pb-1" class="text-center text-sm w-full pb-1"
@click="[field.options.push(''), save()]" @click="addOption"
> >
+ Add option + Add option
</button> </button>
@@ -194,26 +260,33 @@
<script> <script>
import Contenteditable from './contenteditable' import Contenteditable from './contenteditable'
import FieldType from './field_type' 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 { export default {
name: 'TemplateField', name: 'TemplateField',
components: { components: {
Contenteditable, Contenteditable,
IconSettings,
IconShape, IconShape,
IconNewSection, IconNewSection,
IconTrashX, IconTrashX,
IconCopy, IconCopy,
FieldType FieldType
}, },
inject: ['template', 'save'], inject: ['template', 'save', 'backgroundColor', 'selectedAreaRef'],
props: { props: {
field: { field: {
type: Object, type: Object,
required: true 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 () { data () {
return { return {
isNameFocus: false isNameFocus: false
@@ -259,19 +332,45 @@ export default {
}, 1) }, 1)
} }
}, },
maybeFocusOnOptionArea (option) {
const area = this.field.areas.find((a) => a.option_uuid === option.uuid)
if (area) {
this.selectedAreaRef.value = area
}
},
scrollToFirstArea () { scrollToFirstArea () {
return this.field.areas?.[0] && this.$emit('scroll-to', this.field.areas[0]) return this.field.areas?.[0] && this.$emit('scroll-to', this.field.areas[0])
}, },
closeDropdown () { closeDropdown () {
document.activeElement.blur() 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 () { maybeUpdateOptions () {
delete this.field.default_value
if (!['radio', 'multiple', 'select'].includes(this.field.type)) { if (!['radio', 'multiple', 'select'].includes(this.field.type)) {
delete this.field.options delete this.field.options
} }
if (['radio', 'multiple', 'select'].includes(this.field.type)) { if (['radio', 'multiple', 'select'].includes(this.field.type)) {
this.field.options ||= [''] this.field.options ||= [{ value: '', uuid: v4() }]
} }
(this.field.areas || []).forEach((area) => { (this.field.areas || []).forEach((area) => {
@@ -1,5 +1,91 @@
<template> <template>
<div class="dropdown"> <div v-if="mobileView">
<div class="flex space-x-2 items-end">
<div class="group/contenteditable-container bg-base-100 rounded-md p-2 border border-base-300 w-full flex justify-between items-end">
<div class="flex items-center space-x-2">
<span
class="w-3 h-3 flex-shrink-0 rounded-full"
:class="colors[submitters.indexOf(selectedSubmitter)]"
/>
<Contenteditable
v-model="selectedSubmitter.name"
class="cursor-text"
:icon-inline="true"
:editable="editable"
:select-on-edit-click="true"
:icon-width="18"
@update:model-value="$emit('name-change', selectedSubmitter)"
/>
</div>
</div>
<div class="dropdown dropdown-top dropdown-end">
<label
tabindex="0"
class="bg-base-100 cursor-pointer rounded-md p-2 border border-base-300 w-full flex justify-center"
>
<IconChevronUp
width="24"
height="24"
/>
</label>
<ul
v-if="editable"
tabindex="0"
class="rounded-md min-w-max mb-2"
:class="menuClasses"
@click="closeDropdown"
>
<li
v-for="(submitter, index) in submitters"
:key="submitter.uuid"
>
<a
href="#"
class="flex px-2 group justify-between items-center"
:class="{ 'active': submitter === selectedSubmitter }"
@click.prevent="selectSubmitter(submitter)"
>
<span class="py-1 flex items-center">
<span
class="rounded-full w-3 h-3 ml-1 mr-3"
:class="colors[index]"
/>
<span>
{{ submitter.name }}
</span>
</span>
<button
v-if="submitters.length > 1 && editable"
class="px-2"
@click.stop="remove(submitter)"
>
<IconTrashX :width="18" />
</button>
</a>
</li>
<li v-if="submitters.length < 10 && editable">
<a
href="#"
class="flex px-2"
@click.prevent="addSubmitter"
>
<IconUserPlus
:width="20"
:stroke-width="1.6"
/>
<span class="py-1">
Add {{ names[submitters.length] }}
</span>
</a>
</li>
</ul>
</div>
</div>
</div>
<div
v-else
class="dropdown"
>
<label <label
v-if="compact" v-if="compact"
tabindex="0" tabindex="0"
@@ -14,7 +100,7 @@
<label <label
v-else v-else
tabindex="0" tabindex="0"
class="cursor-pointer group/contenteditable-container rounded-md p-2 border border-base-300 w-full flex justify-between" class="group cursor-pointer group/contenteditable-container rounded-md p-2 border border-base-300 hover:border-content w-full flex justify-between"
> >
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
<span <span
@@ -25,12 +111,13 @@
v-model="selectedSubmitter.name" v-model="selectedSubmitter.name"
class="cursor-text" class="cursor-text"
:icon-inline="true" :icon-inline="true"
:editable="editable"
:select-on-edit-click="true" :select-on-edit-click="true"
:icon-width="18" :icon-width="18"
@update:model-value="$emit('name-change', selectedSubmitter)" @update:model-value="$emit('name-change', selectedSubmitter)"
/> />
</div> </div>
<span class="flex items-center"> <span class="flex items-center transition-all duration-75 group-hover:border border-base-content/20 border-dashed w-6 h-6 flex justify-center items-center rounded">
<IconPlus <IconPlus
width="18" width="18"
height="18" height="18"
@@ -38,6 +125,7 @@
</span> </span>
</label> </label>
<ul <ul
v-if="editable || !compact"
tabindex="0" tabindex="0"
:class="menuClasses" :class="menuClasses"
@click="closeDropdown" @click="closeDropdown"
@@ -62,7 +150,7 @@
</span> </span>
</span> </span>
<button <button
v-if="!compact && submitters.length > 1" v-if="!compact && submitters.length > 1 && editable"
class="hidden group-hover:block px-2" class="hidden group-hover:block px-2"
@click.stop="remove(submitter)" @click.stop="remove(submitter)"
> >
@@ -70,7 +158,7 @@
</button> </button>
</a> </a>
</li> </li>
<li v-if="submitters.length < 10"> <li v-if="submitters.length < 10 && editable">
<a <a
href="#" href="#"
class="flex px-2" class="flex px-2"
@@ -81,7 +169,7 @@
:stroke-width="1.6" :stroke-width="1.6"
/> />
<span class="py-1"> <span class="py-1">
Add Submitter Add {{ names[submitters.length] }}
</span> </span>
</a> </a>
</li> </li>
@@ -90,7 +178,7 @@
</template> </template>
<script> <script>
import { IconUserPlus, IconTrashX, IconPlus } from '@tabler/icons-vue' import { IconUserPlus, IconTrashX, IconPlus, IconChevronUp } from '@tabler/icons-vue'
import Contenteditable from './contenteditable' import Contenteditable from './contenteditable'
import { v4 } from 'uuid' import { v4 } from 'uuid'
@@ -100,18 +188,29 @@ export default {
IconUserPlus, IconUserPlus,
Contenteditable, Contenteditable,
IconPlus, IconPlus,
IconTrashX IconTrashX,
IconChevronUp
}, },
props: { props: {
submitters: { submitters: {
type: Array, type: Array,
required: true required: true
}, },
editable: {
type: Boolean,
required: false,
default: true
},
compact: { compact: {
type: Boolean, type: Boolean,
required: false, required: false,
default: false default: false
}, },
mobileView: {
type: Boolean,
required: false,
default: false
},
modelValue: { modelValue: {
type: String, type: String,
required: true required: true
@@ -140,16 +239,16 @@ export default {
}, },
names () { names () {
return [ return [
'First Submitter', 'First Party',
'Second Submitter', 'Second Party',
'Third Submitter', 'Third Party',
'Fourth Submitter', 'Fourth Party',
'Fifth Submitter', 'Fifth Party',
'Sixth Submitter', 'Sixth Party',
'Seventh Submitter', 'Seventh Party',
'Eighth Submitter', 'Eighth Party',
'Ninth Submitter', 'Ninth Party',
'Tenth Submitter' 'Tenth Party'
] ]
}, },
selectedSubmitter () { selectedSubmitter () {
@@ -15,6 +15,7 @@
</label> </label>
</slot> </slot>
<ul <ul
v-if="editable"
tabindex="0" tabindex="0"
class="dropdown-content menu menu-xs p-2 shadow rounded-box w-52 z-10 mb-3" class="dropdown-content menu menu-xs p-2 shadow rounded-box w-52 z-10 mb-3"
:class="menuClasses" :class="menuClasses"
@@ -65,6 +66,11 @@ export default {
required: false, required: false,
default: '' default: ''
}, },
editable: {
type: Boolean,
required: false,
default: true
},
buttonWidth: { buttonWidth: {
type: Number, type: Number,
required: false, required: false,
+97 -55
View File
@@ -5,26 +5,66 @@
class="w-full rounded-lg" class="w-full rounded-lg"
:class="{ 'bg-base-100': withStickySubmitters }" :class="{ 'bg-base-100': withStickySubmitters }"
:submitters="submitters" :submitters="submitters"
:editable="editable"
@new-submitter="save" @new-submitter="save"
@remove="removeSubmitter" @remove="removeSubmitter"
@name-change="save" @name-change="save"
@update:model-value="$emit('change-submitter', submitters.find((s) => s.uuid === $event))" @update:model-value="$emit('change-submitter', submitters.find((s) => s.uuid === $event))"
/> />
</div> </div>
<div class="mb-1 mt-2"> <div
class="mb-1 mt-2"
@dragover.prevent="onFieldDragover"
@drop="save"
>
<Field <Field
v-for="field in submitterFields" v-for="field in submitterFields"
:key="field.uuid" :key="field.uuid"
:data-uuid="field.uuid"
:field="field" :field="field"
:type-index="fields.filter((f) => f.type === field.type).indexOf(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" @remove="removeField"
@move-up="move(field, -1)"
@move-down="move(field, 1)"
@scroll-to="$emit('scroll-to-area', $event)" @scroll-to="$emit('scroll-to-area', $event)"
@set-draw="$emit('set-draw', $event)" @set-draw="$emit('set-draw', $event)"
/> />
</div> </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 <template
v-for="(icon, type) in fieldIcons" v-for="(icon, type) in fieldIcons"
:key="type" :key="type"
@@ -32,37 +72,14 @@
<button <button
v-if="withPhone || type != 'phone'" v-if="withPhone || type != 'phone'"
draggable="true" draggable="true"
class="flex items-center justify-center border border-dashed border-base-300 w-full rounded relative" class="group flex items-center justify-center border border-dashed border-base-300 hover:border-base-content/20 w-full rounded relative"
:style="{ backgroundColor: backgroundColor }" :style="{ backgroundColor: backgroundColor }"
@dragstart="onDragstart(type)" @dragstart="onDragstart({ type: type })"
@dragend="$emit('drag-end')" @dragend="$emit('drag-end')"
@click="addField(type)" @click="addField(type)"
> >
<div class="w-0 absolute left-0"> <div class="flex items-console group-hover:bg-base-200/50 transition-all cursor-grab h-full absolute left-0">
<svg <IconDrag class=" my-auto" />
xmlns="http://www.w3.org/2000/svg"
class="cursor-grab"
width="18"
height="18"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
>
<path
stroke="none"
d="M0 0h24v24H0z"
fill="none"
/>
<path d="M9 5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
<path d="M9 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
<path d="M9 19m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
<path d="M15 5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
<path d="M15 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
<path d="M15 19m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
</svg>
</div> </div>
<div class="flex items-center flex-col px-2 py-2"> <div class="flex items-center flex-col px-2 py-2">
<component :is="icon" /> <component :is="icon" />
@@ -100,7 +117,7 @@
</template> </template>
</div> </div>
<div <div
v-if="fields.length < 4" v-if="fields.length < 4 && editable"
class="text-xs p-2 border border-base-200 rounded" class="text-xs p-2 border border-base-200 rounded"
> >
<ul class="list-disc list-outside ml-3"> <ul class="list-disc list-outside ml-3">
@@ -123,12 +140,15 @@ import { v4 } from 'uuid'
import FieldType from './field_type' import FieldType from './field_type'
import FieldSubmitter from './field_submitter' import FieldSubmitter from './field_submitter'
import { IconLock } from '@tabler/icons-vue' import { IconLock } from '@tabler/icons-vue'
import IconDrag from './icon_drag'
export default { export default {
name: 'TemplateFields', name: 'TemplateFields',
components: { components: {
Field, Field,
FieldType,
FieldSubmitter, FieldSubmitter,
IconDrag,
IconLock IconLock
}, },
inject: ['save', 'backgroundColor', 'withPhone'], inject: ['save', 'backgroundColor', 'withPhone'],
@@ -137,6 +157,16 @@ export default {
type: Array, type: Array,
required: true required: true
}, },
editable: {
type: Boolean,
required: false,
default: true
},
defaultFields: {
type: Array,
required: false,
default: () => []
},
withStickySubmitters: { withStickySubmitters: {
type: Boolean, type: Boolean,
required: false, required: false,
@@ -152,16 +182,44 @@ export default {
} }
}, },
emits: ['set-draw', 'set-drag', 'drag-end', 'scroll-to-area', 'change-submitter'], emits: ['set-draw', 'set-drag', 'drag-end', 'scroll-to-area', 'change-submitter'],
data () {
return {
dragField: null
}
},
computed: { computed: {
fieldNames: FieldType.computed.fieldNames, fieldNames: FieldType.computed.fieldNames,
fieldIcons: FieldType.computed.fieldIcons, fieldIcons: FieldType.computed.fieldIcons,
submitterFields () { submitterFields () {
return this.fields.filter((f) => f.submitter_uuid === this.selectedSubmitter.uuid) return this.fields.filter((f) => f.submitter_uuid === this.selectedSubmitter.uuid)
},
submitterDefaultFields () {
return this.defaultFields.filter((f) => {
return !this.submitterFields.find((field) => field.name === f.name) && (!f.role || f.role === this.selectedSubmitter.name)
})
} }
}, },
methods: { methods: {
onDragstart (fieldType) { onDragstart (field) {
this.$emit('set-drag', fieldType) 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) { removeSubmitter (submitter) {
[...this.fields].forEach((field) => { [...this.fields].forEach((field) => {
@@ -178,26 +236,6 @@ export default {
this.save() 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) { removeField (field) {
this.fields.splice(this.fields.indexOf(field), 1) this.fields.splice(this.fields.indexOf(field), 1)
@@ -214,11 +252,15 @@ export default {
} }
if (['select', 'multiple', 'radio'].includes(type)) { if (['select', 'multiple', 'radio'].includes(type)) {
field.options = [''] field.options = [{ value: '', uuid: v4() }]
} }
this.fields.push(field) this.fields.push(field)
if (['signature', 'initials', 'cells'].includes(type)) {
this.$emit('set-draw', { field })
}
this.save() this.save()
} }
} }
@@ -0,0 +1,31 @@
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
>
<path
stroke="none"
d="M0 0h24v24H0z"
fill="none"
/>
<path d="M9 5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
<path d="M9 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
<path d="M9 19m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
<path d="M15 5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
<path d="M15 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
<path d="M15 19m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" />
</svg>
</template>
<script>
export default {
name: 'DragIcon'
}
</script>
@@ -0,0 +1,98 @@
<template>
<div class="fixed text-center w-full bottom-0 pr-6 mb-4">
<span class="w-full bg-base-200 px-4 py-2 rounded-md inline-flex space-x-2 mx-auto items-center justify-between mb-2 z-20 md:hidden">
<div class="flex items-center space-x-2">
<component
:is="fieldIcons[drawField.type]"
:width="20"
:height="20"
class="inline"
:stroke-width="1.6"
/>
<span> Draw {{ fieldNames[drawField.type] }} Field </span>
</div>
<a
href="#"
class="link block text-center"
@click.prevent="$emit('cancel')"
>
Cancel
</a>
</span>
<FieldSubmitter
:model-value="selectedSubmitter.uuid"
:submitters="submitters"
:editable="editable"
:mobile-view="true"
@new-submitter="save"
@remove="removeSubmitter"
@name-change="save"
@update:model-value="$emit('change-submitter', submitters.find((s) => s.uuid === $event))"
/>
</div>
</template>
<script>
import Field from './field'
import FieldType from './field_type'
import FieldSubmitter from './field_submitter'
export default {
name: 'MobileDrawField',
components: {
Field,
FieldSubmitter
},
inject: ['save'],
props: {
drawField: {
type: Object,
required: true
},
editable: {
type: Boolean,
required: false,
default: true
},
submitters: {
type: Array,
required: true
},
fields: {
type: Array,
required: true
},
selectedSubmitter: {
type: Object,
required: true
}
},
emits: ['change-submitter', 'cancel'],
computed: {
fieldNames: FieldType.computed.fieldNames,
fieldIcons: FieldType.computed.fieldIcons
},
methods: {
removeSubmitter (submitter) {
[...this.fields].forEach((field) => {
if (field.submitter_uuid === submitter.uuid) {
this.removeField(field)
}
})
this.submitters.splice(this.submitters.indexOf(submitter), 1)
if (this.selectedSubmitter === submitter) {
this.$emit('change-submitter', this.submitters[0])
}
this.save()
},
removeField (field) {
this.fields.splice(this.fields.indexOf(field), 1)
this.save()
}
}
}
</script>
+12 -2
View File
@@ -22,6 +22,7 @@
:ref="setAreaRefs" :ref="setAreaRefs"
:area="item.area" :area="item.area"
:field="item.field" :field="item.field"
:editable="editable"
@start-resize="resizeDirection = $event" @start-resize="resizeDirection = $event"
@stop-resize="resizeDirection = null" @stop-resize="resizeDirection = null"
@start-drag="isMove = true" @start-drag="isMove = true"
@@ -39,8 +40,8 @@
v-show="resizeDirection || isMove || isDrag || showMask || (drawField && isMobile)" v-show="resizeDirection || isMove || isDrag || showMask || (drawField && isMobile)"
id="mask" id="mask"
ref="mask" ref="mask"
class="top-0 bottom-0 left-0 right-0 absolute z-10" class="top-0 bottom-0 left-0 right-0 absolute"
:class="{ 'cursor-grab': isDrag || isMove, 'cursor-nwse-resize': drawField, [resizeDirectionClasses[resizeDirection]]: !!resizeDirectionClasses }" :class="{ 'z-10': !isMobile, 'cursor-grab': isDrag || isMove, 'cursor-nwse-resize': drawField, [resizeDirectionClasses[resizeDirection]]: !!resizeDirectionClasses }"
@pointermove="onPointermove" @pointermove="onPointermove"
@pointerdown="onStartDraw" @pointerdown="onStartDraw"
@dragover.prevent @dragover.prevent
@@ -77,6 +78,11 @@ export default {
required: false, required: false,
default: null default: null
}, },
editable: {
type: Boolean,
required: false,
default: true
},
isDrag: { isDrag: {
type: Boolean, type: Boolean,
required: false, required: false,
@@ -141,6 +147,10 @@ export default {
return return
} }
if (!this.editable) {
return
}
this.showMask = true this.showMask = true
this.$nextTick(() => { this.$nextTick(() => {
+15 -1
View File
@@ -12,10 +12,14 @@
class="group flex justify-end cursor-pointer top-0 bottom-0 left-0 right-0 absolute p-1" class="group flex justify-end cursor-pointer top-0 bottom-0 left-0 right-0 absolute p-1"
@click="$emit('scroll-to', item)" @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 style="width: 26px" />
<div class=""> <div class="">
<ReplaceButton <ReplaceButton
v-if="withReplaceButton"
:is-direct-upload="isDirectUpload" :is-direct-upload="isDirectUpload"
:template-id="template.id" :template-id="template.id"
:accept-file-types="acceptFileTypes" :accept-file-types="acceptFileTypes"
@@ -95,11 +99,21 @@ export default {
type: Object, type: Object,
required: true required: true
}, },
editable: {
type: Boolean,
required: false,
default: true
},
acceptFileTypes: { acceptFileTypes: {
type: String, type: String,
required: false, required: false,
default: 'image/*, application/pdf' default: 'image/*, application/pdf'
}, },
withReplaceButton: {
type: Boolean,
required: true,
default: true
},
isDirectUpload: { isDirectUpload: {
type: Boolean, type: Boolean,
required: true, required: true,
+10 -10
View File
@@ -10,19 +10,18 @@ class ProcessSubmitterCompletionJob < ApplicationJob
Submissions::EnsureResultGenerated.call(submitter) Submissions::EnsureResultGenerated.call(submitter)
if submitter.account.encrypted_configs.exists?(key: EncryptedConfig::WEBHOOK_URL_KEY) if is_all_completed && submitter.completed_at == submitter.submission.submitters.maximum(:completed_at)
SendFormCompletedWebhookRequestJob.perform_later(submitter) Submissions::GenerateAuditTrail.call(submitter.submission)
enqueue_completed_emails(submitter)
end end
return unless is_all_completed return if Accounts.load_webhook_configs(submitter.account).blank?
return if submitter.completed_at != submitter.submission.submitters.maximum(:completed_at)
Submissions::GenerateAuditTrail.call(submitter.submission) SendFormCompletedWebhookRequestJob.perform_later(submitter)
enqueue_emails(submitter)
end end
def enqueue_emails(submitter) def enqueue_completed_emails(submitter)
user = submitter.submission.created_by_user || submitter.template.author user = submitter.submission.created_by_user || submitter.template.author
if submitter.template.account.users.exists?(id: user.id) if submitter.template.account.users.exists?(id: user.id)
@@ -32,7 +31,8 @@ class ProcessSubmitterCompletionJob < ApplicationJob
SubmitterMailer.completed_email(submitter, user, bcc:).deliver_later! SubmitterMailer.completed_email(submitter, user, bcc:).deliver_later!
end end
to = submitter.submission.submitters.sort_by(&:completed_at).select(&:email?).map(&:friendly_name).join(', ') to = submitter.submission.submitters.reject { |e| e.preferences['send_email'] == false }
.sort_by(&:completed_at).select(&:email?).map(&:friendly_name).join(', ')
SubmitterMailer.documents_copy_email(submitter, to:).deliver_later! if to.present? SubmitterMailer.documents_copy_email(submitter, to:).deliver_later! if to.present?
end end
@@ -49,6 +49,6 @@ class ProcessSubmitterCompletionJob < ApplicationJob
next_submitter = submitter.submission.submitters.find { |s| s.uuid == next_submitter_item['uuid'] } next_submitter = submitter.submission.submitters.find { |s| s.uuid == next_submitter_item['uuid'] }
Submitters.send_signature_requests([next_submitter], send_email: true) Submitters.send_signature_requests([next_submitter])
end end
end end
@@ -4,7 +4,7 @@ class SendSubmitterInvitationEmailJob < ApplicationJob
def perform(params = {}) def perform(params = {})
submitter = Submitter.find(params['submitter_id']) submitter = Submitter.find(params['submitter_id'])
SubmitterMailer.invitation_email(submitter, subject: params['subject'], body: params['body']).deliver_now! SubmitterMailer.invitation_email(submitter).deliver_now!
SubmissionEvent.create!(submitter:, event_type: 'send_email') SubmissionEvent.create!(submitter:, event_type: 'send_email')
+16 -8
View File
@@ -3,18 +3,26 @@
class SubmitterMailer < ApplicationMailer class SubmitterMailer < ApplicationMailer
MAX_ATTACHMENTS_SIZE = 10.megabytes MAX_ATTACHMENTS_SIZE = 10.megabytes
def invitation_email(submitter, body: nil, subject: nil) DEFAULT_INVITATION_SUBJECT = 'You are invited to submit a form'
def invitation_email(submitter)
@current_account = submitter.submission.template.account @current_account = submitter.submission.template.account
@submitter = submitter @submitter = submitter
@body = body.presence
@email_config = @current_account.account_configs.find_by(key: AccountConfig::SUBMITTER_INVITATION_EMAIL_KEY) if submitter.preferences['email_message_uuid']
@email_message = submitter.account.email_messages.find_by(uuid: submitter.preferences['email_message_uuid'])
end
@body = @email_message&.body.presence
@subject = @email_message&.subject.presence
@email_config = AccountConfigs.find_for_account(@current_account, AccountConfig::SUBMITTER_INVITATION_EMAIL_KEY)
subject = subject =
if @email_config || subject.present? if @email_config || @subject
ReplaceEmailVariables.call(subject.presence || @email_config.value['subject'], submitter:) ReplaceEmailVariables.call(@subject || @email_config.value['subject'], submitter:)
else else
'You are invited to submit a form' DEFAULT_INVITATION_SUBJECT
end end
mail(to: @submitter.friendly_name, mail(to: @submitter.friendly_name,
@@ -31,7 +39,7 @@ class SubmitterMailer < ApplicationMailer
Submissions::EnsureResultGenerated.call(submitter) 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) add_completed_email_attachments!(submitter)
@@ -58,7 +66,7 @@ class SubmitterMailer < ApplicationMailer
@documents = add_completed_email_attachments!(submitter) @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 = subject =
if @email_config if @email_config
+1 -1
View File
@@ -21,7 +21,7 @@
# fk_rails_... (user_id => users.id) # fk_rails_... (user_id => users.id)
# #
class AccessToken < ApplicationRecord class AccessToken < ApplicationRecord
TOKEN_LENGTH = 22 TOKEN_LENGTH = 43
belongs_to :user belongs_to :user
+1
View File
@@ -15,6 +15,7 @@ class Account < ApplicationRecord
has_many :users, dependent: :destroy has_many :users, dependent: :destroy
has_many :encrypted_configs, dependent: :destroy has_many :encrypted_configs, dependent: :destroy
has_many :account_configs, dependent: :destroy has_many :account_configs, dependent: :destroy
has_many :email_messages, dependent: :destroy
has_many :templates, dependent: :destroy has_many :templates, dependent: :destroy
has_many :template_folders, dependent: :destroy has_many :template_folders, dependent: :destroy
has_one :default_template_folder, -> { where(name: TemplateFolder::DEFAULT_NAME) }, has_one :default_template_folder, -> { where(name: TemplateFolder::DEFAULT_NAME) },
+1 -1
View File
@@ -49,7 +49,7 @@ class AccountConfig < ApplicationRecord
'body' => "Hi there,\n\n" \ 'body' => "Hi there,\n\n" \
"Please check the copy of your \"{{template.name}}\" submission in the email attachments.\n" \ "Please check the copy of your \"{{template.name}}\" submission in the email attachments.\n" \
"Alternatively, you can download your copy using:\n\n" \ "Alternatively, you can download your copy using:\n\n" \
"{{documents.links}}\n\n" \ "{{documents.link}}\n\n" \
"Thanks,\n" \ "Thanks,\n" \
'{{account.name}}' '{{account.name}}'
} }
+39
View File
@@ -0,0 +1,39 @@
# frozen_string_literal: true
# == Schema Information
#
# Table name: email_messages
#
# id :bigint not null, primary key
# body :text not null
# sha1 :string not null
# subject :text not null
# uuid :string not null
# created_at :datetime not null
# updated_at :datetime not null
# account_id :bigint not null
# author_id :bigint not null
#
# Indexes
#
# index_email_messages_on_account_id (account_id)
# index_email_messages_on_sha1 (sha1)
# index_email_messages_on_uuid (uuid)
#
# Foreign Keys
#
# fk_rails_... (account_id => accounts.id)
# fk_rails_... (author_id => users.id)
#
class EmailMessage < ApplicationRecord
belongs_to :author, class_name: 'User'
belongs_to :account
attribute :uuid, :string, default: -> { SecureRandom.uuid }
before_validation :set_sha1, on: :create
def set_sha1
self.sha1 = Digest::SHA1.hexdigest({ subject:, body: }.to_json)
end
end
+14 -1
View File
@@ -6,6 +6,8 @@
# #
# id :bigint not null, primary key # id :bigint not null, primary key
# deleted_at :datetime # deleted_at :datetime
# preferences :text not null
# slug :string not null
# source :text not null # source :text not null
# submitters_order :string not null # submitters_order :string not null
# template_fields :text # template_fields :text
@@ -19,6 +21,7 @@
# Indexes # Indexes
# #
# index_submissions_on_created_by_user_id (created_by_user_id) # index_submissions_on_created_by_user_id (created_by_user_id)
# index_submissions_on_slug (slug) UNIQUE
# index_submissions_on_template_id (template_id) # index_submissions_on_template_id (template_id)
# #
# Foreign Keys # Foreign Keys
@@ -34,13 +37,18 @@ class Submission < ApplicationRecord
has_many :submitters, dependent: :destroy has_many :submitters, dependent: :destroy
has_many :submission_events, dependent: :destroy has_many :submission_events, dependent: :destroy
attribute :preferences, :string, default: -> { {} }
serialize :template_fields, JSON serialize :template_fields, JSON
serialize :template_schema, JSON serialize :template_schema, JSON
serialize :template_submitters, JSON serialize :template_submitters, JSON
serialize :preferences, JSON
attribute :source, :string, default: 'link' attribute :source, :string, default: 'link'
attribute :submitters_order, :string, default: 'random' attribute :submitters_order, :string, default: 'random'
attribute :slug, :string, default: -> { SecureRandom.base58(14) }
has_one_attached :audit_trail has_one_attached :audit_trail
has_many :template_schema_documents, has_many :template_schema_documents,
@@ -49,7 +57,7 @@ class Submission < ApplicationRecord
scope :active, -> { where(deleted_at: nil) } scope :active, -> { where(deleted_at: nil) }
scope :pending, -> { joins(:submitters).where(submitters: { completed_at: nil }).distinct } scope :pending, -> { joins(:submitters).where(submitters: { completed_at: nil }).distinct }
scope :completed, -> { left_joins(:submitters).where.not(submitters: { completed_at: nil }).distinct } scope :completed, -> { where.not(id: pending.select(:submission_id)) }
enum :source, { enum :source, {
invite: 'invite', invite: 'invite',
@@ -62,4 +70,9 @@ class Submission < ApplicationRecord
random: 'random', random: 'random',
preserved: 'preserved' preserved: 'preserved'
}, scope: false, prefix: true }, scope: false, prefix: true
def audit_trail_url
audit_trail&.url
end
alias audit_log_url audit_trail_url
end end
+4 -1
View File
@@ -12,6 +12,7 @@
# name :string # name :string
# opened_at :datetime # opened_at :datetime
# phone :string # phone :string
# preferences :text not null
# sent_at :datetime # sent_at :datetime
# slug :string not null # slug :string not null
# ua :string # ua :string
@@ -37,9 +38,11 @@ class Submitter < ApplicationRecord
has_one :account, through: :template has_one :account, through: :template
attribute :values, :string, default: -> { {} } attribute :values, :string, default: -> { {} }
attribute :preferences, :string, default: -> { {} }
attribute :slug, :string, default: -> { SecureRandom.base58(14) } attribute :slug, :string, default: -> { SecureRandom.base58(14) }
serialize :values, JSON serialize :values, JSON
serialize :preferences, JSON
has_many_attached :documents has_many_attached :documents
has_many_attached :attachments has_many_attached :attachments
@@ -61,7 +64,7 @@ class Submitter < ApplicationRecord
def friendly_name def friendly_name
if name.present? && email.present? && email.exclude?(',') if name.present? && email.present? && email.exclude?(',')
"#{name} <#{email}>" %("#{name.delete('"')}" <#{email}>)
else else
email email
end end
+15 -14
View File
@@ -4,19 +4,20 @@
# #
# Table name: templates # Table name: templates
# #
# id :bigint not null, primary key # id :bigint not null, primary key
# deleted_at :datetime # application_key :string
# fields :text not null # deleted_at :datetime
# name :string not null # fields :text not null
# schema :text not null # name :string not null
# slug :string not null # schema :text not null
# source :text not null # slug :string not null
# submitters :text not null # source :text not null
# created_at :datetime not null # submitters :text not null
# updated_at :datetime not null # created_at :datetime not null
# account_id :bigint not null # updated_at :datetime not null
# author_id :bigint not null # account_id :bigint not null
# folder_id :bigint not null # author_id :bigint not null
# folder_id :bigint not null
# #
# Indexes # Indexes
# #
@@ -32,7 +33,7 @@
# fk_rails_... (folder_id => template_folders.id) # fk_rails_... (folder_id => template_folders.id)
# #
class Template < ApplicationRecord class Template < ApplicationRecord
DEFAULT_SUBMITTER_NAME = 'First Submitter' DEFAULT_SUBMITTER_NAME = 'First Party'
belongs_to :author, class_name: 'User' belongs_to :author, class_name: 'User'
belongs_to :account belongs_to :account
+3 -1
View File
@@ -52,8 +52,10 @@ class User < ApplicationRecord
belongs_to :account belongs_to :account
has_one :access_token, dependent: :destroy has_one :access_token, dependent: :destroy
has_many :templates, dependent: :destroy, foreign_key: :author_id, inverse_of: :author 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 :user_configs, dependent: :destroy
has_many :encrypted_configs, dependent: :destroy, class_name: 'EncryptedUserConfig' has_many :encrypted_configs, dependent: :destroy, class_name: 'EncryptedUserConfig'
has_many :email_messages, dependent: :destroy
devise :two_factor_authenticatable, :recoverable, :rememberable, :validatable, :trackable devise :two_factor_authenticatable, :recoverable, :rememberable, :validatable, :trackable
devise :registerable, :omniauthable, omniauth_providers: [:google_oauth2] if Docuseal.multitenant? devise :registerable, :omniauthable, omniauth_providers: [:google_oauth2] if Docuseal.multitenant?
@@ -94,7 +96,7 @@ class User < ApplicationRecord
def friendly_name def friendly_name
if full_name.present? if full_name.present?
"#{full_name} <#{email}>" %("#{full_name.delete('"')}" <#{email}>)
else else
email email
end end
+1 -1
View File
@@ -34,7 +34,7 @@
"submitters": [ "submitters": [
{ {
"name": "John Doe", "name": "John Doe",
"role": "<%= current_account.templates.last ? current_account.templates.last.submitters.first['name'] : 'First Submitter' %>", "role": "<%= current_account.templates.last ? current_account.templates.last.submitters.first['name'] : 'First Party' %>",
"email": "<%= current_user.email.sub('@', '+test@') %>", "email": "<%= current_user.email.sub('@', '+test@') %>",
"values": { "values": {
"Form Text Field Name": "Default Value" "Form Text Field Name": "Default Value"
+5 -1
View File
@@ -16,7 +16,11 @@
</div> </div>
<% end %> <% end %>
<% if devise_mapping.omniauthable? %> <% 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 %> <% end %>
<%= render 'devise/shared/links' %> <%= render 'devise/shared/links' %>
</div> </div>
+5 -1
View File
@@ -20,7 +20,11 @@
</div> </div>
<% end %> <% end %>
<% if devise_mapping.omniauthable? %> <% if devise_mapping.omniauthable? %>
<%= button_to button_title(title: 'Sign in with Google', icon: svg_icon('brand_google', class: 'w-6 h-6')), omniauth_authorize_path(resource_name, :google_oauth2), class: 'white-button w-full mt-4', data: { turbo: false }, method: :post %> <%= 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 %> <% end %>
<%= render 'devise/shared/links' %> <%= render 'devise/shared/links' %>
</div> </div>
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -0,0 +1,15 @@
<script>
if (!window.customElements.get('autosize-field')) {
window.customElements.define('autosize-field', class extends HTMLElement {
connectedCallback() {
if (this.field.scrollHeight > this.field.clientHeight) {
this.field.classList.remove('text-[1.5vw]', 'lg:text-base');
this.field.classList.add('text-[1.0vw]', 'lg:text-[0.70rem]');
}
}
get field() {
return this.closest('field-value');
}
});
}
</script>
+6 -9
View File
@@ -3,7 +3,9 @@
<a href="<%= root_path %>" class="text-2xl font-bold items-center flex space-x-2"> <a href="<%= root_path %>" class="text-2xl font-bold items-center flex space-x-2">
<%= render 'shared/title' %> <%= render 'shared/title' %>
</a> </a>
<%= render 'shared/github' %> <span class="hidden sm:inline">
<%= render 'shared/github' %>
</span>
</div> </div>
<% if signed_in? %> <% if signed_in? %>
<div class="space-x-4 flex items-center"> <div class="space-x-4 flex items-center">
@@ -16,13 +18,7 @@
</span> </span>
<% else %> <% else %>
<div class="flex items-center justify-center space-x-4 mr-1"> <div class="flex items-center justify-center space-x-4 mr-1">
<% if can?(:manage, EncryptedConfig) && !can?(:manage, :tenants) %> <%= render 'shared/navbar_buttons' %>
<%= 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 %>
<%= link_to 'Settings', settings_profile_index_path, class: 'hidden md:inline-flex font-medium text-lg' %> <%= link_to 'Settings', settings_profile_index_path, class: 'hidden md:inline-flex font-medium text-lg' %>
</div> </div>
<% end %> <% end %>
@@ -75,7 +71,8 @@
<% if Docuseal.multitenant? && !request.path.in?([registration_path, new_registration_path]) %> <% if Docuseal.multitenant? && !request.path.in?([registration_path, new_registration_path]) %>
<%= link_to registration_path, class: 'btn btn-neutral btn-sm btn-outline' do %> <%= 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="flex items-center justify-center space-x-1">
<span>Create free Account</span> <span class="hidden sm:block">Create free Account</span>
<span class="md:hidden">Sign Up</span>
</span> </span>
<% end %> <% end %>
<% end %> <% end %>
@@ -0,0 +1,4 @@
<%= link_to Docuseal.multitenant? ? console_redirect_index_path(redir: "#{Docuseal::CONSOLE_URL}/plans") : "#{Docuseal::CONSOLE_URL}/on_premise", class: 'hidden md:inline-flex btn btn-warning btn-sm', data: { prefetch: false } do %>
Upgrade
<% end %>
<span class="hidden md:inline-flex h-3 border-r border-base-content"></span>
-9
View File
@@ -1,9 +0,0 @@
<script defer data-domain="<%= ENV.fetch('PLAUSIBLE_DOMAIN', nil) %>" src="https://plausible.io/js/script.manual.js"></script>
<script>window.plausible = window.plausible || function() { (window.plausible.q = window.plausible.q || []).push(arguments) }</script>
<script>
document.addEventListener("turbo:load", function (e) {
if (e.detail.url.match(/sign_in|sign_up|password/)) {
plausible('pageview')
}
})
</script>
+12 -4
View File
@@ -1,7 +1,8 @@
<div class="block w-full md:w-52 flex-none"> <div class="block w-full md:w-52 flex-none">
<menu-active> <menu-active>
<ul class="menu px-0"> <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></li>
<li> <li>
<%= link_to 'Profile', settings_profile_index_path, class: 'text-base hover:bg-base-300' %> <%= link_to 'Profile', settings_profile_index_path, class: 'text-base hover:bg-base-300' %>
@@ -70,16 +71,23 @@
<li> <li>
<%= link_to Docuseal.multitenant? ? console_redirect_index_path(redir: "#{Docuseal::CONSOLE_URL}/plans") : "#{Docuseal::CONSOLE_URL}/on_premise", class: 'text-base hover:bg-base-300', data: { prefetch: false } do %> <%= 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 Plans
<span class="badge badge-warning">New</span>
<% end %> <% end %>
</li> </li>
<% end %> <% end %>
<% if !Docuseal.demo? && can?(:manage, EncryptedConfig) %> <% if !Docuseal.demo? && can?(:manage, EncryptedConfig) %>
<li> <li>
<%= link_to Docuseal.multitenant? ? console_redirect_index_path : Docuseal::CONSOLE_URL, class: 'text-base hover:bg-base-300', data: { prefetch: false } do %> <%= 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 %>
Console <% if Docuseal.multitenant? %> API <% else %> Console <% end %>
<span class="badge badge-warning">New</span>
<% end %> <% end %>
</li> </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 %> <% end %>
</ul> </ul>
</menu-active> </menu-active>
+1 -1
View File
@@ -27,7 +27,7 @@
<%= 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| %> <%= 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"> <div class="form-control !mt-0">
<%= f.label :email, class: 'label' %> <%= 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>
<div class="form-control"> <div class="form-control">
<%= f.button button_title(title: 'Start', disabled_with: 'Starting'), class: 'base-button' %> <%= f.button button_title(title: 'Start', disabled_with: 'Starting'), class: 'base-button' %>
+1
View File
@@ -0,0 +1 @@
<%= render 'shared/logo', width: 40, height: 40 %>
@@ -2,8 +2,8 @@
<div class="form-control"> <div class="form-control">
<%= f.label :preserve_order, for: uuid = SecureRandom.uuid, class: 'flex items-center cursor-pointer' do %> <%= f.label :preserve_order, for: uuid = SecureRandom.uuid, class: 'flex items-center cursor-pointer' do %>
<%= f.check_box :preserve_order, id: uuid, class: 'base-checkbox', checked: template.submissions.last&.submitters_order.in?(['preserved', nil]) %> <%= f.check_box :preserve_order, id: uuid, class: 'base-checkbox', checked: template.submissions.last&.submitters_order.in?(['preserved', nil]) %>
<span class="label">Preserve submitters order</span> <span class="label">Preserve order</span>
<span class="tooltip" data-tip="When checked, notifications will be sent to the second submitter once the form is completed by the previous submitter. Uncheck this option to send notifications to all submitters simultaneously right away."> <span class="tooltip" data-tip="When checked, notifications will be sent to the second party once the form is completed by the previous party. Uncheck this option to send notifications to all parties simultaneously right away.">
<%= svg_icon('info_circle', class: 'w-4 h-4') %> <%= svg_icon('info_circle', class: 'w-4 h-4') %>
</span> </span>
<% end %> <% end %>
+13 -3
View File
@@ -1,7 +1,8 @@
<div class="flex absolute text-[1.5vw] lg:text-base" style="width: <%= area['w'] * 100 %>%; height: <%= area['h'] * 100 %>%; left: <%= area['x'] * 100 %>%; top: <%= area['y'] * 100 %>%"> <field-value class="flex absolute text-[1.5vw] lg:text-base" style="width: <%= area['w'] * 100 %>%; height: <%= area['h'] * 100 %>%; left: <%= area['x'] * 100 %>%; top: <%= area['y'] * 100 %>%">
<% if field['type'].in?(['signature', 'image', 'initials']) %> <% if field['type'].in?(['signature', 'image', 'initials']) %>
<img class="object-contain mx-auto" src="<%= attachments_index[value].url %>" loading="lazy"> <img class="object-contain mx-auto" src="<%= attachments_index[value].url %>" loading="lazy">
<% elsif field['type'] == 'file' %> <% elsif field['type'] == 'file' %>
<autosize-field></autosize-field>
<div class="px-0.5 flex flex-col justify-center"> <div class="px-0.5 flex flex-col justify-center">
<% Array.wrap(value).each do |val| %> <% Array.wrap(value).each do |val| %>
<a target="_blank" href="<%= attachments_index[val].url %>"> <a target="_blank" href="<%= attachments_index[val].url %>">
@@ -11,9 +12,16 @@
<% end %> <% end %>
</div> </div>
<% elsif field['type'] == 'checkbox' %> <% 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'}") %> <%= svg_icon('check', class: "aspect-square #{area['w'] > area['h'] ? '!w-auto !h-full' : '!w-full !h-auto'}") %>
</div> </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' %> <% elsif field['type'] == 'cells' %>
<% cell_width = area['cell_w'] / area['w'] * 100 %> <% cell_width = area['cell_w'] / area['w'] * 100 %>
<div class="w-full flex items-center"> <div class="w-full flex items-center">
@@ -24,10 +32,12 @@
<% end %> <% end %>
</div> </div>
<% elsif field['type'] == 'date' %> <% elsif field['type'] == 'date' %>
<autosize-field></autosize-field>
<div class="flex items-center px-0.5"> <div class="flex items-center px-0.5">
<%= l(Date.parse(value), format: :long, locale: local_assigns[:locale]) %> <%= l(Date.parse(value), format: :long, locale: local_assigns[:locale]) %>
</div> </div>
<% else %> <% else %>
<autosize-field></autosize-field>
<div class="flex items-center px-0.5 whitespace-pre-wrap"><%= Array.wrap(value).join(', ') %></div> <div class="flex items-center px-0.5 whitespace-pre-wrap"><%= Array.wrap(value).join(', ') %></div>
<% end %> <% end %>
</div> </field-value>
+15 -12
View File
@@ -1,25 +1,27 @@
<div style="max-width: 1600px" class="mx-auto pl-4"> <div style="max-width: 1600px" class="mx-auto pl-4">
<div class="flex justify-between py-1.5 items-center pr-4 sticky top-0 md:relative z-10 bg-base-100"> <div class="flex justify-between py-1.5 items-center pr-4 sticky top-0 md:relative z-10 bg-base-100">
<a href="<%= template_path(@submission.template) %>" class="flex space-x-3 py-1"> <a href="<%= signed_in? && @submission.template.account_id == current_account&.id ? template_path(@submission.template) : '/' %>" class="flex items-center space-x-3 py-1">
<span><%= render 'shared/logo', width: 40, height: 40 %></span> <span><%= render 'submissions/logo' %></span>
<span class="text-3xl font-semibold focus:text-clip"><%= @submission.template.name %></span> <span class="text-xl md:text-3xl font-semibold focus:text-clip" style="overflow: hidden; display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2;"><%= @submission.template.name %></span>
</a> </a>
<div class="space-x-3 flex items-center"> <div class="space-x-3 flex items-center">
<% if @submission.audit_trail.present? %> <% if @submission.audit_trail.present? %>
<a href="<%= rails_blob_path(@submission.audit_trail) %>" class="white-button" target="_blank"> <a href="<%= rails_blob_path(@submission.audit_trail) %>" class="white-button" target="_blank">
<%= svg_icon('external_link', class: 'w-6 h-6') %> <%= svg_icon('external_link', class: 'w-6 h-6') %>
Audit Log <span class="hidden md:inline">
Audit Log
</span>
</a> </a>
<% end %> <% end %>
<% if last_submitter = @submission.submitters.to_a.select(&:completed_at?).max_by(&:completed_at) %> <% if last_submitter = @submission.submitters.to_a.select(&:completed_at?).max_by(&:completed_at) %>
<download-button data-src="<%= submitter_download_index_path(last_submitter.slug) %>" class="base-button"> <download-button data-src="<%= submitter_download_index_path(last_submitter.slug) %>" class="base-button">
<span class="flex items-center justify-center space-x-2" data-target="download-button.defaultButton"> <span class="flex items-center justify-center space-x-2" data-target="download-button.defaultButton">
<%= svg_icon('download', class: 'w-6 h-6') %> <%= svg_icon('download', class: 'w-6 h-6') %>
<span>Download</span> <span class="hidden md:inline">Download</span>
</span> </span>
<span class="flex items-center justify-center space-x-2 hidden" data-target="download-button.loadingButton"> <span class="flex items-center justify-center space-x-2 hidden" data-target="download-button.loadingButton">
<%= svg_icon('loader', class: 'w-6 h-6 animate-spin') %> <%= svg_icon('loader', class: 'w-6 h-6 animate-spin') %>
<span>Downloading</span> <span class="hidden md:inline">Downloading</span>
</span> </span>
</download-button> </download-button>
<% elsif @submission.submitters.to_a.size == 1 %> <% elsif @submission.submitters.to_a.size == 1 %>
@@ -61,7 +63,7 @@
<% fields_index.dig(document.uuid, index)&.each do |(area, field)| %> <% fields_index.dig(document.uuid, index)&.each do |(area, field)| %>
<% value = values[field['uuid']] %> <% value = values[field['uuid']] %>
<% next if value.blank? %> <% 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 %> <% end %>
</div> </div>
</div> </div>
@@ -109,18 +111,18 @@
<div class="flex items-center space-x-1 mt-1"> <div class="flex items-center space-x-1 mt-1">
<%= svg_icon('writing', class: 'w-5 h-5') %> <%= svg_icon('writing', class: 'w-5 h-5') %>
<span> <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> </span>
</div> </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"> <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' %> <%= 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> </div>
<% end %> <% 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: %> <%= render 'send_sms_button', submitter: %>
<% end %> <% 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"> <div class="mt-2 mb-1">
<a class="btn btn-sm btn-primary w-full" target="_blank" href="<%= submit_form_path(slug: submitter.slug) %>"> <a class="btn btn-sm btn-primary w-full" target="_blank" href="<%= submit_form_path(slug: submitter.slug) %>">
Submit Form Submit Form
@@ -160,7 +162,7 @@
<% elsif field['type'] == 'checkbox' %> <% elsif field['type'] == 'checkbox' %>
<%= svg_icon('check', class: 'w-6 h-6') %> <%= svg_icon('check', class: 'w-6 h-6') %>
<% elsif field['type'] == 'date' %> <% 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 %> <% else %>
<%= Array.wrap(value).join(', ') %> <%= Array.wrap(value).join(', ') %>
<% end %> <% end %>
@@ -172,3 +174,4 @@
</div> </div>
</div> </div>
</div> </div>
<%= render 'scripts/autosize_field' %>
+3 -1
View File
@@ -1 +1,3 @@
<%= render 'docuseal_logo' %> <div class="flex mt-4">
<%= render 'docuseal_logo' %>
</div>
+1 -1
View File
@@ -39,7 +39,7 @@
<% if @submitter.submission.template.submitters.size == 1 %> <% if @submitter.submission.template.submitters.size == 1 %>
<div class="divider">OR</div> <div class="divider">OR</div>
<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 }, resubmit: true }, method: :put, form: { onsubmit: 'event.submitter.disabled = true' }, class: 'white-button w-full' %> <%= 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> </div>
<% end %> <% end %>
</div> </div>
+4 -4
View File
@@ -5,9 +5,8 @@
<div style="max-height: -webkit-fill-available;"> <div style="max-height: -webkit-fill-available;">
<div id="scrollbox"> <div id="scrollbox">
<div class="mx-auto block pb-72" style="max-width: 1000px"> <div class="mx-auto block pb-72" style="max-width: 1000px">
<div class="mt-4 flex"> <%# flex block w-full sticky top-0 z-50 space-x-2 items-center bg-yellow-100 p-2 border-y border-yellow-200 %>
<%= render 'banner' %> <%= render 'banner' %>
</div>
<% (@submitter.submission.template_schema || @submitter.submission.template.schema).each do |item| %> <% (@submitter.submission.template_schema || @submitter.submission.template.schema).each do |item| %>
<% document = @submitter.submission.template_schema_documents.find { |a| a.uuid == item['attachment_uuid'] } %> <% document = @submitter.submission.template_schema_documents.find { |a| a.uuid == item['attachment_uuid'] } %>
<% document_annots_index = document.metadata.dig('pdf', 'annotations')&.group_by { |e| e['page'] } || {} %> <% document_annots_index = document.metadata.dig('pdf', 'annotations')&.group_by { |e| e['page'] } || {} %>
@@ -22,7 +21,7 @@
<%= render 'submissions/annotation', annot: %> <%= render 'submissions/annotation', annot: %>
<% end %> <% end %>
<% fields_index.dig(document.uuid, index)&.each do |(area, field)| %> <% fields_index.dig(document.uuid, index)&.each do |(area, field)| %>
<% value = values[field['uuid']] %> <% value = values[field['uuid']].presence || (field['readonly'] ? Submitters::SubmitValues.template_default_value_for_submitter(field['default_value'], @submitter.submission.submitters.find { |e| e.uuid == field['submitter_uuid'] }, with_time: false) : nil) %>
<% next if value.blank? %> <% next if value.blank? %>
<% next if !field['readonly'] && field['submitter_uuid'] == @submitter.uuid %> <% next if !field['readonly'] && field['submitter_uuid'] == @submitter.uuid %>
<%= render 'submissions/value', area:, field:, attachments_index:, value:, locale: @submitter.submission.template.account.locale %> <%= render 'submissions/value', area:, field:, attachments_index:, value:, locale: @submitter.submission.template.account.locale %>
@@ -42,3 +41,4 @@
</div> </div>
</div> </div>
</div> </div>
<%= render 'scripts/autosize_field' %>
@@ -3,14 +3,10 @@
<% else %> <% else %>
<p>Hi there,</p> <p>Hi there,</p>
<p>Please check the copy of your "<%= @submitter.submission.template.name %>" submission in the email attachments.</p> <p>Please check the copy of your "<%= @submitter.submission.template.name %>" submission in the email attachments.</p>
<p>Alternatively, you can download your copy using:</p> <p>Alternatively, you can review and download your copy using:</p>
<% @documents.each do |document| %> <p>
<ul> <%= link_to @submitter.template.name, submissions_preview_url(@submitter.submission.slug) %>
<li> </p>
<%= link_to document.filename.to_s, rails_blob_url(document) %>
</li>
</ul>
<% end %>
<p> <p>
Thanks,<br><%= @current_account.name %> Thanks,<br><%= @current_account.name %>
</p> </p>
+2 -2
View File
@@ -4,14 +4,14 @@
<%= hidden_field_tag :base_template_id, @base_template.id %> <%= hidden_field_tag :base_template_id, @base_template.id %>
<% end %> <% end %>
<div class="form-control mt-6"> <div class="form-control mt-6">
<%= f.text_field :name, required: true, placeholder: 'Template Name', class: 'base-input' %> <%= f.text_field :name, required: true, placeholder: 'Document Template Name', class: 'base-input' %>
</div> </div>
<div class="mt-3 mb-4 flex items-center justify-between"> <div class="mt-3 mb-4 flex items-center justify-between">
<a href="#" onclick="[event.preventDefault(), window.folder_name.focus()]"> <a href="#" onclick="[event.preventDefault(), window.folder_name.focus()]">
<%= svg_icon('folder', class: 'w-6 h-6') %> <%= svg_icon('folder', class: 'w-6 h-6') %>
</a> </a>
<folder-autocomplete class="flex justify-between w-full"> <folder-autocomplete class="flex justify-between w-full">
<input id="folder_name" type="text" class="w-64 outline-none border-transparent focus:border-transparent focus:ring-0 bg-base-100 px-1 peer" name="folder_name" value="<%= params[:folder_name].presence || @base_template&.folder&.name || TemplateFolder::DEFAULT_NAME %>" onblur="window.folder_name.value = window.folder_name.value || 'Default'" autocomplete="off"> <input id="folder_name" placeholder="Folder Name" type="text" class="w-64 outline-none border-transparent focus:border-transparent focus:ring-0 bg-base-100 px-1 peer" name="folder_name" value="<%= params[:folder_name].presence || @base_template&.folder&.name || TemplateFolder::DEFAULT_NAME %>" onblur="window.folder_name.value = window.folder_name.value || 'Default'" autocomplete="off">
<a href="#" onclick="[event.preventDefault(), window.folder_name.value = '', window.folder_name.focus()]" class="link peer-focus:hidden mr-1.5"> <a href="#" onclick="[event.preventDefault(), window.folder_name.value = '', window.folder_name.focus()]" class="link peer-focus:hidden mr-1.5">
Change Folder Change Folder
</a> </a>
+3 -1
View File
@@ -22,9 +22,11 @@ module DocuSeal
config.active_storage.routes_prefix = '' config.active_storage.routes_prefix = ''
config.i18n.available_locales = %i[en en-US en-GB es-ES pt-PT de-DE] config.i18n.available_locales = %i[en en-US en-GB es-ES fr-FR pt-PT de-DE]
config.i18n.fallbacks = [:en] config.i18n.fallbacks = [:en]
config.exceptions_app = ->(env) { ErrorsController.action(:show).call(env) }
config.action_view.frozen_string_literal = true config.action_view.frozen_string_literal = true
config.middleware.insert_before ActionDispatch::Static, Rack::Deflater config.middleware.insert_before ActionDispatch::Static, Rack::Deflater
+14
View File
@@ -24,5 +24,19 @@ if ENV['RAILS_ENV'] == 'production' && ENV['SECRET_KEY_BASE'].to_s.empty?
ENV['DATABASE_URL'] = ENV['DATABASE_URL'].to_s.empty? ? database_url : ENV.fetch('DATABASE_URL', nil) ENV['DATABASE_URL'] = ENV['DATABASE_URL'].to_s.empty? ? database_url : ENV.fetch('DATABASE_URL', nil)
end end
if ENV['DATABASE_URL'].to_s.split('@').last.to_s.split('/').first.to_s.include?('_')
require 'addressable'
url = Addressable::URI.parse(ENV.fetch('DATABASE_URL', ''))
ENV['DATABASE_HOST'] = url.host
ENV['DATABASE_PORT'] = (url.port || 5432).to_s
ENV['DATABASE_USER'] = url.user
ENV['DATABASE_PASSWORD'] = url.password
ENV['DATABASE_NAME'] = url.path.to_s.delete_prefix('/')
ENV.delete('DATABASE_URL')
end
require 'bundler/setup' # Set up gems listed in the Gemfile. require 'bundler/setup' # Set up gems listed in the Gemfile.
require 'bootsnap/setup' # Speed up boot time by caching expensive operations. require 'bootsnap/setup' # Speed up boot time by caching expensive operations.
+8 -1
View File
@@ -12,7 +12,14 @@ test:
database: docuseal_test database: docuseal_test
production: production:
<% if ENV['DATABASE_URL'].to_s.empty? %> <% if !ENV['DATABASE_HOST'].to_s.empty? %>
<<: *default
host: <%= ENV['DATABASE_HOST'] %>
port: <%= ENV['DATABASE_PORT'] %>
username: <%= ENV['DATABASE_USER'] %>
password: <%= ENV['DATABASE_PASSWORD'] %>
database: <%= ENV['DATABASE_NAME'] %>
<% elsif ENV['DATABASE_URL'].to_s.empty? %>
adapter: sqlite3 adapter: sqlite3
database: <%= ENV['WORKDIR'] || '.' %>/db.sqlite3 database: <%= ENV['WORKDIR'] || '.' %>/db.sqlite3
<% elsif ENV['DATABASE_URL'].match?(/\Apostgres/) %> <% elsif ENV['DATABASE_URL'].match?(/\Apostgres/) %>
+14
View File
@@ -0,0 +1,14 @@
# frozen_string_literal: true
# fix NoMethodError: undefined method `field_value' for #<HexaPDF::Type::AcroForm::Field
module HexaPDF
module Type
module AcroForm
class Field
def field_value
''
end
end
end
end
end
+9 -2
View File
@@ -1,2 +1,9 @@
en: en-US:
hello: "Hello world" date:
formats:
default: "%m/%d/%Y"
en-GB:
date:
formats:
default: "%d/%m/%Y"
+2
View File
@@ -80,6 +80,8 @@ Rails.application.routes.draw do
get :completed get :completed
end end
resources :submissions_preview, only: %i[show], path: 'e', param: 'slug'
resources :send_submission_email, only: %i[create] do resources :send_submission_email, only: %i[create] do
get :success, on: :collection get :success, on: :collection
end end
@@ -1,6 +1,6 @@
# frozen_string_literal: true # frozen_string_literal: true
class AddApplicationIdToSubmitters < ActiveRecord::Migration[7.0] class AddApplicationKeyToSubmitters < ActiveRecord::Migration[7.0]
def change def change
add_column :submitters, :application_key, :string add_column :submitters, :application_key, :string
end end
@@ -0,0 +1,7 @@
# frozen_string_literal: true
class AddApplicationKeyToTemplates < ActiveRecord::Migration[7.0]
def change
add_column :templates, :application_key, :string
end
end
@@ -0,0 +1,51 @@
# frozen_string_literal: true
class UpdateFieldOptions < ActiveRecord::Migration[7.0]
class MigrationTemplate < ApplicationRecord
self.table_name = 'templates'
end
class MigrationSubmission < ApplicationRecord
self.table_name = 'submissions'
end
# rubocop:disable Metrics
def up
MigrationTemplate.find_each do |template|
next if template.fields.blank?
template_fields = JSON.parse(template.fields)
new_fields = template_fields.deep_dup
new_fields.each do |field|
if field['options'].present? && !field['options'].first.is_a?(Hash)
field['options'] = field['options'].map { |o| { value: o || '', uuid: SecureRandom.uuid } }
end
end
template.update_columns(fields: new_fields.to_json) if template_fields != new_fields
end
MigrationSubmission.find_each do |submission|
next if submission.template_fields.blank?
template_fields = JSON.parse(submission.template_fields)
new_fields = template_fields.deep_dup
new_fields.each do |field|
if field['options'].present? && !field['options'].first.is_a?(Hash)
field['options'] = field['options'].map { |o| { value: o || '', uuid: SecureRandom.uuid } }
end
end
submission.update_columns(template_fields: new_fields.to_json) if template_fields != new_fields
end
end
# rubocop:enable Metrics
def down
nil
end
end
@@ -0,0 +1,23 @@
# frozen_string_literal: true
class AddSlugToSubmissions < ActiveRecord::Migration[7.0]
class MigrationSubmission < ApplicationRecord
self.table_name = 'submissions'
end
def up
add_column :submissions, :slug, :string
MigrationSubmission.where(slug: nil).find_each do |submission|
submission.update_columns(slug: SecureRandom.base58(14))
end
change_column_null :submissions, :slug, false
add_index :submissions, :slug, unique: true
end
def down
remove_column :submissions, :slug
end
end
@@ -0,0 +1,15 @@
# frozen_string_literal: true
class AddPreferencesToSubmitters < ActiveRecord::Migration[7.0]
class MigrationSubmitter < ApplicationRecord
self.table_name = 'submitters'
end
def change
add_column :submitters, :preferences, :text
MigrationSubmitter.where(preferences: nil).update_all(preferences: '{}')
change_column_null :submitters, :preferences, false
end
end
@@ -0,0 +1,15 @@
# frozen_string_literal: true
class AddPreferencesToSubmissions < ActiveRecord::Migration[7.0]
class MigrationSubmission < ApplicationRecord
self.table_name = 'submissions'
end
def change
add_column :submissions, :preferences, :text
MigrationSubmission.where(preferences: nil).update_all(preferences: '{}')
change_column_null :submissions, :preferences, false
end
end
@@ -0,0 +1,16 @@
# frozen_string_literal: true
class CreateEmailMessages < ActiveRecord::Migration[7.0]
def change
create_table :email_messages do |t|
t.string :uuid, null: false, index: true
t.references :author, null: false, foreign_key: { to_table: :users }, index: false
t.references :account, null: false, foreign_key: true, index: true
t.text :subject, null: false
t.text :body, null: false
t.string :sha1, null: false, index: true
t.timestamps
end
end
end
+22 -1
View File
@@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.0].define(version: 2023_10_17_213639) do ActiveRecord::Schema[7.0].define(version: 2023_11_22_212612) do
# These are extensions that must be enabled in order to support this database # These are extensions that must be enabled in order to support this database
enable_extension "plpgsql" enable_extension "plpgsql"
@@ -81,6 +81,20 @@ ActiveRecord::Schema[7.0].define(version: 2023_10_17_213639) do
t.index ["submitter_id"], name: "index_document_generation_events_on_submitter_id" t.index ["submitter_id"], name: "index_document_generation_events_on_submitter_id"
end end
create_table "email_messages", force: :cascade do |t|
t.string "uuid", null: false
t.bigint "author_id", null: false
t.bigint "account_id", null: false
t.text "subject", null: false
t.text "body", null: false
t.string "sha1", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["account_id"], name: "index_email_messages_on_account_id"
t.index ["sha1"], name: "index_email_messages_on_sha1"
t.index ["uuid"], name: "index_email_messages_on_uuid"
end
create_table "encrypted_configs", force: :cascade do |t| create_table "encrypted_configs", force: :cascade do |t|
t.bigint "account_id", null: false t.bigint "account_id", null: false
t.string "key", null: false t.string "key", null: false
@@ -124,7 +138,10 @@ ActiveRecord::Schema[7.0].define(version: 2023_10_17_213639) do
t.text "template_submitters" t.text "template_submitters"
t.text "source", null: false t.text "source", null: false
t.string "submitters_order", null: false t.string "submitters_order", null: false
t.string "slug", null: false
t.text "preferences", null: false
t.index ["created_by_user_id"], name: "index_submissions_on_created_by_user_id" t.index ["created_by_user_id"], name: "index_submissions_on_created_by_user_id"
t.index ["slug"], name: "index_submissions_on_slug", unique: true
t.index ["template_id"], name: "index_submissions_on_template_id" t.index ["template_id"], name: "index_submissions_on_template_id"
end end
@@ -144,6 +161,7 @@ ActiveRecord::Schema[7.0].define(version: 2023_10_17_213639) do
t.string "name" t.string "name"
t.string "phone" t.string "phone"
t.string "application_key" t.string "application_key"
t.text "preferences", null: false
t.index ["email"], name: "index_submitters_on_email" t.index ["email"], name: "index_submitters_on_email"
t.index ["slug"], name: "index_submitters_on_slug", unique: true t.index ["slug"], name: "index_submitters_on_slug", unique: true
t.index ["submission_id"], name: "index_submitters_on_submission_id" t.index ["submission_id"], name: "index_submitters_on_submission_id"
@@ -173,6 +191,7 @@ ActiveRecord::Schema[7.0].define(version: 2023_10_17_213639) do
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.text "source", null: false t.text "source", null: false
t.bigint "folder_id", null: false t.bigint "folder_id", null: false
t.string "application_key"
t.index ["account_id"], name: "index_templates_on_account_id" t.index ["account_id"], name: "index_templates_on_account_id"
t.index ["author_id"], name: "index_templates_on_author_id" t.index ["author_id"], name: "index_templates_on_author_id"
t.index ["folder_id"], name: "index_templates_on_folder_id" t.index ["folder_id"], name: "index_templates_on_folder_id"
@@ -226,6 +245,8 @@ ActiveRecord::Schema[7.0].define(version: 2023_10_17_213639) do
add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id" add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"
add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id" add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
add_foreign_key "document_generation_events", "submitters" add_foreign_key "document_generation_events", "submitters"
add_foreign_key "email_messages", "accounts"
add_foreign_key "email_messages", "users", column: "author_id"
add_foreign_key "encrypted_configs", "accounts" add_foreign_key "encrypted_configs", "accounts"
add_foreign_key "encrypted_user_configs", "users" add_foreign_key "encrypted_user_configs", "users"
add_foreign_key "submission_events", "submissions" add_foreign_key "submission_events", "submissions"
+9 -1
View File
@@ -17,7 +17,15 @@ module AccountConfigs
module_function module_function
def find_or_initialize_for_key(account, key) def find_or_initialize_for_key(account, key)
account.account_configs.find_by(key:) || find_for_account(account, key) ||
account.account_configs.new(key:, value: AccountConfig::DEFAULT_VALUES[key]) account.account_configs.new(key:, value: AccountConfig::DEFAULT_VALUES[key])
end end
def find_for_account(account, key)
configs = account.account_configs.find_by(key:)
configs ||= Account.order(:id).first.account_configs.find_by(key:) unless Docuseal.multitenant?
configs
end
end end
+6 -2
View File
@@ -47,9 +47,13 @@ module Accounts
end end
def load_webhook_configs(account) def load_webhook_configs(account)
EncryptedConfig.find_by(key: EncryptedConfig::WEBHOOK_URL_KEY) unless Docuseal.multitenant? configs = account.encrypted_configs.find_by(key: EncryptedConfig::WEBHOOK_URL_KEY)
account.encrypted_configs.find_by(key: EncryptedConfig::WEBHOOK_URL_KEY) unless Docuseal.multitenant?
configs ||= Account.order(:id).first.encrypted_configs.find_by(key: EncryptedConfig::WEBHOOK_URL_KEY)
end
configs
end end
def load_signing_pkcs(account) def load_signing_pkcs(account)
+1 -1
View File
@@ -23,7 +23,7 @@ module ActionMailerConfigsInterceptor
if email_configs if email_configs
message.delivery_method(:smtp, build_smtp_configs_hash(email_configs)) message.delivery_method(:smtp, build_smtp_configs_hash(email_configs))
message.from = "#{email_configs.account.name} <#{email_configs.value['from_email']}>" message.from = %("#{email_configs.account.name.to_s.delete('"')}" <#{email_configs.value['from_email']}>)
else else
message.delivery_method(:test) message.delivery_method(:test)
end end
-2
View File
@@ -15,7 +15,5 @@ class AuthWithTokenStrategy < Devise::Strategies::Base
else else
fail!('Invalid token') fail!('Invalid token')
end end
rescue JWT::VerificationError
fail!('Invalid token')
end end
end end
+17
View File
@@ -0,0 +1,17 @@
# frozen_string_literal: true
module EmailMessages
module_function
def find_or_create_for_account_user(account, user, subject, body)
subject = SubmitterMailer::DEFAULT_INVITATION_SUBJECT if subject.blank?
sha1 = Digest::SHA1.hexdigest({ subject:, body: }.to_json)
message = account.email_messages.find_by(sha1:)
message ||= account.email_messages.create!(author: user, subject:, body:)
message
end
end
+5 -8
View File
@@ -9,6 +9,7 @@ module ReplaceEmailVariables
SUBMISSION_LINK = '{{submission.link}}' SUBMISSION_LINK = '{{submission.link}}'
SUBMISSION_SUBMITTERS = '{{submission.submitters}}' SUBMISSION_SUBMITTERS = '{{submission.submitters}}'
DOCUMENTS_LINKS = '{{documents.links}}' DOCUMENTS_LINKS = '{{documents.links}}'
DOCUMENTS_LINK = '{{documents.link}}'
module_function module_function
@@ -26,6 +27,7 @@ module ReplaceEmailVariables
text = text.gsub(SUBMISSION_SUBMITTERS, build_submission_submitters(submitter.submission)) text = text.gsub(SUBMISSION_SUBMITTERS, build_submission_submitters(submitter.submission))
end end
text = text.gsub(DOCUMENTS_LINKS, build_documents_links_text(submitter)) text = text.gsub(DOCUMENTS_LINKS, build_documents_links_text(submitter))
text = text.gsub(DOCUMENTS_LINK, build_documents_links_text(submitter))
text = text.gsub(ACCOUNT_NAME, submitter.template.account.name) if submitter.template text = text.gsub(ACCOUNT_NAME, submitter.template.account.name) if submitter.template
@@ -33,14 +35,9 @@ module ReplaceEmailVariables
end end
def build_documents_links_text(submitter) def build_documents_links_text(submitter)
Submitters.select_attachments_for_download(submitter).map do |document| Rails.application.routes.url_helpers.submissions_preview_url(
link = submitter.submission.slug, **Docuseal.default_url_options
Rails.application.routes.url_helpers.rails_blob_url( )
document, **Docuseal.default_url_options
)
"#{link}\n"
end.join
end end
def build_submitter_link(submitter, tracking_event_type) def build_submitter_link(submitter, tracking_event_type)
+19 -9
View File
@@ -27,27 +27,37 @@ module Submissions
submission.save! submission.save!
end end
def create_from_emails(template:, user:, emails:, source:, mark_as_sent: false) def create_from_emails(template:, user:, emails:, source:, mark_as_sent: false, params: {})
emails = emails.to_s.scan(User::EMAIL_REGEXP) unless emails.is_a?(Array) preferences = Submitters.normalize_preferences(template.account, user, params)
parse_emails(emails).uniq.map do |email|
submission = template.submissions.new(created_by_user: user,
source:,
template_submitters: template.submitters)
emails.uniq.map do |email|
submission = template.submissions.new(created_by_user: user, source:, template_submitters: template.submitters)
submission.submitters.new(email: normalize_email(email), submission.submitters.new(email: normalize_email(email),
uuid: template.submitters.first['uuid'], uuid: template.submitters.first['uuid'],
preferences:,
sent_at: mark_as_sent ? Time.current : nil) sent_at: mark_as_sent ? Time.current : nil)
submission.tap(&:save!) submission.tap(&:save!)
end end
end end
def parse_emails(emails)
emails = emails.to_s.scan(User::EMAIL_REGEXP) unless emails.is_a?(Array)
emails
end
def create_from_submitters(template:, user:, submissions_attrs:, source:, mark_as_sent: false, def create_from_submitters(template:, user:, submissions_attrs:, source:, mark_as_sent: false,
submitters_order: DEFAULT_SUBMITTERS_ORDER) submitters_order: DEFAULT_SUBMITTERS_ORDER, params: {})
Submissions::CreateFromSubmitters.call( Submissions::CreateFromSubmitters.call(
template:, user:, submissions_attrs:, source:, mark_as_sent:, submitters_order: template:, user:, submissions_attrs:, source:, mark_as_sent:, submitters_order:, params:
) )
end end
def send_signature_requests(submissions, params) def send_signature_requests(submissions)
submissions.each do |submission| submissions.each do |submission|
submitters = submission.submitters.reject(&:completed_at?) submitters = submission.submitters.reject(&:completed_at?)
@@ -55,9 +65,9 @@ module Submissions
first_submitter = first_submitter =
submission.template_submitters.filter_map { |s| submitters.find { |e| e.uuid == s['uuid'] } }.first submission.template_submitters.filter_map { |s| submitters.find { |e| e.uuid == s['uuid'] } }.first
Submitters.send_signature_requests([first_submitter], params) Submitters.send_signature_requests([first_submitter])
else else
Submitters.send_signature_requests(submitters, params) Submitters.send_signature_requests(submitters)
end end
end end
end end
+11 -3
View File
@@ -4,7 +4,9 @@ module Submissions
module CreateFromSubmitters module CreateFromSubmitters
module_function module_function
def call(template:, user:, submissions_attrs:, source:, submitters_order:, mark_as_sent: false) def call(template:, user:, submissions_attrs:, source:, submitters_order:, mark_as_sent: false, params: {})
preferences = Submitters.normalize_preferences(template.account, user, params)
Array.wrap(submissions_attrs).map do |attrs| Array.wrap(submissions_attrs).map do |attrs|
submission = template.submissions.new(created_by_user: user, source:, submission = template.submissions.new(created_by_user: user, source:,
template_submitters: template.submitters, submitters_order:) template_submitters: template.submitters, submitters_order:)
@@ -18,7 +20,11 @@ module Submissions
is_order_sent = submitters_order == 'random' || index.zero? is_order_sent = submitters_order == 'random' || index.zero?
build_submitter(submission:, attrs: submitter_attrs, uuid:, is_order_sent:, mark_as_sent:) submission_preferences = Submitters.normalize_preferences(template.account, user, attrs)
build_submitter(submission:, attrs: submitter_attrs, uuid:,
is_order_sent:, mark_as_sent:, user:,
preferences: preferences.merge(submission_preferences))
end end
submission.tap(&:save!) submission.tap(&:save!)
@@ -85,8 +91,9 @@ module Submissions
template.submitters[index]&.dig('uuid') template.submitters[index]&.dig('uuid')
end end
def build_submitter(submission:, attrs:, uuid:, is_order_sent:, mark_as_sent:) def build_submitter(submission:, attrs:, uuid:, is_order_sent:, mark_as_sent:, user:, preferences:)
email = Submissions.normalize_email(attrs[:email]) email = Submissions.normalize_email(attrs[:email])
submitter_preferences = Submitters.normalize_preferences(submission.account, user, attrs)
submission.submitters.new( submission.submitters.new(
email:, email:,
@@ -96,6 +103,7 @@ module Submissions
completed_at: attrs[:completed] ? Time.current : nil, completed_at: attrs[:completed] ? Time.current : nil,
sent_at: mark_as_sent && email.present? && is_order_sent ? Time.current : nil, sent_at: mark_as_sent && email.present? && is_order_sent ? Time.current : nil,
values: attrs[:values] || {}, values: attrs[:values] || {},
preferences: preferences.merge(submitter_preferences),
uuid: uuid:
) )
end end
+7 -14
View File
@@ -24,6 +24,7 @@ module Submissions
else else
'Verified' 'Verified'
end end
UNVERIFIED_TEXT = 'Unverified'
module_function module_function
@@ -62,7 +63,7 @@ module Submissions
composer.new_page composer.new_page
composer.column(columns: 1) do |column| composer.column(columns: 1) do |column|
add_logo(column) add_logo(column, account)
column.text('Audit Log', column.text('Audit Log',
font_size: 16, font_size: 16,
@@ -106,7 +107,7 @@ module Submissions
"\n", "\n",
{ text: 'Generated at: ', font: [FONT_BOLD_NAME, { variant: :bold }] }, { text: 'Generated at: ', font: [FONT_BOLD_NAME, { variant: :bold }] },
"#{I18n.l(document.created_at.in_time_zone(account.timezone), format: :long, locale: account.locale)} " \ "#{I18n.l(document.created_at.in_time_zone(account.timezone), format: :long, locale: account.locale)} " \
"#{timezone_abbr(account.timezone, document.created_at)}" "#{TimeUtils.timezone_abbr(account.timezone, document.created_at)}"
], line_spacing: 1.8 ], line_spacing: 1.8
) )
] ]
@@ -151,10 +152,10 @@ module Submissions
composer.document.layout.formatted_text_box( composer.document.layout.formatted_text_box(
[ [
submitter.email && { submitter.email && {
text: "Email verification: #{click_email_event ? VERIFIED_TEXT : 'Unverififed'}\n" text: "Email verification: #{click_email_event ? VERIFIED_TEXT : UNVERIFIED_TEXT}\n"
}, },
submitter.phone && { submitter.phone && {
text: "Phone verification: #{is_phone_verified ? VERIFIED_TEXT : 'Unverififed'}\n" text: "Phone verification: #{is_phone_verified ? VERIFIED_TEXT : UNVERIFIED_TEXT}\n"
}, },
completed_event.data['ip'] && { text: "IP: #{completed_event.data['ip']}\n" }, completed_event.data['ip'] && { text: "IP: #{completed_event.data['ip']}\n" },
completed_event.data['sid'] && { text: "Session ID: #{completed_event.data['sid']}\n" }, completed_event.data['sid'] && { text: "Session ID: #{completed_event.data['sid']}\n" },
@@ -231,7 +232,7 @@ module Submissions
submitter = submission.submitters.find { |e| e.id == event.submitter_id } submitter = submission.submitters.find { |e| e.id == event.submitter_id }
[ [
"#{I18n.l(event.event_timestamp.in_time_zone(account.timezone), format: :long, locale: account.locale)} " \ "#{I18n.l(event.event_timestamp.in_time_zone(account.timezone), format: :long, locale: account.locale)} " \
"#{timezone_abbr(account.timezone, event.event_timestamp)}", "#{TimeUtils.timezone_abbr(account.timezone, event.event_timestamp)}",
composer.document.layout.formatted_text_box( composer.document.layout.formatted_text_box(
[ [
{ text: SubmissionEvents::EVENT_NAMES[event.event_type.to_sym], { text: SubmissionEvents::EVENT_NAMES[event.event_type.to_sym],
@@ -267,15 +268,7 @@ module Submissions
) )
end end
def timezone_abbr(timezone, time = Time.current) def add_logo(column, _account = nil)
tz_info = TZInfo::Timezone.get(
ActiveSupport::TimeZone::MAPPING[timezone] || timezone || 'UTC'
)
tz_info.abbreviation(time)
end
def add_logo(column)
column.image(PdfIcons.logo_io, width: 40, height: 40, position: :float) column.image(PdfIcons.logo_io, width: 40, height: 40, position: :float)
column.formatted_text([{ text: 'DocuSeal', column.formatted_text([{ text: 'DocuSeal',
+18 -1
View File
@@ -124,7 +124,13 @@ module Submissions
layouter.fit(items, area['w'] * width, height_diff.positive? ? box_height : area['h'] * height) layouter.fit(items, area['w'] * width, height_diff.positive? ? box_height : area['h'] * height)
.draw(canvas, (area['x'] * width) + TEXT_LEFT_MARGIN, .draw(canvas, (area['x'] * width) + TEXT_LEFT_MARGIN,
height - (area['y'] * height) + height_diff - TEXT_TOP_MARGIN) height - (area['y'] * height) + height_diff - TEXT_TOP_MARGIN)
when 'checkbox' when ->(type) { type == 'checkbox' || (type.in?(%w[multiple radio]) && area['option_uuid'].present?) }
if field['type'].in?(%w[multiple radio])
option = field['options']&.find { |o| o['uuid'] == area['option_uuid'] }
value = Array.wrap(value).include?(option['value'])
end
next unless value == true next unless value == true
scale = [(area['w'] * width) / PdfIcons::WIDTH, (area['h'] * height) / PdfIcons::HEIGHT].min scale = [(area['w'] * width) / PdfIcons::WIDTH, (area['h'] * height) / PdfIcons::HEIGHT].min
@@ -157,6 +163,17 @@ module Submissions
lines = layouter.fit([text], area['w'] * width, height).lines lines = layouter.fit([text], area['w'] * width, height).lines
box_height = lines.sum(&:height) box_height = lines.sum(&:height)
if box_height > (area['h'] * height) + 1
text = HexaPDF::Layout::TextFragment.create(Array.wrap(value).join(', '),
font: pdf.fonts.add(FONT_NAME),
font_size: (font_size / 1.4).to_i)
lines = layouter.fit([text], area['w'] * width, height).lines
box_height = lines.sum(&:height)
end
height_diff = [0, box_height - (area['h'] * height)].max height_diff = [0, box_height - (area['h'] * height)].max
layouter.fit([text], area['w'] * width, height_diff.positive? ? box_height : area['h'] * height) layouter.fit([text], area['w'] * width, height_diff.positive? ? box_height : area['h'] * height)
+22 -11
View File
@@ -1,6 +1,8 @@
# frozen_string_literal: true # frozen_string_literal: true
module Submitters module Submitters
TRUE_VALUES = ['1', 'true', true].freeze
module_function module_function
def search(submitters, keyword) def search(submitters, keyword)
@@ -43,21 +45,30 @@ module Submitters
) )
end end
def send_signature_requests(submitters, params) def normalize_preferences(account, user, params)
return if params[:send_email] != true && params[:send_email] != '1' preferences = {}
message_params = params['message'].presence || params.slice('subject', 'body').presence
if message_params.present?
email_message = EmailMessages.find_or_create_for_account_user(account, user,
message_params['subject'],
message_params['body'])
end
preferences['email_message_uuid'] = email_message.uuid if email_message
preferences['send_email'] = params['send_email'].in?(TRUE_VALUES) if params.key?('send_email')
preferences['send_sms'] = params['send_sms'].in?(TRUE_VALUES) if params.key?('send_sms')
preferences
end
def send_signature_requests(submitters)
submitters.each do |submitter| submitters.each do |submitter|
next if submitter.email.blank? next if submitter.email.blank?
next if submitter.preferences['send_email'] == false
enqueue_invitation_email(submitter, params) SendSubmitterInvitationEmailJob.perform_later('submitter_id' => submitter.id)
end end
end end
def enqueue_invitation_email(submitter, params)
subject, body = params.values_at(:subject, :body) if params[:is_custom_message] == '1'
SendSubmitterInvitationEmailJob.perform_later('submitter_id' => submitter.id,
'body' => body,
'subject' => subject)
end
end end
@@ -19,7 +19,9 @@ module Submitters
fields.each do |field| fields.each do |field|
next if field['submitter_uuid'] != submitter.uuid next if field['submitter_uuid'] != submitter.uuid
submitter.values[field['uuid']] ||= get_default_value_for_field(field, user, submitter) default_value = get_default_value_for_field(field, user, submitter)
submitter.values[field['uuid']] ||= default_value if default_value.present?
end end
submitter.save! submitter.save!
+1 -1
View File
@@ -71,7 +71,7 @@ module Submitters
return blob if blob return blob if blob
data = conn.get(url).body data = conn.get(Addressable::URI.parse(url).display_uri.to_s).body
checksum = Digest::MD5.base64digest(data) checksum = Digest::MD5.base64digest(data)
+1
View File
@@ -20,6 +20,7 @@ module Submitters
.except('uuid', 'values', 'slug') .except('uuid', 'values', 'slug')
.merge('values' => values, .merge('values' => values,
'documents' => documents, 'documents' => documents,
'audit_log_url' => submitter.submission.audit_log_url,
'role' => submitter_name) 'role' => submitter_name)
end end
+54
View File
@@ -4,6 +4,8 @@ module Submitters
module SubmitValues module SubmitValues
ValidationError = Class.new(StandardError) ValidationError = Class.new(StandardError)
VARIABLE_REGEXP = /\{\{?(\w+)\}\}?/
module_function module_function
def call(submitter, params, request) def call(submitter, params, request)
@@ -36,6 +38,7 @@ module Submitters
submitter.completed_at = Time.current submitter.completed_at = Time.current
submitter.ip = request.remote_ip submitter.ip = request.remote_ip
submitter.ua = request.user_agent submitter.ua = request.user_agent
submitter.values = merge_default_values(submitter)
end end
ApplicationRecord.transaction do ApplicationRecord.transaction do
@@ -69,6 +72,57 @@ module Submitters
end end
end end
def merge_default_values(submitter)
default_values = submitter.submission.template_fields.each_with_object({}) do |field, acc|
next if field['submitter_uuid'] != submitter.uuid
value = field['default_value']
next if value.blank?
acc[field['uuid']] = template_default_value_for_submitter(value, submitter, with_time: true)
end
default_values.compact_blank.merge(submitter.values)
end
def template_default_value_for_submitter(value, submitter, with_time: false)
return if value.blank?
return if submitter.blank?
role = submitter.submission.template_submitters.find { |e| e['uuid'] == submitter.uuid }['name']
replace_default_variables(value,
submitter.attributes.merge('role' => role),
submitter.submission.template,
with_time:)
end
def replace_default_variables(value, attrs, template, with_time: false)
return if value.blank?
value.gsub(VARIABLE_REGEXP) do |e|
case key = ::Regexp.last_match(1)
when 'time'
if with_time
I18n.l(Time.current.in_time_zone(template.account.timezone), format: :long, locale: template.account.locale)
else
e
end
when 'date'
if with_time
I18n.l(Time.current.in_time_zone(template.account.timezone).to_date)
else
e
end
when 'role', 'email', 'phone', 'name'
attrs[key] || e
else
e
end
end
end
def validate_value!(_value, _field, _params, _submitter, _request) def validate_value!(_value, _field, _params, _submitter, _request)
true true
end end
+3 -1
View File
@@ -17,7 +17,9 @@ module Templates
build_external_link_hash(page, annot).merge('page' => index) build_external_link_hash(page, annot).merge('page' => index)
end end
end end
rescue PDF::Reader::MalformedPDFError, OpenSSL::Cipher::CipherError rescue StandardError => e
Rollbar.error(e) if defined?(Rollbar)
[] []
end end
+13
View File
@@ -0,0 +1,13 @@
# frozen_string_literal: true
module TimeUtils
module_function
def timezone_abbr(timezone, time = Time.current)
tz_info = TZInfo::Timezone.get(
ActiveSupport::TimeZone::MAPPING[timezone] || timezone || 'UTC'
)
tz_info.abbreviation(time)
end
end
Loaded 100 of 102 files, more files were not shown because too many files have changed in this diff. Show more