diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 3be2f058..14699880 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -138,4 +138,5 @@ jobs:
run: |
bundle exec rake db:create
bundle exec rake db:migrate
+ bundle exec rake assets:precompile
bundle exec rspec
diff --git a/.rubocop.yml b/.rubocop.yml
index 623c3db2..5e4b6895 100644
--- a/.rubocop.yml
+++ b/.rubocop.yml
@@ -67,8 +67,14 @@ RSpec/MultipleMemoizedHelpers:
Rails/I18nLocaleTexts:
Enabled: false
+Rails/FindEach:
+ Enabled: false
+
Rails/SkipsModelValidations:
Enabled: false
Rails/ApplicationController:
Enabled: false
+
+Capybara/ClickLinkOrButtonStyle:
+ Enabled: false
diff --git a/Dockerfile b/Dockerfile
index acf9dfbf..e0c2b4b8 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -31,7 +31,7 @@ ENV BUNDLE_WITHOUT="development:test"
WORKDIR /app
-RUN apk add --no-cache build-base sqlite-dev libpq-dev mariadb-dev vips-dev vips-poppler vips-heif libc6-compat ttf-freefont ttf-liberation && cp /usr/share/fonts/liberation/LiberationSans-Regular.ttf /usr/share/fonts/liberation/LiberationSans-Bold.ttf / && apk del ttf-liberation
+RUN apk add --no-cache build-base sqlite-dev libpq-dev mariadb-dev vips-dev vips-poppler poppler-utils vips-heif libc6-compat ttf-freefont ttf-liberation && mkdir /fonts && cp /usr/share/fonts/liberation/LiberationSans-Regular.ttf /usr/share/fonts/liberation/LiberationSans-Bold.ttf /fonts && apk del ttf-liberation && wget -O /fonts/DancingScript.otf "https://github.com/impallari/DancingScript/raw/master/fonts/DancingScript-Regular.otf" && wget -O /fonts/DancingScript-License.txt https://github.com/impallari/DancingScript/blob/master/OFL.txt
COPY ./Gemfile ./Gemfile.lock ./
@@ -49,6 +49,7 @@ COPY LICENSE README.md Rakefile config.ru ./
COPY --from=webpack /app/public/packs ./public/packs
+RUN ln -s /fonts /app/public/fonts
RUN bundle exec bootsnap precompile --gemfile app/ lib/
WORKDIR /data/docuseal
diff --git a/Gemfile b/Gemfile
index 63efe6c7..c967bd8f 100644
--- a/Gemfile
+++ b/Gemfile
@@ -13,6 +13,7 @@ gem 'devise-two-factor'
gem 'dotenv', require: false
gem 'email_typo'
gem 'faraday'
+gem 'faraday-follow_redirects'
gem 'google-cloud-storage', require: false
gem 'hexapdf'
gem 'image_processing'
diff --git a/Gemfile.lock b/Gemfile.lock
index d07463b2..92fb8dcf 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -98,7 +98,6 @@ GEM
faraday_middleware (~> 1.0, >= 1.0.0.rc1)
net-http-persistent (~> 4.0)
nokogiri (~> 1, >= 1.10.8)
- base64 (0.1.1)
bcrypt (3.1.19)
better_html (2.0.2)
actionview (>= 6.0)
@@ -189,6 +188,8 @@ GEM
faraday-em_http (1.0.0)
faraday-em_synchrony (1.0.0)
faraday-excon (1.1.0)
+ faraday-follow_redirects (0.3.0)
+ faraday (>= 1, < 3)
faraday-httpclient (1.0.1)
faraday-multipart (1.0.4)
multipart-post (~> 2)
@@ -292,7 +293,7 @@ GEM
mini_magick (4.12.0)
mini_mime (1.1.5)
mini_portile2 (2.8.4)
- minitest (5.19.0)
+ minitest (5.20.0)
msgpack (1.7.2)
multi_json (1.15.0)
multi_xml (0.6.0)
@@ -343,7 +344,7 @@ GEM
os (1.1.4)
pagy (6.0.4)
parallel (1.23.0)
- parser (3.2.2.3)
+ parser (3.2.2.4)
ast (~> 2.4.1)
racc
pdf-reader (2.11.0)
@@ -416,7 +417,7 @@ GEM
rake (13.0.6)
redis-client (0.16.0)
connection_pool
- regexp_parser (2.8.1)
+ regexp_parser (2.8.2)
reline (0.3.8)
io-console (~> 0.5)
representable (3.2.0)
@@ -453,33 +454,32 @@ GEM
rspec-mocks (~> 3.12)
rspec-support (~> 3.12)
rspec-support (3.12.1)
- rubocop (1.56.1)
- base64 (~> 0.1.1)
+ rubocop (1.57.2)
json (~> 2.3)
language_server-protocol (>= 3.17.0)
parallel (~> 1.10)
- parser (>= 3.2.2.3)
+ parser (>= 3.2.2.4)
rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 1.8, < 3.0)
rexml (>= 3.2.5, < 4.0)
rubocop-ast (>= 1.28.1, < 2.0)
ruby-progressbar (~> 1.7)
unicode-display_width (>= 2.4.0, < 3.0)
- rubocop-ast (1.29.0)
+ rubocop-ast (1.30.0)
parser (>= 3.2.1.0)
- rubocop-capybara (2.18.0)
+ rubocop-capybara (2.19.0)
rubocop (~> 1.41)
- rubocop-factory_bot (2.23.1)
+ rubocop-factory_bot (2.24.0)
rubocop (~> 1.33)
- rubocop-performance (1.19.0)
+ rubocop-performance (1.19.1)
rubocop (>= 1.7.0, < 2.0)
rubocop-ast (>= 0.4.0)
- rubocop-rails (2.20.2)
+ rubocop-rails (2.22.1)
activesupport (>= 4.2.0)
rack (>= 1.1)
rubocop (>= 1.33.0, < 2.0)
- rubocop-rspec (2.23.2)
- rubocop (~> 1.33)
+ rubocop-rspec (2.25.0)
+ rubocop (~> 1.40)
rubocop-capybara (~> 2.17)
rubocop-factory_bot (~> 2.22)
ruby-progressbar (1.13.0)
@@ -535,7 +535,7 @@ GEM
tzinfo-data (1.2023.3)
tzinfo (>= 1.0.0)
uber (0.1.0)
- unicode-display_width (2.4.2)
+ unicode-display_width (2.5.0)
uniform_notifier (1.16.0)
version_gem (1.1.3)
warden (1.2.9)
@@ -580,6 +580,7 @@ DEPENDENCIES
factory_bot_rails
faker
faraday
+ faraday-follow_redirects
google-cloud-storage
hexapdf
image_processing
diff --git a/README.md b/README.md
index 2eab1042..6ec713e5 100644
--- a/README.md
+++ b/README.md
@@ -52,8 +52,8 @@ DocuSeal is an open source platform that provides secure and efficient digital d
| [](https://heroku.com/deploy?template=https://github.com/docusealco/docuseal-heroku) | [
](https://railway.app/template/IGoDnc?referralCode=ruU7JR)|
|**DigitalOcean**|**Render**|
| [
](https://cloud.digitalocean.com/apps/new?repo=https://github.com/docusealco/docuseal-digitalocean/tree/master&refcode=421d50f53990) | [
](https://render.com/deploy?repo=https://github.com/docusealco/docuseal-render)
-|**Koyeb**| |
-| [
](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;/) | |
+|**Koyeb**|**Elestio**|
+| [
](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;/) | [
](https://dash.elest.io/deploy?soft=DocuSeal&id=339) |
#### 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.
-[](https://cal.com/docuseal)
+[Book a Meeting](https://calendly.com/kriti-docuseal/30min)
## License
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.
diff --git a/app/controllers/api/api_base_controller.rb b/app/controllers/api/api_base_controller.rb
index 57e3d216..acaef82f 100644
--- a/app/controllers/api/api_base_controller.rb
+++ b/app/controllers/api/api_base_controller.rb
@@ -3,6 +3,12 @@
module Api
class ApiBaseController < ActionController::API
include ActiveStorage::SetCurrent
+ include Pagy::Backend
+
+ DEFAULT_LIMIT = 10
+ MAX_LIMIT = 100
+
+ wrap_parameters false
before_action :authenticate_user!
check_authorization
@@ -17,6 +23,16 @@ module Api
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
current_user&.account
end
diff --git a/app/controllers/api/submissions_controller.rb b/app/controllers/api/submissions_controller.rb
index c3000285..bff765a8 100644
--- a/app/controllers/api/submissions_controller.rb
+++ b/app/controllers/api/submissions_controller.rb
@@ -2,78 +2,158 @@
module Api
class SubmissionsController < ApiBaseController
- UnknownFieldName = Class.new(StandardError)
- UnknownSubmitterName = Class.new(StandardError)
+ load_and_authorize_resource :template, only: :create
+ load_and_authorize_resource :submission, only: %i[show index]
- load_and_authorize_resource :template
-
- before_action do
+ before_action only: :create do
authorize!(:create, Submission)
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
+ is_send_email = !params[:send_email].in?(['false', false])
+
submissions =
- if (emails = (params[:emails] || params[:email]).presence)
+ if (emails = (params[:emails] || params[:email]).presence) && params[:submission].blank?
Submissions.create_from_emails(template: @template,
user: current_user,
source: :api,
- mark_as_sent: params[:send_email] != 'false',
+ mark_as_sent: is_send_email,
emails:)
else
- submissions_attrs = normalize_submissions_params!(submissions_params[:submission], @template)
+ submissions_attrs, attachments = normalize_submissions_params!(submissions_params[:submission], @template)
Submissions.create_from_submitters(
template: @template,
user: current_user,
source: :api,
- mark_as_sent: params[:send_email] != 'false',
+ mark_as_sent: is_send_email,
submitters_order: params[:submitters_order] || 'preserved',
submissions_attrs:
)
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)
- rescue UnknownFieldName, UnknownSubmitterName => e
+ render json: submitters
+ rescue Submitters::NormalizeValues::UnknownFieldName, Submitters::NormalizeValues::UnknownSubmitterName => e
render json: { error: e.message }, status: :unprocessable_entity
end
+ def destroy
+ @submission.update!(deleted_at: Time.current)
+
+ render json: @submission.as_json(only: %i[id deleted_at])
+ end
+
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
- 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
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|
- next if submitter[:values].blank?
+ default_values = submitter[:values] || {}
- submitter[:values] =
- normalize_submitter_values(template,
- submitter[:values],
- submitter[:role] || template.submitters[index]['name'])
+ submitter[:fields]&.each { |f| default_values[f[:name]] = f[:default_value] if f[:default_value].present? }
+
+ next if default_values.blank?
+
+ values, new_attachments =
+ Submitters::NormalizeValues.call(template,
+ default_values,
+ submitter[:role] || template.submitters[index]['name'])
+
+ attachments.push(*new_attachments)
+
+ submitter[:values] = values
end
end
- submissions_params
+ [submissions_params, attachments]
end
- def normalize_submitter_values(template, values, submitter_name)
- submitter =
- template.submitters.find { |e| e['name'] == submitter_name } ||
- raise(UnknownSubmitterName, "Unknown submitter: #{submitter_name}")
+ def save_default_value_attachments!(attachments, submitters)
+ return if attachments.blank?
+
+ 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'] }
- fields_name_index = fields.index_by { |e| e['name'] }
+ next unless attachment
- values.transform_keys do |key|
- next key if fields_uuid_index[key].present?
+ attachment.record = submitter
- fields_name_index[key]&.dig('uuid') || raise(UnknownFieldName, "Unknown field: #{key}")
+ attachment.save!
+ end
end
end
end
diff --git a/app/controllers/api/submitters_autocomplete_controller.rb b/app/controllers/api/submitters_autocomplete_controller.rb
new file mode 100644
index 00000000..ff7dc2e6
--- /dev/null
+++ b/app/controllers/api/submitters_autocomplete_controller.rb
@@ -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
diff --git a/app/controllers/api/submitters_controller.rb b/app/controllers/api/submitters_controller.rb
new file mode 100644
index 00000000..a0abb9c8
--- /dev/null
+++ b/app/controllers/api/submitters_controller.rb
@@ -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
diff --git a/app/controllers/api/template_folders_autocomplete_controller.rb b/app/controllers/api/template_folders_autocomplete_controller.rb
new file mode 100644
index 00000000..19365e76
--- /dev/null
+++ b/app/controllers/api/template_folders_autocomplete_controller.rb
@@ -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
diff --git a/app/controllers/api/templates_controller.rb b/app/controllers/api/templates_controller.rb
index ef4a1e33..8bf8a1a2 100644
--- a/app/controllers/api/templates_controller.rb
+++ b/app/controllers/api/templates_controller.rb
@@ -5,28 +5,60 @@ module Api
load_and_authorize_resource :template
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
def show
- render json: @template.as_json(include: { author: { only: %i[id email first_name last_name] },
- documents: { only: %i[id uuid], methods: %i[url filename] } })
+ render json: @template.as_json(serialize_params)
end
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)
- 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
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
- params.require(:template).permit(:name,
- schema: [%i[attachment_uuid name]],
- submitters: [%i[name uuid]],
- fields: [[:uuid, :submitter_uuid, :name, :type, :required,
- { options: [], areas: [%i[x y w h cell_w attachment_uuid page]] }]])
+ params.require(:template).permit(
+ :name,
+ schema: [%i[attachment_uuid name]],
+ submitters: [%i[name uuid]],
+ fields: [[:uuid, :submitter_uuid, :name, :type, :required, :readonly, :default_value,
+ { options: [], areas: [%i[x y w h cell_w attachment_uuid page]] }]]
+ )
end
end
end
diff --git a/app/controllers/api/templates_documents_controller.rb b/app/controllers/api/templates_documents_controller.rb
index 1657e70e..04fdb12c 100644
--- a/app/controllers/api/templates_documents_controller.rb
+++ b/app/controllers/api/templates_documents_controller.rb
@@ -16,6 +16,7 @@ module Api
render json: {
schema:,
documents: documents.as_json(
+ methods: [:metadata],
include: {
preview_images: { methods: %i[url metadata filename] }
}
diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb
index e860fe4f..38b541aa 100644
--- a/app/controllers/application_controller.rb
+++ b/app/controllers/application_controller.rb
@@ -33,7 +33,7 @@ class ApplicationController < ActionController::Base
private
def sign_in_for_demo
- sign_in(User.order('random()').take) unless signed_in?
+ sign_in(User.active.order('random()').take) unless signed_in?
end
def current_account
diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb
index fe7c1c5b..aa08ae4d 100644
--- a/app/controllers/dashboard_controller.rb
+++ b/app/controllers/dashboard_controller.rb
@@ -6,17 +6,56 @@ class DashboardController < ApplicationController
before_action :maybe_redirect_product_url
before_action :maybe_render_landing
+ load_and_authorize_resource :template_folder, parent: false
load_and_authorize_resource :template, parent: false
+ SHOW_TEMPLATES_FOLDERS_THRESHOLD = 9
+ TEMPLATES_PER_PAGE = 12
+ FOLDERS_PER_PAGE = 18
+
def index
- @templates = @templates.active.preload(:author).order(id: :desc)
- @templates = Templates.search(@templates, params[:q])
+ @template_folders = filter_template_folders(@template_folders)
+
+ @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
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
return if !Docuseal.multitenant? || signed_in?
diff --git a/app/controllers/enquiries_controller.rb b/app/controllers/enquiries_controller.rb
new file mode 100644
index 00000000..829b578c
--- /dev/null
+++ b/app/controllers/enquiries_controller.rb
@@ -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
diff --git a/app/controllers/mfa_setup_controller.rb b/app/controllers/mfa_setup_controller.rb
index 32cea983..ce690643 100644
--- a/app/controllers/mfa_setup_controller.rb
+++ b/app/controllers/mfa_setup_controller.rb
@@ -10,7 +10,7 @@ class MfaSetupController < ApplicationController
current_user.save!
- @provision_url = current_user.otp_provisioning_uri(current_user.email, issuer: Docuseal::PRODUCT_NAME)
+ @provision_url = current_user.otp_provisioning_uri(current_user.email, issuer: Docuseal.product_name)
end
def edit; end
@@ -22,7 +22,7 @@ class MfaSetupController < ApplicationController
redirect_to settings_profile_index_path, notice: '2FA has been configured'
else
- @provision_url = current_user.otp_provisioning_uri(current_user.email, issuer: Docuseal::PRODUCT_NAME)
+ @provision_url = current_user.otp_provisioning_uri(current_user.email, issuer: Docuseal.product_name)
@error_message = 'Code is invalid'
diff --git a/app/controllers/passwords_controller.rb b/app/controllers/passwords_controller.rb
new file mode 100644
index 00000000..da67abc6
--- /dev/null
+++ b/app/controllers/passwords_controller.rb
@@ -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
diff --git a/app/controllers/preview_document_page_controller.rb b/app/controllers/preview_document_page_controller.rb
new file mode 100644
index 00000000..8c51507e
--- /dev/null
+++ b/app/controllers/preview_document_page_controller.rb
@@ -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
diff --git a/app/controllers/registrations_controller.rb b/app/controllers/registrations_controller.rb
index bca21d22..d0b4a403 100644
--- a/app/controllers/registrations_controller.rb
+++ b/app/controllers/registrations_controller.rb
@@ -31,10 +31,10 @@ class RegistrationsController < Devise::RegistrationsController
redirect_to after_sign_up_path_for(current_user), allow_other_host: true
end
- def require_no_authentication
- super
+ def set_flash_message(key, kind, options = {})
+ return if key == :alert && kind == 'already_authenticated'
- flash.clear
+ super
end
def build_resource(_hash = {})
@@ -43,7 +43,7 @@ class RegistrationsController < Devise::RegistrationsController
self.resource = account.users.new(user_params)
- account.name ||= "#{resource.full_name}'s Company" if params[:action] == 'create'
+ account.name ||= resource.full_name if params[:action] == 'create'
end
def user_params
diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb
index 4cfa542e..96379fd9 100644
--- a/app/controllers/sessions_controller.rb
+++ b/app/controllers/sessions_controller.rb
@@ -32,9 +32,9 @@ class SessionsController < Devise::SessionsController
devise_parameter_sanitizer.permit(:sign_in, keys: [:otp_attempt])
end
- def require_no_authentication
- super
+ def set_flash_message(key, kind, options = {})
+ return if key == :alert && kind == 'already_authenticated'
- flash.clear
+ super
end
end
diff --git a/app/controllers/sso_settings_controller.rb b/app/controllers/sso_settings_controller.rb
new file mode 100644
index 00000000..9e90d1de
--- /dev/null
+++ b/app/controllers/sso_settings_controller.rb
@@ -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
diff --git a/app/controllers/start_form_controller.rb b/app/controllers/start_form_controller.rb
index 8e8d27d6..ca054121 100644
--- a/app/controllers/start_form_controller.rb
+++ b/app/controllers/start_form_controller.rb
@@ -14,14 +14,14 @@ class StartFormController < ApplicationController
def update
@submitter = Submitter.where(submission: @template.submissions.where(deleted_at: nil))
- .find_or_initialize_by(email: submitter_params[:email])
+ .then { |rel| params[:resubmit].present? ? rel.where(completed_at: nil) : rel }
+ .find_or_initialize_by(**submitter_params.compact_blank)
if @submitter.completed_at?
redirect_to start_form_completed_path(@template.slug, email: submitter_params[:email])
else
@submitter.assign_attributes(
uuid: @template.submitters.first['uuid'],
- opened_at: Time.current,
ip: request.remote_ip,
ua: request.user_agent
)
@@ -47,7 +47,7 @@ class StartFormController < ApplicationController
private
def submitter_params
- params.require(:submitter).permit(:email).tap do |attrs|
+ params.require(:submitter).permit(:email, :phone, :name).tap do |attrs|
attrs[:email] = Submissions.normalize_email(attrs[:email])
end
end
diff --git a/app/controllers/submissions_controller.rb b/app/controllers/submissions_controller.rb
index 311efb19..a327d56e 100644
--- a/app/controllers/submissions_controller.rb
+++ b/app/controllers/submissions_controller.rb
@@ -6,12 +6,24 @@ class SubmissionsController < ApplicationController
load_and_authorize_resource :submission, only: %i[show destroy]
+ PRELOAD_ALL_PAGES_AMOUNT = 200
+
def show
ActiveRecord::Associations::Preloader.new(
records: [@submission],
- associations: [:template, { template_schema_documents: [:blob, { preview_images_attachments: :blob }] }]
+ associations: [:template, { template_schema_documents: :blob }]
).call
+ total_pages =
+ @submission.template_schema_documents.sum { |e| e.metadata.dig('pdf', 'number_of_pages').to_i }
+
+ if total_pages < PRELOAD_ALL_PAGES_AMOUNT
+ ActiveRecord::Associations::Preloader.new(
+ records: @submission.template_schema_documents,
+ associations: [:blob, { preview_images_attachments: :blob }]
+ ).call
+ end
+
render :show, layout: 'plain'
end
diff --git a/app/controllers/submit_form_controller.rb b/app/controllers/submit_form_controller.rb
index cf27e8f1..01fa5adc 100644
--- a/app/controllers/submit_form_controller.rb
+++ b/app/controllers/submit_form_controller.rb
@@ -6,16 +6,33 @@ class SubmitFormController < ApplicationController
skip_before_action :authenticate_user!
skip_authorization_check
+ PRELOAD_ALL_PAGES_AMOUNT = 200
+
def show
- @submitter =
- Submitter.preload(submission: [
- :template, { template_schema_documents: [:blob, { preview_images_attachments: :blob }] }
- ])
- .find_by!(slug: params[:slug])
+ @submitter = Submitter.find_by!(slug: params[:slug])
return redirect_to submit_form_completed_path(@submitter.slug) if @submitter.completed_at?
+ ActiveRecord::Associations::Preloader.new(
+ records: [@submitter],
+ associations: [submission: [:template, { template_schema_documents: :blob }]]
+ ).call
+
+ total_pages =
+ @submitter.submission.template_schema_documents.sum { |e| e.metadata.dig('pdf', 'number_of_pages').to_i }
+
+ if total_pages < PRELOAD_ALL_PAGES_AMOUNT
+ ActiveRecord::Associations::Preloader.new(
+ records: @submitter.submission.template_schema_documents,
+ associations: [:blob, { preview_images_attachments: :blob }]
+ ).call
+ end
+
+ Submitters::MaybeUpdateDefaultValues.call(@submitter, current_user)
+
cookies[:submitter_sid] = @submitter.signed_id
+
+ render @submitter.submission.template.deleted_at? ? :archived : :show
end
def update
diff --git a/app/controllers/template_folders_controller.rb b/app/controllers/template_folders_controller.rb
new file mode 100644
index 00000000..80b81632
--- /dev/null
+++ b/app/controllers/template_folders_controller.rb
@@ -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
diff --git a/app/controllers/templates_archived_controller.rb b/app/controllers/templates_archived_controller.rb
index f598b817..82f286d0 100644
--- a/app/controllers/templates_archived_controller.rb
+++ b/app/controllers/templates_archived_controller.rb
@@ -4,7 +4,7 @@ class TemplatesArchivedController < ApplicationController
load_and_authorize_resource :template, parent: false
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])
@pagy, @templates = pagy(@templates, items: 12)
diff --git a/app/controllers/templates_controller.rb b/app/controllers/templates_controller.rb
index 5d9a7e27..971a6ba2 100644
--- a/app/controllers/templates_controller.rb
+++ b/app/controllers/templates_controller.rb
@@ -10,6 +10,11 @@ class TemplatesController < ApplicationController
submissions = submissions.active if @template.deleted_at.blank?
submissions = Submissions.search(submissions, params[:q])
+ @base_submissions = submissions
+
+ submissions = submissions.pending if params[:status] == 'pending'
+ submissions = submissions.completed if params[:status] == 'completed'
+
@pagy, @submissions = pagy(submissions.preload(:submitters).order(id: :desc))
rescue ActiveRecord::RecordNotFound
redirect_to root_path
@@ -25,12 +30,21 @@ class TemplatesController < ApplicationController
associations: [schema_documents: { preview_images_attachments: :blob }]
).call
+ @template_data =
+ @template.as_json.merge(
+ documents: @template.schema_documents.as_json(
+ methods: [:metadata],
+ include: { preview_images: { methods: %i[url metadata filename] } }
+ )
+ ).to_json
+
render :edit, layout: 'plain'
end
def create
@template.account = current_account
@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
if @template.save
@@ -43,9 +57,18 @@ class TemplatesController < ApplicationController
end
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
private
diff --git a/app/controllers/templates_folders_controller.rb b/app/controllers/templates_folders_controller.rb
new file mode 100644
index 00000000..70b6e341
--- /dev/null
+++ b/app/controllers/templates_folders_controller.rb
@@ -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
diff --git a/app/controllers/templates_uploads_controller.rb b/app/controllers/templates_uploads_controller.rb
index fd57f577..7a41c1c2 100644
--- a/app/controllers/templates_uploads_controller.rb
+++ b/app/controllers/templates_uploads_controller.rb
@@ -3,18 +3,55 @@
class TemplatesUploadsController < ApplicationController
load_and_authorize_resource :template, parent: false
+ layout 'plain'
+
+ def show; end
+
def create
+ url_params = create_file_params_from_url if params[:url].present?
+
+ @template.account = current_account
@template.author = current_user
- @template.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!
- documents = Templates::CreateAttachments.call(@template, params)
+ documents = Templates::CreateAttachments.call(@template, url_params || params)
schema = documents.map { |doc| { attachment_uuid: doc.uuid, name: doc.filename.base } }
@template.update!(schema:)
redirect_to edit_template_path(@template)
+ rescue StandardError => e
+ Rollbar.error(e) if defined?(Rollbar)
+
+ redirect_to root_path, alert: 'Unable to upload file'
+ end
+
+ private
+
+ def create_file_params_from_url
+ tempfile = Tempfile.new
+ tempfile.binmode
+ tempfile.write(conn.get(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
diff --git a/app/controllers/user_signatures_controller.rb b/app/controllers/user_signatures_controller.rb
new file mode 100644
index 00000000..69d0e795
--- /dev/null
+++ b/app/controllers/user_signatures_controller.rb
@@ -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
diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb
index 7cb672ce..c84efe0c 100644
--- a/app/controllers/users_controller.rb
+++ b/app/controllers/users_controller.rb
@@ -27,7 +27,10 @@ class UsersController < ApplicationController
def update
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'
else
render turbo_stream: turbo_stream.replace(:modal, template: 'users/edit'), status: :unprocessable_entity
diff --git a/app/controllers/verify_pdf_signature_controller.rb b/app/controllers/verify_pdf_signature_controller.rb
index 49d9e44a..26bcdcc5 100644
--- a/app/controllers/verify_pdf_signature_controller.rb
+++ b/app/controllers/verify_pdf_signature_controller.rb
@@ -12,7 +12,7 @@ class VerifyPdfSignatureController < ApplicationController
cert_data = if Docuseal.multitenant?
Docuseal::CERTS
else
- EncryptedConfig.find_by(account: current_account, key: EncryptedConfig::ESIGN_CERTS_KEY)&.value || {}
+ EncryptedConfig.find_by(key: EncryptedConfig::ESIGN_CERTS_KEY)&.value || {}
end
default_pkcs = GenerateCertificate.load_pkcs(cert_data)
diff --git a/app/javascript/application.js b/app/javascript/application.js
index 8860eb7f..da94ac1f 100644
--- a/app/javascript/application.js
+++ b/app/javascript/application.js
@@ -15,6 +15,10 @@ import DownloadButton from './elements/download_button'
import SetOriginUrl from './elements/set_origin_url'
import SetTimezone from './elements/set_timezone'
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'
@@ -43,6 +47,10 @@ window.customElements.define('download-button', DownloadButton)
window.customElements.define('set-origin-url', SetOriginUrl)
window.customElements.define('set-timezone', SetTimezone)
window.customElements.define('autoresize-textarea', AutoresizeTextarea)
+window.customElements.define('submitters-autocomplete', SubmittersAutocomplete)
+window.customElements.define('folder-autocomplete', FolderAutocomplete)
+window.customElements.define('signature-form', SignatureForm)
+window.customElements.define('submit-form', SubmitForm)
document.addEventListener('turbo:before-fetch-request', encodeMethodIntoRequestBody)
document.addEventListener('turbo:submit-end', async (event) => {
@@ -70,12 +78,12 @@ document.addEventListener('turbo:submit-end', async (event) => {
window.customElements.define('template-builder', class extends HTMLElement {
connectedCallback () {
this.appElem = document.createElement('div')
- this.appElem.classList.add('max-h-screen')
this.app = createApp(TemplateBuilder, {
template: reactive(JSON.parse(this.dataset.template)),
backgroundColor: '#faf7f5',
withPhone: this.dataset.withPhone === 'true',
+ withLogo: this.dataset.withLogo !== 'false',
acceptFileTypes: this.dataset.acceptFileTypes,
isDirectUpload: this.dataset.isDirectUpload === 'true'
})
diff --git a/app/javascript/application.scss b/app/javascript/application.scss
index ea130f21..672e1b23 100644
--- a/app/javascript/application.scss
+++ b/app/javascript/application.scss
@@ -74,6 +74,58 @@ button[disabled] .enabled {
.base-select {
@apply select base-input w-full font-normal;
}
+
.bg-redact{
background: black;
-}
\ No newline at end of file
+}
+
+.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);
+}
diff --git a/app/javascript/elements/file_dropzone.js b/app/javascript/elements/file_dropzone.js
index 1bf0c8bd..7511da06 100644
--- a/app/javascript/elements/file_dropzone.js
+++ b/app/javascript/elements/file_dropzone.js
@@ -91,11 +91,13 @@ export default actionable(targetable(class extends HTMLElement {
this.append(input)
})
- if (this.dataset.submitOnUpload) {
+ if (this.dataset.submitOnUpload === 'true') {
this.closest('form').querySelector('button[type="submit"]').click()
}
}).finally(() => {
- this.toggleLoading()
+ if (this.dataset.submitOnUpload !== 'true') {
+ this.toggleLoading()
+ }
})
} else {
if (this.dataset.submitOnUpload) {
diff --git a/app/javascript/elements/folder_autocomplete.js b/app/javascript/elements/folder_autocomplete.js
new file mode 100644
index 00000000..3d0bb4e3
--- /dev/null
+++ b/app/javascript/elements/folder_autocomplete.js
@@ -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')
+ }
+}
diff --git a/app/javascript/elements/signature_form.js b/app/javascript/elements/signature_form.js
new file mode 100644
index 00000000..535fc8a6
--- /dev/null
+++ b/app/javascript/elements/signature_form.js
@@ -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()
+ }
+})
diff --git a/app/javascript/elements/submit_form.js b/app/javascript/elements/submit_form.js
new file mode 100644
index 00000000..4b5969e5
--- /dev/null
+++ b/app/javascript/elements/submit_form.js
@@ -0,0 +1,5 @@
+export default class extends HTMLElement {
+ connectedCallback () {
+ this.querySelector('form').requestSubmit()
+ }
+}
diff --git a/app/javascript/elements/submitter_autocomplete.js b/app/javascript/elements/submitter_autocomplete.js
new file mode 100644
index 00000000..0e5f8a8f
--- /dev/null
+++ b/app/javascript/elements/submitter_autocomplete.js
@@ -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')
+ }
+}
diff --git a/app/javascript/form.js b/app/javascript/form.js
index df1eff3d..ebdcea17 100644
--- a/app/javascript/form.js
+++ b/app/javascript/form.js
@@ -13,10 +13,12 @@ window.customElements.define('submission-form', class extends HTMLElement {
authenticityToken: this.dataset.authenticityToken,
canSendEmail: this.dataset.canSendEmail === 'true',
isDirectUpload: this.dataset.isDirectUpload === 'true',
+ goToLast: this.dataset.goToLast === 'true',
isDemo: this.dataset.isDemo === 'true',
attribution: this.dataset.attribution !== 'false',
withConfetti: true,
values: reactive(JSON.parse(this.dataset.values)),
+ completedButton: JSON.parse(this.dataset.completedButton),
attachments: reactive(JSON.parse(this.dataset.attachments)),
fields: JSON.parse(this.dataset.fields)
})
diff --git a/app/javascript/form.scss b/app/javascript/form.scss
index a9bac997..c6686829 100644
--- a/app/javascript/form.scss
+++ b/app/javascript/form.scss
@@ -47,6 +47,10 @@ select:required:invalid {
@apply border-base-content/20;
}
+.base-textarea {
+ @apply textarea textarea-bordered bg-white rounded-3xl;
+}
+
.btn {
@apply no-animation;
}
diff --git a/app/javascript/submission_form/area.vue b/app/javascript/submission_form/area.vue
index 263f7409..91327d74 100644
--- a/app/javascript/submission_form/area.vue
+++ b/app/javascript/submission_form/area.vue
@@ -86,6 +86,11 @@
class="object-contain mx-auto"
:src="signature.url"
>
+