Merge remote-tracking branch master into redacting

pull/109/head
iozeey 2 years ago
commit 7f624de1a5

@ -138,4 +138,5 @@ jobs:
run: | run: |
bundle exec rake db:create bundle exec rake db:create
bundle exec rake db:migrate bundle exec rake db:migrate
bundle exec rake assets:precompile
bundle exec rspec bundle exec rspec

@ -67,8 +67,14 @@ RSpec/MultipleMemoizedHelpers:
Rails/I18nLocaleTexts: Rails/I18nLocaleTexts:
Enabled: false Enabled: false
Rails/FindEach:
Enabled: false
Rails/SkipsModelValidations: Rails/SkipsModelValidations:
Enabled: false Enabled: false
Rails/ApplicationController: Rails/ApplicationController:
Enabled: false Enabled: false
Capybara/ClickLinkOrButtonStyle:
Enabled: false

@ -31,7 +31,7 @@ ENV BUNDLE_WITHOUT="development:test"
WORKDIR /app WORKDIR /app
RUN apk add --no-cache build-base sqlite-dev libpq-dev mariadb-dev vips-dev vips-poppler vips-heif libc6-compat ttf-freefont ttf-liberation && cp /usr/share/fonts/liberation/LiberationSans-Regular.ttf /usr/share/fonts/liberation/LiberationSans-Bold.ttf / && apk del ttf-liberation RUN apk add --no-cache build-base sqlite-dev libpq-dev mariadb-dev vips-dev vips-poppler poppler-utils vips-heif libc6-compat ttf-freefont ttf-liberation && mkdir /fonts && cp /usr/share/fonts/liberation/LiberationSans-Regular.ttf /usr/share/fonts/liberation/LiberationSans-Bold.ttf /fonts && apk del ttf-liberation && wget -O /fonts/DancingScript.otf "https://github.com/impallari/DancingScript/raw/master/fonts/DancingScript-Regular.otf" && wget -O /fonts/DancingScript-License.txt https://github.com/impallari/DancingScript/blob/master/OFL.txt
COPY ./Gemfile ./Gemfile.lock ./ COPY ./Gemfile ./Gemfile.lock ./
@ -49,6 +49,7 @@ COPY LICENSE README.md Rakefile config.ru ./
COPY --from=webpack /app/public/packs ./public/packs COPY --from=webpack /app/public/packs ./public/packs
RUN ln -s /fonts /app/public/fonts
RUN bundle exec bootsnap precompile --gemfile app/ lib/ RUN bundle exec bootsnap precompile --gemfile app/ lib/
WORKDIR /data/docuseal WORKDIR /data/docuseal

@ -13,6 +13,7 @@ gem 'devise-two-factor'
gem 'dotenv', require: false gem 'dotenv', require: false
gem 'email_typo' gem 'email_typo'
gem 'faraday' gem 'faraday'
gem 'faraday-follow_redirects'
gem 'google-cloud-storage', require: false gem 'google-cloud-storage', require: false
gem 'hexapdf' gem 'hexapdf'
gem 'image_processing' gem 'image_processing'

@ -98,7 +98,6 @@ GEM
faraday_middleware (~> 1.0, >= 1.0.0.rc1) faraday_middleware (~> 1.0, >= 1.0.0.rc1)
net-http-persistent (~> 4.0) net-http-persistent (~> 4.0)
nokogiri (~> 1, >= 1.10.8) nokogiri (~> 1, >= 1.10.8)
base64 (0.1.1)
bcrypt (3.1.19) bcrypt (3.1.19)
better_html (2.0.2) better_html (2.0.2)
actionview (>= 6.0) actionview (>= 6.0)
@ -189,6 +188,8 @@ GEM
faraday-em_http (1.0.0) faraday-em_http (1.0.0)
faraday-em_synchrony (1.0.0) faraday-em_synchrony (1.0.0)
faraday-excon (1.1.0) faraday-excon (1.1.0)
faraday-follow_redirects (0.3.0)
faraday (>= 1, < 3)
faraday-httpclient (1.0.1) faraday-httpclient (1.0.1)
faraday-multipart (1.0.4) faraday-multipart (1.0.4)
multipart-post (~> 2) multipart-post (~> 2)
@ -292,7 +293,7 @@ GEM
mini_magick (4.12.0) mini_magick (4.12.0)
mini_mime (1.1.5) mini_mime (1.1.5)
mini_portile2 (2.8.4) mini_portile2 (2.8.4)
minitest (5.19.0) minitest (5.20.0)
msgpack (1.7.2) msgpack (1.7.2)
multi_json (1.15.0) multi_json (1.15.0)
multi_xml (0.6.0) multi_xml (0.6.0)
@ -343,7 +344,7 @@ GEM
os (1.1.4) os (1.1.4)
pagy (6.0.4) pagy (6.0.4)
parallel (1.23.0) parallel (1.23.0)
parser (3.2.2.3) parser (3.2.2.4)
ast (~> 2.4.1) ast (~> 2.4.1)
racc racc
pdf-reader (2.11.0) pdf-reader (2.11.0)
@ -416,7 +417,7 @@ GEM
rake (13.0.6) rake (13.0.6)
redis-client (0.16.0) redis-client (0.16.0)
connection_pool connection_pool
regexp_parser (2.8.1) regexp_parser (2.8.2)
reline (0.3.8) reline (0.3.8)
io-console (~> 0.5) io-console (~> 0.5)
representable (3.2.0) representable (3.2.0)
@ -453,33 +454,32 @@ GEM
rspec-mocks (~> 3.12) rspec-mocks (~> 3.12)
rspec-support (~> 3.12) rspec-support (~> 3.12)
rspec-support (3.12.1) rspec-support (3.12.1)
rubocop (1.56.1) rubocop (1.57.2)
base64 (~> 0.1.1)
json (~> 2.3) json (~> 2.3)
language_server-protocol (>= 3.17.0) language_server-protocol (>= 3.17.0)
parallel (~> 1.10) parallel (~> 1.10)
parser (>= 3.2.2.3) parser (>= 3.2.2.4)
rainbow (>= 2.2.2, < 4.0) rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 1.8, < 3.0) regexp_parser (>= 1.8, < 3.0)
rexml (>= 3.2.5, < 4.0) rexml (>= 3.2.5, < 4.0)
rubocop-ast (>= 1.28.1, < 2.0) rubocop-ast (>= 1.28.1, < 2.0)
ruby-progressbar (~> 1.7) ruby-progressbar (~> 1.7)
unicode-display_width (>= 2.4.0, < 3.0) unicode-display_width (>= 2.4.0, < 3.0)
rubocop-ast (1.29.0) rubocop-ast (1.30.0)
parser (>= 3.2.1.0) parser (>= 3.2.1.0)
rubocop-capybara (2.18.0) rubocop-capybara (2.19.0)
rubocop (~> 1.41) rubocop (~> 1.41)
rubocop-factory_bot (2.23.1) rubocop-factory_bot (2.24.0)
rubocop (~> 1.33) rubocop (~> 1.33)
rubocop-performance (1.19.0) rubocop-performance (1.19.1)
rubocop (>= 1.7.0, < 2.0) rubocop (>= 1.7.0, < 2.0)
rubocop-ast (>= 0.4.0) rubocop-ast (>= 0.4.0)
rubocop-rails (2.20.2) rubocop-rails (2.22.1)
activesupport (>= 4.2.0) activesupport (>= 4.2.0)
rack (>= 1.1) rack (>= 1.1)
rubocop (>= 1.33.0, < 2.0) rubocop (>= 1.33.0, < 2.0)
rubocop-rspec (2.23.2) rubocop-rspec (2.25.0)
rubocop (~> 1.33) rubocop (~> 1.40)
rubocop-capybara (~> 2.17) rubocop-capybara (~> 2.17)
rubocop-factory_bot (~> 2.22) rubocop-factory_bot (~> 2.22)
ruby-progressbar (1.13.0) ruby-progressbar (1.13.0)
@ -535,7 +535,7 @@ GEM
tzinfo-data (1.2023.3) tzinfo-data (1.2023.3)
tzinfo (>= 1.0.0) tzinfo (>= 1.0.0)
uber (0.1.0) uber (0.1.0)
unicode-display_width (2.4.2) unicode-display_width (2.5.0)
uniform_notifier (1.16.0) uniform_notifier (1.16.0)
version_gem (1.1.3) version_gem (1.1.3)
warden (1.2.9) warden (1.2.9)
@ -580,6 +580,7 @@ DEPENDENCIES
factory_bot_rails factory_bot_rails
faker faker
faraday faraday
faraday-follow_redirects
google-cloud-storage google-cloud-storage
hexapdf hexapdf
image_processing image_processing

@ -52,8 +52,8 @@ DocuSeal is an open source platform that provides secure and efficient digital d
| [<img alt="Deploy on Heroku" src="https://www.herokucdn.com/deploy/button.svg" height="40">](https://heroku.com/deploy?template=https://github.com/docusealco/docuseal-heroku) | [<img alt="Deploy on Railway" src="https://railway.app/button.svg" height="40">](https://railway.app/template/IGoDnc?referralCode=ruU7JR)| | [<img alt="Deploy on Heroku" src="https://www.herokucdn.com/deploy/button.svg" height="40">](https://heroku.com/deploy?template=https://github.com/docusealco/docuseal-heroku) | [<img alt="Deploy on Railway" src="https://railway.app/button.svg" height="40">](https://railway.app/template/IGoDnc?referralCode=ruU7JR)|
|**DigitalOcean**|**Render**| |**DigitalOcean**|**Render**|
| [<img alt="Deploy on DigitalOcean" src="https://www.deploytodo.com/do-btn-blue.svg" height="40">](https://cloud.digitalocean.com/apps/new?repo=https://github.com/docusealco/docuseal-digitalocean/tree/master&refcode=421d50f53990) | [<img alt="Deploy to Render" src="https://render.com/images/deploy-to-render-button.svg" height="40">](https://render.com/deploy?repo=https://github.com/docusealco/docuseal-render) | [<img alt="Deploy on DigitalOcean" src="https://www.deploytodo.com/do-btn-blue.svg" height="40">](https://cloud.digitalocean.com/apps/new?repo=https://github.com/docusealco/docuseal-digitalocean/tree/master&refcode=421d50f53990) | [<img alt="Deploy to Render" src="https://render.com/images/deploy-to-render-button.svg" height="40">](https://render.com/deploy?repo=https://github.com/docusealco/docuseal-render)
|**Koyeb**| | |**Koyeb**|**Elestio**|
| [<img alt="Deploy on Koyeb" src="https://www.koyeb.com/static/images/deploy/button.svg" height="40">](https://app.koyeb.com/deploy?name=docuseal&type=docker&image=docker.io/docuseal/docuseal&env[PORT]=8000&env[DATABASE_URL]=CHANGE_ME&env[SECRET_KEY_BASE]=CHANGE_ME&ports=8000;http;/) | | | [<img alt="Deploy on Koyeb" src="https://www.koyeb.com/static/images/deploy/button.svg" height="40">](https://app.koyeb.com/deploy?name=docuseal&type=docker&image=docker.io/docuseal/docuseal&env[PORT]=8000&env[DATABASE_URL]=CHANGE_ME&env[SECRET_KEY_BASE]=CHANGE_ME&ports=8000;http;/) | [<img alt="Deploy on Elestio" src="https://pub-da36157c854648669813f3f76c526c2b.r2.dev/deploy-on-elestio-black.png">](https://dash.elest.io/deploy?soft=DocuSeal&id=339) |
#### Docker #### Docker
@ -81,9 +81,9 @@ HOST=your-domain-name.com docker-compose up
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.
[![Book an Integration Demo](https://cal.com/book-with-cal-dark.svg)](https://cal.com/docuseal) [Book a Meeting](https://calendly.com/kriti-docuseal/30min)
## License ## License
Distributed under the AGPLv3 License. See [LICENSE](https://github.com/docusealco/docuseal/blob/master/LICENSE) for more information. Distributed under the AGPLv3 License. See [LICENSE](https://github.com/docusealco/docuseal/blob/master/LICENSE) for more information.
Unless otherwise noted, all files © 2023 Oleksandr Turchyn. Unless otherwise noted, all files © 2023 DocuSeal LLC.

@ -3,6 +3,12 @@
module Api module Api
class ApiBaseController < ActionController::API class ApiBaseController < ActionController::API
include ActiveStorage::SetCurrent include ActiveStorage::SetCurrent
include Pagy::Backend
DEFAULT_LIMIT = 10
MAX_LIMIT = 100
wrap_parameters false
before_action :authenticate_user! before_action :authenticate_user!
check_authorization check_authorization
@ -17,6 +23,16 @@ module Api
private private
def paginate(relation)
result = relation.order(id: :desc)
.limit([params.fetch(:limit, DEFAULT_LIMIT).to_i, MAX_LIMIT].min)
result = result.where('id < ?', params[:after]) if params[:after].present?
result = result.where('id > ?', params[:before]) if params[:before].present?
result
end
def current_account def current_account
current_user&.account current_user&.account
end end

@ -2,78 +2,158 @@
module Api module Api
class SubmissionsController < ApiBaseController class SubmissionsController < ApiBaseController
UnknownFieldName = Class.new(StandardError) load_and_authorize_resource :template, only: :create
UnknownSubmitterName = Class.new(StandardError) load_and_authorize_resource :submission, only: %i[show index]
load_and_authorize_resource :template before_action only: :create do
before_action do
authorize!(:create, Submission) authorize!(:create, Submission)
end end
def index
submissions = Submissions.search(@submissions, params[:q])
submissions = submissions.where(template_id: params[:template_id]) if params[:template_id].present?
submissions = paginate(submissions.preload(:created_by_user, :template, :submitters,
audit_trail_attachment: :blob))
render json: {
data: submissions.as_json(serialize_params),
pagination: {
count: submissions.size,
next: submissions.last&.id,
prev: submissions.first&.id
}
}
end
def show
serialized_subbmitters =
@submission.submitters.preload(documents_attachments: :blob, attachments_attachments: :blob).map do |submitter|
Submissions::EnsureResultGenerated.call(submitter) if submitter.completed_at?
Submitters::SerializeForApi.call(submitter)
end
json = @submission.as_json(
serialize_params.deep_merge(
include: {
submission_events: {
only: %i[id submitter_id event_type event_timestamp]
}
}
)
)
json[:submitters] = serialized_subbmitters
render json:
end
def create def create
is_send_email = !params[:send_email].in?(['false', false])
submissions = submissions =
if (emails = (params[:emails] || params[:email]).presence) if (emails = (params[:emails] || params[:email]).presence) && params[:submission].blank?
Submissions.create_from_emails(template: @template, Submissions.create_from_emails(template: @template,
user: current_user, user: current_user,
source: :api, source: :api,
mark_as_sent: params[:send_email] != 'false', mark_as_sent: is_send_email,
emails:) emails:)
else else
submissions_attrs = normalize_submissions_params!(submissions_params[:submission], @template) submissions_attrs, attachments = normalize_submissions_params!(submissions_params[:submission], @template)
Submissions.create_from_submitters( Submissions.create_from_submitters(
template: @template, template: @template,
user: current_user, user: current_user,
source: :api, source: :api,
mark_as_sent: params[:send_email] != 'false', mark_as_sent: is_send_email,
submitters_order: params[:submitters_order] || 'preserved', submitters_order: params[:submitters_order] || 'preserved',
submissions_attrs: submissions_attrs:
) )
end end
Submissions.send_signature_requests(submissions, send_email: params[:send_email] != 'false') Submissions.send_signature_requests(submissions, send_email: is_send_email)
submitters = submissions.flat_map(&:submitters)
save_default_value_attachments!(attachments, submitters)
render json: submissions.flat_map(&:submitters) render json: submitters
rescue UnknownFieldName, 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
def destroy
@submission.update!(deleted_at: Time.current)
render json: @submission.as_json(only: %i[id deleted_at])
end
private private
def serialize_params
{
only: %i[id source submitters_order created_at updated_at],
methods: %i[audit_log_url],
include: {
submitters: { only: %i[id slug uuid name email phone
completed_at opened_at sent_at
created_at updated_at],
methods: %i[status] },
template: { only: %i[id name created_at updated_at] },
created_by_user: { only: %i[id email first_name last_name] }
}
}
end
def submissions_params def submissions_params
params.permit(submission: [{ submitters: [[:uuid, :name, :email, :role, :phone, { values: {} }]] }]) params.permit(submission: [{
submitters: [[:uuid, :name, :email, :role, :completed, :phone, :application_key,
{ values: {}, readonly_fields: [],
fields: [%i[name default_value readonly validation_pattern invalid_message]] }]]
}])
end end
def normalize_submissions_params!(submissions_params, template) def normalize_submissions_params!(submissions_params, template)
submissions_params.each do |submission| attachments = []
Array.wrap(submissions_params).each do |submission|
submission[:submitters].each_with_index do |submitter, index| submission[:submitters].each_with_index do |submitter, index|
next if submitter[:values].blank? default_values = submitter[:values] || {}
submitter[:values] = submitter[:fields]&.each { |f| default_values[f[:name]] = f[:default_value] if f[:default_value].present? }
normalize_submitter_values(template,
submitter[:values], next if default_values.blank?
submitter[:role] || template.submitters[index]['name'])
values, new_attachments =
Submitters::NormalizeValues.call(template,
default_values,
submitter[:role] || template.submitters[index]['name'])
attachments.push(*new_attachments)
submitter[:values] = values
end end
end end
submissions_params [submissions_params, attachments]
end end
def normalize_submitter_values(template, values, submitter_name) def save_default_value_attachments!(attachments, submitters)
submitter = return if attachments.blank?
template.submitters.find { |e| e['name'] == submitter_name } ||
raise(UnknownSubmitterName, "Unknown submitter: #{submitter_name}") attachments_index = attachments.index_by(&:uuid)
fields = template.fields.select { |e| e['submitter_uuid'] == submitter['uuid'] } submitters.each do |submitter|
submitter.values.to_a.each do |_, value|
attachment = attachments_index[value]
fields_uuid_index = fields.index_by { |e| e['uuid'] } next unless attachment
fields_name_index = fields.index_by { |e| e['name'] }
values.transform_keys do |key| attachment.record = submitter
next key if fields_uuid_index[key].present?
fields_name_index[key]&.dig('uuid') || raise(UnknownFieldName, "Unknown field: #{key}") attachment.save!
end
end end
end end
end end

@ -0,0 +1,35 @@
# frozen_string_literal: true
module Api
class SubmittersAutocompleteController < ApiBaseController
load_and_authorize_resource :submitter, parent: false
SELECT_COLUMNS = %w[email phone name].freeze
LIMIT = 100
def index
submitters = search_submitters(@submitters)
values = submitters.limit(LIMIT).group(SELECT_COLUMNS.join(', ')).pluck(SELECT_COLUMNS.join(', '))
attrs = values.map { |row| SELECT_COLUMNS.zip(row).to_h }
attrs = attrs.uniq { |e| e[params[:field]] } if params[:field].present?
render json: attrs
end
private
def search_submitters(submitters)
if SELECT_COLUMNS.include?(params[:field])
column = Submitter.arel_table[params[:field].to_sym]
term = "%#{params[:q].downcase}%"
submitters.where(column.lower.matches(term))
else
Submitters.search(submitters, params[:q])
end
end
end
end

@ -0,0 +1,34 @@
# frozen_string_literal: true
module Api
class SubmittersController < ApiBaseController
load_and_authorize_resource :submitter
def index
submitters = Submitters.search(@submitters, params[:q])
submitters = submitters.where(application_key: params[:application_key]) if params[:application_key].present?
submitters = submitters.where(submission_id: params[:submission_id]) if params[:submission_id].present?
submitters = paginate(
submitters.preload(:template, :submission, :submission_events,
documents_attachments: :blob, attachments_attachments: :blob)
)
render json: {
data: submitters.map { |s| Submitters::SerializeForApi.call(s, with_template: true, with_events: true) },
pagination: {
count: submitters.size,
next: submitters.last&.id,
prev: submitters.first&.id
}
}
end
def show
Submissions::EnsureResultGenerated.call(@submitter) if @submitter.completed_at?
render json: Submitters::SerializeForApi.call(@submitter, with_template: true, with_events: true)
end
end
end

@ -0,0 +1,16 @@
# frozen_string_literal: true
module Api
class TemplateFoldersAutocompleteController < ApiBaseController
load_and_authorize_resource :template_folder, parent: false
LIMIT = 100
def index
template_folders = @template_folders.joins(:templates).where(templates: { deleted_at: nil }).distinct
template_folders = TemplateFolders.search(template_folders, params[:q]).limit(LIMIT)
render json: template_folders.as_json(only: %i[name deleted_at])
end
end
end

@ -5,28 +5,60 @@ module Api
load_and_authorize_resource :template load_and_authorize_resource :template
def index def index
render json: @templates templates = Templates.search(@templates, params[:q])
templates = params[:archived] ? templates.archived : templates.active
templates = templates.where(application_key: params[:application_key]) if params[:application_key].present?
templates = paginate(templates.preload(:author, documents_attachments: :blob))
render json: {
data: templates.as_json(serialize_params),
pagination: {
count: templates.size,
next: templates.last&.id,
prev: templates.first&.id
}
}
end end
def show def show
render json: @template.as_json(include: { author: { only: %i[id email first_name last_name] }, render json: @template.as_json(serialize_params)
documents: { only: %i[id uuid], methods: %i[url filename] } })
end end
def update def update
if (folder_name = params.dig(:template, :folder_name))
@template.folder = TemplateFolders.find_or_create_by_name(current_user, folder_name)
end
@template.update!(template_params) @template.update!(template_params)
render :ok render json: @template.as_json(only: %i[id updated_at])
end
def destroy
@template.update!(deleted_at: Time.current)
render json: @template.as_json(only: %i[id deleted_at])
end end
private private
def serialize_params
{
include: { author: { only: %i[id email first_name last_name] },
documents: { only: %i[id uuid], methods: %i[url filename] } }
}
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: [], areas: [%i[x y w h cell_w attachment_uuid page]] }]]
)
end end
end end
end end

@ -16,6 +16,7 @@ module Api
render json: { render json: {
schema:, schema:,
documents: documents.as_json( documents: documents.as_json(
methods: [:metadata],
include: { include: {
preview_images: { methods: %i[url metadata filename] } preview_images: { methods: %i[url metadata filename] }
} }

@ -33,7 +33,7 @@ class ApplicationController < ActionController::Base
private private
def sign_in_for_demo def sign_in_for_demo
sign_in(User.order('random()').take) unless signed_in? sign_in(User.active.order('random()').take) unless signed_in?
end end
def current_account def current_account

@ -6,17 +6,56 @@ class DashboardController < ApplicationController
before_action :maybe_redirect_product_url before_action :maybe_redirect_product_url
before_action :maybe_render_landing before_action :maybe_render_landing
load_and_authorize_resource :template_folder, parent: false
load_and_authorize_resource :template, parent: false load_and_authorize_resource :template, parent: false
SHOW_TEMPLATES_FOLDERS_THRESHOLD = 9
TEMPLATES_PER_PAGE = 12
FOLDERS_PER_PAGE = 18
def index def index
@templates = @templates.active.preload(:author).order(id: :desc) @template_folders = filter_template_folders(@template_folders)
@templates = Templates.search(@templates, params[:q])
@pagy, @template_folders = pagy(
@template_folders,
items: FOLDERS_PER_PAGE,
page: @template_folders.count > SHOW_TEMPLATES_FOLDERS_THRESHOLD ? params[:page] : 1
)
if @pagy.count > SHOW_TEMPLATES_FOLDERS_THRESHOLD
@templates = @templates.none
else
@template_folders = @template_folders.reject { |e| e.name == TemplateFolder::DEFAULT_NAME }
@templates = filter_templates(@templates)
@pagy, @templates = pagy(@templates, items: 12) items =
if @template_folders.size < 4
TEMPLATES_PER_PAGE
else
(@template_folders.size < 7 ? 9 : 6)
end
@pagy, @templates = pagy(@templates, items:)
end
end end
private private
def filter_template_folders(template_folders)
rel = template_folders.joins(:active_templates)
.order(id: :desc)
.distinct
TemplateFolders.search(rel, params[:q])
end
def filter_templates(templates)
rel = templates.active.preload(:author).order(id: :desc)
rel = rel.where(folder_id: current_account.default_template_folder.id) if params[:q].blank?
Templates.search(rel, params[:q])
end
def maybe_redirect_product_url def maybe_redirect_product_url
return if !Docuseal.multitenant? || signed_in? return if !Docuseal.multitenant? || signed_in?

@ -0,0 +1,22 @@
# frozen_string_literal: true
class EnquiriesController < ApplicationController
skip_before_action :authenticate_user!
skip_authorization_check
def create
if params[:talk_to_sales] == 'on'
Faraday.post(Docuseal::ENQUIRIES_URL,
enquiry_params.merge(type: :talk_to_sales).to_json,
'Content-Type' => 'application/json')
end
head :ok
end
private
def enquiry_params
params.require(:user).permit(:email)
end
end

@ -10,7 +10,7 @@ class MfaSetupController < ApplicationController
current_user.save! current_user.save!
@provision_url = current_user.otp_provisioning_uri(current_user.email, issuer: Docuseal::PRODUCT_NAME) @provision_url = current_user.otp_provisioning_uri(current_user.email, issuer: Docuseal.product_name)
end end
def edit; end def edit; end
@ -22,7 +22,7 @@ class MfaSetupController < ApplicationController
redirect_to settings_profile_index_path, notice: '2FA has been configured' redirect_to settings_profile_index_path, notice: '2FA has been configured'
else else
@provision_url = current_user.otp_provisioning_uri(current_user.email, issuer: Docuseal::PRODUCT_NAME) @provision_url = current_user.otp_provisioning_uri(current_user.email, issuer: Docuseal.product_name)
@error_message = 'Code is invalid' @error_message = 'Code is invalid'

@ -0,0 +1,13 @@
# frozen_string_literal: true
class PasswordsController < Devise::PasswordsController
class Current < ActiveSupport::CurrentAttributes
attribute :user
end
def update
super do |resource|
Current.user = resource
end
end
end

@ -0,0 +1,46 @@
# frozen_string_literal: true
class PreviewDocumentPageController < ActionController::API
include ActiveStorage::SetCurrent
FORMAT = Templates::ProcessDocument::FORMAT
def show
attachment = ActiveStorage::Attachment.find_by(uuid: params[:attachment_uuid])
return head :not_found unless attachment
preview_image = attachment.preview_images.joins(:blob).find_by(blob: { filename: "#{params[:id]}#{FORMAT}" })
return redirect_to preview_image.url, allow_other_host: true if preview_image
file_path =
if attachment.service.name == :disk
ActiveStorage::Blob.service.path_for(attachment.key)
else
find_or_create_document_tempfile_path(attachment)
end
io = Templates::ProcessDocument.generate_pdf_preview_from_file(attachment, file_path, params[:id].to_i)
render plain: io.tap(&:rewind).read
end
def find_or_create_document_tempfile_path(attachment)
file_path = "#{Dir.tmpdir}/#{attachment.uuid}"
File.open(file_path, File::RDWR | File::CREAT, 0o644) do |f|
f.flock(File::LOCK_EX)
# rubocop:disable Style/ZeroLengthPredicate
if f.size.zero?
f.binmode
f.write(attachment.download)
end
# rubocop:enable Style/ZeroLengthPredicate
end
file_path
end
end

@ -31,10 +31,10 @@ class RegistrationsController < Devise::RegistrationsController
redirect_to after_sign_up_path_for(current_user), allow_other_host: true redirect_to after_sign_up_path_for(current_user), allow_other_host: true
end end
def require_no_authentication def set_flash_message(key, kind, options = {})
super return if key == :alert && kind == 'already_authenticated'
flash.clear super
end end
def build_resource(_hash = {}) def build_resource(_hash = {})
@ -43,7 +43,7 @@ class RegistrationsController < Devise::RegistrationsController
self.resource = account.users.new(user_params) self.resource = account.users.new(user_params)
account.name ||= "#{resource.full_name}'s Company" if params[:action] == 'create' account.name ||= resource.full_name if params[:action] == 'create'
end end
def user_params def user_params

@ -32,9 +32,9 @@ class SessionsController < Devise::SessionsController
devise_parameter_sanitizer.permit(:sign_in, keys: [:otp_attempt]) devise_parameter_sanitizer.permit(:sign_in, keys: [:otp_attempt])
end end
def require_no_authentication def set_flash_message(key, kind, options = {})
super return if key == :alert && kind == 'already_authenticated'
flash.clear super
end end
end end

@ -0,0 +1,16 @@
# frozen_string_literal: true
class SsoSettingsController < ApplicationController
before_action :load_encrypted_config
authorize_resource :encrypted_config, only: :index
authorize_resource :encrypted_config, parent: false, except: :index
def index; end
private
def load_encrypted_config
@encrypted_config =
EncryptedConfig.find_or_initialize_by(account: current_account, key: 'saml_configs')
end
end

@ -14,14 +14,14 @@ class StartFormController < ApplicationController
def update def update
@submitter = Submitter.where(submission: @template.submissions.where(deleted_at: nil)) @submitter = Submitter.where(submission: @template.submissions.where(deleted_at: nil))
.find_or_initialize_by(email: submitter_params[:email]) .then { |rel| params[:resubmit].present? ? rel.where(completed_at: nil) : rel }
.find_or_initialize_by(**submitter_params.compact_blank)
if @submitter.completed_at? if @submitter.completed_at?
redirect_to start_form_completed_path(@template.slug, email: submitter_params[:email]) redirect_to start_form_completed_path(@template.slug, email: submitter_params[:email])
else else
@submitter.assign_attributes( @submitter.assign_attributes(
uuid: @template.submitters.first['uuid'], uuid: @template.submitters.first['uuid'],
opened_at: Time.current,
ip: request.remote_ip, ip: request.remote_ip,
ua: request.user_agent ua: request.user_agent
) )
@ -47,7 +47,7 @@ class StartFormController < ApplicationController
private private
def submitter_params def submitter_params
params.require(:submitter).permit(:email).tap do |attrs| params.require(:submitter).permit(:email, :phone, :name).tap do |attrs|
attrs[:email] = Submissions.normalize_email(attrs[:email]) attrs[:email] = Submissions.normalize_email(attrs[:email])
end end
end end

@ -6,12 +6,24 @@ class SubmissionsController < ApplicationController
load_and_authorize_resource :submission, only: %i[show destroy] load_and_authorize_resource :submission, only: %i[show destroy]
PRELOAD_ALL_PAGES_AMOUNT = 200
def show def show
ActiveRecord::Associations::Preloader.new( ActiveRecord::Associations::Preloader.new(
records: [@submission], records: [@submission],
associations: [:template, { template_schema_documents: [:blob, { preview_images_attachments: :blob }] }] associations: [:template, { template_schema_documents: :blob }]
).call ).call
total_pages =
@submission.template_schema_documents.sum { |e| e.metadata.dig('pdf', 'number_of_pages').to_i }
if total_pages < PRELOAD_ALL_PAGES_AMOUNT
ActiveRecord::Associations::Preloader.new(
records: @submission.template_schema_documents,
associations: [:blob, { preview_images_attachments: :blob }]
).call
end
render :show, layout: 'plain' render :show, layout: 'plain'
end end

@ -6,16 +6,33 @@ class SubmitFormController < ApplicationController
skip_before_action :authenticate_user! skip_before_action :authenticate_user!
skip_authorization_check skip_authorization_check
PRELOAD_ALL_PAGES_AMOUNT = 200
def show def show
@submitter = @submitter = Submitter.find_by!(slug: params[:slug])
Submitter.preload(submission: [
:template, { template_schema_documents: [:blob, { preview_images_attachments: :blob }] }
])
.find_by!(slug: params[:slug])
return redirect_to submit_form_completed_path(@submitter.slug) if @submitter.completed_at? return redirect_to submit_form_completed_path(@submitter.slug) if @submitter.completed_at?
ActiveRecord::Associations::Preloader.new(
records: [@submitter],
associations: [submission: [:template, { template_schema_documents: :blob }]]
).call
total_pages =
@submitter.submission.template_schema_documents.sum { |e| e.metadata.dig('pdf', 'number_of_pages').to_i }
if total_pages < PRELOAD_ALL_PAGES_AMOUNT
ActiveRecord::Associations::Preloader.new(
records: @submitter.submission.template_schema_documents,
associations: [:blob, { preview_images_attachments: :blob }]
).call
end
Submitters::MaybeUpdateDefaultValues.call(@submitter, current_user)
cookies[:submitter_sid] = @submitter.signed_id cookies[:submitter_sid] = @submitter.signed_id
render @submitter.submission.template.deleted_at? ? :archived : :show
end end
def update def update

@ -0,0 +1,29 @@
# frozen_string_literal: true
class TemplateFoldersController < ApplicationController
load_and_authorize_resource :template_folder
def show
@templates = @template_folder.templates.active.preload(:author).order(id: :desc)
@templates = Templates.search(@templates, params[:q])
@pagy, @templates = pagy(@templates, items: 12)
end
def edit; end
def update
if @template_folder != current_account.default_template_folder &&
@template_folder.update(template_folder_params)
redirect_to folder_path(@template_folder), notice: 'Folder name has been updated'
else
redirect_to folder_path(@template_folder), alert: 'Unable to rename folder'
end
end
private
def template_folder_params
params.require(:template_folder).permit(:name)
end
end

@ -4,7 +4,7 @@ class TemplatesArchivedController < ApplicationController
load_and_authorize_resource :template, parent: false load_and_authorize_resource :template, parent: false
def index def index
@templates = @templates.where.not(deleted_at: nil).preload(:author).order(id: :desc) @templates = @templates.where.not(deleted_at: nil).preload(:author, :folder).order(id: :desc)
@templates = Templates.search(@templates, params[:q]) @templates = Templates.search(@templates, params[:q])
@pagy, @templates = pagy(@templates, items: 12) @pagy, @templates = pagy(@templates, items: 12)

@ -10,6 +10,11 @@ class TemplatesController < ApplicationController
submissions = submissions.active if @template.deleted_at.blank? submissions = submissions.active if @template.deleted_at.blank?
submissions = Submissions.search(submissions, params[:q]) submissions = Submissions.search(submissions, params[:q])
@base_submissions = submissions
submissions = submissions.pending if params[:status] == 'pending'
submissions = submissions.completed if params[:status] == 'completed'
@pagy, @submissions = pagy(submissions.preload(:submitters).order(id: :desc)) @pagy, @submissions = pagy(submissions.preload(:submitters).order(id: :desc))
rescue ActiveRecord::RecordNotFound rescue ActiveRecord::RecordNotFound
redirect_to root_path redirect_to root_path
@ -25,12 +30,21 @@ class TemplatesController < ApplicationController
associations: [schema_documents: { preview_images_attachments: :blob }] associations: [schema_documents: { preview_images_attachments: :blob }]
).call ).call
@template_data =
@template.as_json.merge(
documents: @template.schema_documents.as_json(
methods: [:metadata],
include: { preview_images: { methods: %i[url metadata filename] } }
)
).to_json
render :edit, layout: 'plain' render :edit, layout: 'plain'
end end
def create def create
@template.account = current_account @template.account = current_account
@template.author = current_user @template.author = current_user
@template.folder = TemplateFolders.find_or_create_by_name(current_user, params[:folder_name])
@template.assign_attributes(@base_template.slice(:fields, :schema, :submitters)) if @base_template @template.assign_attributes(@base_template.slice(:fields, :schema, :submitters)) if @base_template
if @template.save if @template.save
@ -43,9 +57,18 @@ class TemplatesController < ApplicationController
end end
def destroy def destroy
@template.update!(deleted_at: Time.current) notice =
if !Docuseal.multitenant? && params[:permanently].present?
@template.destroy!
'Template has been removed.'
else
@template.update!(deleted_at: Time.current)
'Template has been archived.'
end
redirect_back(fallback_location: root_path, notice: 'Template has been archived.') redirect_back(fallback_location: root_path, notice:)
end end
private private

@ -0,0 +1,23 @@
# frozen_string_literal: true
class TemplatesFoldersController < ApplicationController
load_and_authorize_resource :template
def edit; end
def update
@template.folder = TemplateFolders.find_or_create_by_name(current_user, params[:name])
if @template.save
redirect_back(fallback_location: template_path(@template), notice: 'Document template has been moved')
else
redirect_back(fallback_location: template_path(@template), notice: 'Unable to move template into folder')
end
end
private
def template_folder_params
params.require(:template_folder).permit(:name)
end
end

@ -3,18 +3,55 @@
class TemplatesUploadsController < ApplicationController class TemplatesUploadsController < ApplicationController
load_and_authorize_resource :template, parent: false load_and_authorize_resource :template, parent: false
layout 'plain'
def show; end
def create def create
url_params = create_file_params_from_url if params[:url].present?
@template.account = current_account
@template.author = current_user @template.author = current_user
@template.name = File.basename(params[:files].first.original_filename, '.*') @template.folder = TemplateFolders.find_or_create_by_name(current_user, params[:folder_name])
@template.name = File.basename((url_params || params)[:files].first.original_filename, '.*')
@template.save! @template.save!
documents = Templates::CreateAttachments.call(@template, params) documents = Templates::CreateAttachments.call(@template, url_params || params)
schema = documents.map { |doc| { attachment_uuid: doc.uuid, name: doc.filename.base } } schema = documents.map { |doc| { attachment_uuid: doc.uuid, name: doc.filename.base } }
@template.update!(schema:) @template.update!(schema:)
redirect_to edit_template_path(@template) redirect_to edit_template_path(@template)
rescue StandardError => e
Rollbar.error(e) if defined?(Rollbar)
redirect_to root_path, alert: 'Unable to upload file'
end
private
def create_file_params_from_url
tempfile = Tempfile.new
tempfile.binmode
tempfile.write(conn.get(params[:url]).body)
tempfile.rewind
file = ActionDispatch::Http::UploadedFile.new(
tempfile:,
filename: File.basename(
URI.decode_www_form_component(params[:filename].presence || params[:url])
),
type: Marcel::MimeType.for(tempfile)
)
{ files: [file] }
end
def conn
Faraday.new do |faraday|
faraday.response :follow_redirects
end
end end
end end

@ -0,0 +1,37 @@
# frozen_string_literal: true
class UserSignaturesController < ApplicationController
before_action :load_user_config
authorize_resource :user_config
def edit; end
def update
file = params[:file]
return redirect_to settings_profile_index_path, notice: 'Unable to save signature' if file.blank?
blob = ActiveStorage::Blob.create_and_upload!(io: file.open,
filename: file.original_filename,
content_type: file.content_type)
attachment = ActiveStorage::Attachment.create!(
blob:,
name: 'signature',
record: current_user
)
if @user_config.update(value: attachment.uuid)
redirect_to settings_profile_index_path, notice: 'Signature has been saved'
else
redirect_to settings_profile_index_path, notice: 'Unable to save signature'
end
end
private
def load_user_config
@user_config =
UserConfig.find_or_initialize_by(user: current_user, key: UserConfig::SIGNATURE_KEY)
end
end

@ -27,7 +27,10 @@ class UsersController < ApplicationController
def update def update
return redirect_to settings_users_path, notice: 'Unable to update user.' if Docuseal.demo? return redirect_to settings_users_path, notice: 'Unable to update user.' if Docuseal.demo?
if @user.update(user_params.compact_blank.except(current_user == @user ? :role : nil)) attrs = user_params.compact_blank
attrs.delete(:role) if User::ROLES.exclude?(attrs[:role])
if @user.update(attrs.except(current_user == @user ? :role : nil))
redirect_to settings_users_path, notice: 'User has been updated' redirect_to settings_users_path, notice: 'User has been updated'
else else
render turbo_stream: turbo_stream.replace(:modal, template: 'users/edit'), status: :unprocessable_entity render turbo_stream: turbo_stream.replace(:modal, template: 'users/edit'), status: :unprocessable_entity

@ -12,7 +12,7 @@ class VerifyPdfSignatureController < ApplicationController
cert_data = if Docuseal.multitenant? cert_data = if Docuseal.multitenant?
Docuseal::CERTS Docuseal::CERTS
else else
EncryptedConfig.find_by(account: current_account, key: EncryptedConfig::ESIGN_CERTS_KEY)&.value || {} EncryptedConfig.find_by(key: EncryptedConfig::ESIGN_CERTS_KEY)&.value || {}
end end
default_pkcs = GenerateCertificate.load_pkcs(cert_data) default_pkcs = GenerateCertificate.load_pkcs(cert_data)

@ -15,6 +15,10 @@ import DownloadButton from './elements/download_button'
import SetOriginUrl from './elements/set_origin_url' import SetOriginUrl from './elements/set_origin_url'
import SetTimezone from './elements/set_timezone' import SetTimezone from './elements/set_timezone'
import AutoresizeTextarea from './elements/autoresize_textarea' import AutoresizeTextarea from './elements/autoresize_textarea'
import SubmittersAutocomplete from './elements/submitter_autocomplete'
import FolderAutocomplete from './elements/folder_autocomplete'
import SignatureForm from './elements/signature_form'
import SubmitForm from './elements/submit_form'
import * as TurboInstantClick from './lib/turbo_instant_click' import * as TurboInstantClick from './lib/turbo_instant_click'
@ -43,6 +47,10 @@ window.customElements.define('download-button', DownloadButton)
window.customElements.define('set-origin-url', SetOriginUrl) window.customElements.define('set-origin-url', SetOriginUrl)
window.customElements.define('set-timezone', SetTimezone) window.customElements.define('set-timezone', SetTimezone)
window.customElements.define('autoresize-textarea', AutoresizeTextarea) window.customElements.define('autoresize-textarea', AutoresizeTextarea)
window.customElements.define('submitters-autocomplete', SubmittersAutocomplete)
window.customElements.define('folder-autocomplete', FolderAutocomplete)
window.customElements.define('signature-form', SignatureForm)
window.customElements.define('submit-form', SubmitForm)
document.addEventListener('turbo:before-fetch-request', encodeMethodIntoRequestBody) document.addEventListener('turbo:before-fetch-request', encodeMethodIntoRequestBody)
document.addEventListener('turbo:submit-end', async (event) => { document.addEventListener('turbo:submit-end', async (event) => {
@ -70,12 +78,12 @@ document.addEventListener('turbo:submit-end', async (event) => {
window.customElements.define('template-builder', class extends HTMLElement { window.customElements.define('template-builder', class extends HTMLElement {
connectedCallback () { connectedCallback () {
this.appElem = document.createElement('div') this.appElem = document.createElement('div')
this.appElem.classList.add('max-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',
withPhone: this.dataset.withPhone === 'true', withPhone: this.dataset.withPhone === 'true',
withLogo: this.dataset.withLogo !== 'false',
acceptFileTypes: this.dataset.acceptFileTypes, acceptFileTypes: this.dataset.acceptFileTypes,
isDirectUpload: this.dataset.isDirectUpload === 'true' isDirectUpload: this.dataset.isDirectUpload === 'true'
}) })

@ -74,6 +74,58 @@ button[disabled] .enabled {
.base-select { .base-select {
@apply select base-input w-full font-normal; @apply select base-input w-full font-normal;
} }
.bg-redact{ .bg-redact{
background: black; background: black;
} }
.tooltip-bottom-end:before {
transform: translateX(-95%);
top: var(--tooltip-offset);
left: 100%;
right: auto;
bottom: auto;
}
.tooltip-bottom-end:after {
transform: translateX(-25%);
border-color: transparent transparent var(--tooltip-color) transparent;
top: var(--tooltip-tail-offset);
left: 50%;
right: auto;
bottom: auto;
}
.autocomplete {
background: white;
z-index: 1000;
font: 14px/22px "-apple-system", BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
overflow: auto;
box-sizing: border-box;
@apply border border-base-300 mt-1 rounded-md;
}
.autocomplete * {
font: inherit;
}
.autocomplete > div {
@apply px-2 py-1 font-normal text-sm;
}
.autocomplete .group {
background: #eee;
}
.autocomplete > div:hover:not(.group),
.autocomplete > div.selected {
@apply bg-base-300;
cursor: pointer;
}
.input-outlined {
outline-style: solid;
outline-width: 1px;
outline-offset: 3px;
outline-color: hsl(var(--bc) / 0.2);
}

@ -91,11 +91,13 @@ export default actionable(targetable(class extends HTMLElement {
this.append(input) this.append(input)
}) })
if (this.dataset.submitOnUpload) { if (this.dataset.submitOnUpload === 'true') {
this.closest('form').querySelector('button[type="submit"]').click() this.closest('form').querySelector('button[type="submit"]').click()
} }
}).finally(() => { }).finally(() => {
this.toggleLoading() if (this.dataset.submitOnUpload !== 'true') {
this.toggleLoading()
}
}) })
} else { } else {
if (this.dataset.submitOnUpload) { if (this.dataset.submitOnUpload) {

@ -0,0 +1,43 @@
import autocomplete from 'autocompleter'
export default class extends HTMLElement {
connectedCallback () {
autocomplete({
input: this.input,
preventSubmit: this.dataset.submitOnSelect === 'true' ? 0 : 1,
minLength: 0,
showOnFocus: true,
onSelect: this.onSelect,
render: this.render,
fetch: this.fetch
})
}
onSelect = (item) => {
this.input.value = item.name
}
fetch = (text, resolve) => {
const queryParams = new URLSearchParams({ q: text })
fetch('/api/template_folders_autocomplete?' + queryParams).then(async (resp) => {
const items = await resp.json()
resolve(items)
}).catch(() => {
resolve([])
})
}
render = (item) => {
const div = document.createElement('div')
div.textContent = item.name
return div
}
get input () {
return this.querySelector('input')
}
}

@ -0,0 +1,46 @@
import { target, targetable } from '@github/catalyst/lib/targetable'
import { cropCanvasAndExportToPNG } from '../submission_form/crop_canvas'
export default targetable(class extends HTMLElement {
static [target.static] = ['canvas', 'input', 'clear', 'button']
async connectedCallback () {
this.canvas.width = this.canvas.parentNode.parentNode.clientWidth
this.canvas.height = this.canvas.parentNode.parentNode.clientWidth / 3
const { default: SignaturePad } = await import('signature_pad')
this.pad = new SignaturePad(this.canvas)
this.clear.addEventListener('click', (e) => {
e.preventDefault()
this.pad.clear()
})
this.button.addEventListener('click', (e) => {
e.preventDefault()
this.button.disabled = true
this.submit()
})
}
async submit () {
const blob = await cropCanvasAndExportToPNG(this.canvas)
const file = new File([blob], 'signature.png', { type: 'image/png' })
const dataTransfer = new DataTransfer()
dataTransfer.items.add(file)
this.input.files = dataTransfer.files
if (this.input.webkitEntries.length) {
this.input.dataset.file = `${dataTransfer.files[0].name}`
}
this.closest('form').requestSubmit()
}
})

@ -0,0 +1,5 @@
export default class extends HTMLElement {
connectedCallback () {
this.querySelector('form').requestSubmit()
}
}

@ -0,0 +1,67 @@
import autocomplete from 'autocompleter'
export default class extends HTMLElement {
connectedCallback () {
autocomplete({
input: this.input,
preventSubmit: 1,
minLength: 1,
showOnFocus: true,
onSelect: this.onSelect,
render: this.render,
fetch: this.fetch
})
}
onSelect = (item) => {
const fields = ['email', 'name', 'phone']
const submitterItemEl = this.closest('submitter-item')
fields.forEach((field) => {
const input = submitterItemEl.querySelector(`submitters-autocomplete[data-field="${field}"] input`)
const textarea = submitterItemEl.querySelector(`submitters-autocomplete[data-field="${field}"] textarea`)
if (input && item[field]) {
input.value = item[field]
}
if (textarea && item[field]) {
textarea.value = textarea.value.replace(/[^;,\s]+$/, item[field] + ' ')
}
})
}
fetch = (text, resolve) => {
const q = text.split(/[;,\s]+/).pop().trim()
if (q) {
const queryParams = new URLSearchParams({ q, field: this.dataset.field })
fetch('/api/submitters_autocomplete?' + queryParams).then(async (resp) => {
const items = await resp.json()
if (q.length < 3) {
resolve(items.filter((e) => e[this.dataset.field].startsWith(q)))
} else {
resolve(items)
}
}).catch(() => {
resolve([])
})
} else {
resolve([])
}
}
render = (item) => {
const div = document.createElement('div')
div.textContent = item[this.dataset.field]
return div
}
get input () {
return this.querySelector('input') || this.querySelector('textarea')
}
}

@ -13,10 +13,12 @@ window.customElements.define('submission-form', class extends HTMLElement {
authenticityToken: this.dataset.authenticityToken, authenticityToken: this.dataset.authenticityToken,
canSendEmail: this.dataset.canSendEmail === 'true', canSendEmail: this.dataset.canSendEmail === 'true',
isDirectUpload: this.dataset.isDirectUpload === 'true', isDirectUpload: this.dataset.isDirectUpload === 'true',
goToLast: this.dataset.goToLast === 'true',
isDemo: this.dataset.isDemo === 'true', isDemo: this.dataset.isDemo === 'true',
attribution: this.dataset.attribution !== 'false', attribution: this.dataset.attribution !== 'false',
withConfetti: true, withConfetti: true,
values: reactive(JSON.parse(this.dataset.values)), values: reactive(JSON.parse(this.dataset.values)),
completedButton: JSON.parse(this.dataset.completedButton),
attachments: reactive(JSON.parse(this.dataset.attachments)), attachments: reactive(JSON.parse(this.dataset.attachments)),
fields: JSON.parse(this.dataset.fields) fields: JSON.parse(this.dataset.fields)
}) })

@ -47,6 +47,10 @@ select:required:invalid {
@apply border-base-content/20; @apply border-base-content/20;
} }
.base-textarea {
@apply textarea textarea-bordered bg-white rounded-3xl;
}
.btn { .btn {
@apply no-animation; @apply no-animation;
} }

@ -86,6 +86,11 @@
class="object-contain mx-auto" class="object-contain mx-auto"
:src="signature.url" :src="signature.url"
> >
<img
v-else-if="field.type === 'initials' && initials"
class="object-contain mx-auto"
:src="initials.url"
>
<div <div
v-else-if="field.type === 'file'" v-else-if="field.type === 'file'"
class="px-0.5 flex flex-col justify-center" class="px-0.5 flex flex-col justify-center"
@ -144,15 +149,16 @@
<span v-else-if="field.type === 'date'"> <span v-else-if="field.type === 'date'">
{{ formattedDate }} {{ formattedDate }}
</span> </span>
<span v-else> <span
{{ modelValue }} v-else
</span> class="whitespace-pre-wrap"
>{{ modelValue }}</span>
</div> </div>
</div> </div>
</template> </template>
<script> <script>
import { IconTextSize, IconWritingSign, IconCalendarEvent, IconPhoto, IconCheckbox, IconPaperclip, IconSelect, IconCircleDot, IconChecks, IconCheck, IconColumns3, IconPhoneCheck, IconBarrierBlock } from '@tabler/icons-vue' import { IconTextSize, IconWritingSign, IconCalendarEvent, IconPhoto, IconCheckbox, IconPaperclip, IconSelect, IconCircleDot, IconChecks, IconCheck, IconColumns3, IconPhoneCheck, IconBarrierBlock, IconLetterCaseUpper } from '@tabler/icons-vue'
export default { export default {
name: 'FieldArea', name: 'FieldArea',
@ -214,6 +220,7 @@ export default {
signature: 'Signature', signature: 'Signature',
date: 'Date', date: 'Date',
image: 'Image', image: 'Image',
initials: 'Initials',
file: 'File', file: 'File',
select: 'Select', select: 'Select',
checkbox: 'Checkbox', checkbox: 'Checkbox',
@ -229,6 +236,7 @@ export default {
signature: IconWritingSign, signature: IconWritingSign,
date: IconCalendarEvent, date: IconCalendarEvent,
image: IconPhoto, image: IconPhoto,
initials: IconLetterCaseUpper,
file: IconPaperclip, file: IconPaperclip,
select: IconSelect, select: IconSelect,
checkbox: IconCheckbox, checkbox: IconCheckbox,
@ -253,6 +261,13 @@ export default {
return null return null
} }
}, },
initials () {
if (this.field.type === 'initials') {
return this.attachmentsIndex[this.modelValue]
} else {
return null
}
},
formattedDate () { formattedDate () {
if (this.field.type === 'date' && this.modelValue) { if (this.field.type === 'date' && this.modelValue) {
return new Intl.DateTimeFormat([], { year: 'numeric', month: 'long', day: 'numeric', timeZone: 'UTC' }).format(new Date(this.modelValue)) return new Intl.DateTimeFormat([], { year: 'numeric', month: 'long', day: 'numeric', timeZone: 'UTC' }).format(new Date(this.modelValue))

@ -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)])
} }
} }
} }

@ -11,6 +11,15 @@
</span> </span>
</p> </p>
<div class="space-y-3 mt-5"> <div class="space-y-3 mt-5">
<a
v-if="completedButton.url"
:href="completedButton.url"
class="white-button flex items-center w-full"
>
<span>
{{ completedButton.title || 'Back to Website' }}
</span>
</a>
<button <button
v-if="canSendEmail && !isDemo" v-if="canSendEmail && !isDemo"
class="white-button !h-auto flex items-center space-x-1 w-full" class="white-button !h-auto flex items-center space-x-1 w-full"
@ -27,6 +36,7 @@
</span> </span>
</button> </button>
<button <button
v-if="!isWebView"
class="base-button flex items-center space-x-1 w-full" class="base-button flex items-center space-x-1 w-full"
:disabled="isDownloading" :disabled="isDownloading"
@click.prevent="download" @click.prevent="download"
@ -68,7 +78,7 @@
> >
{{ t('signed_with') }} {{ t('signed_with') }}
<a <a
href="https://www.docuseal.co" href="https://www.docuseal.co/start"
target="_blank" target="_blank"
class="underline" class="underline"
>DocuSeal</a> - {{ t('open_source_documents_software') }} >DocuSeal</a> - {{ t('open_source_documents_software') }}
@ -114,6 +124,11 @@ export default {
type: Boolean, type: Boolean,
required: false, required: false,
default: false default: false
},
completedButton: {
type: Object,
required: false,
default: () => ({})
} }
}, },
data () { data () {
@ -122,6 +137,11 @@ export default {
isDownloading: false isDownloading: false
} }
}, },
computed: {
isWebView () {
return /webview|wv|ip((?!.*Safari)|(?=.*like Safari))/i.test(window.navigator.userAgent)
}
},
async mounted () { async mounted () {
if (this.withConfetti) { if (this.withConfetti) {
const { default: confetti } = await import('canvas-confetti') const { default: confetti } = await import('canvas-confetti')

@ -0,0 +1,49 @@
function cropCanvasAndExportToPNG (canvas) {
const ctx = canvas.getContext('2d')
const width = canvas.width
const height = canvas.height
let topmost = height
let bottommost = 0
let leftmost = width
let rightmost = 0
const imageData = ctx.getImageData(0, 0, width, height)
const pixels = imageData.data
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const pixelIndex = (y * width + x) * 4
const alpha = pixels[pixelIndex + 3]
if (alpha !== 0) {
topmost = Math.min(topmost, y)
bottommost = Math.max(bottommost, y)
leftmost = Math.min(leftmost, x)
rightmost = Math.max(rightmost, x)
}
}
}
const croppedWidth = rightmost - leftmost + 1
const croppedHeight = bottommost - topmost + 1
const croppedCanvas = document.createElement('canvas')
croppedCanvas.width = croppedWidth
croppedCanvas.height = croppedHeight
const croppedCtx = croppedCanvas.getContext('2d')
croppedCtx.drawImage(canvas, leftmost, topmost, croppedWidth, croppedHeight, 0, 0, croppedWidth, croppedHeight)
return new Promise((resolve, reject) => {
croppedCanvas.toBlob((blob) => {
if (blob) {
resolve(blob)
} else {
reject(new Error('Failed to create a PNG blob.'))
}
}, 'image/png')
})
}
export { cropCanvasAndExportToPNG }

@ -0,0 +1,73 @@
<template>
<div>
<div class="flex justify-between items-center w-full mb-2">
<label
:for="field.uuid"
class="label text-2xl"
>{{ field.name || t('date') }}
<template v-if="!field.required">({{ t('optional') }})</template>
</label>
<button
class="btn btn-outline btn-sm !normal-case font-normal"
@click.prevent="setCurrentDate"
>
<IconCalendarCheck :width="16" />
{{ t('set_today') }}
</button>
</div>
<div class="text-center">
<input
ref="input"
v-model="value"
class="base-input !text-2xl text-center w-full"
:required="field.required"
type="date"
:name="`values[${field.uuid}]`"
@focus="$emit('focus')"
>
</div>
</div>
</template>
<script>
import { IconCalendarCheck } from '@tabler/icons-vue'
export default {
name: 'DateStep',
components: {
IconCalendarCheck
},
inject: ['t'],
props: {
field: {
type: Object,
required: true
},
modelValue: {
type: String,
required: false,
default: ''
}
},
emits: ['update:model-value', 'focus'],
computed: {
value: {
set (value) {
this.$emit('update:model-value', value)
},
get () {
return this.modelValue
}
}
},
methods: {
setCurrentDate () {
const inputEl = this.$refs.input
inputEl.valueAsDate = new Date(new Date().getTime() - new Date().getTimezoneOffset() * 60000)
inputEl.dispatchEvent(new Event('input', { bubbles: true }))
}
}
}
</script>

@ -58,54 +58,20 @@
> >
<div class="md:mt-4"> <div class="md:mt-4">
<div v-if="['cells', 'text'].includes(currentField.type)"> <div v-if="['cells', 'text'].includes(currentField.type)">
<label <TextStep
v-if="currentField.name" :key="currentField.uuid"
:for="currentField.uuid" v-model="values[currentField.uuid]"
class="label text-2xl mb-2" :field="currentField"
>{{ currentField.name }} @focus="$refs.areas.scrollIntoField(currentField)"
<template v-if="!currentField.required">({{ t('optional') }})</template>
</label>
<div
v-else
class="py-1"
/> />
<div>
<input
:id="currentField.uuid"
v-model="values[currentField.uuid]"
class="base-input !text-2xl w-full"
:required="currentField.required"
:placeholder="`${t('type_here')}...${currentField.required ? '' : ` (${t('optional')})`}`"
type="text"
:name="`values[${currentField.uuid}]`"
@focus="$refs.areas.scrollIntoField(currentField)"
>
</div>
</div>
<div v-else-if="currentField.type === 'date'">
<label
v-if="currentField.name"
:for="currentField.uuid"
class="label text-2xl mb-2"
>{{ currentField.name }}
<template v-if="!currentField.required">({{ t('optional') }})</template>
</label>
<div
v-else
class="py-1"
/>
<div class="text-center">
<input
:id="currentField.uuid"
v-model="values[currentField.uuid]"
class="base-input !text-2xl text-center w-full"
:required="currentField.required"
type="date"
:name="`values[${currentField.uuid}]`"
@focus="$refs.areas.scrollIntoField(currentField)"
>
</div>
</div> </div>
<DateStep
v-else-if="currentField.type === 'date'"
:key="currentField.uuid"
v-model="values[currentField.uuid]"
:field="currentField"
@focus="$refs.areas.scrollIntoField(currentField)"
/>
<div v-else-if="currentField.type === 'select'"> <div v-else-if="currentField.type === 'select'">
<label <label
v-if="currentField.name" v-if="currentField.name"
@ -179,6 +145,7 @@
</div> </div>
<MultiSelectStep <MultiSelectStep
v-else-if="currentField.type === 'multiple'" v-else-if="currentField.type === 'multiple'"
:key="currentField.uuid"
v-model="values[currentField.uuid]" v-model="values[currentField.uuid]"
:field="currentField" :field="currentField"
/> />
@ -224,6 +191,9 @@
:id="field.uuid" :id="field.uuid"
type="checkbox" type="checkbox"
class="base-checkbox !h-7 !w-7" class="base-checkbox !h-7 !w-7"
:oninvalid="`this.setCustomValidity('${t('please_check_the_box_to_continue')}')`"
:onchange="`this.setCustomValidity(validity.valueMissing ? '${t('please_check_the_box_to_continue')}' : '');`"
:required="field.required"
:checked="!!values[field.uuid]" :checked="!!values[field.uuid]"
@click="[$refs.areas.scrollIntoField(field), values[field.uuid] = !values[field.uuid]]" @click="[$refs.areas.scrollIntoField(field), values[field.uuid] = !values[field.uuid]]"
> >
@ -237,6 +207,7 @@
</div> </div>
<ImageStep <ImageStep
v-else-if="currentField.type === 'image'" v-else-if="currentField.type === 'image'"
:key="currentField.uuid"
v-model="values[currentField.uuid]" v-model="values[currentField.uuid]"
:field="currentField" :field="currentField"
:is-direct-upload="isDirectUpload" :is-direct-upload="isDirectUpload"
@ -247,8 +218,10 @@
<SignatureStep <SignatureStep
v-else-if="currentField.type === 'signature'" v-else-if="currentField.type === 'signature'"
ref="currentStep" ref="currentStep"
:key="currentField.uuid"
v-model="values[currentField.uuid]" v-model="values[currentField.uuid]"
:field="currentField" :field="currentField"
:previous-value="previousSignatureValue"
:is-direct-upload="isDirectUpload" :is-direct-upload="isDirectUpload"
:attachments-index="attachmentsIndex" :attachments-index="attachmentsIndex"
:submitter-slug="submitterSlug" :submitter-slug="submitterSlug"
@ -256,8 +229,24 @@
@start="$refs.areas.scrollIntoField(currentField)" @start="$refs.areas.scrollIntoField(currentField)"
@minimize="isFormVisible = false" @minimize="isFormVisible = false"
/> />
<InitialsStep
v-else-if="currentField.type === 'initials'"
ref="currentStep"
:key="currentField.uuid"
v-model="values[currentField.uuid]"
:field="currentField"
:previous-value="previousInitialsValue"
:is-direct-upload="isDirectUpload"
:attachments-index="attachmentsIndex"
:submitter-slug="submitterSlug"
@attached="attachments.push($event)"
@start="$refs.areas.scrollIntoField(currentField)"
@focus="$refs.areas.scrollIntoField(currentField)"
@minimize="isFormVisible = false"
/>
<AttachmentStep <AttachmentStep
v-else-if="currentField.type === 'file'" v-else-if="currentField.type === 'file'"
:key="currentField.uuid"
v-model="values[currentField.uuid]" v-model="values[currentField.uuid]"
:is-direct-upload="isDirectUpload" :is-direct-upload="isDirectUpload"
:field="currentField" :field="currentField"
@ -268,6 +257,7 @@
<PhoneStep <PhoneStep
v-else-if="currentField.type === 'phone'" v-else-if="currentField.type === 'phone'"
ref="currentStep" ref="currentStep"
:key="currentField.uuid"
v-model="values[currentField.uuid]" v-model="values[currentField.uuid]"
:field="currentField" :field="currentField"
:default-value="submitter.phone" :default-value="submitter.phone"
@ -287,6 +277,7 @@
</div> </div>
<div class="mt-6 md:mt-8"> <div class="mt-6 md:mt-8">
<button <button
ref="submitButton"
type="submit" type="submit"
class="base-button w-full flex justify-center" class="base-button w-full flex justify-center"
:disabled="isButtonDisabled" :disabled="isButtonDisabled"
@ -307,12 +298,19 @@
><span>...</span></span> ><span>...</span></span>
</span> </span>
</button> </button>
<div
v-if="showFillAllRequiredFields"
class="text-center mt-1"
>
{{ t('please_fill_all_required_fields') }}
</div>
</div> </div>
</form> </form>
<FormCompleted <FormCompleted
v-else v-else
:is-demo="isDemo" :is-demo="isDemo"
:attribution="attribution" :attribution="attribution"
:completed-button="completedButton"
:with-confetti="withConfetti" :with-confetti="withConfetti"
:can-send-email="canSendEmail && !!submitter.email" :can-send-email="canSendEmail && !!submitter.email"
:submitter-slug="submitterSlug" :submitter-slug="submitterSlug"
@ -324,7 +322,7 @@
:key="step[0].uuid" :key="step[0].uuid"
href="#" href="#"
class="inline border border-base-300 h-3 w-3 rounded-full mx-1" class="inline border border-base-300 h-3 w-3 rounded-full mx-1"
:class="{ 'bg-base-300': index === currentStep, 'bg-base-content': index < currentStep || isCompleted, 'bg-white': index > currentStep }" :class="{ 'bg-base-300': index === currentStep, 'bg-base-content': (index < currentStep && stepFields[index].every((f) => !f.required || ![null, undefined, ''].includes(values[f.uuid]))) || isCompleted, 'bg-white': index > currentStep }"
@click.prevent="isCompleted ? '' : [saveStep(), goToStep(step, true)]" @click.prevent="isCompleted ? '' : [saveStep(), goToStep(step, true)]"
/> />
</div> </div>
@ -337,10 +335,13 @@
import FieldAreas from './areas' import FieldAreas from './areas'
import ImageStep from './image_step' import ImageStep from './image_step'
import SignatureStep from './signature_step' import SignatureStep from './signature_step'
import InitialsStep from './initials_step'
import AttachmentStep from './attachment_step' import AttachmentStep from './attachment_step'
import MultiSelectStep from './multi_select_step' import MultiSelectStep from './multi_select_step'
import PhoneStep from './phone_step' import PhoneStep from './phone_step'
import RedactStep from './redact_step.vue' import RedactStep from './redact_step.vue'
import TextStep from './text_step'
import DateStep from './date_step'
import FormCompleted from './completed' import FormCompleted from './completed'
import { IconInnerShadowTop, IconArrowsDiagonal, IconArrowsDiagonalMinimize2 } from '@tabler/icons-vue' import { IconInnerShadowTop, IconArrowsDiagonal, IconArrowsDiagonalMinimize2 } from '@tabler/icons-vue'
import { t } from './i18n' import { t } from './i18n'
@ -352,9 +353,12 @@ export default {
ImageStep, ImageStep,
SignatureStep, SignatureStep,
AttachmentStep, AttachmentStep,
InitialsStep,
MultiSelectStep, MultiSelectStep,
IconInnerShadowTop, IconInnerShadowTop,
DateStep,
IconArrowsDiagonal, IconArrowsDiagonal,
TextStep,
PhoneStep, PhoneStep,
RedactStep, RedactStep,
IconArrowsDiagonalMinimize2, IconArrowsDiagonalMinimize2,
@ -381,6 +385,13 @@ export default {
required: false, required: false,
default: () => [] default: () => []
}, },
onComplete: {
type: Function,
required: false,
default () {
return () => {}
}
},
withConfetti: { withConfetti: {
type: Boolean, type: Boolean,
required: false, required: false,
@ -411,6 +422,16 @@ export default {
required: false, required: false,
default: false default: false
}, },
allowToSkip: {
type: Boolean,
required: false,
default: false
},
goToLast: {
type: Boolean,
required: false,
default: true
},
isDemo: { isDemo: {
type: Boolean, type: Boolean,
required: false, required: false,
@ -425,14 +446,21 @@ export default {
type: Object, type: Object,
required: false, required: false,
default: () => ({}) default: () => ({})
},
completedButton: {
type: Object,
required: false,
default: () => ({})
} }
}, },
data () { data () {
return { return {
isCompleted: false, isCompleted: false,
isFormVisible: true, isFormVisible: true,
showFillAllRequiredFields: false,
currentStep: 0, currentStep: 0,
isSubmitting: false, isSubmitting: false,
submittedValues: {},
recalculateButtonDisabledKey: '' recalculateButtonDisabledKey: ''
} }
}, },
@ -443,6 +471,16 @@ export default {
submitterSlug () { submitterSlug () {
return this.submitter.slug return this.submitter.slug
}, },
previousSignatureValue () {
const signatureField = [...this.fields].reverse().find((field) => field.type === 'signature' && !!this.values[field.uuid])
return this.values[signatureField?.uuid]
},
previousInitialsValue () {
const initialsField = [...this.fields].reverse().find((field) => field.type === 'initials' && !!this.values[field.uuid])
return this.values[initialsField?.uuid]
},
isAnonymousChecboxes () { isAnonymousChecboxes () {
return this.currentField.type === 'checkbox' && this.currentStepFields.every((e) => !e.name) && this.currentStepFields.length > 4 return this.currentField.type === 'checkbox' && this.currentStepFields.every((e) => !e.name) && this.currentStepFields.length > 4
}, },
@ -450,7 +488,8 @@ export default {
if (this.recalculateButtonDisabledKey) { if (this.recalculateButtonDisabledKey) {
return this.isSubmitting || return this.isSubmitting ||
(this.currentField.required && ['image', 'file'].includes(this.currentField.type) && !this.values[this.currentField.uuid]?.length) || (this.currentField.required && ['image', 'file'].includes(this.currentField.type) && !this.values[this.currentField.uuid]?.length) ||
(this.currentField.required && this.currentField.type === 'signature' && !this.values[this.currentField.uuid]?.length && this.$refs.currentStep && !this.$refs.currentStep.isSignatureStarted) (this.currentField.required && this.currentField.type === 'signature' && !this.values[this.currentField.uuid]?.length && this.$refs.currentStep && !this.$refs.currentStep.isSignatureStarted) ||
(this.currentField.required && this.currentField.type === 'initials' && !this.values[this.currentField.uuid]?.length && this.$refs.currentStep && !this.$refs.currentStep.isInitialsStarted)
} else { } else {
return false return false
} }
@ -459,7 +498,7 @@ export default {
return this.currentStepFields[0] return this.currentStepFields[0]
}, },
stepFields () { stepFields () {
return this.fields.reduce((acc, f) => { return this.fields.filter((f) => !f.readonly).reduce((acc, f) => {
const prevStep = acc[acc.length - 1] const prevStep = acc[acc.length - 1]
if (f.type === 'checkbox' && Array.isArray(prevStep) && prevStep[0].type === 'checkbox') { if (f.type === 'checkbox' && Array.isArray(prevStep) && prevStep[0].type === 'checkbox') {
@ -483,10 +522,30 @@ export default {
} }
}, },
mounted () { mounted () {
this.currentStep = Math.min( this.submittedValues = JSON.parse(JSON.stringify(this.values))
this.stepFields.indexOf([...this.stepFields].reverse().find((fields) => fields.some((f) => !!this.values[f.uuid]))) + 1,
this.stepFields.length - 1 this.fields.forEach((field) => {
) if (field.default_value && !field.readonly) {
this.values[field.uuid] = field.default_value
}
})
if (this.goToLast) {
const requiredEmptyStepIndex = this.stepFields.indexOf(this.stepFields.find((fields) => fields.some((f) => f.required && !this.submittedValues[f.uuid])))
const lastFilledStepIndex = this.stepFields.indexOf([...this.stepFields].reverse().find((fields) => fields.some((f) => !!this.submittedValues[f.uuid]))) + 1
const indexesList = [this.stepFields.length - 1]
if (requiredEmptyStepIndex !== -1) {
indexesList.push(requiredEmptyStepIndex)
}
if (lastFilledStepIndex !== -1) {
indexesList.push(lastFilledStepIndex)
}
this.currentStep = Math.min(...indexesList)
}
if (/iPhone|iPad|iPod/i.test(navigator.userAgent)) { if (/iPhone|iPad|iPod/i.test(navigator.userAgent)) {
this.$nextTick(() => { this.$nextTick(() => {
@ -505,7 +564,10 @@ export default {
this.$nextTick(() => { this.$nextTick(() => {
this.recalculateButtonDisabledKey = Math.random() this.recalculateButtonDisabledKey = Math.random()
this.maybeTrackEmailClick().finally(() => { Promise.all([
this.maybeTrackEmailClick(),
this.maybeTrackSmsClick()
]).finally(() => {
this.trackViewForm() this.trackViewForm()
}) })
}) })
@ -536,6 +598,30 @@ export default {
return Promise.resolve({}) return Promise.resolve({})
} }
}, },
maybeTrackSmsClick () {
const queryParams = new URLSearchParams(window.location.search)
if (queryParams.has('c')) {
const c = queryParams.get('c')
queryParams.delete('c')
const newUrl = [window.location.pathname, queryParams.toString()].filter(Boolean).join('?')
window.history.replaceState({}, document.title, newUrl)
return fetch(this.baseUrl + '/api/submitter_sms_clicks', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
c,
submitter_slug: this.submitterSlug
})
})
} else {
return Promise.resolve({})
}
},
trackViewForm () { trackViewForm () {
fetch(this.baseUrl + '/api/submitter_form_views', { fetch(this.baseUrl + '/api/submitter_form_views', {
method: 'POST', method: 'POST',
@ -549,6 +635,7 @@ export default {
}, },
goToStep (step, scrollToArea = false, clickUpload = false) { goToStep (step, scrollToArea = false, clickUpload = false) {
this.currentStep = this.stepFields.indexOf(step) this.currentStep = this.stepFields.indexOf(step)
this.showFillAllRequiredFields = false
this.$nextTick(() => { this.$nextTick(() => {
this.recalculateButtonDisabledKey = Math.random() this.recalculateButtonDisabledKey = Math.random()
@ -577,14 +664,19 @@ export default {
async submitStep () { async submitStep () {
this.isSubmitting = true this.isSubmitting = true
const stepPromise = ['signature', 'phone'].includes(this.currentField.type) const stepPromise = ['signature', 'phone', 'initials'].includes(this.currentField.type)
? this.$refs.currentStep.submit ? this.$refs.currentStep.submit
: () => Promise.resolve({}) : () => Promise.resolve({})
stepPromise().then(async () => { stepPromise().then(async () => {
const emptyRequiredField = this.stepFields.find((fields, index) => {
return index < this.currentStep && fields[0].required && (fields[0].type === 'phone' || !this.allowToSkip) && !this.submittedValues[fields[0].uuid]
})
const formData = new FormData(this.$refs.form) const formData = new FormData(this.$refs.form)
const isLastStep = this.currentStep === this.stepFields.length - 1
if (this.currentStep === this.stepFields.length - 1) { if (isLastStep && !emptyRequiredField) {
formData.append('completed', 'true') formData.append('completed', 'true')
} }
@ -597,12 +689,28 @@ export default {
return Promise.reject(new Error(data.error)) return Promise.reject(new Error(data.error))
} }
const nextStep = this.stepFields[this.currentStep + 1] this.submittedValues[this.currentField.uuid] = this.values[this.currentField.uuid]
if (isLastStep) {
this.isSecondWalkthrough = true
}
const nextStep = (isLastStep && emptyRequiredField) || this.stepFields[this.currentStep + 1]
if (nextStep) { if (nextStep) {
this.goToStep(this.stepFields[this.currentStep + 1], true) this.goToStep(nextStep, true)
if (emptyRequiredField === nextStep) {
this.showFillAllRequiredFields = true
}
} else { } else {
this.isCompleted = true this.isCompleted = true
const respData = await response.text()
if (respData) {
this.onComplete(JSON.parse(respData))
}
} }
}).catch(error => { }).catch(error => {
console.error('Error submitting form:', error) console.error('Error submitting form:', error)

@ -10,9 +10,17 @@ const en = {
or_drag_and_drop_files: 'or drag and drop files', or_drag_and_drop_files: 'or drag and drop files',
send_copy_via_email: 'Send copy via email', send_copy_via_email: 'Send copy via email',
download: 'Download', download: 'Download',
signature: 'Signature',
initials: 'Initials',
clear: 'Clear',
redraw: 'Redraw',
draw_initials: 'Draw initials',
type_signature_here: 'Type signature here',
type_initial_here: 'Type initials here',
form_has_been_completed: 'Form has been completed!', form_has_been_completed: 'Form has been completed!',
create_a_free_account: 'Create a Free Account', create_a_free_account: 'Create a Free Account',
signed_with: 'Signed with', signed_with: 'Signed with',
please_check_the_box_to_continue: 'Please check the box to continue',
open_source_documents_software: 'open source documents software', open_source_documents_software: 'open source documents software',
verified_phone_number: 'Verify Phone Number', verified_phone_number: 'Verify Phone Number',
redact: 'redact', redact: 'redact',
@ -22,6 +30,10 @@ const en = {
sending: 'Sending...', sending: 'Sending...',
resend_code: 'Re-send code', resend_code: 'Re-send code',
verification_code_has_been_resent: 'Verification code has been re-sent via SMS', verification_code_has_been_resent: 'Verification code has been re-sent via SMS',
please_fill_all_required_fields: 'Please fill all required fields',
set_today: 'Set Today',
toggle_multiline_text: 'Toggle Multiline Text',
date: 'Date',
email_has_been_sent: 'Email has been sent' email_has_been_sent: 'Email has been sent'
} }
@ -37,9 +49,17 @@ const es = {
or_drag_and_drop_files: 'o arrastra y suelta archivos', or_drag_and_drop_files: 'o arrastra y suelta archivos',
send_copy_via_email: 'Enviar copia por correo electrónico', send_copy_via_email: 'Enviar copia por correo electrónico',
download: 'Descargar', download: 'Descargar',
signature: 'Firma',
initials: 'Iniciales',
clear: 'Borrar',
redraw: 'Redibujar',
draw_initials: 'Dibujar iniciales',
type_signature_here: 'Escribe la firma aquí',
type_initial_here: 'Escribe las iniciales aquí',
form_has_been_completed: '¡El formulario ha sido completado!', form_has_been_completed: '¡El formulario ha sido completado!',
create_a_free_account: 'Crear una Cuenta Gratuita', create_a_free_account: 'Crear una Cuenta Gratuita',
signed_with: 'Firmado con', signed_with: 'Firmado con',
please_check_the_box_to_continue: 'Por favor marque la casilla para continuar',
open_source_documents_software: 'software de documentos de código abierto', open_source_documents_software: 'software de documentos de código abierto',
verified_phone_number: 'Verificar número de teléfono', verified_phone_number: 'Verificar número de teléfono',
redact: 'redact', redact: 'redact',
@ -49,6 +69,10 @@ const es = {
sending: 'Enviando...', sending: 'Enviando...',
resend_code: 'Reenviar código', resend_code: 'Reenviar código',
verification_code_has_been_resent: 'El código de verificación ha sido reenviado por SMS', verification_code_has_been_resent: 'El código de verificación ha sido reenviado por SMS',
please_fill_all_required_fields: 'Por favor, complete todos los campos obligatorios',
set_today: 'Establecer Hoy',
date: 'Fecha',
toggle_multiline_text: 'Alternar Texto Multilínea',
email_has_been_sent: 'El correo electrónico ha sido enviado' email_has_been_sent: 'El correo electrónico ha sido enviado'
} }
@ -64,9 +88,17 @@ const it = {
or_drag_and_drop_files: 'oppure trascina e rilascia i file', or_drag_and_drop_files: 'oppure trascina e rilascia i file',
send_copy_via_email: 'Invia copia via email', send_copy_via_email: 'Invia copia via email',
download: 'Scarica', download: 'Scarica',
signature: 'Firma',
initials: 'Iniziali',
clear: 'Cancella',
redraw: 'Ridisegna',
draw_initials: 'Disegna iniziali',
type_signature_here: 'Scrivi la firma qui',
type_initial_here: 'Scrivi le iniziali qui',
form_has_been_completed: 'Il modulo è stato completato!', form_has_been_completed: 'Il modulo è stato completato!',
create_a_free_account: 'Crea un Account Gratuito', create_a_free_account: 'Crea un Account Gratuito',
signed_with: 'Firmato con', signed_with: 'Firmato con',
please_check_the_box_to_continue: 'Si prega di spuntare la casella per continuare',
open_source_documents_software: 'software di documenti open source', open_source_documents_software: 'software di documenti open source',
verified_phone_number: 'Verifica numero di telefono', verified_phone_number: 'Verifica numero di telefono',
redact: 'redact', redact: 'redact',
@ -76,6 +108,10 @@ const it = {
sending: 'Invio in corso...', sending: 'Invio in corso...',
resend_code: 'Rinvia codice', resend_code: 'Rinvia codice',
verification_code_has_been_resent: 'Il codice di verifica è stato rinviato tramite SMS', verification_code_has_been_resent: 'Il codice di verifica è stato rinviato tramite SMS',
please_fill_all_required_fields: 'Si prega di compilare tutti i campi obbligatori',
set_today: 'Imposta Oggi',
date: 'Data',
toggle_multiline_text: 'Attiva Testo Multilinea',
email_has_been_sent: "L'email è stata inviata" email_has_been_sent: "L'email è stata inviata"
} }
@ -91,9 +127,17 @@ const de = {
or_drag_and_drop_files: 'oder Dateien hierher ziehen und ablegen', or_drag_and_drop_files: 'oder Dateien hierher ziehen und ablegen',
send_copy_via_email: 'Kopie per E-Mail senden', send_copy_via_email: 'Kopie per E-Mail senden',
download: 'Herunterladen', download: 'Herunterladen',
signature: 'Unterschrift',
initials: 'Initialen',
clear: 'Löschen',
redraw: 'Neu zeichnen',
draw_initials: 'Initialen zeichnen',
type_signature_here: 'Unterschrift hier eingeben',
type_initial_here: 'Initialen hier eingeben',
form_has_been_completed: 'Formular wurde ausgefüllt!', form_has_been_completed: 'Formular wurde ausgefüllt!',
create_a_free_account: 'Kostenloses Konto erstellen', create_a_free_account: 'Kostenloses Konto erstellen',
signed_with: 'Unterschrieben mit', signed_with: 'Unterschrieben mit',
please_check_the_box_to_continue: 'Bitte setzen Sie das Häkchen, um fortzufahren',
open_source_documents_software: 'Open-Source-Dokumentensoftware', open_source_documents_software: 'Open-Source-Dokumentensoftware',
verified_phone_number: 'Telefonnummer überprüfen', verified_phone_number: 'Telefonnummer überprüfen',
redact: 'redact', redact: 'redact',
@ -103,6 +147,10 @@ const de = {
sending: 'Senden...', sending: 'Senden...',
resend_code: 'Code erneut senden', resend_code: 'Code erneut senden',
verification_code_has_been_resent: 'Die Verifizierungscode wurde erneut per SMS gesendet', verification_code_has_been_resent: 'Die Verifizierungscode wurde erneut per SMS gesendet',
please_fill_all_required_fields: 'Bitte füllen Sie alle erforderlichen Felder aus',
set_today: 'Heute einstellen',
date: 'Datum',
toggle_multiline_text: 'Mehrzeiligen Text umschalten',
email_has_been_sent: 'Die E-Mail wurde gesendet' email_has_been_sent: 'Die E-Mail wurde gesendet'
} }
@ -118,9 +166,17 @@ const fr = {
or_drag_and_drop_files: 'ou faites glisser-déposer les fichiers', or_drag_and_drop_files: 'ou faites glisser-déposer les fichiers',
send_copy_via_email: 'Envoyer une copie par e-mail', send_copy_via_email: 'Envoyer une copie par e-mail',
download: 'Télécharger', download: 'Télécharger',
signature: 'Signature',
initials: 'Initiales',
clear: 'Effacer',
redraw: 'Redessiner',
draw_initials: 'Dessiner les initiales',
type_signature_here: 'Tapez la signature ici',
type_initial_here: 'Tapez les initiales ici',
form_has_been_completed: 'Le formulaire a été complété !', form_has_been_completed: 'Le formulaire a été complété !',
create_a_free_account: 'Créer un Compte Gratuit', create_a_free_account: 'Créer un Compte Gratuit',
signed_with: 'Signé avec', signed_with: 'Signé avec',
please_check_the_box_to_continue: 'Veuillez cocher la case pour continuer',
open_source_documents_software: 'logiciel de documents open source', open_source_documents_software: 'logiciel de documents open source',
verified_phone_number: 'Vérifier le numéro de téléphone', verified_phone_number: 'Vérifier le numéro de téléphone',
redact: 'redact', redact: 'redact',
@ -130,6 +186,10 @@ const fr = {
sending: 'Envoi en cours...', sending: 'Envoi en cours...',
resend_code: 'Renvoyer le code', resend_code: 'Renvoyer le code',
verification_code_has_been_resent: 'Le code de vérification a été renvoyé par SMS', verification_code_has_been_resent: 'Le code de vérification a été renvoyé par SMS',
please_fill_all_required_fields: 'Veuillez remplir tous les champs obligatoires',
set_today: "Définir Aujourd'hui",
date: 'Date',
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é"
} }
@ -145,9 +205,17 @@ const pl = {
or_drag_and_drop_files: 'lub przeciągnij i upuść pliki', or_drag_and_drop_files: 'lub przeciągnij i upuść pliki',
send_copy_via_email: 'Wyślij kopię drogą mailową', send_copy_via_email: 'Wyślij kopię drogą mailową',
download: 'Pobierz', download: 'Pobierz',
signature: 'Podpis',
initials: 'Inicjały',
clear: 'Wyczyść',
redraw: 'Przerysuj',
draw_initials: 'Narysuj inicjały',
type_signature_here: 'Wpisz podpis tutaj',
type_initial_here: 'Wpisz inicjały tutaj',
form_has_been_completed: 'Formularz został wypełniony!', form_has_been_completed: 'Formularz został wypełniony!',
create_a_free_account: 'Utwórz darmowe konto', create_a_free_account: 'Utwórz darmowe konto',
signed_with: 'Podpisane za pomocą', signed_with: 'Podpisane za pomocą',
please_check_the_box_to_continue: 'Proszę zaznaczyć pole, aby kontynuować',
open_source_documents_software: 'oprogramowanie do dokumentów open source', open_source_documents_software: 'oprogramowanie do dokumentów open source',
verified_phone_number: 'Zweryfikuj numer telefonu', verified_phone_number: 'Zweryfikuj numer telefonu',
redact: 'redact', redact: 'redact',
@ -157,6 +225,10 @@ const pl = {
sending: 'Wysyłanie...', sending: 'Wysyłanie...',
resend_code: 'Ponownie wyślij kod', resend_code: 'Ponownie wyślij kod',
verification_code_has_been_resent: 'Kod weryfikacyjny został ponownie wysłany', verification_code_has_been_resent: 'Kod weryfikacyjny został ponownie wysłany',
please_fill_all_required_fields: 'Proszę wypełnić wszystkie wymagane pola',
set_today: 'Ustaw Dziś',
date: 'Data',
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'
} }
@ -172,9 +244,17 @@ const uk = {
or_drag_and_drop_files: 'або перетягніть файли сюди', or_drag_and_drop_files: 'або перетягніть файли сюди',
send_copy_via_email: 'Надіслати копію електронною поштою', send_copy_via_email: 'Надіслати копію електронною поштою',
download: 'Завантажити', download: 'Завантажити',
signature: 'Підпис',
initials: 'Ініціали',
clear: 'Очистити',
redraw: 'Перемалювати',
draw_initials: 'Намалювати ініціали',
type_signature_here: 'Введіть підпис тут',
type_initial_here: 'Введіть ініціали тут',
form_has_been_completed: 'Форму заповнено!', form_has_been_completed: 'Форму заповнено!',
create_a_free_account: 'Створити безкоштовний обліковий запис', create_a_free_account: 'Створити безкоштовний обліковий запис',
signed_with: 'Підписано за допомогою', signed_with: 'Підписано за допомогою',
please_check_the_box_to_continue: 'Будь ласка, позначте прапорець, щоб продовжити',
open_source_documents_software: 'відкритий програмний засіб для документів', open_source_documents_software: 'відкритий програмний засіб для документів',
verified_phone_number: 'Підтвердіть номер телефону', verified_phone_number: 'Підтвердіть номер телефону',
redact: 'redact', redact: 'redact',
@ -184,6 +264,10 @@ const uk = {
sending: 'Надсилаю...', sending: 'Надсилаю...',
resend_code: 'Повторно відправити код', resend_code: 'Повторно відправити код',
verification_code_has_been_resent: 'Код підтвердження був повторно надісланий', verification_code_has_been_resent: 'Код підтвердження був повторно надісланий',
please_fill_all_required_fields: "Будь ласка, заповніть всі обов'язкові поля",
set_today: 'Задати Сьогодні',
date: 'Дата',
toggle_multiline_text: 'Перемкнути Багаторядковий Текст',
email_has_been_sent: 'Електронний лист був відправлений' email_has_been_sent: 'Електронний лист був відправлений'
} }
@ -199,9 +283,17 @@ const cs = {
or_drag_and_drop_files: 'nebo přetáhněte soubory sem', or_drag_and_drop_files: 'nebo přetáhněte soubory sem',
send_copy_via_email: 'Odeslat kopii e-mailem', send_copy_via_email: 'Odeslat kopii e-mailem',
download: 'Stáhnout', download: 'Stáhnout',
signature: 'Podpis',
initials: 'Iniciály',
clear: 'Smazat',
redraw: 'Překreslit',
draw_initials: 'Nakreslit iniciály',
type_signature_here: 'Sem zadejte podpis',
type_initial_here: 'Sem zadejte iniciály',
form_has_been_completed: 'Formulář byl dokončen!', form_has_been_completed: 'Formulář byl dokončen!',
create_a_free_account: 'Vytvořit bezplatný účet', create_a_free_account: 'Vytvořit bezplatný účet',
signed_with: 'Podepsáno pomocí', signed_with: 'Podepsáno pomocí',
please_check_the_box_to_continue: 'Prosím, zaškrtněte políčko pro pokračování',
open_source_documents_software: 'open source software pro dokumenty', open_source_documents_software: 'open source software pro dokumenty',
verified_phone_number: 'Ověřte telefonní číslo', verified_phone_number: 'Ověřte telefonní číslo',
redact: 'redact', redact: 'redact',
@ -211,6 +303,10 @@ const cs = {
sending: 'Odesílání...', sending: 'Odesílání...',
resend_code: 'Znovu odeslat kód', resend_code: 'Znovu odeslat kód',
verification_code_has_been_resent: 'Ověřovací kód byl znovu odeslán', verification_code_has_been_resent: 'Ověřovací kód byl znovu odeslán',
please_fill_all_required_fields: 'Prosím vyplňte všechny povinné položky',
set_today: 'Nastavit Dnes',
date: 'Datum',
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'
} }
@ -226,9 +322,17 @@ const pt = {
or_drag_and_drop_files: 'ou arraste e solte arquivos', or_drag_and_drop_files: 'ou arraste e solte arquivos',
send_copy_via_email: 'Enviar cópia por e-mail', send_copy_via_email: 'Enviar cópia por e-mail',
download: 'Baixar', download: 'Baixar',
signature: 'Assinatura',
initials: 'Iniciais',
clear: 'Limpar',
redraw: 'Redesenhar',
draw_initials: 'Desenhar iniciais',
type_signature_here: 'Digite a assinatura aqui',
type_initial_here: 'Digite as iniciais aqui',
form_has_been_completed: 'O formulário foi concluído!', form_has_been_completed: 'O formulário foi concluído!',
create_a_free_account: 'Criar uma Conta Gratuita', create_a_free_account: 'Criar uma Conta Gratuita',
signed_with: 'Assinado com', signed_with: 'Assinado com',
please_check_the_box_to_continue: 'Por favor, marque a caixa para continuar',
open_source_documents_software: 'software de documentos de código aberto', open_source_documents_software: 'software de documentos de código aberto',
verified_phone_number: 'Verificar Número de Telefone', verified_phone_number: 'Verificar Número de Telefone',
redact: 'redact', redact: 'redact',
@ -238,6 +342,10 @@ const pt = {
sending: 'Enviando...', sending: 'Enviando...',
resend_code: 'Reenviar código', resend_code: 'Reenviar código',
verification_code_has_been_resent: 'O código de verificação foi reenviado via SMS', verification_code_has_been_resent: 'O código de verificação foi reenviado via SMS',
please_fill_all_required_fields: 'Por favor, preencha todos os campos obrigatórios',
set_today: 'Definir Hoje',
date: 'Data',
toggle_multiline_text: 'Alternar Texto Multilinha',
email_has_been_sent: 'Email enviado' email_has_been_sent: 'Email enviado'
} }

@ -0,0 +1,269 @@
<template>
<div>
<div class="flex justify-between items-center w-full mb-2">
<label
class="label text-2xl"
>{{ field.name || t('initials') }}</label>
<div class="space-x-2 flex">
<span
class="tooltip"
:data-tip="t('draw_initials')"
>
<a
id="type_text_button"
href="#"
class="btn btn-sm btn-circle"
:class="{ 'btn-neutral': isDrawInitials, 'btn-outline': !isDrawInitials }"
@click.prevent="toggleTextInput"
>
<IconSignature :width="16" />
</a>
</span>
<a
v-if="modelValue || computedPreviousValue"
href="#"
class="btn btn-outline btn-sm"
@click.prevent="remove"
>
<IconReload :width="16" />
{{ t('clear') }}
</a>
<a
v-else
href="#"
class="btn btn-outline btn-sm"
@click.prevent="clear"
>
<IconReload :width="16" />
{{ t('clear') }}
</a>
<a
title="Minimize"
href="#"
class="py-1.5 inline md:hidden"
@click.prevent="$emit('minimize')"
>
<IconArrowsDiagonalMinimize2
:width="20"
:height="20"
/>
</a>
</div>
</div>
<input
:value="modelValue || computedPreviousValue"
type="hidden"
:name="`values[${field.uuid}]`"
>
<img
v-if="modelValue || computedPreviousValue"
:src="attachmentsIndex[modelValue || computedPreviousValue].url"
class="mx-auto bg-white border border-base-300 rounded max-h-72"
>
<canvas
v-show="!modelValue && !computedPreviousValue"
ref="canvas"
class="bg-white border border-base-300 rounded"
/>
<input
v-if="!isDrawInitials && !modelValue && !computedPreviousValue"
id="initials_text_input"
ref="textInput"
class="base-input !text-2xl w-full mt-6 text-center"
:required="field.required && !isInitialsStarted"
:placeholder="`${t('type_initial_here')}...`"
type="text"
@focus="$emit('focus')"
@input="updateWrittenInitials"
>
</div>
</template>
<script>
import { cropCanvasAndExportToPNG } from './crop_canvas'
import { IconReload, IconSignature, IconArrowsDiagonalMinimize2 } from '@tabler/icons-vue'
import SignaturePad from 'signature_pad'
export default {
name: 'InitialsStep',
components: {
IconReload,
IconSignature,
IconArrowsDiagonalMinimize2
},
inject: ['baseUrl', 't'],
props: {
field: {
type: Object,
required: true
},
submitterSlug: {
type: String,
required: true
},
isDirectUpload: {
type: Boolean,
required: true,
default: false
},
attachmentsIndex: {
type: Object,
required: false,
default: () => ({})
},
previousValue: {
type: String,
required: false,
default: ''
},
modelValue: {
type: String,
required: false,
default: ''
}
},
emits: ['attached', 'update:model-value', 'start', 'minimize', 'focus'],
data () {
return {
isInitialsStarted: !!this.previousValue,
isUsePreviousValue: true,
isDrawInitials: false
}
},
computed: {
computedPreviousValue () {
if (this.isUsePreviousValue) {
return this.previousValue
} else {
return null
}
}
},
async mounted () {
this.$nextTick(() => {
if (this.$refs.canvas) {
this.$refs.canvas.width = this.$refs.canvas.parentNode.clientWidth
this.$refs.canvas.height = this.$refs.canvas.parentNode.clientWidth / 5
}
this.$refs.textInput?.focus()
})
if (this.isDirectUpload) {
import('@rails/activestorage')
}
if (this.$refs.canvas) {
this.pad = new SignaturePad(this.$refs.canvas)
this.pad.addEventListener('beginStroke', () => {
this.isInitialsStarted = true
this.$emit('start')
})
}
},
methods: {
remove () {
this.$emit('update:model-value', '')
this.isUsePreviousValue = false
this.isInitialsStarted = false
},
clear () {
this.pad.clear()
this.isInitialsStarted = false
if (this.$refs.textInput) {
this.$refs.textInput.value = ''
}
},
updateWrittenInitials (e) {
this.isInitialsStarted = true
const canvas = this.$refs.canvas
const context = canvas.getContext('2d')
const fontFamily = 'Arial'
const fontSize = '44px'
const fontStyle = 'italic'
const fontWeight = ''
context.font = fontStyle + ' ' + fontWeight + ' ' + fontSize + ' ' + fontFamily
context.textAlign = 'center'
context.clearRect(0, 0, canvas.width, canvas.height)
context.fillText(e.target.value, canvas.width / 2, canvas.height / 2 + 11)
},
toggleTextInput () {
this.remove()
this.clear()
this.isDrawInitials = !this.isDrawInitials
if (!this.isDrawInitials) {
this.$nextTick(() => {
this.$refs.textInput.focus()
this.$emit('start')
})
}
},
async submit () {
if (this.modelValue || this.computedPreviousValue) {
if (this.computedPreviousValue) {
this.$emit('update:model-value', this.computedPreviousValue)
}
return Promise.resolve({})
}
return new Promise((resolve) => {
cropCanvasAndExportToPNG(this.$refs.canvas).then(async (blob) => {
const file = new File([blob], 'initials.png', { type: 'image/png' })
if (this.isDirectUpload) {
const { DirectUpload } = await import('@rails/activestorage')
new DirectUpload(
file,
'/direct_uploads'
).create((_error, data) => {
fetch(this.baseUrl + '/api/attachments', {
method: 'POST',
body: JSON.stringify({
submitter_slug: this.submitterSlug,
blob_signed_id: data.signed_id,
name: 'attachments'
}),
headers: { 'Content-Type': 'application/json' }
}).then((resp) => resp.json()).then((attachment) => {
this.$emit('update:model-value', attachment.uuid)
this.$emit('attached', attachment)
return resolve(attachment)
})
})
} else {
const formData = new FormData()
formData.append('file', file)
formData.append('submitter_slug', this.submitterSlug)
formData.append('name', 'attachments')
return fetch(this.baseUrl + '/api/attachments', {
method: 'POST',
body: formData
}).then((resp) => resp.json()).then((attachment) => {
this.$emit('attached', attachment)
this.$emit('update:model-value', attachment.uuid)
return resolve(attachment)
})
}
})
})
}
}
}
</script>

@ -21,7 +21,7 @@
:name="`values[${field.uuid}][]`" :name="`values[${field.uuid}][]`"
:value="option" :value="option"
class="base-checkbox !h-7 !w-7" class="base-checkbox !h-7 !w-7"
:checked="modelValue.includes(option)" :checked="(modelValue || []).includes(option)"
@change="onChange" @change="onChange"
> >
<span class="text-xl"> <span class="text-xl">

@ -28,6 +28,7 @@
> >
<div class="flex justify-between mt-2 -mb-2 md:-mb-4"> <div class="flex justify-between mt-2 -mb-2 md:-mb-4">
<a <a
v-if="!defaultValue"
href="#" href="#"
class="link" class="link"
@click.prevent="isCodeSent = false" @click.prevent="isCodeSent = false"
@ -48,6 +49,7 @@
:id="field.uuid" :id="field.uuid"
ref="phone" ref="phone"
:value="modelValue || defaultValue" :value="modelValue || defaultValue"
:readonly="!!defaultValue"
class="base-input !text-2xl w-full" class="base-input !text-2xl w-full"
autocomplete="tel" autocomplete="tel"
pattern="^\+[0-9\s\-]+$" pattern="^\+[0-9\s\-]+$"

@ -3,20 +3,21 @@
<div class="flex justify-between items-center w-full mb-2"> <div class="flex justify-between items-center w-full mb-2">
<label <label
class="label text-2xl" class="label text-2xl"
>{{ field.name || 'Signature' }}</label> >{{ field.name || t('signature') }}</label>
<div class="space-x-2 flex"> <div class="space-x-2 flex">
<span <span
class="tooltip" class="tooltip"
data-tip="Type text" data-tip="Type text"
> >
<button <a
id="type_text_button" id="type_text_button"
href="#"
class="btn btn-sm btn-circle" class="btn btn-sm btn-circle"
:class="{ 'btn-neutral': isTextSignature, 'btn-outline': !isTextSignature }" :class="{ 'btn-neutral': isTextSignature, 'btn-outline': !isTextSignature }"
@click.prevent="toggleTextInput" @click.prevent="toggleTextInput"
> >
<IconTextSize :width="16" /> <IconTextSize :width="16" />
</button> </a>
</span> </span>
<span <span
class="tooltip" class="tooltip"
@ -34,23 +35,26 @@
> >
</label> </label>
</span> </span>
<button <a
v-if="modelValue" v-if="modelValue || computedPreviousValue"
href="#"
class="btn btn-outline btn-sm" class="btn btn-outline btn-sm"
@click.prevent="remove" @click.prevent="remove"
> >
<IconReload :width="16" /> <IconReload :width="16" />
Redraw {{ t('redraw') }}
</button> </a>
<button <a
v-else v-else
href="#"
class="btn btn-outline btn-sm" class="btn btn-outline btn-sm"
@click.prevent="clear" @click.prevent="clear"
> >
<IconReload :width="16" /> <IconReload :width="16" />
Clear {{ t('clear') }}
</button> </a>
<button <a
href="#"
title="Minimize" title="Minimize"
class="py-1.5 inline md:hidden" class="py-1.5 inline md:hidden"
@click.prevent="$emit('minimize')" @click.prevent="$emit('minimize')"
@ -59,21 +63,21 @@
:width="20" :width="20"
:height="20" :height="20"
/> />
</button> </a>
</div> </div>
</div> </div>
<input <input
:value="modelValue" :value="modelValue || computedPreviousValue"
type="hidden" type="hidden"
:name="`values[${field.uuid}]`" :name="`values[${field.uuid}]`"
> >
<img <img
v-if="modelValue" v-if="modelValue || computedPreviousValue"
:src="attachmentsIndex[modelValue].url" :src="attachmentsIndex[modelValue || computedPreviousValue].url"
class="mx-auto bg-white border border-base-300 rounded max-h-72" class="mx-auto bg-white border border-base-300 rounded max-h-72"
> >
<canvas <canvas
v-show="!modelValue" v-show="!modelValue && !computedPreviousValue"
ref="canvas" ref="canvas"
class="bg-white border border-base-300 rounded" class="bg-white border border-base-300 rounded"
/> />
@ -83,7 +87,7 @@
ref="textInput" ref="textInput"
class="base-input !text-2xl w-full mt-6" class="base-input !text-2xl w-full mt-6"
:required="field.required" :required="field.required"
:placeholder="`Type signature here...`" :placeholder="`${t('type_signature_here')}...`"
type="text" type="text"
@input="updateWrittenSignature" @input="updateWrittenSignature"
> >
@ -92,6 +96,10 @@
<script> <script>
import { IconReload, IconCamera, IconTextSize, IconArrowsDiagonalMinimize2 } from '@tabler/icons-vue' import { IconReload, IconCamera, IconTextSize, IconArrowsDiagonalMinimize2 } from '@tabler/icons-vue'
import { cropCanvasAndExportToPNG } from './crop_canvas'
import SignaturePad from 'signature_pad'
let isFontLoaded = false
export default { export default {
name: 'SignatureStep', name: 'SignatureStep',
@ -101,7 +109,7 @@ export default {
IconTextSize, IconTextSize,
IconArrowsDiagonalMinimize2 IconArrowsDiagonalMinimize2
}, },
inject: ['baseUrl'], inject: ['baseUrl', 't'],
props: { props: {
field: { field: {
type: Object, type: Object,
@ -121,6 +129,11 @@ export default {
required: false, required: false,
default: () => ({}) default: () => ({})
}, },
previousValue: {
type: String,
required: false,
default: ''
},
modelValue: { modelValue: {
type: String, type: String,
required: false, required: false,
@ -130,33 +143,61 @@ export default {
emits: ['attached', 'update:model-value', 'start', 'minimize'], emits: ['attached', 'update:model-value', 'start', 'minimize'],
data () { data () {
return { return {
isSignatureStarted: false, isSignatureStarted: !!this.previousValue,
isUsePreviousValue: true,
isTextSignature: false isTextSignature: false
} }
}, },
computed: {
computedPreviousValue () {
if (this.isUsePreviousValue) {
return this.previousValue
} else {
return null
}
}
},
async mounted () { async mounted () {
this.$nextTick(() => { this.$nextTick(() => {
this.$refs.canvas.width = this.$refs.canvas.parentNode.clientWidth if (this.$refs.canvas) {
this.$refs.canvas.height = this.$refs.canvas.parentNode.clientWidth / 3 this.$refs.canvas.width = this.$refs.canvas.parentNode.clientWidth
this.$refs.canvas.height = this.$refs.canvas.parentNode.clientWidth / 3
}
}) })
if (this.isDirectUpload) { if (this.isDirectUpload) {
import('@rails/activestorage') import('@rails/activestorage')
} }
const { default: SignaturePad } = await import('signature_pad') if (this.$refs.canvas) {
this.pad = new SignaturePad(this.$refs.canvas)
this.pad = new SignaturePad(this.$refs.canvas) this.pad.addEventListener('beginStroke', () => {
this.isSignatureStarted = true
this.pad.addEventListener('beginStroke', () => { this.$emit('start')
this.isSignatureStarted = true })
}
this.$emit('start')
})
}, },
methods: { methods: {
remove () { remove () {
this.$emit('update:model-value', '') this.$emit('update:model-value', '')
this.isUsePreviousValue = false
this.isSignatureStarted = false
},
loadFont () {
if (!isFontLoaded) {
const font = new FontFace('Dancing Script', `url(${this.baseUrl}/fonts/DancingScript.otf) format("opentype")`)
font.load().then((loadedFont) => {
document.fonts.add(loadedFont)
isFontLoaded = true
}).catch((error) => {
console.error('Font loading failed:', error)
})
}
}, },
clear () { clear () {
this.pad.clear() this.pad.clear()
@ -173,8 +214,8 @@ export default {
const canvas = this.$refs.canvas const canvas = this.$refs.canvas
const context = canvas.getContext('2d') const context = canvas.getContext('2d')
const fontFamily = 'Arial' const fontFamily = 'Dancing Script'
const fontSize = '44px' const fontSize = '38px'
const fontStyle = 'italic' const fontStyle = 'italic'
const fontWeight = '' const fontWeight = ''
@ -192,6 +233,8 @@ export default {
this.$nextTick(() => { this.$nextTick(() => {
this.$refs.textInput.focus() this.$refs.textInput.focus()
this.loadFont()
this.$emit('start') this.$emit('start')
}) })
} }
@ -244,60 +287,17 @@ export default {
reader.readAsDataURL(file) reader.readAsDataURL(file)
} }
}, },
cropCanvasAndExportToPNG (canvas) { async submit () {
const ctx = canvas.getContext('2d') if (this.modelValue || this.computedPreviousValue) {
if (this.computedPreviousValue) {
const width = canvas.width this.$emit('update:model-value', this.computedPreviousValue)
const height = canvas.height
let topmost = height
let bottommost = 0
let leftmost = width
let rightmost = 0
const imageData = ctx.getImageData(0, 0, width, height)
const pixels = imageData.data
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const pixelIndex = (y * width + x) * 4
const alpha = pixels[pixelIndex + 3]
if (alpha !== 0) {
topmost = Math.min(topmost, y)
bottommost = Math.max(bottommost, y)
leftmost = Math.min(leftmost, x)
rightmost = Math.max(rightmost, x)
}
} }
}
const croppedWidth = rightmost - leftmost + 1
const croppedHeight = bottommost - topmost + 1
const croppedCanvas = document.createElement('canvas')
croppedCanvas.width = croppedWidth
croppedCanvas.height = croppedHeight
const croppedCtx = croppedCanvas.getContext('2d')
croppedCtx.drawImage(canvas, leftmost, topmost, croppedWidth, croppedHeight, 0, 0, croppedWidth, croppedHeight)
return new Promise((resolve, reject) => {
croppedCanvas.toBlob((blob) => {
if (blob) {
resolve(blob)
} else {
reject(new Error('Failed to create a PNG blob.'))
}
}, 'image/png')
})
},
async submit () {
if (this.modelValue) {
return Promise.resolve({}) return Promise.resolve({})
} }
return new Promise((resolve) => { return new Promise((resolve) => {
this.cropCanvasAndExportToPNG(this.$refs.canvas).then(async (blob) => { cropCanvasAndExportToPNG(this.$refs.canvas).then(async (blob) => {
const file = new File([blob], 'signature.png', { type: 'image/png' }) const file = new File([blob], 'signature.png', { type: 'image/png' })
if (this.isDirectUpload) { if (this.isDirectUpload) {

@ -0,0 +1,120 @@
<template>
<label
v-if="field.name"
:for="field.uuid"
class="label text-2xl mb-2"
>{{ field.name }}
<template v-if="!field.required">({{ t('optional') }})</template>
</label>
<div
v-else
class="py-1"
/>
<div class="items-center flex">
<input
v-if="!isTextArea"
:id="field.uuid"
v-model="text"
class="base-input !text-2xl w-full !pr-11 -mr-10"
:required="field.required"
:pattern="field.validation?.pattern"
:oninvalid="field.validation?.message ? `this.setCustomValidity(${JSON.stringify(field.validation.message)})` : ''"
:oninput="field.validation?.message ? `this.setCustomValidity('')` : ''"
:placeholder="`${t('type_here')}...${field.required ? '' : ` (${t('optional')})`}`"
type="text"
:name="`values[${field.uuid}]`"
@focus="$emit('focus')"
>
<textarea
v-if="isTextArea"
:id="field.uuid"
ref="textarea"
v-model="text"
class="base-textarea !text-2xl w-full"
:placeholder="`${t('type_here')}...${field.required ? '' : ` (${t('optional')})`}`"
:required="field.required"
:name="`values[${field.uuid}]`"
@input="resizeTextarea"
@focus="$emit('focus')"
/>
<div
v-if="!isTextArea"
class="tooltip"
:data-tip="t('toggle_multiline_text')"
>
<a
href="#"
class="btn btn-ghost btn-circle btn-sm"
@click.prevent="toggleTextArea"
>
<IconAlignBoxLeftTop />
</a>
</div>
</div>
</template>
<script>
import { IconAlignBoxLeftTop } from '@tabler/icons-vue'
export default {
name: 'TextStep',
components: {
IconAlignBoxLeftTop
},
inject: ['t'],
props: {
field: {
type: Object,
required: true
},
modelValue: {
type: String,
required: false,
default: ''
}
},
emits: ['update:model-value', 'focus'],
data () {
return {
isTextArea: false
}
},
computed: {
text: {
set (value) {
this.$emit('update:model-value', value)
},
get () {
return this.modelValue
}
}
},
mounted () {
this.isTextArea = this.modelValue?.includes('\n')
if (this.isTextArea) {
this.$nextTick(() => {
this.resizeTextarea()
})
}
},
methods: {
resizeTextarea () {
const textarea = this.$refs.textarea
textarea.style.height = 'auto'
textarea.style.height = textarea.scrollHeight + 'px'
},
toggleTextArea () {
this.isTextArea = true
this.$nextTick(() => {
this.$refs.textarea.focus()
this.$refs.textarea.setSelectionRange(this.$refs.textarea.value.length, this.$refs.textarea.value.length)
this.resizeTextarea()
})
}
}
}
</script>

@ -4,6 +4,7 @@
:style="positionStyle" :style="positionStyle"
@pointerdown.stop @pointerdown.stop
@mousedown.stop="startDrag" @mousedown.stop="startDrag"
@touchstart="startTouchDrag"
> >
<div <div
v-if="isSelected || isDraw" v-if="isSelected || isDraw"
@ -22,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"
@ -32,7 +33,7 @@
<div <div
v-if="field?.type" v-if="field?.type"
class="absolute bg-white rounded-t border overflow-visible whitespace-nowrap group-hover:flex group-hover:z-10" class="absolute bg-white rounded-t border overflow-visible whitespace-nowrap group-hover:flex group-hover:z-10"
:class="{ 'flex z-10': isNameFocus || isSelected, hidden: !isNameFocus && !isSelected }" :class="{ 'flex z-10': isNameFocus || isSelected, invisible: !isNameFocus && !isSelected }"
style="top: -25px; height: 25px" style="top: -25px; height: 25px"
@mousedown.stop @mousedown.stop
@pointerdown.stop @pointerdown.stop
@ -41,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"
@ -49,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()]"
@ -57,7 +60,7 @@
<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"
@ -83,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')"
@ -109,28 +112,39 @@
</div> </div>
<div <div
v-else v-else
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"
>
<div 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>
<div <div
ref="touchTarget"
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-2.5 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"
/> />
</div> </div>
</template> </template>
@ -159,6 +173,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,
@ -199,20 +218,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/50',
'border-cyan-500/50',
'border-orange-500/50',
'border-lime-500/50',
'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/50',
'bg-cyan-100/50',
'bg-orange-100/50',
'bg-lime-100/50',
'bg-indigo-100/50'
] ]
}, },
isSelected () { isSelected () {
@ -266,6 +295,8 @@ 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
} }
@ -317,6 +348,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 }
@ -326,6 +361,47 @@ export default {
this.$emit('start-drag') this.$emit('start-drag')
}, },
startTouchDrag (e) {
if (e.target !== this.$refs.touchTarget) {
return
}
this.$refs?.name?.blur()
e.preventDefault()
this.isDragged = true
const rect = e.target.getBoundingClientRect()
this.selectedAreaRef.value = this.area
this.dragFrom = { x: rect.left - e.touches[0].clientX, y: rect.top - e.touches[0].clientY }
this.$el.getRootNode().addEventListener('touchmove', this.touchDrag)
this.$el.getRootNode().addEventListener('touchend', this.stopTouchDrag)
this.$emit('start-drag')
},
touchDrag (e) {
const page = this.$parent.$refs.mask.previousSibling
const rect = page.getBoundingClientRect()
this.area.x = (this.dragFrom.x + e.touches[0].clientX - rect.left) / rect.width
this.area.y = (this.dragFrom.y + e.touches[0].clientY - rect.top) / rect.height
},
stopTouchDrag () {
this.$el.getRootNode().removeEventListener('touchmove', this.touchDrag)
this.$el.getRootNode().removeEventListener('touchend', this.stopTouchDrag)
if (this.isDragged) {
this.save()
}
this.isDragged = false
this.$emit('stop-drag')
},
stopDrag () { stopDrag () {
this.$el.getRootNode().removeEventListener('mousemove', this.drag) this.$el.getRootNode().removeEventListener('mousemove', this.drag)
this.$el.getRootNode().removeEventListener('mouseup', this.stopDrag) this.$el.getRootNode().removeEventListener('mouseup', this.stopDrag)
@ -352,6 +428,33 @@ export default {
this.$emit('stop-resize') this.$emit('stop-resize')
this.save()
},
startTouchResize (e) {
this.selectedAreaRef.value = this.area
this.$refs?.name?.blur()
e.preventDefault()
this.$el.getRootNode().addEventListener('touchmove', this.touchResize)
this.$el.getRootNode().addEventListener('touchend', this.stopTouchResize)
this.$emit('start-resize', 'nwse')
},
touchResize (e) {
const page = this.$parent.$refs.mask.previousSibling
const rect = page.getBoundingClientRect()
this.area.w = (e.touches[0].clientX - rect.left) / rect.width - this.area.x
this.area.h = (e.touches[0].clientY - rect.top) / rect.height - this.area.y
},
stopTouchResize () {
this.$el.getRootNode().removeEventListener('touchmove', this.touchResize)
this.$el.getRootNode().removeEventListener('touchend', this.stopTouchResize)
this.$emit('stop-resize')
this.save() this.save()
} }
} }

@ -1,19 +1,22 @@
<template> <template>
<div <div
style="max-width: 1600px" style="max-width: 1600px"
class="mx-auto pl-4 h-full" class="mx-auto pl-3 md:pl-4 h-full"
> >
<div class="flex justify-between py-1.5 items-center pr-4"> <div
class="flex justify-between py-1.5 items-center pr-4 sticky top-0 z-10"
:style="{ backgroundColor }"
>
<div class="flex space-x-3"> <div class="flex space-x-3">
<a <a
v-if="withLogoLink" v-if="withLogo"
href="/" href="/"
> >
<Logo /> <Logo />
</a> </a>
<Logo v-else />
<Contenteditable <Contenteditable
: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"
@ -28,7 +31,8 @@
<a <a
:href="`/templates/${template.id}/submissions/new`" :href="`/templates/${template.id}/submissions/new`"
data-turbo-frame="modal" data-turbo-frame="modal"
class="btn btn-primary" class="btn btn-primary text-base"
@click="maybeShowEmptyTemplateAlert"
> >
<IconUsersPlus <IconUsersPlus
width="20" width="20"
@ -46,12 +50,12 @@
> >
<IconInnerShadowTop <IconInnerShadowTop
v-if="isSaving" v-if="isSaving"
width="20" width="22"
class="animate-spin" class="animate-spin"
/> />
<IconDeviceFloppy <IconDeviceFloppy
v-else v-else
width="20" width="22"
/> />
<span class="hidden md:inline"> <span class="hidden md:inline">
Save Save
@ -60,10 +64,7 @@
</template> </template>
</div> </div>
</div> </div>
<div <div class="flex md:max-h-[calc(100vh-60px)]">
class="flex"
style="max-height: calc(100% - 60px)"
>
<div <div
ref="previews" ref="previews"
:style="{ 'display': isBreakpointLg ? 'none' : 'initial' }" :style="{ 'display': isBreakpointLg ? 'none' : 'initial' }"
@ -76,6 +77,7 @@
:item="item" :item="item"
:document="sortedDocuments[index]" :document="sortedDocuments[index]"
:accept-file-types="acceptFileTypes" :accept-file-types="acceptFileTypes"
:editable="editable"
:template="template" :template="template"
:is-direct-upload="isDirectUpload" :is-direct-upload="isDirectUpload"
@scroll-to="scrollIntoDocument(item)" @scroll-to="scrollIntoDocument(item)"
@ -90,7 +92,7 @@
:class="{ 'bg-base-100': withStickySubmitters }" :class="{ 'bg-base-100': withStickySubmitters }"
> >
<Upload <Upload
v-if="sortedDocuments.length" v-if="sortedDocuments.length && editable"
:accept-file-types="acceptFileTypes" :accept-file-types="acceptFileTypes"
:template-id="template.id" :template-id="template.id"
:is-direct-upload="isDirectUpload" :is-direct-upload="isDirectUpload"
@ -98,7 +100,7 @@
/> />
</div> </div>
</div> </div>
<div class="w-full overflow-y-auto overflow-x-hidden mt-0.5 pt-0.5"> <div class="w-full overflow-y-hidden md:overflow-y-auto overflow-x-hidden mt-0.5 pt-0.5">
<div <div
ref="documents" ref="documents"
class="pr-3.5 pl-0.5" class="pr-3.5 pl-0.5"
@ -122,12 +124,13 @@
:document="document" :document="document"
:is-drag="!!dragFieldType" :is-drag="!!dragFieldType"
:draw-field="drawField" :draw-field="drawField"
:editable="editable"
@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)"
:document="document" :document="document"
@ -142,7 +145,7 @@
/> />
</template> </template>
<div <div
v-if="sortedDocuments.length && isBreakpointLg" v-if="sortedDocuments.length && isBreakpointLg && editable"
class="pb-4" class="pb-4"
> >
<Upload <Upload
@ -153,17 +156,39 @@
</div> </div>
</template> </template>
</div> </div>
<div <MobileDrawField
v-if="sortedDocuments.length" v-if="drawField && isBreakpointLg"
class="sticky md:hidden" :draw-field="drawField"
style="bottom: 100px" :fields="template.fields"
:submitters="template.submitters"
:selected-submitter="selectedSubmitter"
class="md:hidden"
:editable="editable"
@cancel="drawField = null"
@change-submitter="[selectedSubmitter = $event, drawField.submitter_uuid = $event.uuid]"
/>
<FieldType
v-if="sortedDocuments.length && !drawField && editable"
class="dropdown-top dropdown-end fixed bottom-4 right-4 z-10 md:hidden"
:model-value="''"
@update:model-value="startFieldDraw($event)"
> >
<div class="px-4 py-3 rounded-2xl bg-base-200 flex items-center justify-between ml-4 mr-6"> <label
<span class="w-full text-center text-lg"> class="btn btn-neutral text-white btn-circle btn-lg group"
You need a larger screen to use builder tools. tabindex="0"
</span> >
</div> <IconPlus
</div> class="group-focus:hidden"
width="28"
height="28"
/>
<IconX
class="hidden group-focus:inline"
width="28"
height="28"
/>
</label>
</FieldType>
</div> </div>
<div <div
class="relative w-80 flex-none mt-1 pr-4 pl-0.5 hidden md:block" class="relative w-80 flex-none mt-1 pr-4 pl-0.5 hidden md:block"
@ -194,6 +219,7 @@
:submitters="template.submitters" :submitters="template.submitters"
:selected-submitter="selectedSubmitter" :selected-submitter="selectedSubmitter"
:with-sticky-submitters="withStickySubmitters" :with-sticky-submitters="withStickySubmitters"
:editable="editable"
@set-draw="drawField = $event" @set-draw="drawField = $event"
@set-drag="dragFieldType = $event" @set-drag="dragFieldType = $event"
@change-submitter="selectedSubmitter = $event" @change-submitter="selectedSubmitter = $event"
@ -210,12 +236,14 @@
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'
import DocumentPreview from './preview' import DocumentPreview from './preview'
import DocumentControls from './controls' import DocumentControls from './controls'
import { IconUsersPlus, IconDeviceFloppy, IconInnerShadowTop } from '@tabler/icons-vue' import FieldType from './field_type'
import { IconUsersPlus, IconDeviceFloppy, IconInnerShadowTop, IconPlus, IconX } from '@tabler/icons-vue'
import { v4 } from 'uuid' import { v4 } from 'uuid'
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
@ -225,6 +253,10 @@ export default {
Upload, Upload,
Document, Document,
Fields, Fields,
MobileDrawField,
IconPlus,
FieldType,
IconX,
Logo, Logo,
Dropzone, Dropzone,
DocumentPreview, DocumentPreview,
@ -259,6 +291,11 @@ export default {
required: false, required: false,
default: '' default: ''
}, },
editable: {
type: Boolean,
required: false,
default: true
},
acceptFileTypes: { acceptFileTypes: {
type: String, type: String,
required: false, required: false,
@ -269,7 +306,7 @@ export default {
required: false, required: false,
default: '' default: ''
}, },
withLogoLink: { withLogo: {
type: Boolean, type: Boolean,
required: false, required: false,
default: true default: true
@ -301,6 +338,8 @@ export default {
} }
}, },
computed: { computed: {
fieldIcons: FieldType.computed.fieldIcons,
fieldNames: FieldType.computed.fieldNames,
selectedAreaRef: () => ref(), selectedAreaRef: () => ref(),
fieldAreasIndex () { fieldAreasIndex () {
const areas = {} const areas = {}
@ -330,16 +369,21 @@ export default {
this.selectedSubmitter = this.template.submitters[0] this.selectedSubmitter = this.template.submitters[0]
}, },
mounted () { mounted () {
this.undoStack = [JSON.stringify(this.template)]
this.redoStack = []
this.$nextTick(() => { this.$nextTick(() => {
this.onWindowResize() this.onWindowResize()
}) })
document.addEventListener('keyup', this.onKeyUp) document.addEventListener('keyup', this.onKeyUp)
window.addEventListener('keydown', this.onKeyDown)
window.addEventListener('resize', this.onWindowResize) window.addEventListener('resize', this.onWindowResize)
}, },
unmounted () { unmounted () {
document.removeEventListener('keyup', this.onKeyUp) document.removeEventListener('keyup', this.onKeyUp)
window.removeEventListener('keydown', this.onKeyDown)
window.removeEventListener('resize', this.onWindowResize) window.removeEventListener('resize', this.onWindowResize)
}, },
@ -347,6 +391,52 @@ export default {
this.documentRefs = [] this.documentRefs = []
}, },
methods: { methods: {
startFieldDraw (type) {
const field = {
name: '',
uuid: v4(),
required: type !== 'checkbox',
areas: [],
submitter_uuid: this.selectedSubmitter.uuid,
type
}
if (['select', 'multiple', 'radio'].includes(type)) {
field.options = ['']
}
this.drawField = field
},
undo () {
if (this.undoStack.length > 1) {
this.undoStack.pop()
const stringData = this.undoStack[this.undoStack.length - 1]
const currentStringData = JSON.stringify(this.template)
if (stringData && stringData !== currentStringData) {
this.redoStack.push(currentStringData)
Object.assign(this.template, JSON.parse(stringData))
this.save()
}
}
},
redo () {
const stringData = this.redoStack.pop()
this.lastRedoData = stringData
const currentStringData = JSON.stringify(this.template)
if (stringData && stringData !== currentStringData) {
if (this.undoStack[this.undoStack.length - 1] !== currentStringData) {
this.undoStack.push(currentStringData)
}
Object.assign(this.template, JSON.parse(stringData))
this.save()
}
},
onWindowResize (e) { onWindowResize (e) {
const breakpointLg = 1024 const breakpointLg = 1024
@ -374,6 +464,19 @@ export default {
this.selectedAreaRef.value = null this.selectedAreaRef.value = null
} }
}, },
onKeyDown (event) {
if ((event.metaKey && event.shiftKey && event.key === 'z') || (event.ctrlKey && event.key === 'Z')) {
event.stopImmediatePropagation()
event.preventDefault()
this.redo()
} else if ((event.ctrlKey || event.metaKey) && event.key === 'z') {
event.stopImmediatePropagation()
event.preventDefault()
this.undo()
}
},
removeArea (area) { removeArea (area) {
const field = this.template.fields.find((f) => f.areas?.includes(area)) const field = this.template.fields.find((f) => f.areas?.includes(area))
@ -385,12 +488,31 @@ export default {
this.save() this.save()
}, },
pushUndo () {
const stringData = JSON.stringify(this.template)
if (this.undoStack[this.undoStack.length - 1] !== stringData) {
this.undoStack.push(stringData)
if (this.lastRedoData !== stringData) {
this.redoStack = []
}
}
},
onDraw (area) { onDraw (area) {
if (this.drawField) { if (this.drawField) {
this.drawField.areas ||= [] this.drawField.areas ||= []
this.drawField.areas.push(area) this.drawField.areas.push(area)
if (this.template.fields.indexOf(this.drawField) === -1) {
this.template.fields.push(this.drawField)
}
this.drawField = null this.drawField = null
this.selectedAreaRef.value = area
this.save()
} else { } else {
const documentRef = this.documentRefs.find((e) => e.document.uuid === area.attachment_uuid) const documentRef = this.documentRefs.find((e) => e.document.uuid === area.attachment_uuid)
const pageMask = documentRef.pageRefs[area.page].$refs.mask const pageMask = documentRef.pageRefs[area.page].$refs.mask
@ -401,33 +523,37 @@ export default {
const previousField = [...this.template.fields].reverse().find((f) => f.type === type) const previousField = [...this.template.fields].reverse().find((f) => f.type === type)
const previousArea = previousField?.areas?.[previousField.areas.length - 1] const previousArea = previousField?.areas?.[previousField.areas.length - 1]
const areaW = previousArea?.w || (30 / pageMask.clientWidth) if (previousArea || area.w) {
const areaH = previousArea?.h || (30 / pageMask.clientHeight) const areaW = previousArea?.w || (30 / pageMask.clientWidth)
const areaH = previousArea?.h || (30 / pageMask.clientHeight)
if ((pageMask.clientWidth * area.w) < 5) { if ((pageMask.clientWidth * area.w) < 5) {
area.x = area.x - (areaW / 2) area.x = area.x - (areaW / 2)
area.y = area.y - (areaH / 2) area.y = area.y - (areaH / 2)
} }
area.w = areaW area.w = areaW
area.h = areaH area.h = areaH
}
} }
const field = { if (area.w) {
name: '', const field = {
uuid: v4(), name: '',
required: type !== 'checkbox', uuid: v4(),
type, required: type !== 'checkbox',
submitter_uuid: this.selectedSubmitter.uuid, type,
areas: [area] submitter_uuid: this.selectedSubmitter.uuid,
} areas: [area]
}
this.template.fields.push(field) this.template.fields.push(field)
}
this.selectedAreaRef.value = area this.selectedAreaRef.value = area
this.save() this.save()
}
}
}, },
onDropfield (area) { onDropfield (area) {
const field = { const field = {
@ -473,6 +599,11 @@ export default {
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') {
baseArea = {
w: area.maskW / 10 / area.maskW,
h: area.maskW / 35 / area.maskW
}
} else { } else {
baseArea = { baseArea = {
w: area.maskW / 5 / area.maskW, w: area.maskW / 5 / area.maskW,
@ -557,14 +688,25 @@ export default {
this.save() this.save()
}, },
maybeShowEmptyTemplateAlert (e) {
if (!this.template.fields.length) {
e.preventDefault()
alert('Please draw fields to prepare the document.')
}
},
onSaveClick () { onSaveClick () {
this.isSaving = true if (this.template.fields.length) {
this.isSaving = true
this.save().then(() => { this.save().then(() => {
window.Turbo.visit(`/templates/${this.template.id}`) window.Turbo.visit(`/templates/${this.template.id}`)
}).finally(() => { }).finally(() => {
this.isSaving = false this.isSaving = false
}) })
} else {
alert('Please draw fields to prepare the document.')
}
}, },
scrollToArea (area) { scrollToArea (area) {
const documentRef = this.documentRefs.find((a) => a.document.uuid === area.attachment_uuid) const documentRef = this.documentRefs.find((a) => a.document.uuid === area.attachment_uuid)
@ -584,6 +726,8 @@ export default {
this.$el.closest('template-builder').dataset.template = JSON.stringify(this.template) this.$el.closest('template-builder').dataset.template = JSON.stringify(this.template)
} }
this.pushUndo()
return this.baseFetch(`/api/templates/${this.template.id}`, { return this.baseFetch(`/api/templates/${this.template.id}`, {
method: 'PUT', method: 'PUT',
body: JSON.stringify({ body: JSON.stringify({

@ -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"
@ -24,13 +24,14 @@
* *
</span> </span>
<IconPencil <IconPencil
class="cursor-pointer flex-none opacity-0 group-hover/contenteditable:opacity-100 align-middle peer-focus:hidden" v-if="editable"
class="cursor-pointer flex-none opacity-0 group-hover/contenteditable-container:opacity-100 group-hover/contenteditable:opacity-100 align-middle peer-focus:hidden"
:style="iconInline ? {} : { right: -(1.1 * iconWidth) + 'px' }" :style="iconInline ? {} : { right: -(1.1 * iconWidth) + 'px' }"
title="Edit" title="Edit"
:class="{ 'ml-1': !withRequired, 'absolute': !iconInline, 'inline align-bottom': iconInline }" :class="{ 'ml-1': !withRequired, 'absolute': !iconInline, 'inline align-bottom': iconInline }"
:width="iconWidth" :width="iconWidth"
:stroke-width="iconStrokeWidth" :stroke-width="iconStrokeWidth"
@click="focusContenteditable" @click="[focusContenteditable(), selectOnEditClick && selectContent()]"
/> />
</div> </div>
</template> </template>
@ -64,6 +65,16 @@ export default {
required: false, required: false,
default: false default: false
}, },
selectOnEditClick: {
type: Boolean,
required: false,
default: false
},
editable: {
type: Boolean,
required: false,
default: true
},
iconStrokeWidth: { iconStrokeWidth: {
type: Number, type: Number,
required: false, required: false,
@ -85,6 +96,19 @@ export default {
} }
}, },
methods: { methods: {
selectContent () {
const el = this.$refs.contenteditable
const range = document.createRange()
range.selectNodeContents(el)
const sel = window.getSelection()
sel.removeAllRanges()
sel.addRange(range)
},
onBlur (e) { onBlur (e) {
setTimeout(() => { setTimeout(() => {
this.value = this.$refs.contenteditable.innerText.trim() || this.modelValue this.value = this.$refs.contenteditable.innerText.trim() || this.modelValue

@ -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,6 +39,11 @@ 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,
@ -56,8 +62,26 @@ export default {
} }
}, },
computed: { computed: {
numberOfPages () {
return this.document.metadata?.pdf?.number_of_pages || this.document.preview_images.length
},
sortedPreviewImages () { sortedPreviewImages () {
return [...this.document.preview_images].sort((a, b) => parseInt(a.filename) - parseInt(b.filename)) const lazyloadMetadata = this.document.preview_images[this.document.preview_images.length - 1].metadata
return [...Array(this.numberOfPages).keys()].map((i) => {
return this.previewImagesIndex[i] || {
metadata: lazyloadMetadata,
id: Math.random().toString(),
url: `/preview/${this.document.uuid}/${i}.jpg`
}
})
},
previewImagesIndex () {
return this.document.preview_images.reduce((acc, e) => {
acc[parseInt(e.filename)] = e
return acc
}, {})
} }
}, },
beforeUpdate () { beforeUpdate () {

@ -4,8 +4,9 @@
> >
<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"> <div class="flex items-center justify-between relative group/contenteditable-container">
<div <div
class="absolute top-0 bottom-0 right-0 left-0 cursor-pointer" class="absolute top-0 bottom-0 right-0 left-0 cursor-pointer"
@click="scrollToFirstArea" @click="scrollToFirstArea"
@ -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,36 +34,44 @@
v-if="isNameFocus" v-if="isNameFocus"
class="flex items-center relative" class="flex items-center relative"
> >
<template v-if="field.type !== 'checkbox'"> <input
<input :id="`required-checkbox-${field.uuid}`"
:id="`required-checkbox-${field.uuid}`" v-model="field.required"
v-model="field.required" type="checkbox"
type="checkbox" class="checkbox checkbox-xs no-animation rounded"
class="checkbox checkbox-xs no-animation rounded" @mousedown.prevent
@mousedown.prevent >
> <label
<label :for="`required-checkbox-${field.uuid}`"
:for="`required-checkbox-${field.uuid}`" class="label text-xs"
class="label text-xs" @click.prevent="field.required = !field.required"
@click.prevent="field.required = !field.required" @mousedown.prevent
@mousedown.prevent >Required</label>
>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"
/> />
@ -68,8 +79,57 @@
<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 @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"
@ -99,21 +159,23 @@
Draw New Area Draw New Area
</a> </a>
</li> </li>
<li v-if="field.areas?.length === 1 && ['date', 'signature', 'initials', 'text', 'cells'].includes(field.type)">
<a
href="#"
class="text-sm py-1 px-2"
@click.prevent="copyToAllPages(field)"
>
<IconCopy
:width="20"
:stroke-width="1.6"
/>
Copy to All Pages
</a>
</li>
</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)"
> >
@ -122,24 +184,6 @@
: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
@ -183,25 +227,32 @@
<script> <script>
import Contenteditable from './contenteditable' import Contenteditable from './contenteditable'
import FieldType from './field_type' import FieldType from './field_type'
import { IconShape, IconNewSection, IconTrashX } from '@tabler/icons-vue' import { IconShape, IconNewSection, IconTrashX, IconCopy, IconSettings } from '@tabler/icons-vue'
export default { export default {
name: 'TemplateField', name: 'TemplateField',
components: { components: {
Contenteditable, Contenteditable,
IconSettings,
IconShape, IconShape,
IconNewSection, IconNewSection,
IconTrashX, IconTrashX,
IconCopy,
FieldType FieldType
}, },
inject: ['template', 'save'], inject: ['template', 'save', 'backgroundColor'],
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
@ -221,6 +272,23 @@ export default {
} }
}, },
methods: { methods: {
copyToAllPages (field) {
const areaString = JSON.stringify(field.areas[0])
this.template.documents.forEach((attachment) => {
attachment.preview_images.forEach((page) => {
if (!field.areas.find((area) => area.attachment_uuid === attachment.uuid && area.page === parseInt(page.filename))) {
field.areas.push({ ...JSON.parse(areaString), page: parseInt(page.filename) })
}
})
})
this.$nextTick(() => {
this.$emit('scroll-to', this.field.areas[this.field.areas.length - 1])
})
this.save()
},
onNameFocus (e) { onNameFocus (e) {
this.isNameFocus = true this.isNameFocus = true
@ -237,6 +305,8 @@ export default {
document.activeElement.blur() document.activeElement.blur()
}, },
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
} }

@ -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 Submitter
</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 rounded-md p-2 border border-base-300 w-full flex justify-between" class="cursor-pointer group/contenteditable-container rounded-md p-2 border border-base-300 w-full flex justify-between"
> >
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
<span <span
@ -25,6 +111,8 @@
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"
:icon-width="18" :icon-width="18"
@update:model-value="$emit('name-change', selectedSubmitter)" @update:model-value="$emit('name-change', selectedSubmitter)"
/> />
@ -37,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"
@ -61,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)"
> >
@ -69,7 +158,7 @@
</button> </button>
</a> </a>
</li> </li>
<li v-if="submitters.length < 5"> <li v-if="submitters.length < 10 && editable">
<a <a
href="#" href="#"
class="flex px-2" class="flex px-2"
@ -89,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'
@ -99,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
@ -129,7 +229,12 @@ export default {
'bg-sky-500', 'bg-sky-500',
'bg-emerald-500', 'bg-emerald-500',
'bg-yellow-300', 'bg-yellow-300',
'bg-purple-600' 'bg-purple-600',
'bg-pink-500',
'bg-cyan-500',
'bg-orange-500',
'bg-lime-500',
'bg-indigo-500'
] ]
}, },
names () { names () {
@ -138,7 +243,12 @@ export default {
'Second Submitter', 'Second Submitter',
'Third Submitter', 'Third Submitter',
'Fourth Submitter', 'Fourth Submitter',
'Fifth Submitter' 'Fifth Submitter',
'Sixth Submitter',
'Seventh Submitter',
'Eighth Submitter',
'Ninth Submitter',
'Tenth Submitter'
] ]
}, },
selectedSubmitter () { selectedSubmitter () {

@ -1,20 +1,23 @@
<template> <template>
<span class="dropdown"> <span class="dropdown">
<label <slot>
tabindex="0" <label
:title="fieldNames[modelValue]" tabindex="0"
class="cursor-pointer" :title="fieldNames[modelValue]"
> class="cursor-pointer"
<component >
:is="fieldIcons[modelValue]" <component
:width="buttonWidth" :is="fieldIcons[modelValue]"
:class="buttonClasses" :width="buttonWidth"
:stroke-width="1.6" :class="buttonClasses"
/> :stroke-width="1.6"
</label> />
</label>
</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" class="dropdown-content menu menu-xs p-2 shadow rounded-box w-52 z-10 mb-3"
:class="menuClasses" :class="menuClasses"
@click="closeDropdown" @click="closeDropdown"
> >
@ -43,7 +46,7 @@
</template> </template>
<script> <script>
import { IconTextSize, IconWritingSign, IconCalendarEvent, IconPhoto, IconCheckbox, IconPaperclip, IconSelect, IconCircleDot, IconChecks, IconColumns3, IconPhoneCheck, IconBarrierBlock } from '@tabler/icons-vue' import { IconTextSize, IconWritingSign, IconCalendarEvent, IconPhoto, IconCheckbox, IconPaperclip, IconSelect, IconCircleDot, IconChecks, IconColumns3, IconPhoneCheck, IconBarrierBlock, IconLetterCaseUpper } from '@tabler/icons-vue'
export default { export default {
name: 'FiledTypeDropdown', name: 'FiledTypeDropdown',
inject: ['withPhone'], inject: ['withPhone'],
@ -62,6 +65,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,
@ -74,6 +82,7 @@ export default {
return { return {
text: 'Text', text: 'Text',
signature: 'Signature', signature: 'Signature',
initials: 'Initials',
date: 'Date', date: 'Date',
image: 'Image', image: 'Image',
file: 'File', file: 'File',
@ -90,16 +99,16 @@ export default {
return { return {
text: IconTextSize, text: IconTextSize,
signature: IconWritingSign, signature: IconWritingSign,
initials: IconLetterCaseUpper,
date: IconCalendarEvent, date: IconCalendarEvent,
image: IconPhoto, image: IconPhoto,
file: IconPaperclip, file: IconPaperclip,
select: IconSelect, select: IconSelect,
checkbox: IconCheckbox, checkbox: IconCheckbox,
cells: IconColumns3, radio: IconCircleDot,
multiple: IconChecks, multiple: IconChecks,
radio: IconCircleDot, radio: IconCircleDot,
phone: IconPhoneCheck, phone: IconPhoneCheck
redact: IconBarrierBlock
} }
} }
}, },

@ -5,26 +5,37 @@
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="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"
@ -73,7 +84,7 @@
</button> </button>
<div <div
v-else v-else
class="tooltip flex" class="tooltip tooltip-bottom-end flex"
data-tip="Unlock SMS-verified phone number field with paid plan. Use text field for phone numbers without verification." data-tip="Unlock SMS-verified phone number field with paid plan. Use text field for phone numbers without verification."
> >
<a <a
@ -100,16 +111,13 @@
</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">
<li> <li>
Draw a text field on the page with a mouse Draw a text field on the page with a mouse
</li> </li>
<li>
Single click on the page to add a checkbox
</li>
<li> <li>
Drag &amp; drop any other field type on the page Drag &amp; drop any other field type on the page
</li> </li>
@ -140,6 +148,11 @@ export default {
type: Array, type: Array,
required: true required: true
}, },
editable: {
type: Boolean,
required: false,
default: true
},
withStickySubmitters: { withStickySubmitters: {
type: Boolean, type: Boolean,
required: false, required: false,
@ -155,6 +168,11 @@ 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,
@ -166,6 +184,24 @@ export default {
onDragstart (fieldType) { onDragstart (fieldType) {
this.$emit('set-drag', fieldType) this.$emit('set-drag', fieldType)
}, },
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) => {
if (field.submitter_uuid === submitter.uuid) { if (field.submitter_uuid === submitter.uuid) {
@ -181,26 +217,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)

@ -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>

@ -1,12 +1,16 @@
<template> <template>
<div class="relative cursor-crosshair select-none"> <div
class="relative cursor-crosshair select-none"
:style="drawField ? 'touch-action: none' : ''"
>
<img <img
ref="image" ref="image"
loading="lazy"
:src="image.url" :src="image.url"
:width="width" :width="width"
class="border rounded mb-4"
:height="height" :height="height"
loading="lazy" class="border rounded mb-4"
@load="onImageLoad"
> >
<div <div
class="top-0 bottom-0 left-0 right-0 absolute" class="top-0 bottom-0 left-0 right-0 absolute"
@ -18,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"
@ -32,12 +37,13 @@
/> />
</div> </div>
<div <div
v-show="resizeDirection || isMove || isDrag || showMask" 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="{ 'cursor-grab': isDrag || isMove, 'cursor-nwse-resize': drawField, [resizeDirectionClasses[resizeDirection]]: !!resizeDirectionClasses }"
@pointermove="onPointermove" @pointermove="onPointermove"
@pointerdown="onStartDraw"
@dragover.prevent @dragover.prevent
@drop="onDrop" @drop="onDrop"
@pointerup="onPointerup" @pointerup="onPointerup"
@ -72,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,
@ -93,6 +104,9 @@ export default {
} }
}, },
computed: { computed: {
isMobile () {
return /android|iphone|ipad/i.test(navigator.userAgent)
},
resizeDirectionClasses () { resizeDirectionClasses () {
return { return {
nwse: 'cursor-nwse-resize', nwse: 'cursor-nwse-resize',
@ -110,6 +124,10 @@ export default {
this.areaRefs = [] this.areaRefs = []
}, },
methods: { methods: {
onImageLoad (e) {
e.target.setAttribute('width', e.target.naturalWidth)
e.target.setAttribute('height', e.target.naturalHeight)
},
setAreaRefs (el) { setAreaRefs (el) {
if (el) { if (el) {
this.areaRefs.push(el) this.areaRefs.push(el)
@ -125,6 +143,14 @@ export default {
}) })
}, },
onStartDraw (e) { onStartDraw (e) {
if (this.isMobile && !this.drawField) {
return
}
if (!this.editable) {
return
}
this.showMask = true this.showMask = true
this.$nextTick(() => { this.$nextTick(() => {

@ -12,7 +12,10 @@
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
@ -95,6 +98,11 @@ 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,
@ -114,7 +122,7 @@ export default {
emits: ['scroll-to', 'change', 'remove', 'up', 'down', 'replace'], emits: ['scroll-to', 'change', 'remove', 'up', 'down', 'replace'],
computed: { computed: {
previewImage () { previewImage () {
return this.document.preview_images[0] return [...this.document.preview_images].sort((a, b) => parseInt(a.filename) - parseInt(b.filename))[0]
} }
}, },
mounted () { mounted () {

@ -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)
end
return unless is_all_completed enqueue_completed_emails(submitter)
return if submitter.completed_at != submitter.submission.submitters.maximum(:completed_at) end
Submissions::GenerateAuditTrail.call(submitter.submission) return if Accounts.load_webhook_configs(submitter.account).blank?
enqueue_emails(submitter) SendFormCompletedWebhookRequestJob.perform_later(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)

@ -4,7 +4,7 @@ class SendFormCompletedWebhookRequestJob < ApplicationJob
USER_AGENT = 'DocuSeal.co Webhook' USER_AGENT = 'DocuSeal.co Webhook'
def perform(submitter) def perform(submitter)
config = submitter.submission.account.encrypted_configs.find_by(key: EncryptedConfig::WEBHOOK_URL_KEY) config = Accounts.load_webhook_configs(submitter.submission.account)
return if config.blank? || config.value.blank? return if config.blank? || config.value.blank?
@ -15,7 +15,7 @@ class SendFormCompletedWebhookRequestJob < ApplicationJob
Faraday.post(config.value, Faraday.post(config.value,
{ {
event_type: 'form.completed', event_type: 'form.completed',
timestamp: Time.current.iso8601, timestamp: Time.current,
data: Submitters::SerializeForWebhook.call(submitter) data: Submitters::SerializeForWebhook.call(submitter)
}.to_json, }.to_json,
'Content-Type' => 'application/json', 'Content-Type' => 'application/json',

@ -4,7 +4,7 @@ class SendFormStartedWebhookRequestJob < ApplicationJob
USER_AGENT = 'DocuSeal.co Webhook' USER_AGENT = 'DocuSeal.co Webhook'
def perform(submitter) def perform(submitter)
config = submitter.submission.account.encrypted_configs.find_by(key: EncryptedConfig::WEBHOOK_URL_KEY) config = Accounts.load_webhook_configs(submitter.submission.account)
return if config.blank? || config.value.blank? return if config.blank? || config.value.blank?
@ -13,7 +13,7 @@ class SendFormStartedWebhookRequestJob < ApplicationJob
Faraday.post(config.value, Faraday.post(config.value,
{ {
event_type: 'form.started', event_type: 'form.started',
timestamp: Time.current.iso8601, timestamp: Time.current,
data: Submitters::SerializeForWebhook.call(submitter) data: Submitters::SerializeForWebhook.call(submitter)
}.to_json, }.to_json,
'Content-Type' => 'application/json', 'Content-Type' => 'application/json',

@ -4,7 +4,7 @@ class SendFormViewedWebhookRequestJob < ApplicationJob
USER_AGENT = 'DocuSeal.co Webhook' USER_AGENT = 'DocuSeal.co Webhook'
def perform(submitter) def perform(submitter)
config = submitter.submission.account.encrypted_configs.find_by(key: EncryptedConfig::WEBHOOK_URL_KEY) config = Accounts.load_webhook_configs(submitter.submission.account)
return if config.blank? || config.value.blank? return if config.blank? || config.value.blank?
@ -13,7 +13,7 @@ class SendFormViewedWebhookRequestJob < ApplicationJob
Faraday.post(config.value, Faraday.post(config.value,
{ {
event_type: 'form.viewed', event_type: 'form.viewed',
timestamp: Time.current.iso8601, timestamp: Time.current,
data: Submitters::SerializeForWebhook.call(submitter) data: Submitters::SerializeForWebhook.call(submitter)
}.to_json, }.to_json,
'Content-Type' => 'application/json', 'Content-Type' => 'application/json',

@ -1,6 +1,8 @@
# frozen_string_literal: true # frozen_string_literal: true
class SubmitterMailer < ApplicationMailer class SubmitterMailer < ApplicationMailer
MAX_ATTACHMENTS_SIZE = 10.megabytes
def invitation_email(submitter, body: nil, subject: nil) def invitation_email(submitter, body: nil, subject: nil)
@current_account = submitter.submission.template.account @current_account = submitter.submission.template.account
@submitter = submitter @submitter = submitter
@ -12,7 +14,7 @@ class SubmitterMailer < ApplicationMailer
if @email_config || subject.present? if @email_config || subject.present?
ReplaceEmailVariables.call(subject.presence || @email_config.value['subject'], submitter:) ReplaceEmailVariables.call(subject.presence || @email_config.value['subject'], submitter:)
else else
'You have been invited to submit a form' 'You are invited to submit a form'
end end
mail(to: @submitter.friendly_name, mail(to: @submitter.friendly_name,
@ -62,7 +64,7 @@ class SubmitterMailer < ApplicationMailer
if @email_config if @email_config
ReplaceEmailVariables.call(@email_config.value['subject'], submitter:) ReplaceEmailVariables.call(@email_config.value['subject'], submitter:)
else else
'Your copy of documents' 'Your document copy'
end end
mail(from: from_address_for_submitter(submitter), mail(from: from_address_for_submitter(submitter),
@ -75,13 +77,24 @@ class SubmitterMailer < ApplicationMailer
def add_completed_email_attachments!(submitter) def add_completed_email_attachments!(submitter)
documents = Submitters.select_attachments_for_download(submitter) documents = Submitters.select_attachments_for_download(submitter)
total_size = 0
audit_trail_data = nil
if submitter.submission.audit_trail.present?
audit_trail_data = submitter.submission.audit_trail.download
total_size = audit_trail_data.size
end
documents.each do |attachment| documents.each do |attachment|
total_size += attachment.byte_size
break if total_size >= MAX_ATTACHMENTS_SIZE
attachments[attachment.filename.to_s] = attachment.download attachments[attachment.filename.to_s] = attachment.download
end end
if submitter.submission.audit_trail.present? attachments[submitter.submission.audit_trail.filename.to_s] = audit_trail_data if audit_trail_data
attachments[submitter.submission.audit_trail.filename.to_s] = submitter.submission.audit_trail.download
end
documents documents
end end

@ -1,12 +1,12 @@
# frozen_string_literal: true # frozen_string_literal: true
class UserMailer < ApplicationMailer class UserMailer < ApplicationMailer
def invitation_email(user) def invitation_email(user, invited_by: nil)
@current_account = user.account @current_account = invited_by&.account || user.account
@user = user @user = user
@token = @user.send(:set_reset_password_token) @token = @user.send(:set_reset_password_token)
mail(to: @user.friendly_name, mail(to: @user.friendly_name,
subject: 'You have been invited to Docuseal') subject: 'You are invited to DocuSeal')
end end
end end

@ -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

@ -16,6 +16,9 @@ class Account < ApplicationRecord
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 :templates, dependent: :destroy has_many :templates, dependent: :destroy
has_many :template_folders, dependent: :destroy
has_one :default_template_folder, -> { where(name: TemplateFolder::DEFAULT_NAME) },
class_name: 'TemplateFolder', dependent: :destroy, inverse_of: :account
has_many :submissions, through: :templates has_many :submissions, through: :templates
has_many :submitters, through: :submissions has_many :submitters, through: :submissions
has_many :active_users, -> { active }, dependent: :destroy, has_many :active_users, -> { active }, dependent: :destroy,
@ -23,4 +26,9 @@ class Account < ApplicationRecord
attribute :timezone, :string, default: 'UTC' attribute :timezone, :string, default: 'UTC'
attribute :locale, :string, default: 'en-US' attribute :locale, :string, default: 'en-US'
def default_template_folder
super || build_default_template_folder(name: TemplateFolder::DEFAULT_NAME,
author_id: users.minimum(:id)).tap(&:save!)
end
end end

@ -26,12 +26,13 @@ class AccountConfig < ApplicationRecord
SUBMITTER_DOCUMENTS_COPY_EMAIL_KEY = 'submitter_documents_copy_email' SUBMITTER_DOCUMENTS_COPY_EMAIL_KEY = 'submitter_documents_copy_email'
BCC_EMAILS = 'bcc_emails' BCC_EMAILS = 'bcc_emails'
SUBMITTER_REMAILERS = 'submitter_reminders' SUBMITTER_REMAILERS = 'submitter_reminders'
FORM_COMPLETED_BUTTON_KEY = 'form_completed_button'
DEFAULT_VALUES = { DEFAULT_VALUES = {
SUBMITTER_INVITATION_EMAIL_KEY => { SUBMITTER_INVITATION_EMAIL_KEY => {
'subject' => 'You have been invited to submit a form', 'subject' => 'You are invited to submit a form',
'body' => "Hi there,\n\n" \ 'body' => "Hi there,\n\n" \
"You have been invited to submit the \"{{template.name}}\" form:\n\n" \ "You have been invited to submit the \"{{template.name}}\" form.\n\n" \
"{{submitter.link}}\n\n" \ "{{submitter.link}}\n\n" \
"Please contact us by replying to this email if you didn't request this.\n\n" \ "Please contact us by replying to this email if you didn't request this.\n\n" \
"Thanks,\n" \ "Thanks,\n" \
@ -44,10 +45,10 @@ class AccountConfig < ApplicationRecord
'{{submission.link}}' '{{submission.link}}'
}, },
SUBMITTER_DOCUMENTS_COPY_EMAIL_KEY => { SUBMITTER_DOCUMENTS_COPY_EMAIL_KEY => {
'subject' => 'Your copy of documents', 'subject' => 'Your document copy',
'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 the copy using:\n\n" \ "Alternatively, you can download your copy using:\n\n" \
"{{documents.links}}\n\n" \ "{{documents.links}}\n\n" \
"Thanks,\n" \ "Thanks,\n" \
'{{account.name}}' '{{account.name}}'

@ -0,0 +1,29 @@
# frozen_string_literal: true
# == Schema Information
#
# Table name: encrypted_user_configs
#
# id :bigint not null, primary key
# key :string not null
# value :text not null
# created_at :datetime not null
# updated_at :datetime not null
# user_id :bigint not null
#
# Indexes
#
# index_encrypted_user_configs_on_user_id (user_id)
# index_encrypted_user_configs_on_user_id_and_key (user_id,key) UNIQUE
#
# Foreign Keys
#
# fk_rails_... (user_id => users.id)
#
class EncryptedUserConfig < ApplicationRecord
belongs_to :user
encrypts :value
serialize :value, JSON
end

@ -48,6 +48,8 @@ class Submission < ApplicationRecord
through: :template, source: :documents_attachments through: :template, source: :documents_attachments
scope :active, -> { where(deleted_at: nil) } scope :active, -> { where(deleted_at: nil) }
scope :pending, -> { joins(:submitters).where(submitters: { completed_at: nil }).distinct }
scope :completed, -> { where.not(id: pending.select(:submission_id)) }
enum :source, { enum :source, {
invite: 'invite', invite: 'invite',
@ -60,4 +62,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

@ -42,6 +42,7 @@ class SubmissionEvent < ApplicationRecord
open_email: 'open_email', open_email: 'open_email',
click_email: 'click_email', click_email: 'click_email',
click_sms: 'click_sms', click_sms: 'click_sms',
phone_verified: 'phone_verified',
start_form: 'start_form', start_form: 'start_form',
view_form: 'view_form', view_form: 'view_form',
complete_form: 'complete_form' complete_form: 'complete_form'

@ -4,21 +4,22 @@
# #
# Table name: submitters # Table name: submitters
# #
# id :bigint not null, primary key # id :bigint not null, primary key
# completed_at :datetime # application_key :string
# email :string # completed_at :datetime
# ip :string # email :string
# name :string # ip :string
# opened_at :datetime # name :string
# phone :string # opened_at :datetime
# sent_at :datetime # phone :string
# slug :string not null # sent_at :datetime
# ua :string # slug :string not null
# uuid :string not null # ua :string
# values :text not null # uuid :string not null
# created_at :datetime not null # values :text not null
# updated_at :datetime not null # created_at :datetime not null
# submission_id :bigint not null # updated_at :datetime not null
# submission_id :bigint not null
# #
# Indexes # Indexes
# #

@ -4,35 +4,42 @@
# #
# 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
# author_id :bigint not null
# folder_id :bigint not null
# #
# Indexes # Indexes
# #
# index_templates_on_account_id (account_id) # index_templates_on_account_id (account_id)
# index_templates_on_author_id (author_id) # index_templates_on_author_id (author_id)
# index_templates_on_folder_id (folder_id)
# index_templates_on_slug (slug) UNIQUE # index_templates_on_slug (slug) UNIQUE
# #
# Foreign Keys # Foreign Keys
# #
# fk_rails_... (account_id => accounts.id) # fk_rails_... (account_id => accounts.id)
# fk_rails_... (author_id => users.id) # fk_rails_... (author_id => users.id)
# fk_rails_... (folder_id => template_folders.id)
# #
class Template < ApplicationRecord class Template < ApplicationRecord
DEFAULT_SUBMITTER_NAME = 'First Submitter' DEFAULT_SUBMITTER_NAME = 'First Submitter'
belongs_to :author, class_name: 'User' belongs_to :author, class_name: 'User'
belongs_to :account belongs_to :account
belongs_to :folder, class_name: 'TemplateFolder'
before_validation :maybe_set_default_folder, on: :create
attribute :fields, :string, default: -> { [] } attribute :fields, :string, default: -> { [] }
attribute :schema, :string, default: -> { [] } attribute :schema, :string, default: -> { [] }
@ -52,6 +59,7 @@ class Template < ApplicationRecord
has_many :submissions, dependent: :destroy has_many :submissions, dependent: :destroy
scope :active, -> { where(deleted_at: nil) } scope :active, -> { where(deleted_at: nil) }
scope :archived, -> { where.not(deleted_at: nil) }
after_save :create_secure_images after_save :create_secure_images
@ -61,4 +69,10 @@ class Template < ApplicationRecord
Templates::ProcessDocument.generate_pdf_secured_preview_images(self, doc, document_data) Templates::ProcessDocument.generate_pdf_secured_preview_images(self, doc, document_data)
end end
end end
private
def maybe_set_default_folder
self.folder ||= account.default_template_folder
end
end end

@ -0,0 +1,40 @@
# frozen_string_literal: true
# == Schema Information
#
# Table name: template_folders
#
# id :bigint not null, primary key
# deleted_at :datetime
# name :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_template_folders_on_account_id (account_id)
# index_template_folders_on_author_id (author_id)
#
# Foreign Keys
#
# fk_rails_... (account_id => accounts.id)
# fk_rails_... (author_id => users.id)
#
class TemplateFolder < ApplicationRecord
DEFAULT_NAME = 'Default'
belongs_to :author, class_name: 'User'
belongs_to :account
has_many :templates, dependent: :destroy, foreign_key: :folder_id, inverse_of: :folder
has_many :active_templates, -> { where(deleted_at: nil) },
class_name: 'Template', dependent: :destroy, foreign_key: :folder_id, inverse_of: :folder
scope :active, -> { where(deleted_at: nil) }
def default?
name == DEFAULT_NAME
end
end

@ -52,6 +52,8 @@ 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 :user_configs, dependent: :destroy
has_many :encrypted_configs, dependent: :destroy, class_name: 'EncryptedUserConfig'
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?
@ -74,6 +76,14 @@ class User < ApplicationRecord
true true
end end
def self.sign_in_after_reset_password
if PasswordsController::Current.user.present?
!PasswordsController::Current.user.otp_required_for_login
else
true
end
end
def initials def initials
[first_name&.first, last_name&.first].compact_blank.join.upcase [first_name&.first, last_name&.first].compact_blank.join.upcase
end end

@ -0,0 +1,29 @@
# frozen_string_literal: true
# == Schema Information
#
# Table name: user_configs
#
# id :bigint not null, primary key
# key :string not null
# value :text not null
# created_at :datetime not null
# updated_at :datetime not null
# user_id :bigint not null
#
# Indexes
#
# index_user_configs_on_user_id (user_id)
# index_user_configs_on_user_id_and_key (user_id,key) UNIQUE
#
# Foreign Keys
#
# fk_rails_... (user_id => users.id)
#
class UserConfig < ApplicationRecord
SIGNATURE_KEY = 'signature'
belongs_to :user
serialize :value, JSON
end

@ -21,8 +21,9 @@
</div> </div>
</div> </div>
<% end %> <% end %>
<% unless Docuseal.multitenant? %> <% encrypted_config = @encrypted_config || EncryptedConfig.find_or_initialize_by(account: current_account, key: EncryptedConfig::APP_URL_KEY) %>
<%= f.fields_for @encrypted_config || EncryptedConfig.find_or_initialize_by(account: current_account, key: EncryptedConfig::APP_URL_KEY) do |ff| %> <% if !Docuseal.multitenant? && can?(:manage, encrypted_config) %>
<%= f.fields_for encrypted_config do |ff| %>
<div class="form-control"> <div class="form-control">
<%= ff.label :value, 'App URL', class: 'label' %> <%= ff.label :value, 'App URL', class: 'label' %>
<%= ff.text_field :value, autocomplete: 'off', class: 'base-input' %> <%= ff.text_field :value, autocomplete: 'off', class: 'base-input' %>

@ -16,7 +16,7 @@
<input type="checkbox"> <input type="checkbox">
<div class="collapse-title text-xl font-medium"> <div class="collapse-title text-xl font-medium">
<div> <div>
Request signature, single submitter Request signature, multiple submitters with default values
</div> </div>
<div class="mt-1"> <div class="mt-1">
<div class="badge badge-warning badge-lg">POST</div> <div class="badge badge-warning badge-lg">POST</div>
@ -28,8 +28,22 @@
<% text = capture do %>curl --location '<%= api_submissions_url %>' \ <% text = capture do %>curl --location '<%= api_submissions_url %>' \
--header 'X-Auth-Token: <%= current_user.access_token.token %>' \ --header 'X-Auth-Token: <%= current_user.access_token.token %>' \
--data-raw '{ --data-raw '{
"template_id": <%= current_account.templates.last&.id || 1 %>, "template_id": <%= current_account.templates.last&.id || 1 %>,
"emails": "<%= current_user.email.sub('@', '+test@') %>, <%= current_user.email.sub('@', '+test2@') %>" "submission": [
{
"submitters": [
{
"name": "John Doe",
"role": "<%= current_account.templates.last ? current_account.templates.last.submitters.first['name'] : 'First Submitter' %>",
"email": "<%= current_user.email.sub('@', '+test@') %>",
"values": {
"Form Text Field Name": "Default Value"
}
},
{ "role": "Second Submitter", "email": "<%= current_user.email.sub('@', '+test2@') %>" }
]
}
]
}'<% end.to_str %> }'<% end.to_str %>
<span class="top-0 right-0 absolute"> <span class="top-0 right-0 absolute">
<%= render 'shared/clipboard_copy', icon: 'copy', text:, class: 'btn btn-ghost text-white', icon_class: 'w-6 h-6 text-white', copy_title: 'Copy', copied_title: 'Copied' %> <%= render 'shared/clipboard_copy', icon: 'copy', text:, class: 'btn btn-ghost text-white', icon_class: 'w-6 h-6 text-white', copy_title: 'Copy', copied_title: 'Copied' %>
@ -42,34 +56,20 @@
<input type="checkbox"> <input type="checkbox">
<div class="collapse-title text-xl font-medium"> <div class="collapse-title text-xl font-medium">
<div> <div>
Request signature, multiple submitters with default values Request signature, single submitter
</div> </div>
<div class="mt-1"> <div class="mt-1">
<div class="badge badge-warning badge-lg">POST</div> <div class="badge badge-warning badge-lg">POST</div>
<div class="badge badge-primary badge-lg"><%= api_submissions_path %></div> <div class="badge badge-primary badge-lg"><%= api_submissions_emails_path %></div>
</div> </div>
</div> </div>
<div class="collapse-content" style="display: inherit"> <div class="collapse-content" style="display: inherit">
<div class="mockup-code overflow-hidden"> <div class="mockup-code overflow-hidden">
<% text = capture do %>curl --location '<%= api_submissions_url %>' \ <% text = capture do %>curl --location '<%= api_submissions_emails_url %>' \
--header 'X-Auth-Token: <%= current_user.access_token.token %>' \ --header 'X-Auth-Token: <%= current_user.access_token.token %>' \
--data-raw '{ --data-raw '{
"template_id": <%= current_account.templates.last&.id || 1 %>, "template_id": <%= current_account.templates.last&.id || 1 %>,
"submission": [ "emails": "<%= current_user.email.sub('@', '+test@') %>, <%= current_user.email.sub('@', '+test2@') %>"
{
"submitters": [
{
"name": "John Doe",
"role": "<%= current_account.templates.last ? current_account.templates.last.submitters.first['name'] : 'First Submitter' %>",
"email": "<%= current_user.email.sub('@', '+test@') %>",
"values": {
"Form Text Field Name": "Default Value"
}
},
{ "name": "Second Submitter", "email": "<%= current_user.email.sub('@', '+test2@') %>" }
]
}
]
}'<% end.to_str %> }'<% end.to_str %>
<span class="top-0 right-0 absolute"> <span class="top-0 right-0 absolute">
<%= render 'shared/clipboard_copy', icon: 'copy', text:, class: 'btn btn-ghost text-white', icon_class: 'w-6 h-6 text-white', copy_title: 'Copy', copied_title: 'Copied' %> <%= render 'shared/clipboard_copy', icon: 'copy', text:, class: 'btn btn-ghost text-white', icon_class: 'w-6 h-6 text-white', copy_title: 'Copy', copied_title: 'Copied' %>
@ -101,5 +101,8 @@
</div> </div>
</div> </div>
</div> </div>
<div class="text-center">
<%= link_to 'Open Full API Reference', "#{Docuseal::PRODUCT_URL}/docs/api", class: 'btn btn-warning text-base mt-4 px-8', target: '_blank', rel: 'noopener' %>
</div>
</div> </div>
</div> </div>

@ -1,9 +1,9 @@
<% if Docuseal.demo? %><%= render 'shared/demo_alert' %><% end %> <% if Docuseal.demo? %><%= render 'shared/demo_alert' %><% end %>
<% if @pagy.count > 0 || params[:q].present? %> <% if @pagy.count > 0 || params[:q].present? || @template_folders.present? %>
<div class="flex justify-between mb-4 items-center"> <div class="flex justify-between mb-4 items-center">
<h1 class="text-4xl font-bold"><span class="hidden md:inline">Document</span> Templates</h1> <h1 class="text-4xl font-bold"><span class="hidden md:inline">Document</span> Templates</h1>
<div class="flex space-x-2"> <div class="flex space-x-2">
<% if params[:q].present? || @pagy.pages > 1 %> <% if params[:q].present? || @pagy.pages > 1 || @template_folders.present? %>
<%= render 'shared/search_input' %> <%= render 'shared/search_input' %>
<% end %> <% end %>
<% if can?(:create, ::Template) %> <% if can?(:create, ::Template) %>
@ -15,17 +15,55 @@
<% end %> <% end %>
</div> </div>
</div> </div>
<% if @pagy.count > 0 %> <% view_archived_html = capture do %>
<% if current_account.templates.where.not(deleted_at: nil).exists? %>
<div>
<a href="<%= templates_archived_index_path %>" class="link text-sm">View Archived</a>
</div>
<% end %>
<% end %>
<% if @template_folders.present? %>
<div class="grid gap-4 md:grid-cols-3 <%= 'mb-6' if @templates.present? %>">
<%= render partial: 'template_folders/folder', collection: @template_folders, as: :folder %>
</div>
<% end %>
<% if @templates.present? %>
<div class="grid gap-4 md:grid-cols-3"> <div class="grid gap-4 md:grid-cols-3">
<%= render partial: 'templates/template', collection: @templates %> <%= render partial: 'templates/template', collection: @templates %>
</div> </div>
<% view_archived_html = capture do %> <% end %>
<% if current_account.templates.where.not(deleted_at: nil).exists? %> <% if params[:q].blank? && @pagy.pages == 1 && ((@template_folders.size < 10 && @templates.size.zero?) || (@template_folders.size < 7 && @templates.size < 4) || (@template_folders.size < 4 && @templates.size < 7)) %>
<div> <%= form_for '', url: templates_upload_path, method: :post, class: 'mt-8 block', html: { enctype: 'multipart/form-data' } do %>
<a href="<%= templates_archived_index_path %>" class="link text-sm">View Archived</a> <button type="submit" class="hidden"></button>
</div> <file-dropzone data-submit-on-upload="true" class="w-full">
<% end %> <label for="file_dropzone_input" class="w-full block h-52 relative hover:bg-base-200/30 rounded-xl border border-2 border-base-300 border-dashed">
<div class="absolute top-0 right-0 left-0 bottom-0 flex items-center justify-center">
<div class="flex flex-col items-center">
<span data-target="file-dropzone.icon" class="flex flex-col items-center">
<span>
<%= svg_icon('cloud_upload', class: 'w-10 h-10') %>
</span>
<div class="font-medium mb-1">
Upload New Document
</div>
<div class="text-xs">
<span class="font-medium">Click to upload</span> or drag and drop
</div>
</span>
<span data-target="file-dropzone.loading" class="flex flex-col items-center hidden">
<%= svg_icon('loader', class: 'w-10 h-10 animate-spin') %>
<div class="font-medium mb-1">
Uploading...
</div>
</span>
</div>
<input id="file_dropzone_input" name="files[]" class="hidden" data-action="change:file-dropzone#onSelectFiles" data-target="file-dropzone.input" type="file" accept="image/*, application/pdf<%= ', .docx, .doc, .xlsx, .xls' if Docuseal.multitenant? %>" multiple>
</div>
</label>
</file-dropzone>
<% end %> <% end %>
<% end %>
<% if @templates.present? || params[:q].blank? %>
<% if @pagy.pages > 1 %> <% if @pagy.pages > 1 %>
<%= render 'shared/pagination', pagy: @pagy, items_name: 'templates', left_additional_html: view_archived_html %> <%= render 'shared/pagination', pagy: @pagy, items_name: 'templates', left_additional_html: view_archived_html %>
<% else %> <% else %>
@ -46,7 +84,7 @@
<div class="flex items-center h-full"> <div class="flex items-center h-full">
<div class="mx-auto"> <div class="mx-auto">
<div class="max-w-xl mx-auto"> <div class="max-w-xl mx-auto">
<h1 class="text-5xl font-bold text-base-content">👋 Welcome to DocuSeal</h1> <h1 class="text-5xl font-bold text-base-content">👋 Welcome to <%= Docuseal.product_name %></h1>
</div> </div>
<div class="max-w-lg mx-auto"> <div class="max-w-lg mx-auto">
<p class="py-6 text-gray-600">Streamline document workflows, from creating customizable templates to filling and signing document forms</p> <p class="py-6 text-gray-600">Streamline document workflows, from creating customizable templates to filling and signing document forms</p>

@ -21,6 +21,5 @@
<div class="form-control"> <div class="form-control">
<%= f.button button_title(title: 'Change my password', disabled_with: 'Changing password'), class: 'base-button' %> <%= f.button button_title(title: 'Change my password', disabled_with: 'Changing password'), class: 'base-button' %>
</div> </div>
<%= render 'devise/shared/links' %>
<% end %> <% end %>
</div> </div>

@ -12,5 +12,4 @@
<%= f.button button_title(title: 'Reset password', disabled_with: 'Resetting password'), class: 'base-button' %> <%= f.button button_title(title: 'Reset password', disabled_with: 'Resetting password'), class: 'base-button' %>
</div> </div>
<% end %> <% end %>
<%= render 'devise/shared/links' %>
</div> </div>

@ -41,6 +41,13 @@
</div> </div>
<div class="form-control"> <div class="form-control">
<%= f.button button_title(title: 'Sign up', disabled_with: 'Signing up'), class: 'base-button' %> <%= f.button button_title(title: 'Sign up', disabled_with: 'Signing up'), class: 'base-button' %>
<% if Docuseal.multitenant? %>
<button id="talk_to_sales_button" class="hidden" type="submit" formaction="<%= enquiries_path %>" formnovalidate="true" formmethod="post"></button>
<label class="flex items-center cursor-pointer">
<input type="checkbox" name="talk_to_sales" class="base-checkbox" onchange="window.talk_to_sales_button.click()">
<span class="label">I would like to talk to sales</span>
</label>
<% end %>
</div> </div>
<% end %> <% end %>
<%= render 'devise/shared/links' %> <%= render 'devise/shared/links' %>

@ -1,6 +1,6 @@
<div class="max-w-lg mx-auto px-2"> <div class="max-w-lg mx-auto px-2">
<%= render 'devise/shared/select_server' if Docuseal.multitenant? %> <%= render 'devise/shared/select_server' if Docuseal.multitenant? %>
<h1 class="text-4xl font-bold text-center mt-8">Log In</h1> <h1 class="text-4xl font-bold text-center mt-8">Sign In</h1>
<%= form_for(resource, as: resource_name, html: { class: 'space-y-6' }, data: { turbo: params[:redir].blank? }, url: session_path(resource_name)) do |f| %> <%= form_for(resource, as: resource_name, html: { class: 'space-y-6' }, data: { turbo: params[:redir].blank? }, url: session_path(resource_name)) do |f| %>
<% if params[:redir].present? %> <% if params[:redir].present? %>
<%= hidden_field_tag :redir, params[:redir] %> <%= hidden_field_tag :redir, params[:redir] %>
@ -16,11 +16,11 @@
</div> </div>
</div> </div>
<div class="form-control"> <div class="form-control">
<%= f.button button_title(title: 'Log In', disabled_with: 'Logging In'), class: 'base-button' %> <%= f.button button_title(title: 'Sign In', disabled_with: 'Signing In'), class: 'base-button' %>
</div> </div>
<% end %> <% end %>
<% if devise_mapping.omniauthable? %> <% if devise_mapping.omniauthable? %>
<%= button_to button_title(title: 'Log in with Google', icon: svg_icon('brand_google', class: 'w-6 h-6')), omniauth_authorize_path(resource_name, :google_oauth2), class: 'white-button w-full mt-4', data: { turbo: false }, method: :post %> <%= 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 %>
<% end %> <% end %>
<%= render 'devise/shared/links' %> <%= render 'devise/shared/links' %>
</div> </div>

@ -1,5 +1,5 @@
<div class="max-w-lg mx-auto px-2"> <div class="max-w-lg mx-auto px-2">
<h1 class="text-4xl font-bold text-center mt-8">Log In</h1> <h1 class="text-4xl font-bold text-center mt-8">Sign In</h1>
<%= form_for(resource, as: resource_name, html: { class: 'space-y-6' }, data: { turbo: params[:redir].blank? }, url: session_path(resource_name)) do |f| %> <%= form_for(resource, as: resource_name, html: { class: 'space-y-6' }, data: { turbo: params[:redir].blank? }, url: session_path(resource_name)) do |f| %>
<%= f.hidden_field :email %> <%= f.hidden_field :email %>
<%= f.hidden_field :password %> <%= f.hidden_field :password %>
@ -13,7 +13,7 @@
</div> </div>
</div> </div>
<div class="form-control"> <div class="form-control">
<%= f.button button_title(title: 'Log In', disabled_with: 'Logging In'), class: 'base-button' %> <%= f.button button_title(title: 'Sign In', disabled_with: 'Signing In'), class: 'base-button' %>
</div> </div>
<% end %> <% end %>
</div> </div>

@ -1,6 +1,6 @@
<div class="flex justify-between mt-4"> <div class="flex justify-between mt-4">
<%- if controller_name != 'sessions' %> <%- if controller_name != 'sessions' %>
<%= link_to 'Log in', new_session_path(resource_name), class: 'link link-hover' %> <%= link_to 'Already have an account?', new_session_path(resource_name), class: 'link link-hover mx-auto' %>
<% end %> <% end %>
<%- if devise_mapping.registerable? && controller_name != 'registrations' %> <%- if devise_mapping.registerable? && controller_name != 'registrations' %>
<%= link_to 'Create free account', registration_path({ redir: params[:redir] }.compact_blank), class: 'link link-hover' %> <%= link_to 'Create free account', registration_path({ redir: params[:redir] }.compact_blank), class: 'link link-hover' %>
@ -8,10 +8,4 @@
<%- if devise_mapping.recoverable? && controller_name != 'passwords' && controller_name != 'registrations' %> <%- if devise_mapping.recoverable? && controller_name != 'passwords' && controller_name != 'registrations' %>
<%= link_to 'Forgot your password?', new_password_path(resource_name), class: 'link link-hover' %> <%= link_to 'Forgot your password?', new_password_path(resource_name), class: 'link link-hover' %>
<% end %> <% end %>
<%- if devise_mapping.confirmable? && controller_name != 'confirmations' %>
<%= link_to "Didn't receive confirmation instructions?", new_confirmation_path(resource_name), class: 'link link-hover' %>
<% end %>
<%- if devise_mapping.lockable? && resource_class.unlock_strategy_enabled?(:email) && controller_name != 'unlocks' %>
<%= link_to "Didn't receive unlock instructions?", new_unlock_path(resource_name), class: 'link link-hover' %>
<% end %>
</div> </div>

@ -18,11 +18,11 @@
<div class="grid md:grid-cols-2 gap-4"> <div class="grid md:grid-cols-2 gap-4">
<div class="form-control"> <div class="form-control">
<%= ff.label :username, class: 'label' %> <%= ff.label :username, class: 'label' %>
<%= ff.text_field :username, value: value['username'], required: true, class: 'base-input' %> <%= ff.text_field :username, value: value['username'], class: 'base-input' %>
</div> </div>
<div class="form-control"> <div class="form-control">
<%= ff.label :password, class: 'label' %> <%= ff.label :password, class: 'label' %>
<%= ff.password_field :password, value: value['password'], required: true, class: 'base-input' %> <%= ff.password_field :password, value: value['password'], class: 'base-input' %>
</div> </div>
</div> </div>
<div class="grid md:grid-cols-2 gap-4"> <div class="grid md:grid-cols-2 gap-4">

@ -1,6 +1,6 @@
<div class="flex flex-wrap space-y-4 md:flex-nowrap md:space-y-0 md:space-x-10"> <div class="flex-wrap space-y-4 md:flex md:flex-nowrap md:space-y-0 md:space-x-10">
<%= render 'shared/settings_nav' %> <%= render 'shared/settings_nav' %>
<div class="flex-grow"> <div class="md:flex-grow">
<div class="max-w-xl"> <div class="max-w-xl">
<h1 class="text-4xl font-bold mb-4">PDF Signature</h1> <h1 class="text-4xl font-bold mb-4">PDF Signature</h1>
<div id="result"> <div id="result">

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" class="<%= local_assigns[:class] %>" width="44" height="44" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M17 7l-10 10"></path>
<path d="M8 7l9 0l0 9"></path>
</svg>

After

Width:  |  Height:  |  Size: 354 B

@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" class="<%= local_assigns[:class] %>" width="44" height="44" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M3 21l18 0"></path>
<path d="M9 8l1 0"></path>
<path d="M9 12l1 0"></path>
<path d="M9 16l1 0"></path>
<path d="M14 8l1 0"></path>
<path d="M14 12l1 0"></path>
<path d="M14 16l1 0"></path>
<path d="M5 21v-16a2 2 0 0 1 2 -2h10a2 2 0 0 1 2 2v16"></path>
</svg>

After

Width:  |  Height:  |  Size: 565 B

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" class="<%= local_assigns[:class] %>" width="44" height="44" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M3 12a9 9 0 1 0 18 0a9 9 0 0 0 -18 0" />
<path d="M12 7v5l3 3" />
</svg>

After

Width:  |  Height:  |  Size: 362 B

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" class="<%= local_assigns[:class] %>" width="44" height="44" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M5 4h4l3 3h7a2 2 0 0 1 2 2v8a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2v-11a2 2 0 0 1 2 -2"></path>
</svg>

After

Width:  |  Height:  |  Size: 391 B

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" class="<%= local_assigns[:class] %>" width="44" height="44" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M13 19h-8a2 2 0 0 1 -2 -2v-11a2 2 0 0 1 2 -2h4l3 3h7a2 2 0 0 1 2 2v4"></path>
<path d="M16 22l5 -5"></path>
<path d="M21 21.5v-4.5h-4.5"></path>
</svg>

After

Width:  |  Height:  |  Size: 448 B

@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" class="<%= local_assigns[:class] %>" width="44" height="44" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M12 3a3 3 0 0 0 -3 3v12a3 3 0 0 0 3 3"></path>
<path d="M6 3a3 3 0 0 1 3 3v12a3 3 0 0 1 -3 3"></path>
<path d="M13 7h7a1 1 0 0 1 1 1v8a1 1 0 0 1 -1 1h-7"></path>
<path d="M5 7h-1a1 1 0 0 0 -1 1v8a1 1 0 0 0 1 1h1"></path>
<path d="M17 12h.01"></path>
<path d="M13 12h.01"></path>
</svg>

After

Width:  |  Height:  |  Size: 588 B

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

After

Width:  |  Height:  |  Size: 440 B

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

Loading…
Cancel
Save