Compare commits

...
50 Commits
Author SHA1 Message Date
DocuSeal 79e158be43 add more icons 2023-10-08 23:22:27 +03:00
Alex Turchyn 41ee2dba6a increase the maximum number of submitters to 10 2023-10-08 17:05:35 +00:00
DocuSeal 8e1354175f add extra settings 2023-10-08 19:33:01 +03:00
DocuSeal 2474adffc2 fix builder color 2023-10-08 19:33:01 +03:00
DocuSeal b8fd5a77d4 adjust cancan permissions 2023-10-08 19:33:01 +03:00
DocuSeal 18bb57aa99 ensure user role is in the list 2023-10-08 19:29:09 +03:00
Alex Turchyn 17ff193e34 use company name 2023-10-08 18:07:16 +03:00
Alex Turchyn 20ac63e524 remove 'Tell about us' 2023-10-08 16:49:09 +03:00
DocuSeal 518bbe3d1c do not allow to submit archived forms 2023-10-07 14:30:12 +03:00
DocuSeal fa5e3e7163 add allow to skip form prop 2023-10-07 10:23:11 +03:00
DocuSeal 2b8280168e move custom completted button to the top 2023-10-07 09:06:09 +03:00
DocuSeal 82854b8ce8 allow to make checkboxes required 2023-10-07 09:04:02 +03:00
Alex Turchyn 276b4f4a5f update the meeting link 2023-10-07 01:56:32 +03:00
DocuSeal b73a730390 update builder hints 2023-10-07 01:55:41 +03:00
DocuSeal 2e98885f41 make builder usable on mobile 2023-10-06 10:46:25 +03:00
DocuSeal bfece574b9 fix audit trail with document images 2023-10-03 02:14:27 +03:00
DocuSeal 0dd5bbe25a fix erblint 2023-10-01 21:31:29 +03:00
DocuSeal 3975c963c5 make SMTP username and password non required 2023-10-01 21:25:54 +03:00
DocuSeal 1f41807412 add more api endpoints 2023-10-01 16:51:00 +03:00
DocuSeal 872fbbc875 add file dropzone to dashboard 2023-10-01 16:50:42 +03:00
DocuSeal b98a874e20 add with logo builder prop 2023-09-30 15:14:29 +03:00
DocuSeal 50e123c221 generate audit trail for templates without documents 2023-09-30 12:36:18 +03:00
DocuSeal 7f97bfb3bd ensure rollbar required with sidekiq 2023-09-30 11:33:51 +03:00
DocuSeal 784665b549 skip pdf reader errors 2023-09-29 18:50:57 +03:00
DocuSeal c651709e45 make form completted button configurable 2023-09-29 01:25:13 +03:00
DocuSeal 881a2acbfc add undo redo to form builder 2023-09-28 10:13:23 +03:00
DocuSeal c49cb4b0c8 improve phone field 2023-09-27 23:28:33 +03:00
Alex Turchyn 17b8354c40 ensure form link is always sent 2023-09-27 09:42:19 +03:00
Alex Turchyn dc7593c70c fix GitHub CI build 2023-09-24 18:21:18 +03:00
Alex Turchyn d2edb2fb69 minor style fixes 2023-09-24 18:16:55 +03:00
Alex Turchyn 154a30b60b hide smtp settings nav link when configured via env 2023-09-24 17:30:56 +03:00
Alex Turchyn 392c5c1c54 allow to use underscore submitter value keys 2023-09-24 12:06:07 +03:00
Alex Turchyn cad0d0f371 do not send emails to completed submitters 2023-09-24 11:52:56 +03:00
Alex Turchyn cb879738c0 add clear search button 2023-09-24 11:21:50 +03:00
Alex Turchyn abfa64140f hide download button in webview 2023-09-24 11:21:50 +03:00
Alex Turchyn db1e2b9aa2 download submitter default value attachments 2023-09-24 11:21:50 +03:00
Alex Turchyn c1ab4fafb2 select submitter name on edit 2023-09-24 11:21:50 +03:00
Alex Turchyn 7ba3a604bd add textarea emails autocomplete 2023-09-24 11:21:50 +03:00
Alex Turchyn cf85bea0df fix dashboard search 2023-09-24 11:21:50 +03:00
Alex Turchyn 5643094a7a add default user signatures 2023-09-24 11:21:50 +03:00
Alex Turchyn 380f553a17 add folders structure 2023-09-24 11:21:50 +03:00
Alex Turchyn f8712a9da9 rescue undefined timezone 2023-09-24 11:21:50 +03:00
Alex Turchyn ef8c3cfe7f add folders migration 2023-09-24 11:21:50 +03:00
Alex Turchyn 9bcf5aacc0 do not add checkbox on click 2023-09-24 11:21:50 +03:00
Alex Turchyn 54592e7264 add submitter fields autocomplete 2023-09-24 11:21:50 +03:00
Alex Turchyn f1f4de53c6 adjust phone form 2023-09-24 11:21:50 +03:00
Alex Turchyn ee350324e5 add initials field 2023-09-24 11:21:50 +03:00
Alex Turchyn dc02964d9b ensure template account is set 2023-09-24 11:21:50 +03:00
Oleksandr Turchyn 0424b93fe7 Merge pull request #112 from jbenguira/patch-1
Added Elestio as a deployment option
2023-09-21 14:06:44 +03:00
Joseph Benguira 905e0b22ed Added Elestio as a deployment option
Hello, I'm Joseph Benguira, Founder of Elest.io
I'm suggesting to add Elestio as a deployment option near the other one-click options in your list.

FYI I'm also trying to contact you through Linkedin to check if you are interested in a revenue sharing partnership (we will share with you part of profits generated on Elestio)
2023-09-21 02:03:05 +03:00
134 changed files with 2632 additions and 471 deletions

No files matched your search

+1
View File
@@ -138,4 +138,5 @@ jobs:
run: |
bundle exec rake db:create
bundle exec rake db:migrate
bundle exec rake assets:precompile
bundle exec rspec
+1
View File
@@ -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'
+3
View File
@@ -189,6 +189,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)
@@ -580,6 +582,7 @@ DEPENDENCIES
factory_bot_rails
faker
faraday
faraday-follow_redirects
google-cloud-storage
hexapdf
image_processing
+4 -4
View File
@@ -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)|
|**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)
|**Koyeb**| |
| [<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;/) | |
|**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 Elestio" src="https://pub-da36157c854648669813f3f76c526c2b.r2.dev/deploy-on-elestio-black.png">](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.
[![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
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
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[:limit] || DEFAULT_LIMIT, 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
+101 -29
View File
@@ -2,78 +2,150 @@
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))
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)
render json: submissions.flat_map(&:submitters)
rescue UnknownFieldName, UnknownSubmitterName => e
submitters = submissions.flat_map(&:submitters)
save_default_value_attachments!(attachments, submitters)
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],
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, { values: {} }]]
}])
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?
submitter[:values] =
normalize_submitter_values(template,
submitter[:values],
submitter[:role] || template.submitters[index]['name'])
values, new_attachments =
Submitters::NormalizeValues.call(template,
submitter[: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?
fields = template.fields.select { |e| e['submitter_uuid'] == submitter['uuid'] }
attachments_index = attachments.index_by(&:uuid)
fields_uuid_index = fields.index_by { |e| e['uuid'] }
fields_name_index = fields.index_by { |e| e['name'] }
submitters.each do |submitter|
submitter.values.to_a.each do |_, value|
attachment = attachments_index[value]
values.transform_keys do |key|
next key if fields_uuid_index[key].present?
next unless attachment
fields_name_index[key]&.dig('uuid') || raise(UnknownFieldName, "Unknown field: #{key}")
attachment.record = submitter
attachment.save!
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,13 @@
# frozen_string_literal: true
module Api
class SubmittersController < ApiBaseController
load_and_authorize_resource :submitter
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
+33 -4
View File
@@ -5,22 +5,51 @@ 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 = 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]],
+43 -4
View File
@@ -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
def index
@templates = @templates.active.preload(:author).order(id: :desc)
@templates = Templates.search(@templates, params[:q])
SHOW_TEMPLATES_FOLDERS_THRESHOLD = 9
TEMPLATES_PER_PAGE = 12
FOLDERS_PER_PAGE = 18
@pagy, @templates = pagy(@templates, items: 12)
def index
@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)
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?
-1
View File
@@ -21,7 +21,6 @@ class StartFormController < ApplicationController
else
@submitter.assign_attributes(
uuid: @template.submitters.first['uuid'],
opened_at: Time.current,
ip: request.remote_ip,
ua: request.user_agent
)
@@ -15,7 +15,11 @@ class SubmitFormController < ApplicationController
return redirect_to submit_form_completed_path(@submitter.slug) if @submitter.completed_at?
Submitters::MaybeUpdateDefaultValues.call(@submitter, current_user)
cookies[:submitter_sid] = @submitter.signed_id
render @submitter.submission.template.deleted_at? ? :archived : :show
end
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
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)
+1
View File
@@ -31,6 +31,7 @@ class TemplatesController < ApplicationController
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
@@ -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
@@ -4,7 +4,9 @@ class TemplatesUploadsController < ApplicationController
load_and_authorize_resource :template, parent: false
def create
@template.account = current_account
@template.author = current_user
@template.folder = TemplateFolders.find_or_create_by_name(current_user, params[:folder_name])
@template.name = File.basename(params[:files].first.original_filename, '.*')
@template.save!
@@ -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
+4 -1
View File
@@ -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
+7 -1
View File
@@ -15,6 +15,9 @@ 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 * as TurboInstantClick from './lib/turbo_instant_click'
@@ -43,6 +46,9 @@ 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)
document.addEventListener('turbo:before-fetch-request', encodeMethodIntoRequestBody)
document.addEventListener('turbo:submit-end', async (event) => {
@@ -70,12 +76,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'
})
+51
View File
@@ -74,3 +74,54 @@ button[disabled] .enabled {
.base-select {
@apply select base-input w-full font-normal;
}
.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);
}
+4 -2
View File
@@ -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) {
@@ -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')
}
}
+46
View File
@@ -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,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')
}
}
+2
View File
@@ -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)
})
+15 -1
View File
@@ -47,6 +47,11 @@
class="object-contain mx-auto"
:src="signature.url"
>
<img
v-else-if="field.type === 'initials' && initials"
class="object-contain mx-auto"
:src="initials.url"
>
<div
v-else-if="field.type === 'file'"
class="px-0.5 flex flex-col justify-center"
@@ -113,7 +118,7 @@
</template>
<script>
import { IconTextSize, IconWritingSign, IconCalendarEvent, IconPhoto, IconCheckbox, IconPaperclip, IconSelect, IconCircleDot, IconChecks, IconCheck, IconColumns3, IconPhoneCheck } from '@tabler/icons-vue'
import { IconTextSize, IconWritingSign, IconCalendarEvent, IconPhoto, IconCheckbox, IconPaperclip, IconSelect, IconCircleDot, IconChecks, IconCheck, IconColumns3, IconPhoneCheck, IconLetterCaseUpper } from '@tabler/icons-vue'
export default {
name: 'FieldArea',
@@ -175,6 +180,7 @@ export default {
signature: 'Signature',
date: 'Date',
image: 'Image',
initials: 'Initials',
file: 'File',
select: 'Select',
checkbox: 'Checkbox',
@@ -189,6 +195,7 @@ export default {
signature: IconWritingSign,
date: IconCalendarEvent,
image: IconPhoto,
initials: IconLetterCaseUpper,
file: IconPaperclip,
select: IconSelect,
checkbox: IconCheckbox,
@@ -212,6 +219,13 @@ export default {
return null
}
},
initials () {
if (this.field.type === 'initials') {
return this.attachmentsIndex[this.modelValue]
} else {
return null
}
},
formattedDate () {
if (this.field.type === 'date' && this.modelValue) {
return new Intl.DateTimeFormat([], { year: 'numeric', month: 'long', day: 'numeric', timeZone: 'UTC' }).format(new Date(this.modelValue))
@@ -11,6 +11,15 @@
</span>
</p>
<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
v-if="canSendEmail && !isDemo"
class="white-button !h-auto flex items-center space-x-1 w-full"
@@ -27,6 +36,7 @@
</span>
</button>
<button
v-if="!isWebView"
class="base-button flex items-center space-x-1 w-full"
:disabled="isDownloading"
@click.prevent="download"
@@ -114,6 +124,11 @@ export default {
type: Boolean,
required: false,
default: false
},
completedButton: {
type: Object,
required: false,
default: () => ({})
}
},
data () {
@@ -122,6 +137,11 @@ export default {
isDownloading: false
}
},
computed: {
isWebView () {
return /webview|wv|ip((?!.*Safari)|(?=.*like Safari))/i.test(window.navigator.userAgent)
}
},
async mounted () {
if (this.withConfetti) {
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 }
+83 -10
View File
@@ -224,6 +224,9 @@
:id="field.uuid"
type="checkbox"
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]"
@click="[$refs.areas.scrollIntoField(field), values[field.uuid] = !values[field.uuid]]"
>
@@ -256,6 +259,19 @@
@start="$refs.areas.scrollIntoField(currentField)"
@minimize="isFormVisible = false"
/>
<InitialsStep
v-else-if="currentField.type === 'initials'"
ref="currentStep"
v-model="values[currentField.uuid]"
:field="currentField"
: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
v-else-if="currentField.type === 'file'"
v-model="values[currentField.uuid]"
@@ -304,6 +320,7 @@
v-else
:is-demo="isDemo"
:attribution="attribution"
:completed-button="completedButton"
:with-confetti="withConfetti"
:can-send-email="canSendEmail && !!submitter.email"
:submitter-slug="submitterSlug"
@@ -328,6 +345,7 @@
import FieldAreas from './areas'
import ImageStep from './image_step'
import SignatureStep from './signature_step'
import InitialsStep from './initials_step'
import AttachmentStep from './attachment_step'
import MultiSelectStep from './multi_select_step'
import PhoneStep from './phone_step'
@@ -342,6 +360,7 @@ export default {
ImageStep,
SignatureStep,
AttachmentStep,
InitialsStep,
MultiSelectStep,
IconInnerShadowTop,
IconArrowsDiagonal,
@@ -400,6 +419,16 @@ export default {
required: false,
default: false
},
allowToSkip: {
type: Boolean,
required: false,
default: true
},
goToLast: {
type: Boolean,
required: false,
default: true
},
isDemo: {
type: Boolean,
required: false,
@@ -414,6 +443,11 @@ export default {
type: Object,
required: false,
default: () => ({})
},
completedButton: {
type: Object,
required: false,
default: () => ({})
}
},
data () {
@@ -422,6 +456,7 @@ export default {
isFormVisible: true,
currentStep: 0,
isSubmitting: false,
submittedValues: {},
recalculateButtonDisabledKey: ''
}
},
@@ -439,7 +474,8 @@ export default {
if (this.recalculateButtonDisabledKey) {
return this.isSubmitting ||
(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 {
return false
}
@@ -472,10 +508,14 @@ export default {
}
},
mounted () {
this.currentStep = Math.min(
this.stepFields.indexOf([...this.stepFields].reverse().find((fields) => fields.some((f) => !!this.values[f.uuid]))) + 1,
this.stepFields.length - 1
)
this.submittedValues = JSON.parse(JSON.stringify(this.values))
if (this.goToLast) {
this.currentStep = Math.min(
this.stepFields.indexOf([...this.stepFields].reverse().find((fields) => fields.some((f) => !!this.values[f.uuid]))) + 1,
this.stepFields.length - 1
)
}
if (/iPhone|iPad|iPod/i.test(navigator.userAgent)) {
this.$nextTick(() => {
@@ -494,7 +534,10 @@ export default {
this.$nextTick(() => {
this.recalculateButtonDisabledKey = Math.random()
this.maybeTrackEmailClick().finally(() => {
Promise.all([
this.maybeTrackEmailClick(),
this.maybeTrackSmsClick()
]).finally(() => {
this.trackViewForm()
})
})
@@ -525,6 +568,30 @@ export default {
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 () {
fetch(this.baseUrl + '/api/submitter_form_views', {
method: 'POST',
@@ -566,14 +633,18 @@ export default {
async submitStep () {
this.isSubmitting = true
const stepPromise = ['signature', 'phone'].includes(this.currentField.type)
const stepPromise = ['signature', 'phone', 'initials'].includes(this.currentField.type)
? this.$refs.currentStep.submit
: () => Promise.resolve({})
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)
if (this.currentStep === this.stepFields.length - 1) {
if (this.currentStep === this.stepFields.length - 1 && !emptyRequiredField) {
formData.append('completed', 'true')
}
@@ -586,10 +657,12 @@ export default {
return Promise.reject(new Error(data.error))
}
const nextStep = this.stepFields[this.currentStep + 1]
this.submittedValues[this.currentField.uuid] = this.values[this.currentField.uuid]
const nextStep = emptyRequiredField || this.stepFields[this.currentStep + 1]
if (nextStep) {
this.goToStep(this.stepFields[this.currentStep + 1], true)
this.goToStep(nextStep, true)
} else {
this.isCompleted = true
}
+72
View File
@@ -10,9 +10,17 @@ const en = {
or_drag_and_drop_files: 'or drag and drop files',
send_copy_via_email: 'Send copy via email',
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!',
create_a_free_account: 'Create a Free Account',
signed_with: 'Signed with',
please_check_the_box_to_continue: 'Please check the box to continue',
open_source_documents_software: 'open source documents software',
verified_phone_number: 'Verify Phone Number',
use_international_format: 'Use internatioanl format: +1xxx',
@@ -36,9 +44,17 @@ const es = {
or_drag_and_drop_files: 'o arrastra y suelta archivos',
send_copy_via_email: 'Enviar copia por correo electrónico',
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!',
create_a_free_account: 'Crear una Cuenta Gratuita',
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',
verified_phone_number: 'Verificar número de teléfono',
use_international_format: 'Usar formato internacional: +1xxx',
@@ -62,9 +78,17 @@ const it = {
or_drag_and_drop_files: 'oppure trascina e rilascia i file',
send_copy_via_email: 'Invia copia via email',
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!',
create_a_free_account: 'Crea un Account Gratuito',
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',
verified_phone_number: 'Verifica numero di telefono',
use_international_format: 'Usa formato internazionale: +1xxx',
@@ -88,9 +112,17 @@ const de = {
or_drag_and_drop_files: 'oder Dateien hierher ziehen und ablegen',
send_copy_via_email: 'Kopie per E-Mail senden',
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!',
create_a_free_account: 'Kostenloses Konto erstellen',
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',
verified_phone_number: 'Telefonnummer überprüfen',
use_international_format: 'Internationales Format verwenden: +1xxx',
@@ -114,9 +146,17 @@ const fr = {
or_drag_and_drop_files: 'ou faites glisser-déposer les fichiers',
send_copy_via_email: 'Envoyer une copie par e-mail',
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é !',
create_a_free_account: 'Créer un Compte Gratuit',
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',
verified_phone_number: 'Vérifier le numéro de téléphone',
use_international_format: 'Utiliser le format international : +1xxx',
@@ -140,9 +180,17 @@ const pl = {
or_drag_and_drop_files: 'lub przeciągnij i upuść pliki',
send_copy_via_email: 'Wyślij kopię drogą mailową',
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!',
create_a_free_account: 'Utwórz darmowe konto',
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',
verified_phone_number: 'Zweryfikuj numer telefonu',
use_international_format: 'Użyj międzynarodowego formatu: +1xxx',
@@ -166,9 +214,17 @@ const uk = {
or_drag_and_drop_files: 'або перетягніть файли сюди',
send_copy_via_email: 'Надіслати копію електронною поштою',
download: 'Завантажити',
signature: 'Підпис',
initials: 'Ініціали',
clear: 'Очистити',
redraw: 'Перемалювати',
draw_initials: 'Намалювати ініціали',
type_signature_here: 'Введіть підпис тут',
type_initial_here: 'Введіть ініціали тут',
form_has_been_completed: 'Форму заповнено!',
create_a_free_account: 'Створити безкоштовний обліковий запис',
signed_with: 'Підписано за допомогою',
please_check_the_box_to_continue: 'Будь ласка, позначте прапорець, щоб продовжити',
open_source_documents_software: 'відкритий програмний засіб для документів',
verified_phone_number: 'Підтвердіть номер телефону',
use_international_format: 'Використовуйте міжнародний формат: +1xxx',
@@ -192,9 +248,17 @@ const cs = {
or_drag_and_drop_files: 'nebo přetáhněte soubory sem',
send_copy_via_email: 'Odeslat kopii e-mailem',
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!',
create_a_free_account: 'Vytvořit bezplatný účet',
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',
verified_phone_number: 'Ověřte telefonní číslo',
use_international_format: 'Použijte mezinárodní formát: +1xxx',
@@ -218,9 +282,17 @@ const pt = {
or_drag_and_drop_files: 'ou arraste e solte arquivos',
send_copy_via_email: 'Enviar cópia por e-mail',
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!',
create_a_free_account: 'Criar uma Conta Gratuita',
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',
verified_phone_number: 'Verificar Número de Telefone',
use_international_format: 'Use formato internacional: +1xxx',
@@ -0,0 +1,244 @@
<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')"
>
<button
id="type_text_button"
class="btn btn-sm btn-circle"
:class="{ 'btn-neutral': isDrawInitials, 'btn-outline': !isDrawInitials }"
@click.prevent="toggleTextInput"
>
<IconSignature :width="16" />
</button>
</span>
<button
v-if="modelValue"
class="btn btn-outline btn-sm"
@click.prevent="remove"
>
<IconReload :width="16" />
{{ t('clear') }}
</button>
<button
v-else
class="btn btn-outline btn-sm"
@click.prevent="clear"
>
<IconReload :width="16" />
{{ t('clear') }}
</button>
<button
title="Minimize"
class="py-1.5 inline md:hidden"
@click.prevent="$emit('minimize')"
>
<IconArrowsDiagonalMinimize2
:width="20"
:height="20"
/>
</button>
</div>
</div>
<input
:value="modelValue"
type="hidden"
:name="`values[${field.uuid}]`"
>
<img
v-if="modelValue"
:src="attachmentsIndex[modelValue].url"
class="mx-auto bg-white border border-base-300 rounded max-h-72"
>
<canvas
v-show="!modelValue"
ref="canvas"
class="bg-white border border-base-300 rounded"
/>
<input
v-if="!isDrawInitials"
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'
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: () => ({})
},
modelValue: {
type: String,
required: false,
default: ''
}
},
emits: ['attached', 'update:model-value', 'start', 'minimize', 'focus'],
data () {
return {
isInitialsStarted: false,
isDrawInitials: false
}
},
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')
}
const { default: SignaturePad } = await import('signature_pad')
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', '')
},
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) {
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}][]`"
:value="option"
class="base-checkbox !h-7 !w-7"
:checked="modelValue.includes(option)"
:checked="(modelValue || []).includes(option)"
@change="onChange"
>
<span class="text-xl">
@@ -3,7 +3,7 @@
<div class="flex justify-between items-center w-full mb-2">
<label
class="label text-2xl"
>{{ field.name || 'Signature' }}</label>
>{{ field.name || t('signature') }}</label>
<div class="space-x-2 flex">
<span
class="tooltip"
@@ -40,7 +40,7 @@
@click.prevent="remove"
>
<IconReload :width="16" />
Redraw
{{ t('redraw') }}
</button>
<button
v-else
@@ -48,7 +48,7 @@
@click.prevent="clear"
>
<IconReload :width="16" />
Clear
{{ t('clear') }}
</button>
<button
title="Minimize"
@@ -83,7 +83,7 @@
ref="textInput"
class="base-input !text-2xl w-full mt-6"
:required="field.required"
:placeholder="`Type signature here...`"
:placeholder="`${t('type_signature_here')}...`"
type="text"
@input="updateWrittenSignature"
>
@@ -92,6 +92,7 @@
<script>
import { IconReload, IconCamera, IconTextSize, IconArrowsDiagonalMinimize2 } from '@tabler/icons-vue'
import { cropCanvasAndExportToPNG } from './crop_canvas'
export default {
name: 'SignatureStep',
@@ -101,7 +102,7 @@ export default {
IconTextSize,
IconArrowsDiagonalMinimize2
},
inject: ['baseUrl'],
inject: ['baseUrl', 't'],
props: {
field: {
type: Object,
@@ -136,8 +137,10 @@ export default {
},
async mounted () {
this.$nextTick(() => {
this.$refs.canvas.width = this.$refs.canvas.parentNode.clientWidth
this.$refs.canvas.height = this.$refs.canvas.parentNode.clientWidth / 3
if (this.$refs.canvas) {
this.$refs.canvas.width = this.$refs.canvas.parentNode.clientWidth
this.$refs.canvas.height = this.$refs.canvas.parentNode.clientWidth / 3
}
})
if (this.isDirectUpload) {
@@ -146,13 +149,15 @@ export default {
const { default: SignaturePad } = await import('signature_pad')
this.pad = new SignaturePad(this.$refs.canvas)
if (this.$refs.canvas) {
this.pad = new SignaturePad(this.$refs.canvas)
this.pad.addEventListener('beginStroke', () => {
this.isSignatureStarted = true
this.pad.addEventListener('beginStroke', () => {
this.isSignatureStarted = true
this.$emit('start')
})
this.$emit('start')
})
}
},
methods: {
remove () {
@@ -244,60 +249,13 @@ export default {
reader.readAsDataURL(file)
}
},
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')
})
},
async submit () {
if (this.modelValue) {
return 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' })
if (this.isDirectUpload) {
+85 -4
View File
@@ -4,6 +4,7 @@
:style="positionStyle"
@pointerdown.stop
@mousedown.stop="startDrag"
@touchstart="startTouchDrag"
>
<div
v-if="isSelected || isDraw"
@@ -32,7 +33,7 @@
<div
v-if="field?.type"
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"
@mousedown.stop
@pointerdown.stop
@@ -108,12 +109,14 @@
</span>
</div>
<div
ref="touchTarget"
class="absolute top-0 bottom-0 right-0 left-0 cursor-pointer"
/>
<span
v-if="field?.type"
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"
@touchstart="startTouchResize"
/>
</div>
</template>
@@ -186,7 +189,12 @@ export default {
'border-sky-500',
'border-emerald-500',
'border-yellow-300',
'border-purple-600'
'border-purple-600',
'border-pink-500',
'border-cyan-500',
'border-orange-500',
'border-lime-500',
'border-indigo-500'
]
},
bgColors () {
@@ -195,7 +203,12 @@ export default {
'bg-sky-100',
'bg-emerald-100',
'bg-yellow-100',
'bg-purple-100'
'bg-purple-100',
'bg-pink-100',
'bg-cyan-100',
'bg-orange-100',
'bg-lime-100',
'bg-indigo-100'
]
},
isSelected () {
@@ -309,6 +322,47 @@ export default {
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 () {
this.$el.getRootNode().removeEventListener('mousemove', this.drag)
this.$el.getRootNode().removeEventListener('mouseup', this.stopDrag)
@@ -335,6 +389,33 @@ export default {
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()
}
}
+180 -44
View File
@@ -1,17 +1,19 @@
<template>
<div
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">
<a
v-if="withLogoLink"
v-if="withLogo"
href="/"
>
<Logo />
</a>
<Logo v-else />
<Contenteditable
:model-value="template.name"
class="text-3xl font-semibold focus:text-clip"
@@ -28,7 +30,7 @@
<a
:href="`/templates/${template.id}/submissions/new`"
data-turbo-frame="modal"
class="btn btn-primary"
class="btn btn-primary text-base"
>
<IconUsersPlus
width="20"
@@ -46,12 +48,12 @@
>
<IconInnerShadowTop
v-if="isSaving"
width="20"
width="22"
class="animate-spin"
/>
<IconDeviceFloppy
v-else
width="20"
width="22"
/>
<span class="hidden md:inline">
Save
@@ -60,10 +62,7 @@
</template>
</div>
</div>
<div
class="flex"
style="max-height: calc(100% - 60px)"
>
<div class="flex md:max-h-[calc(100vh-60px)]">
<div
ref="previews"
:style="{ 'display': isBreakpointLg ? 'none' : 'initial' }"
@@ -98,7 +97,7 @@
/>
</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
ref="documents"
class="pr-3.5 pl-0.5"
@@ -153,17 +152,54 @@
</div>
</template>
</div>
<div
v-if="sortedDocuments.length"
class="sticky md:hidden"
style="bottom: 100px"
<span
v-if="drawField"
class="fixed text-center w-full left-1/2 bottom-0 transform -translate-x-1/2"
>
<div class="px-4 py-3 rounded-2xl bg-base-200 flex items-center justify-between ml-4 mr-6">
<span class="w-full text-center text-lg">
You need a larger screen to use builder tools.
<span
class="rounded bg-base-200 px-4 py-2 rounded-full inline-flex space-x-2 mx-auto items-center mb-4 z-20 md:hidden"
>
<component
:is="fieldIcons[drawField.type]"
:width="20"
:height="20"
class="inline"
:stroke-width="1.6"
/>
<span>
Draw {{ fieldNames[drawField.type] }} Field
</span>
</div>
</div>
<a
href="#"
class="link block text-center"
@click.prevent="drawField = null"
>
Cancel
</a>
</span>
</span>
<FieldType
v-if="sortedDocuments.length && !drawField"
class="dropdown-top dropdown-end fixed bottom-4 right-4 z-10 md:hidden"
:model-value="''"
@update:model-value="startFieldDraw($event)"
>
<label
class="btn btn-neutral text-white btn-circle btn-lg group"
tabindex="0"
>
<IconPlus
class="group-focus:hidden"
width="28"
height="28"
/>
<IconX
class="hidden group-focus:inline"
width="28"
height="28"
/>
</label>
</FieldType>
</div>
<div
class="relative w-80 flex-none mt-1 pr-4 pl-0.5 hidden md:block"
@@ -215,7 +251,8 @@ import Logo from './logo'
import Contenteditable from './contenteditable'
import DocumentPreview from './preview'
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 { ref, computed } from 'vue'
@@ -225,6 +262,9 @@ export default {
Upload,
Document,
Fields,
IconPlus,
FieldType,
IconX,
Logo,
Dropzone,
DocumentPreview,
@@ -269,7 +309,7 @@ export default {
required: false,
default: ''
},
withLogoLink: {
withLogo: {
type: Boolean,
required: false,
default: true
@@ -301,6 +341,8 @@ export default {
}
},
computed: {
fieldIcons: FieldType.computed.fieldIcons,
fieldNames: FieldType.computed.fieldNames,
selectedAreaRef: () => ref(),
fieldAreasIndex () {
const areas = {}
@@ -330,16 +372,21 @@ export default {
this.selectedSubmitter = this.template.submitters[0]
},
mounted () {
this.undoStack = [JSON.stringify(this.template)]
this.redoStack = []
this.$nextTick(() => {
this.onWindowResize()
})
document.addEventListener('keyup', this.onKeyUp)
window.addEventListener('keydown', this.onKeyDown)
window.addEventListener('resize', this.onWindowResize)
},
unmounted () {
document.removeEventListener('keyup', this.onKeyUp)
window.removeEventListener('keydown', this.onKeyDown)
window.removeEventListener('resize', this.onWindowResize)
},
@@ -347,6 +394,52 @@ export default {
this.documentRefs = []
},
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) {
const breakpointLg = 1024
@@ -374,6 +467,19 @@ export default {
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) {
const field = this.template.fields.find((f) => f.areas?.includes(area))
@@ -385,12 +491,31 @@ export default {
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) {
if (this.drawField) {
this.drawField.areas ||= []
this.drawField.areas.push(area)
if (this.template.fields.indexOf(this.drawField) === -1) {
this.template.fields.push(this.drawField)
}
this.drawField = null
this.selectedAreaRef.value = area
this.save()
} else {
const documentRef = this.documentRefs.find((e) => e.document.uuid === area.attachment_uuid)
const pageMask = documentRef.pageRefs[area.page].$refs.mask
@@ -401,33 +526,37 @@ export default {
const previousField = [...this.template.fields].reverse().find((f) => f.type === type)
const previousArea = previousField?.areas?.[previousField.areas.length - 1]
const areaW = previousArea?.w || (30 / pageMask.clientWidth)
const areaH = previousArea?.h || (30 / pageMask.clientHeight)
if (previousArea || area.w) {
const areaW = previousArea?.w || (30 / pageMask.clientWidth)
const areaH = previousArea?.h || (30 / pageMask.clientHeight)
if ((pageMask.clientWidth * area.w) < 5) {
area.x = area.x - (areaW / 2)
area.y = area.y - (areaH / 2)
if ((pageMask.clientWidth * area.w) < 5) {
area.x = area.x - (areaW / 2)
area.y = area.y - (areaH / 2)
}
area.w = areaW
area.h = areaH
}
}
if (area.w) {
const field = {
name: '',
uuid: v4(),
required: type !== 'checkbox',
type,
submitter_uuid: this.selectedSubmitter.uuid,
areas: [area]
}
area.w = areaW
area.h = areaH
}
this.template.fields.push(field)
const field = {
name: '',
uuid: v4(),
required: type !== 'checkbox',
type,
submitter_uuid: this.selectedSubmitter.uuid,
areas: [area]
}
this.selectedAreaRef.value = area
this.template.fields.push(field)
this.save()
}
}
this.selectedAreaRef.value = area
this.save()
},
onDropfield (area) {
const field = {
@@ -473,6 +602,11 @@ export default {
w: area.maskW / 5 / area.maskW,
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 {
baseArea = {
w: area.maskW / 5 / area.maskW,
@@ -584,6 +718,8 @@ export default {
this.$el.closest('template-builder').dataset.template = JSON.stringify(this.template)
}
this.pushUndo()
return this.baseFetch(`/api/templates/${this.template.id}`, {
method: 'PUT',
body: JSON.stringify({
@@ -30,7 +30,7 @@
:class="{ 'ml-1': !withRequired, 'absolute': !iconInline, 'inline align-bottom': iconInline }"
:width="iconWidth"
:stroke-width="iconStrokeWidth"
@click="focusContenteditable"
@click="[focusContenteditable(), selectOnEditClick && selectContent()]"
/>
</div>
</template>
@@ -64,6 +64,11 @@ export default {
required: false,
default: false
},
selectOnEditClick: {
type: Boolean,
required: false,
default: false
},
iconStrokeWidth: {
type: Number,
required: false,
@@ -85,6 +90,19 @@ export default {
}
},
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) {
setTimeout(() => {
this.value = this.$refs.contenteditable.innerText.trim() || this.modelValue
+45 -16
View File
@@ -31,21 +31,19 @@
v-if="isNameFocus"
class="flex items-center relative"
>
<template v-if="field.type !== 'checkbox'">
<input
:id="`required-checkbox-${field.uuid}`"
v-model="field.required"
type="checkbox"
class="checkbox checkbox-xs no-animation rounded"
@mousedown.prevent
>
<label
:for="`required-checkbox-${field.uuid}`"
class="label text-xs"
@click.prevent="field.required = !field.required"
@mousedown.prevent
>Required</label>
</template>
<input
:id="`required-checkbox-${field.uuid}`"
v-model="field.required"
type="checkbox"
class="checkbox checkbox-xs no-animation rounded"
@mousedown.prevent
>
<label
:for="`required-checkbox-${field.uuid}`"
class="label text-xs"
@click.prevent="field.required = !field.required"
@mousedown.prevent
>Required</label>
</div>
<div
v-else
@@ -99,6 +97,19 @@
Draw New Area
</a>
</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>
</span>
<button
@@ -183,7 +194,7 @@
<script>
import Contenteditable from './contenteditable'
import FieldType from './field_type'
import { IconShape, IconNewSection, IconTrashX } from '@tabler/icons-vue'
import { IconShape, IconNewSection, IconTrashX, IconCopy } from '@tabler/icons-vue'
export default {
name: 'TemplateField',
@@ -192,6 +203,7 @@ export default {
IconShape,
IconNewSection,
IconTrashX,
IconCopy,
FieldType
},
inject: ['template', 'save'],
@@ -221,6 +233,23 @@ export default {
}
},
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) {
this.isNameFocus = true
@@ -25,6 +25,7 @@
v-model="selectedSubmitter.name"
class="cursor-text"
:icon-inline="true"
:select-on-edit-click="true"
:icon-width="18"
@update:model-value="$emit('name-change', selectedSubmitter)"
/>
@@ -69,7 +70,7 @@
</button>
</a>
</li>
<li v-if="submitters.length < 5">
<li v-if="submitters.length < 10">
<a
href="#"
class="flex px-2"
@@ -129,7 +130,12 @@ export default {
'bg-sky-500',
'bg-emerald-500',
'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 () {
@@ -138,7 +144,12 @@ export default {
'Second Submitter',
'Third Submitter',
'Fourth Submitter',
'Fifth Submitter'
'Fifth Submitter',
'Sixth Submitter',
'Seventh Submitter',
'Eighth Submitter',
'Ninth Submitter',
'Tenth Submitter'
]
},
selectedSubmitter () {
+20 -16
View File
@@ -1,20 +1,22 @@
<template>
<span class="dropdown">
<label
tabindex="0"
:title="fieldNames[modelValue]"
class="cursor-pointer"
>
<component
:is="fieldIcons[modelValue]"
:width="buttonWidth"
:class="buttonClasses"
:stroke-width="1.6"
/>
</label>
<slot>
<label
tabindex="0"
:title="fieldNames[modelValue]"
class="cursor-pointer"
>
<component
:is="fieldIcons[modelValue]"
:width="buttonWidth"
:class="buttonClasses"
:stroke-width="1.6"
/>
</label>
</slot>
<ul
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"
@click="closeDropdown"
>
@@ -43,7 +45,7 @@
</template>
<script>
import { IconTextSize, IconWritingSign, IconCalendarEvent, IconPhoto, IconCheckbox, IconPaperclip, IconSelect, IconCircleDot, IconChecks, IconColumns3, IconPhoneCheck } from '@tabler/icons-vue'
import { IconTextSize, IconWritingSign, IconCalendarEvent, IconPhoto, IconCheckbox, IconPaperclip, IconSelect, IconCircleDot, IconChecks, IconColumns3, IconPhoneCheck, IconLetterCaseUpper } from '@tabler/icons-vue'
export default {
name: 'FiledTypeDropdown',
@@ -75,6 +77,7 @@ export default {
return {
text: 'Text',
signature: 'Signature',
initials: 'Initials',
date: 'Date',
image: 'Image',
file: 'File',
@@ -90,14 +93,15 @@ export default {
return {
text: IconTextSize,
signature: IconWritingSign,
initials: IconLetterCaseUpper,
date: IconCalendarEvent,
image: IconPhoto,
file: IconPaperclip,
select: IconSelect,
checkbox: IconCheckbox,
cells: IconColumns3,
multiple: IconChecks,
radio: IconCircleDot,
multiple: IconChecks,
cells: IconColumns3,
phone: IconPhoneCheck
}
}
+1 -4
View File
@@ -73,7 +73,7 @@
</button>
<div
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."
>
<a
@@ -107,9 +107,6 @@
<li>
Draw a text field on the page with a mouse
</li>
<li>
Single click on the page to add a checkbox
</li>
<li>
Drag &amp; drop any other field type on the page
</li>
+13 -2
View File
@@ -1,5 +1,8 @@
<template>
<div class="relative cursor-crosshair select-none">
<div
class="relative cursor-crosshair select-none"
:style="drawField ? 'touch-action: none' : ''"
>
<img
ref="image"
:src="image.url"
@@ -32,12 +35,13 @@
/>
</div>
<div
v-show="resizeDirection || isMove || isDrag || showMask"
v-show="resizeDirection || isMove || isDrag || showMask || (drawField && isMobile)"
id="mask"
ref="mask"
class="top-0 bottom-0 left-0 right-0 absolute z-10"
:class="{ 'cursor-grab': isDrag || isMove, 'cursor-nwse-resize': drawField, [resizeDirectionClasses[resizeDirection]]: !!resizeDirectionClasses }"
@pointermove="onPointermove"
@pointerdown="onStartDraw"
@dragover.prevent
@drop="onDrop"
@pointerup="onPointerup"
@@ -93,6 +97,9 @@ export default {
}
},
computed: {
isMobile () {
return /android|iphone|ipad/i.test(navigator.userAgent)
},
resizeDirectionClasses () {
return {
nwse: 'cursor-nwse-resize',
@@ -125,6 +132,10 @@ export default {
})
},
onStartDraw (e) {
if (this.isMobile && !this.drawField) {
return
}
this.showMask = true
this.$nextTick(() => {
@@ -4,7 +4,7 @@ class SendFormCompletedWebhookRequestJob < ApplicationJob
USER_AGENT = 'DocuSeal.co Webhook'
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?
@@ -15,7 +15,7 @@ class SendFormCompletedWebhookRequestJob < ApplicationJob
Faraday.post(config.value,
{
event_type: 'form.completed',
timestamp: Time.current.iso8601,
timestamp: Time.current,
data: Submitters::SerializeForWebhook.call(submitter)
}.to_json,
'Content-Type' => 'application/json',
@@ -4,7 +4,7 @@ class SendFormStartedWebhookRequestJob < ApplicationJob
USER_AGENT = 'DocuSeal.co Webhook'
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?
@@ -13,7 +13,7 @@ class SendFormStartedWebhookRequestJob < ApplicationJob
Faraday.post(config.value,
{
event_type: 'form.started',
timestamp: Time.current.iso8601,
timestamp: Time.current,
data: Submitters::SerializeForWebhook.call(submitter)
}.to_json,
'Content-Type' => 'application/json',
@@ -4,7 +4,7 @@ class SendFormViewedWebhookRequestJob < ApplicationJob
USER_AGENT = 'DocuSeal.co Webhook'
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?
@@ -13,7 +13,7 @@ class SendFormViewedWebhookRequestJob < ApplicationJob
Faraday.post(config.value,
{
event_type: 'form.viewed',
timestamp: Time.current.iso8601,
timestamp: Time.current,
data: Submitters::SerializeForWebhook.call(submitter)
}.to_json,
'Content-Type' => 'application/json',
+8
View File
@@ -16,6 +16,9 @@ class Account < ApplicationRecord
has_many :encrypted_configs, dependent: :destroy
has_many :account_configs, 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 :submitters, through: :submissions
has_many :active_users, -> { active }, dependent: :destroy,
@@ -23,4 +26,9 @@ class Account < ApplicationRecord
attribute :timezone, :string, default: 'UTC'
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
+1
View File
@@ -26,6 +26,7 @@ class AccountConfig < ApplicationRecord
SUBMITTER_DOCUMENTS_COPY_EMAIL_KEY = 'submitter_documents_copy_email'
BCC_EMAILS = 'bcc_emails'
SUBMITTER_REMAILERS = 'submitter_reminders'
FORM_COMPLETED_BUTTON_KEY = 'form_completed_button'
DEFAULT_VALUES = {
SUBMITTER_INVITATION_EMAIL_KEY => {
+1
View File
@@ -42,6 +42,7 @@ class SubmissionEvent < ApplicationRecord
open_email: 'open_email',
click_email: 'click_email',
click_sms: 'click_sms',
phone_verified: 'phone_verified',
start_form: 'start_form',
view_form: 'view_form',
complete_form: 'complete_form'
+13
View File
@@ -16,23 +16,29 @@
# updated_at :datetime not null
# account_id :bigint not null
# author_id :bigint not null
# folder_id :bigint not null
#
# Indexes
#
# index_templates_on_account_id (account_id)
# index_templates_on_author_id (author_id)
# index_templates_on_folder_id (folder_id)
# index_templates_on_slug (slug) UNIQUE
#
# Foreign Keys
#
# fk_rails_... (account_id => accounts.id)
# fk_rails_... (author_id => users.id)
# fk_rails_... (folder_id => template_folders.id)
#
class Template < ApplicationRecord
DEFAULT_SUBMITTER_NAME = 'First Submitter'
belongs_to :author, class_name: 'User'
belongs_to :account
belongs_to :folder, class_name: 'TemplateFolder'
before_validation :maybe_set_default_folder, on: :create
attribute :fields, :string, default: -> { [] }
attribute :schema, :string, default: -> { [] }
@@ -52,4 +58,11 @@ class Template < ApplicationRecord
has_many :submissions, dependent: :destroy
scope :active, -> { where(deleted_at: nil) }
scope :archived, -> { where.not(deleted_at: nil) }
private
def maybe_set_default_folder
self.folder ||= account.default_template_folder
end
end
+40
View File
@@ -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
+1
View File
@@ -52,6 +52,7 @@ class User < ApplicationRecord
belongs_to :account
has_one :access_token, dependent: :destroy
has_many :templates, dependent: :destroy, foreign_key: :author_id, inverse_of: :author
has_many :user_configs, dependent: :destroy
devise :two_factor_authenticatable, :recoverable, :rememberable, :validatable, :trackable
devise :registerable, :omniauthable, omniauth_providers: [:google_oauth2] if Docuseal.multitenant?
+29
View File
@@ -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
+3 -2
View File
@@ -21,8 +21,9 @@
</div>
</div>
<% end %>
<% unless Docuseal.multitenant? %>
<%= f.fields_for @encrypted_config || EncryptedConfig.find_or_initialize_by(account: current_account, key: EncryptedConfig::APP_URL_KEY) do |ff| %>
<% encrypted_config = @encrypted_config || EncryptedConfig.find_or_initialize_by(account: current_account, key: EncryptedConfig::APP_URL_KEY) %>
<% if !Docuseal.multitenant? && can?(:manage, encrypted_config) %>
<%= f.fields_for encrypted_config do |ff| %>
<div class="form-control">
<%= ff.label :value, 'App URL', class: 'label' %>
<%= ff.text_field :value, autocomplete: 'off', class: 'base-input' %>
+30 -27
View File
@@ -12,32 +12,6 @@
</div>
</div>
<div class="space-y-4 mt-4">
<div class="collapse collapse-plus bg-base-200 px-1">
<input type="checkbox">
<div class="collapse-title text-xl font-medium">
<div>
Request signature, single submitter
</div>
<div class="mt-1">
<div class="badge badge-warning badge-lg">POST</div>
<div class="badge badge-primary badge-lg"><%= api_submissions_path %></div>
</div>
</div>
<div class="collapse-content" style="display: inherit">
<div class="mockup-code overflow-hidden">
<% text = capture do %>curl --location '<%= api_submissions_url %>' \
--header 'X-Auth-Token: <%= current_user.access_token.token %>' \
--data-raw '{
"template_id": <%= current_account.templates.last&.id || 1 %>,
"emails": "<%= current_user.email.sub('@', '+test@') %>, <%= current_user.email.sub('@', '+test2@') %>"
}'<% end.to_str %>
<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' %>
</span>
<pre data-prefix="$"><code class="overflow-hidden w-full"><%= text %></code></pre>
</div>
</div>
</div>
<div class="collapse collapse-plus bg-base-200 px-1">
<input type="checkbox">
<div class="collapse-title text-xl font-medium">
@@ -66,7 +40,7 @@
"Form Text Field Name": "Default Value"
}
},
{ "name": "Second Submitter", "email": "<%= current_user.email.sub('@', '+test2@') %>" }
{ "role": "Second Submitter", "email": "<%= current_user.email.sub('@', '+test2@') %>" }
]
}
]
@@ -78,6 +52,32 @@
</div>
</div>
</div>
<div class="collapse collapse-plus bg-base-200 px-1">
<input type="checkbox">
<div class="collapse-title text-xl font-medium">
<div>
Request signature, single submitter
</div>
<div class="mt-1">
<div class="badge badge-warning badge-lg">POST</div>
<div class="badge badge-primary badge-lg"><%= api_submissions_emails_path %></div>
</div>
</div>
<div class="collapse-content" style="display: inherit">
<div class="mockup-code overflow-hidden">
<% text = capture do %>curl --location '<%= api_submissions_emails_url %>' \
--header 'X-Auth-Token: <%= current_user.access_token.token %>' \
--data-raw '{
"template_id": <%= current_account.templates.last&.id || 1 %>,
"emails": "<%= current_user.email.sub('@', '+test@') %>, <%= current_user.email.sub('@', '+test2@') %>"
}'<% end.to_str %>
<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' %>
</span>
<pre data-prefix="$"><code class="overflow-hidden w-full"><%= text %></code></pre>
</div>
</div>
</div>
<div class="collapse collapse-plus bg-base-200 px-1">
<input type="checkbox">
<div class="collapse-title text-xl font-medium">
@@ -101,5 +101,8 @@
</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>
+47 -9
View File
@@ -1,9 +1,9 @@
<% 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">
<h1 class="text-4xl font-bold"><span class="hidden md:inline">Document</span> Templates</h1>
<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' %>
<% end %>
<% if can?(:create, ::Template) %>
@@ -15,17 +15,55 @@
<% end %>
</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">
<%= render partial: 'templates/template', collection: @templates %>
</div>
<% 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 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)) %>
<%= form_for '', url: templates_upload_path, method: :post, class: 'mt-8 block', html: { enctype: 'multipart/form-data' } do %>
<button type="submit" class="hidden"></button>
<file-dropzone data-submit-on-upload="true" class="w-full">
<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 %>
<% if @templates.present? || params[:q].blank? %>
<% if @pagy.pages > 1 %>
<%= render 'shared/pagination', pagy: @pagy, items_name: 'templates', left_additional_html: view_archived_html %>
<% else %>
+2 -2
View File
@@ -18,11 +18,11 @@
<div class="grid md:grid-cols-2 gap-4">
<div class="form-control">
<%= 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 class="form-control">
<%= 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 class="grid md:grid-cols-2 gap-4">
+2 -2
View File
@@ -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' %>
<div class="flex-grow">
<div class="md:flex-grow">
<div class="max-w-xl">
<h1 class="text-4xl font-bold mb-4">PDF Signature</h1>
<div id="result">
+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" class="<%= local_assigns[:class] %>" width="44" height="44" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M17 7l-10 10"></path>
<path d="M8 7l9 0l0 9"></path>
</svg>

After

Width:  |  Height:  |  Size: 354 B

+11
View File
@@ -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

+4
View File
@@ -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

+6
View File
@@ -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

+9
View File
@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" class="<%= local_assigns[:class] %>" width="44" height="44" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<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

+7
View File
@@ -0,0 +1,7 @@
<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="M4 20h4l10.5 -10.5a2.828 2.828 0 1 0 -4 -4l-10.5 10.5v4"></path>
<path d="M13.5 6.5l4 4"></path>
<path d="M16 22l5 -5"></path>
<path d="M21 21.5v-4.5h-4.5"></path>
</svg>

After

Width:  |  Height:  |  Size: 469 B

+7
View File
@@ -0,0 +1,7 @@
<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="M9 7m-4 0a4 4 0 1 0 8 0a4 4 0 1 0 -8 0"></path>
<path d="M3 21v-2a4 4 0 0 1 4 -4h4a4 4 0 0 1 4 4v2"></path>
<path d="M16 3.13a4 4 0 0 1 0 7.75"></path>
<path d="M21 21v-2a4 4 0 0 0 -3 -3.85"></path>
</svg>

After

Width:  |  Height:  |  Size: 504 B

+4
View File
@@ -0,0 +1,4 @@
<title>
DocuSeal | Open Source Document Filling and Signing
</title>
<%= render 'shared/meta' %>
+1 -4
View File
@@ -1,10 +1,7 @@
<!DOCTYPE html>
<html data-theme="docuseal" lang="en">
<head>
<title>
DocuSeal | Open Source Document Filling and Signing
</title>
<%= render 'shared/meta' %>
<%= render 'layouts/head_tags' %>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<%= csrf_meta_tags %>
<%= csp_meta_tag %>
+1 -4
View File
@@ -1,10 +1,7 @@
<!DOCTYPE html>
<html data-theme="docuseal" lang="en">
<head>
<title>
DocuSeal | Open Source Document Filling and Signing
</title>
<%= render 'shared/meta' %>
<%= render 'layouts/head_tags' %>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<%= csrf_meta_tags %>
<%= csp_meta_tag %>
+1 -4
View File
@@ -1,10 +1,7 @@
<!DOCTYPE html>
<html data-theme="docuseal" lang="en">
<head>
<title>
DocuSeal | Open Source Document Filling and Signing
</title>
<%= render 'shared/meta' %>
<%= render 'layouts/head_tags' %>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<%= csrf_meta_tags %>
<%= csp_meta_tag %>
+2 -2
View File
@@ -22,7 +22,7 @@
</div>
<h3 class="mb-4 text-2xl font-semibold">Easy to Start</h3>
<p class="text-base text-gray-500">
Run on your own host using <a href="https://hub.docker.com/r/docuseal/docuseal" class="link link-neutral font-bold" target="_blank">Docker</a> container, or deploy on your favorite managed PaaS with a single <a href="https://www.docuseal.co/install" class="link link-neutral font-bold">click</a>.
Run on your own host using Docker container, or deploy on your favorite managed PaaS with a single <a href="https://www.docuseal.co/install" class="link link-neutral font-bold">click</a>.
</p>
</div>
</div>
@@ -70,4 +70,4 @@
</div>
</div>
</section>
<%= render 'shared/attribution' %>
<%= render 'shared/attribution', with_counter: true %>
@@ -0,0 +1,26 @@
<div class="collapse collapse-plus bg-base-200">
<input type="checkbox">
<div class="collapse-title text-xl font-medium">
<div>
Completed Form Redirect Button
</div>
</div>
<div class="collapse-content">
<%= form_for AccountConfigs.find_or_initialize_for_key(current_account, AccountConfig::FORM_COMPLETED_BUTTON_KEY), url: settings_personalization_path, method: :post, html: { autocomplete: 'off', class: 'space-y-4' } do |f| %>
<%= f.hidden_field :key %>
<%= f.fields_for :value, Struct.new(:title, :url).new(*(f.object.value || {}).values_at('title', 'url')) do |ff| %>
<div class="form-control">
<%= ff.label :title, 'Button title', class: 'label' %>
<%= ff.text_field :title, class: 'base-input' %>
</div>
<div class="form-control">
<%= ff.label :url, 'Button URL', class: 'label' %>
<%= ff.url_field :url, class: 'base-input' %>
</div>
<% end %>
<div class="form-control pt-2">
<%= f.button button_title(title: 'Save', disabled_with: 'Saving'), class: 'base-button' %>
</div>
<% end %>
</div>
</div>
@@ -9,6 +9,8 @@
</div>
<p class="text-4xl font-bold mb-4 mt-8">Company Logo</p>
<%= render 'logo_form' %>
<p class="text-4xl font-bold mb-4 mt-8">Submitter Form</p>
<%= render 'form_completed_button_form' %>
</div>
<div class="w-0 md:w-52"></div>
</div>
+8
View File
@@ -21,6 +21,14 @@
<%= f.button button_title(title: 'Update', disabled_with: 'Updating'), class: 'base-button' %>
</div>
<% end %>
<p class="text-2xl font-bold mt-8 mb-4">Signature</p>
<% signature = UserConfigs.load_signature(current_user) %>
<% if signature %>
<div class="flex justify-center mb-4">
<img src="<%= signature.url %>" style="max-height: 200px; width: auto" width="<%= signature.metadata['width'] %>" height="<%= signature.metadata['height'] %>">
</div>
<% end %>
<a href="<%= edit_user_signature_path %>" data-turbo-frame="modal" class="base-button w-full">Update Signature</a>
<p class="text-2xl font-bold mt-8 mb-4">Change Password</p>
<%= form_for current_user, url: update_password_settings_profile_index_path, method: :patch, html: { autocomplete: 'off', class: 'space-y-4' } do |f| %>
<div class="form-control">
+1 -1
View File
@@ -1 +1 @@
<%= render 'shared/powered_by' %>
<%= render 'shared/powered_by', with_counter: local_assigns[:with_counter] %>
+1
View File
@@ -0,0 +1 @@
<% 'stats stat stat-figure stat-title stat-value text-accent' %>
+3
View File
@@ -0,0 +1,3 @@
<a href="<%= Docuseal::GITHUB_URL %>" target="_blank" class="inline">
<img alt="GitHub Repo stars" src="https://www.docuseal.co/github-badge.svg" style="height: 22px">
</a>
+17 -18
View File
@@ -1,14 +1,9 @@
<div class="max-w-6xl mb-4 mx-auto px-4 md:px-2 py-3 flex items-center justify-between">
<div class="flex items-center space-x-4">
<a href="<%= root_path %>" class="text-2xl font-bold items-center flex space-x-2">
<%= render 'shared/logo' %>
<span>DocuSeal</span>
<%= render 'shared/title' %>
</a>
<% unless Docuseal.demo? %>
<a href="<%= Docuseal::GITHUB_URL %>" target="_blank" class="inline">
<img alt="GitHub Repo stars" src="https://www.docuseal.co/github-badge.svg" style="height: 22px">
</a>
<% end %>
<%= render 'shared/github' %>
</div>
<% if signed_in? %>
<div class="space-x-4 flex items-center">
@@ -16,11 +11,13 @@
<%= render 'shared/github_button' %>
<% else %>
<div class="flex items-center justify-center space-x-4 mr-1">
<%= link_to Docuseal.multitenant? ? console_redirect_index_path : Docuseal::CONSOLE_URL, class: 'hidden md:inline-flex items-center font-medium text-lg', data: { prefetch: false } do %>
Console
<span class="badge badge-warning ml-1">New</span>
<% if can?(:manage, EncryptedConfig) && !can?(:manage, :tenants) %>
<%= link_to Docuseal.multitenant? ? console_redirect_index_path : Docuseal::CONSOLE_URL, class: 'hidden md:inline-flex items-center font-medium text-lg', data: { prefetch: false } do %>
Console
<span class="badge badge-warning ml-1">New</span>
<% end %>
<span class="hidden md:inline-flex h-3 border-r border-base-content"></span>
<% end %>
<span class="hidden md:inline-flex h-3 border-r border-base-content"></span>
<%= link_to 'Settings', settings_profile_index_path, class: 'hidden md:inline-flex font-medium text-lg' %>
</div>
<% end %>
@@ -35,7 +32,7 @@
<span class="mr-1">Profile</span>
<% end %>
</li>
<% unless Docuseal.demo? %>
<% if !Docuseal.demo? && can?(:manage, EncryptedConfig) %>
<li>
<%= link_to Docuseal.multitenant? ? console_redirect_index_path : Docuseal::CONSOLE_URL, data: { prefetch: false }, class: 'flex items-center' do %>
<%= svg_icon('terminal', class: 'w-5 h-5 stroke-2') %>
@@ -43,12 +40,14 @@
<% end %>
</li>
<% end %>
<li>
<%= link_to settings_esign_path, class: 'flex items-center' do %>
<%= svg_icon('zoom_check', class: 'w-5 h-5 stroke-2') %>
<span class="mr-1">Verify PDF</span>
<% end %>
</li>
<% if can?(:read, EncryptedConfig.new(key: EncryptedConfig::ESIGN_CERTS_KEY, account: current_account)) %>
<li>
<%= link_to settings_esign_path, class: 'flex items-center' do %>
<%= svg_icon('zoom_check', class: 'w-5 h-5 stroke-2') %>
<span class="mr-1">Verify PDF</span>
<% end %>
</li>
<% end %>
<li>
<%= button_to destroy_user_session_path, method: :delete, data: { turbo: false }, class: 'flex items-center' do %>
<%= svg_icon('logout', class: 'w-5 h-5 stroke-2 mr-2 inline') %>
+1 -1
View File
@@ -2,7 +2,7 @@
<% if @pagy.pages > 1 %>
<div class="flex my-6 justify-center md:justify-between">
<div class="hidden md:block text-sm">
<%= @pagy.from %>-<%= @pagy.to %> of <%= @pagy.count %> <%= local_assigns[:items_name] || 'items' %>
<%= @pagy.from %>-<%= local_assigns.fetch(:to, @pagy.to) %> of <%= local_assigns.fetch(:count, @pagy.count) %> <%= local_assigns[:items_name] || 'items' %>
<%= local_assigns[:left_additional_html] %>
</div>
<div class="join">
+10 -1
View File
@@ -1,4 +1,13 @@
<div class="text-center px-2">
Powered by
<% if local_assigns[:with_counter] %>
<% count = Submitter.where.not(completed_at: nil).distinct.count(:submission_id) %>
<% if count > 1 %>
<b><%= count %></b> documents signed with
<% else %>
Powered by
<% end %>
<% else %>
Powered by
<% end %>
<a href="<%= Docuseal::PRODUCT_URL %>" class="underline"><%= Docuseal::PRODUCT_NAME %></a> - open source documents software
</div>
+8 -1
View File
@@ -1,5 +1,12 @@
<form action="<%= request.path %>" method="get" class="items-center hidden md:flex">
<input id="search" name="q" value="<%= params[:q] %>" class="input input-ghost text-lg pr-10 -mr-12">
<div class="relative">
<% if params[:q].present? %>
<a href="<%= request.path %>" title="Clear" class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-auto text-neutral text-2xl font-extralight">
&times;
</a>
<% end %>
</div>
<input id="search" name="q" value="<%= params[:q] %>" class="input input-ghost text-lg pr-10 -mr-12 <%= 'pl-8 input-outlined' if params[:q].present? %>">
<button type="submit" title="Search" class="btn btn-ghost btn-circle" onclick="window.search.value || document.activeElement === window.search ? null : [event.preventDefault(), window.search.focus()]">
<span class="enabled">
<%= svg_icon('search', class: 'w-6 h-6 stroke-2') %>
+28 -23
View File
@@ -10,7 +10,7 @@
<%= link_to 'Account', settings_account_path, class: 'text-base hover:bg-base-300' %>
</li>
<% unless Docuseal.multitenant? %>
<% if can?(:read, EncryptedConfig.new(key: EncryptedConfig::EMAIL_SMTP_KEY, account: current_account)) %>
<% if can?(:read, EncryptedConfig.new(key: EncryptedConfig::EMAIL_SMTP_KEY, account: current_account)) && ENV['SMTP_ADDRESS'].blank? %>
<li>
<%= link_to 'Email', settings_email_index_path, class: 'text-base hover:bg-base-300' %>
</li>
@@ -58,7 +58,8 @@
<%= link_to 'Personalization', settings_personalization_path, class: 'text-base hover:bg-base-300' %>
</li>
<% end %>
<% unless Docuseal.demo? %>
<%= render 'shared/settings_nav_extra' %>
<% if !Docuseal.demo? && can?(:manage, EncryptedConfig) %>
<li>
<%= link_to Docuseal.multitenant? ? console_redirect_index_path : Docuseal::CONSOLE_URL, class: 'text-base hover:bg-base-300', data: { prefetch: false } do %>
Console
@@ -68,27 +69,31 @@
<% end %>
</ul>
</menu-active>
<div class="mx-4 border-t border-base-300 hidden md:block">
<div class="text-sm mt-3">
Need help? Ask a question:
</div>
<div class="flex mt-3 space-x-3">
<a href="<%= Docuseal::GITHUB_URL %>" target="_blank" class="btn btn-circle btn-primary btn-md">
<%= svg_icon('brand_github', class: 'w-8 h-8') %>
</a>
<a href="<%= Docuseal::DISCORD_URL %>" target="_blank" class="btn btn-circle btn-primary btn-md">
<%= svg_icon('brand_discord', class: 'w-8 h-8') %>
</a>
<a href="<%= Docuseal::TWITTER_URL %>" target="_blank" class="btn btn-circle btn-primary btn-md">
<%= svg_icon('brand_twitter', class: 'w-8 h-8') %>
<% if !can?(:manage, :tenants) %>
<div class="mx-4 border-t border-base-300 hidden md:block">
<div class="text-sm mt-3">
Need help? Ask a question:
</div>
<div class="flex mt-3 space-x-3">
<div class="tooltip" data-tip="GitHub">
<a href="<%= Docuseal::GITHUB_URL %>" target="_blank" class="btn btn-circle btn-primary btn-md">
<%= svg_icon('brand_github', class: 'w-8 h-8') %>
</a>
</div>
<div class="tooltip" data-tip="Discord Community">
<a href="<%= Docuseal::DISCORD_URL %>" target="_blank" class="btn btn-circle btn-primary btn-md">
<%= svg_icon('brand_discord', class: 'w-8 h-8') %>
</a>
</div>
<div class="tooltip" data-tip="Twitter">
<a href="<%= Docuseal::TWITTER_URL %>" target="_blank" class="btn btn-circle btn-primary btn-md">
<%= svg_icon('brand_twitter', class: 'w-8 h-8') %>
</a>
</div>
</div>
<a href="mailto:<%= Docuseal::SUPPORT_EMAIL %>" target="_blank" class="w-full block mt-4 underline text-center">
<%= Docuseal::SUPPORT_EMAIL %>
</a>
</div>
<a href="https://twitter.com/intent/tweet?<%= { text: "Open source DocuSign alternative\n#{Docuseal::GITHUB_URL} #{Docuseal::TWITTER_HANDLE}" }.to_query %>" target="_blank" class="btn btn-neutral btn-outline w-full mt-5">
<%= svg_icon('send', class: 'h-5 w-5') %>
Tell about us
</a>
<a href="mailto:<%= Docuseal::SUPPORT_EMAIL %>" target="_blank" class="w-full block mt-4 underline text-center">
<%= Docuseal::SUPPORT_EMAIL %>
</a>
</div>
<% end %>
</div>
Whitespace-only changes.
+2
View File
@@ -0,0 +1,2 @@
<%= render 'shared/logo' %>
<span>DocuSeal</span>
+18 -10
View File
@@ -3,7 +3,9 @@
<div class="space-y-6">
<div class="text-center w-full space-y-6">
<%= render 'banner' %>
<p class="text-xl font-semibold text-center">You have been invited to submit a form</p>
<% unless @template.deleted_at? %>
<p class="text-xl font-semibold text-center">You have been invited to submit a form</p>
<% end %>
</div>
<div class="flex items-center bg-base-200 rounded-xl p-4 mb-4">
<div class="flex items-center">
@@ -12,19 +14,25 @@
</div>
<div>
<p class="text-lg font-bold mb-1"><%= @template.name %></p>
<p class="text-sm">Invited by <span class="font-semibold"><%= @template.account.name %></span></p>
<% if @template.deleted_at? %>
<p class="text-sm">Form has been deleted by <span class="font-semibold"><%= @template.account.name %></span>.</p>
<% else %>
<p class="text-sm">Invited by <span class="font-semibold"><%= @template.account.name %></span></p>
<% end %>
</div>
</div>
</div>
</div>
<%= form_for @submitter, url: start_form_path(@template.slug), data: { turbo_frame: :_top }, method: :put, html: { class: 'space-y-4' } do |f| %>
<div class="form-control !mt-0">
<%= f.label :email, class: 'label' %>
<%= f.email_field :email, required: true, class: 'base-input', placeholder: 'Provide your email to start' %>
</div>
<div class="form-control">
<%= f.button button_title(title: 'Start', disabled_with: 'Starting'), class: 'base-button' %>
</div>
<% unless @template.deleted_at? %>
<%= form_for @submitter, url: start_form_path(@template.slug), data: { turbo_frame: :_top }, method: :put, html: { class: 'space-y-4' } do |f| %>
<div class="form-control !mt-0">
<%= f.label :email, class: 'label' %>
<%= f.email_field :email, value: current_user&.email, required: true, class: 'base-input', placeholder: 'Provide your email to start' %>
</div>
<div class="form-control">
<%= f.button button_title(title: 'Start', disabled_with: 'Starting'), class: 'base-button' %>
</div>
<% end %>
<% end %>
</div>
</div>
+11 -5
View File
@@ -10,19 +10,25 @@
</div>
<div class="grid <%= 'md:grid-cols-2' if template.submitters.size > 1 %> gap-4">
<% template.submitters.each do |item| %>
<div class="form-control">
<submitter-item class="form-control">
<% if template.submitters.size > 1 %>
<label class="label pt-0 pb-1 text-xs">
<span class="label-text"> <%= item['name'] %></span>
</label>
<% end %>
<input type="hidden" name="submission[1][submitters][][uuid]" value="<%= item['uuid'] %>">
<input type="text" name="submission[1][submitters][][name]" autocomplete="off" class="input input-sm input-bordered" placeholder="Name" required>
<submitters-autocomplete data-field="name">
<input type="text" name="submission[1][submitters][][name]" autocomplete="off" class="input input-sm input-bordered w-full" placeholder="Name" required>
</submitters-autocomplete>
<div class="grid <%= 'md:grid-cols-2 gap-1' if template.submitters.size == 1 %>">
<input type="email" multiple name="submission[1][submitters][][email]" autocomplete="off" class="input input-sm input-bordered mt-1.5" placeholder="Email (optional)">
<input type="tel" pattern="^\+[0-9\s\-]+$" oninvalid="this.value ? this.setCustomValidity('Use internatioanl format: +1xxx...') : ''" oninput="this.setCustomValidity('')" name="submission[1][submitters][][phone]" autocomplete="off" class="input input-sm input-bordered mt-1.5" placeholder="Phone (optional)">
<submitters-autocomplete data-field="email">
<input type="email" multiple name="submission[1][submitters][][email]" autocomplete="off" class="input input-sm input-bordered mt-1.5 w-full" placeholder="Email (optional)">
</submitters-autocomplete>
<submitters-autocomplete data-field="phone">
<input type="tel" pattern="^\+[0-9\s\-]+$" oninvalid="this.value ? this.setCustomValidity('Use internatioanl format: +1xxx...') : ''" oninput="this.setCustomValidity('')" name="submission[1][submitters][][phone]" autocomplete="off" class="input input-sm input-bordered mt-1.5 w-full" placeholder="Phone (optional)">
</submitters-autocomplete>
</div>
</div>
</submitter-item>
<% end %>
</div>
</div>
+12 -8
View File
@@ -1,10 +1,12 @@
<%= form_for '', url: template_submissions_path(template), html: { class: 'space-y-4', autocomplete: 'off' }, data: { turbo_frame: :_top } do |f| %>
<% if template.submitters.size == 1 %>
<div class="form-control">
<autoresize-textarea>
<%= f.text_area :emails, required: true, class: 'base-textarea w-full', placeholder: 'Type emails here...' %>
</autoresize-textarea>
</div>
<submitter-item class="form-control">
<submitters-autocomplete data-field="email">
<autoresize-textarea>
<%= f.text_area :emails, required: true, class: 'base-textarea w-full', placeholder: 'Type emails here...' %>
</autoresize-textarea>
</submitters-autocomplete>
</submitter-item>
<% else %>
<dynamic-list class="space-y-4">
<div class="space-y-4">
@@ -17,13 +19,15 @@
</div>
<div class="grid md:grid-cols-2 gap-4">
<% template.submitters.each do |item| %>
<div class="form-control">
<submitter-item class="form-control">
<label class="label pt-0 pb-1 text-xs">
<span class="label-text"> <%= item['name'] %></span>
</label>
<input type="hidden" name="submission[1][submitters][][uuid]" value="<%= item['uuid'] %>">
<input type="email" multiple name="submission[1][submitters][][email]" autocomplete="off" class="input input-sm input-bordered" placeholder="Email" required>
</div>
<submitters-autocomplete data-field="email">
<input type="email" multiple name="submission[1][submitters][][email]" autocomplete="off" class="input input-sm input-bordered w-full" placeholder="Email" required>
</submitters-autocomplete>
</submitter-item>
<% end %>
</div>
</div>
+29 -19
View File
@@ -8,25 +8,33 @@
<%= svg_icon('trash', class: 'w-4 h-4') %>
</a>
</div>
<div class="grid md:grid-cols-2 <%= template.submitters.size > 1 ? 'gap-4' : 'gap-1' %>">
<div class="grid <%= template.submitters.size > 1 ? 'md:grid-cols-2 gap-4' : 'gap-1' %>">
<% template.submitters.each do |item| %>
<div class="form-control">
<% if template.submitters.size > 1 %>
<label class="label pt-0 pb-1 text-xs">
<span class="label-text"> <%= item['name'] %></span>
</label>
<% end %>
<input type="hidden" name="submission[1][submitters][][uuid]" value="<%= item['uuid'] %>">
<input type="tel" pattern="^\+[0-9\s\-]+$" oninvalid="this.value ? this.setCustomValidity('Use internatioanl format: +1xxx...') : ''" oninput="this.setCustomValidity('')" name="submission[1][submitters][][phone]" autocomplete="off" class="input input-sm input-bordered" placeholder="Phone" required>
<% if template.submitters.size > 1 %>
<input type="text" name="submission[1][submitters][][name]" autocomplete="off" class="input input-sm input-bordered mt-1.5" placeholder="Name (optional)">
<% end %>
</div>
<% if template.submitters.size == 1 %>
<div class="form-control flex">
<input type="text" name="submission[1][submitters][][name]" autocomplete="off" class="input input-sm input-bordered" placeholder="Name (optional)">
<submitter-item class="grid <%= template.submitters.size > 1 ? 'gap-4' : 'md:grid-cols-2 gap-1' %>">
<div class="form-control">
<% if template.submitters.size > 1 %>
<label class="label pt-0 pb-1 text-xs">
<span class="label-text"> <%= item['name'] %></span>
</label>
<% end %>
<input type="hidden" name="submission[1][submitters][][uuid]" value="<%= item['uuid'] %>">
<submitters-autocomplete data-field="phone">
<input type="tel" pattern="^\+[0-9\s\-]+$" oninvalid="this.value ? this.setCustomValidity('Use internatioanl format: +1xxx...') : ''" oninput="this.setCustomValidity('')" name="submission[1][submitters][][phone]" autocomplete="off" class="input input-sm input-bordered w-full" placeholder="Phone" required>
</submitters-autocomplete>
<% if template.submitters.size > 1 %>
<submitters-autocomplete data-field="name">
<input type="text" name="submission[1][submitters][][name]" autocomplete="off" class="input input-sm input-bordered mt-1.5 w-full" placeholder="Name (optional)">
</submitters-autocomplete>
<% end %>
</div>
<% end %>
<% if template.submitters.size == 1 %>
<div class="form-control flex">
<submitters-autocomplete data-field="name">
<input type="text" name="submission[1][submitters][][name]" autocomplete="off" class="input input-sm input-bordered w-full" placeholder="Name (optional)">
</submitters-autocomplete>
</div>
<% end %>
</submitter-item>
<% end %>
</div>
</div>
@@ -37,8 +45,10 @@
<span>Add New</span>
</a>
</dynamic-list>
<%= render 'send_sms', f: %>
<%= render('submitters_order', f:, template:) if Accounts.can_send_emails?(current_account) %>
<div>
<%= render('submitters_order', f:, template:) if Accounts.can_send_emails?(current_account) %>
<%= render 'send_sms', f: %>
</div>
<div class="form-control">
<%= f.button button_title(title: 'Add Recipients'), class: 'base-button' %>
</div>
+1 -1
View File
@@ -1,5 +1,5 @@
<div class="flex absolute text-[1.5vw] lg:text-base" style="width: <%= area['w'] * 100 %>%; height: <%= area['h'] * 100 %>%; left: <%= area['x'] * 100 %>%; top: <%= area['y'] * 100 %>%">
<% if field['type'].in?(['signature', 'image']) %>
<% if field['type'].in?(['signature', 'image', 'initials']) %>
<img class="object-contain mx-auto" src="<%= attachments_index[value].url %>" loading="lazy">
<% elsif field['type'] == 'file' %>
<div class="px-0.5 flex flex-col justify-center">
+3 -3
View File
@@ -66,7 +66,7 @@
</div>
</div>
<div class="hidden md:block relative w-80 flex-none pt-0.5 pr-4 pl-0.5 overflow-auto space">
<% colors = %w[bg-red-500 bg-sky-500 bg-emerald-500 bg-yellow-300 bg-purple-600] %>
<% colors = %w[bg-red-500 bg-sky-500 bg-emerald-500 bg-yellow-300 bg-purple-600 bg-pink-500 bg-cyan-500 bg-orange-500 bg-lime-500 bg-indigo-500] %>
<% submitter_fields_index = (@submission.template_fields || @submission.template.fields).group_by { |f| f['submitter_uuid'] } %>
<% (@submission.template_submitters || @submission.template.submitters).each_with_index do |item, index| %>
<% submitter = @submission.submitters.find { |e| e.uuid == item['uuid'] } %>
@@ -136,8 +136,8 @@
<%= field['name'].presence || "#{field['type'].titleize} Field #{submitter_field_counters[field['type']]}" %>
</div>
<div>
<% if field['type'] == 'signature' %>
<div class="w-full bg-base-300">
<% if field['type'].in?(%w[signature initials]) %>
<div class="w-full bg-base-300 py-1">
<img class="object-contain mx-auto" height="<%= attachments_index[value].metadata['height'] %>" width="<%= attachments_index[value].metadata['width'] %>" src="<%= attachments_index[value].url %>" loading="lazy">
</div>
<% elsif field['type'] == 'image' %>
@@ -1,3 +1,4 @@
<% data_attachments = attachments_index.values.select { |e| e.record_id == submitter.id }.to_json(only: %i[uuid], methods: %i[url filename content_type]) %>
<% data_fields = (submitter.submission.template_fields || submitter.submission.template.fields).select { |f| f['submitter_uuid'] == submitter.uuid }.to_json %>
<submission-form data-is-demo="<%= Docuseal.demo? %>" data-is-direct-upload="<%= Docuseal.active_storage_public? %>" data-submitter="<%= submitter.to_json(only: %i[uuid slug name phone email]) %>" data-can-send-email="<%= Accounts.can_send_emails?(Struct.new(:id).new(@submitter.submission.template.account_id)) %>" data-attachments="<%= data_attachments %>" data-fields="<%= data_fields %>" data-authenticity-token="<%= form_authenticity_token %>" data-values="<%= submitter.values.to_json %>"></submission-form>
<% completed_button_params = submitter.submission.template.account.account_configs.find_by(key: AccountConfig::FORM_COMPLETED_BUTTON_KEY)&.value || {} %>
<submission-form data-is-demo="<%= Docuseal.demo? %>" data-completed-button="<%= completed_button_params.to_json %>" data-go-to-last="<%= submitter.opened_at? %>" data-is-direct-upload="<%= Docuseal.active_storage_public? %>" data-submitter="<%= submitter.to_json(only: %i[uuid slug name phone email]) %>" data-can-send-email="<%= Accounts.can_send_emails?(Struct.new(:id).new(@submitter.submission.template.account_id)) %>" data-attachments="<%= data_attachments %>" data-fields="<%= data_fields %>" data-authenticity-token="<%= form_authenticity_token %>" data-values="<%= submitter.values.to_json %>"></submission-form>
+21
View File
@@ -0,0 +1,21 @@
<div class="max-w-md mx-auto px-2 mt-12 mb-4">
<div class="space-y-6 mx-auto">
<div class="space-y-6">
<div class="flex items-center justify-center">
<%= render 'start_form/banner' %>
</div>
<div class="flex items-center bg-base-200 rounded-xl p-4 mb-4">
<div class="flex items-center">
<div class="mr-3">
<%= svg_icon('writing_sign', class: 'w-10 h-10') %>
</div>
<div>
<p class="text-lg font-bold mb-1"><%= @submitter.submission.template.name %></p>
<p class="text-sm">Form has been deleted by <span class="font-semibold"><%= @submitter.submission.template.account.name %></span>.</p>
</div>
</div>
</div>
</div>
</div>
</div>
<%= render 'shared/attribution' %>
@@ -1,5 +1,8 @@
<% if @email_config || @body.present? %>
<%= auto_link(simple_format(h(ReplaceEmailVariables.call(@body.presence || @email_config.value['body'], submitter: @submitter)))) %>
<% if !(@body.presence || @email_config.value['body']).include?(ReplaceEmailVariables::SUBMITTER_LINK) %>
<p><%= link_to nil, submit_form_url(slug: @submitter.slug, t: SubmissionEvents.build_tracking_param(@submitter, 'click_email')) %></p>
<% end %>
<% else %>
<p>Hi there,</p>
<p>You have been invited to submit the "<%= @submitter.submission.template.name %>" form</p>
@@ -0,0 +1,12 @@
<% is_long = folder.name.size > 32 %>
<a href="<%= folder_path(folder) %>" class="flex h-full flex-col justify-between rounded-2xl py-5 px-6 w-full bg-base-200">
<% if !is_long %>
<%= svg_icon('folder', class: 'w-6 h-6') %>
<% end %>
<div class="text-lg font-semibold mt-1" style="overflow: hidden; display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: <%= is_long ? 2 : 1 %>;">
<% if is_long %>
<%= svg_icon('folder', class: 'w-6 h-6 inline') %>
<% end %>
<%= folder.name %>
</div>
</a>
+10
View File
@@ -0,0 +1,10 @@
<%= render 'shared/turbo_modal', title: 'Rename Folder' do %>
<%= form_for @template_folder, url: folder_path(@template_folder), data: { turbo_frame: :_top }, html: { autocomplete: :off } do |f| %>
<div class="form-control my-6">
<%= f.text_field :name, required: true, placeholder: 'Folder Name...', class: 'base-input w-full', autofocus: true %>
</div>
<div class="form-control">
<%= f.button button_title(title: 'Rename', disabled_with: 'Saving'), class: 'base-button' %>
</div>
<% end %>
<% end %>
+45
View File
@@ -0,0 +1,45 @@
<div>
<%= link_to root_path do %>
&larr;
<span>Home</span>
<% end %>
</div>
<div class="flex justify-between mb-4 items-center">
<h1 class="text-4xl font-bold flex items-center space-x-2">
<%= svg_icon('folder', class: 'w-9 h-9 flex-shrink-0') %>
<span class="peer">
<%= @template_folder.name %>
</span>
<% if can?(:update, @template_folder) && @template_folder.name != TemplateFolder::DEFAULT_NAME %>
<span class="pl-1 opacity-0 hover:opacity-100 peer-hover:opacity-100">
<a href="<%= edit_folder_path(@template_folder) %>" data-turbo-frame="modal">
<%= svg_icon('pencil', class: 'w-7 h-7') %>
</a>
</span>
<% end %>
</h1>
<div class="flex space-x-2">
<% if params[:q].present? || @pagy.pages > 1 %>
<%= render 'shared/search_input' %>
<% end %>
<% if can?(:create, ::Template) %>
<%= render 'templates/upload_button', folder_name: @template_folder.name %>
<%= link_to new_template_path(folder_name: @template_folder.name), class: 'btn btn-primary text-base btn-md gap-2', data: { turbo_frame: :modal } do %>
<%= svg_icon('plus', class: 'w-6 h-6 stroke-2') %>
<span class="hidden md:block">Create</span>
<% end %>
<% end %>
</div>
</div>
<% if @pagy.count > 0 %>
<div class="grid gap-4 md:grid-cols-3">
<%= render partial: 'templates/template', collection: @templates %>
</div>
<%= render 'shared/pagination', pagy: @pagy, items_name: 'templates' %>
<% elsif params[:q].present? %>
<div class="text-center">
<div class="mt-16 text-3xl font-semibold">
Templates not Found
</div>
</div>
<% end %>
+2 -2
View File
@@ -39,7 +39,7 @@
<% end %>
<span class="btn btn-outline btn-sm w-20 md:w-24">View</span>
<% if !submission.deleted_at? && can?(:destroy, submission) %>
<%= button_to button_title(title: nil, disabled_with: 'Remov', icon: svg_icon('trash', class: 'w-6 h-6')), submission_path(submission), class: 'btn btn-outline btn-sm', title: 'Delete', method: :delete, data: { turbo_confirm: 'Are you sure?' }, onclick: 'event.stopPropagation()' %>
<%= button_to button_title(title: nil, disabled_with: 'Remov', icon: svg_icon('trash', class: 'w-6 h-6')), submission_path(submission), class: 'btn btn-outline btn-sm', form: { class: 'flex' }, title: 'Delete', method: :delete, data: { turbo_confirm: 'Are you sure?' }, onclick: 'event.stopPropagation()' %>
<% end %>
</div>
<% else %>
@@ -113,7 +113,7 @@
<% end %>
<span class="btn btn-outline btn-sm w-20 md:w-24">View</span>
<% unless submission.deleted_at? %>
<%= button_to button_title(title: nil, disabled_with: 'Remov', icon: svg_icon('trash', class: 'w-6 h-6')), submission_path(submission), class: 'btn btn-outline btn-sm', title: 'Delete', method: :delete, data: { turbo_confirm: 'Are you sure?' }, onclick: 'event.stopPropagation()' %>
<%= button_to button_title(title: nil, disabled_with: 'Remov', icon: svg_icon('trash', class: 'w-6 h-6')), submission_path(submission), class: 'btn btn-outline btn-sm', form: { class: 'flex' }, title: 'Delete', method: :delete, data: { turbo_confirm: 'Are you sure?' }, onclick: 'event.stopPropagation()' %>
<% end %>
</div>
<% end %>
+48 -23
View File
@@ -8,33 +8,58 @@
<%= svg_icon('user', class: 'w-4 h-4') %>
<span><%= template.author.full_name.presence || template.author.email %></span>
</p>
<p class="flex items-center space-x-1 text-xs text-base-content/60">
<%= svg_icon('calendar', class: 'w-4 h-4') %>
<span><%= l(template.created_at.in_time_zone(current_account.timezone), format: :short, locale: current_account.locale) %></span>
<p class="flex text-xs text-base-content/60">
<span class="flex items-center space-x-1 w-1/2">
<%= svg_icon('calendar', class: 'w-4 h-4') %>
<span><%= l(template.created_at.in_time_zone(current_account.timezone), format: :short, locale: current_account.locale) %></span>
</span>
<% if template.deleted_at? %>
<span class="flex items-center space-x-1 w-1/2">
<%= svg_icon('folder', class: 'w-4 h-4 flex-shrink-0') %>
<span class="truncate"><%= template.folder.name %></span>
</span>
<% end %>
</p>
</div>
</a>
<div class="absolute top-0 bottom-0 w-0 pt-7 space-y-1.5 hidden md:group-hover:block" style="right: 40px">
<% if template.deleted_at? && can?(:update, template) %>
<%= button_to template_restore_index_path(template), class: 'btn btn-xs hover:btn-outline bg-base-200 btn-circle' do %>
<%= svg_icon('rotate', class: 'w-4 h-4 enabled') %>
<%= svg_icon('loader', class: 'w-4 h-4 animate-spin disabled') %>
<div class="absolute top-0 bottom-0 w-0 flex items-center hidden md:group-hover:flex" style="right: 37px">
<div class="space-y-1">
<% if can?(:update, template) && !template.deleted_at? %>
<span class="tooltip tooltip-left" data-tip="Move">
<a href="<%= edit_template_folder_path(template.id) %>" data-turbo-frame="modal" class="btn btn-xs hover:btn-outline bg-base-200 btn-circle">
<%= svg_icon('folder_share', class: 'w-4 h-4') %>
</a>
</span>
<% end %>
<% elsif can?(:update, template) %>
<a href="<%= edit_template_path(template) %>" class="btn btn-xs hover:btn-outline bg-base-200 btn-circle">
<%= svg_icon('pencil', class: 'w-4 h-4') %>
</a>
<% end %>
<% if can?(:create, template) %>
<a href="<%= new_template_path(base_template_id: template.id) %>" data-turbo-frame="modal" class="btn btn-xs hover:btn-outline bg-base-200 btn-circle">
<%= svg_icon('copy', class: 'w-4 h-4') %>
</a>
<% end %>
<% if !template.deleted_at? && can?(:destroy, template) %>
<%= button_to template_path(template), data: { turbo_confirm: 'Are you sure?' }, method: :delete, class: 'btn btn-xs hover:btn-outline bg-base-200 btn-circle', aria_label: 'Restore' do %>
<%= svg_icon('trash', class: 'w-4 h-4 enabled') %>
<%= svg_icon('loader', class: 'w-4 h-4 animate-spin disabled') %>
<% if template.deleted_at? && can?(:update, template) %>
<span class="tooltip tooltip-left" data-tip="Restore">
<%= button_to template_restore_index_path(template), class: 'btn btn-xs hover:btn-outline bg-base-200 btn-circle' do %>
<%= svg_icon('rotate', class: 'w-4 h-4 enabled') %>
<%= svg_icon('loader', class: 'w-4 h-4 animate-spin disabled') %>
<% end %>
</span>
<% elsif can?(:update, template) %>
<span class="tooltip tooltip-left" data-tip="Edit">
<a href="<%= edit_template_path(template) %>" class="btn btn-xs hover:btn-outline bg-base-200 btn-circle">
<%= svg_icon('pencil', class: 'w-4 h-4') %>
</a>
</span>
<% end %>
<% end %>
<% if can?(:create, template) %>
<span class="tooltip tooltip-left" data-tip="Clone">
<a href="<%= new_template_path(base_template_id: template.id) %>" data-turbo-frame="modal" class="btn btn-xs hover:btn-outline bg-base-200 btn-circle">
<%= svg_icon('copy', class: 'w-4 h-4') %>
</a>
</span>
<% end %>
<% if !template.deleted_at? && can?(:destroy, template) %>
<span class="tooltip tooltip-left" data-tip="Archive">
<%= button_to template_path(template), data: { turbo_confirm: 'Are you sure?' }, method: :delete, class: 'btn btn-xs hover:btn-outline bg-base-200 btn-circle', aria_label: 'Restore' do %>
<%= svg_icon('trash', class: 'w-4 h-4 enabled') %>
<%= svg_icon('loader', class: 'w-4 h-4 animate-spin disabled') %>
<% end %>
</span>
<% end %>
</div>
</div>
</div>
+25 -8
View File
@@ -1,11 +1,28 @@
<div class="flex flex-col items-start md:flex-row space-y-2 md:space-y-0 md:justify-between md:items-center mb-8">
<h1 class="text-4xl font-semibold mr-4" style="overflow: hidden; display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2;">
<%= template.name %>
<% if template.deleted_at? %>
<span class="badge badge-outline badge-lg align-middle">Archived</span>
<% end %>
</h1>
<div class="flex md:justify-between space-x-2 flex-none">
<div class="flex flex-col items-start md:flex-row space-y-2 md:space-y-0 md:justify-between md:items-start mb-3">
<div>
<h1 class="text-4xl font-semibold mr-4" style="overflow: hidden; display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2;">
<%= template.name %>
<% if template.deleted_at? %>
<span class="badge badge-outline badge-lg align-middle">Archived</span>
<% end %>
</h1>
<div class="flex items-center">
<a href="<%= folder_path(@template.folder) %>" class="flex items-center space-x-1 mt-1 peer">
<%= svg_icon('folder', class: 'w-5 h-5 flex-shrink-0') %>
<span class="text-sm">
<%= @template.folder.name %>
</span>
</a>
<% if can?(:update, template) %>
<span class="pl-1 tooltip tooltip-right opacity-0 hover:opacity-100 peer-hover:opacity-100" data-tip="Move">
<a href="<%= edit_template_folder_path(template.id) %>" data-turbo-frame="modal">
<%= svg_icon('pencil_share', class: 'w-5 h-5') %>
</a>
</span>
<% end %>
</div>
</div>
<div class="flex md:justify-between space-x-2 flex-none pt-1">
<% if !template.deleted_at? && can?(:destroy, template) %>
<%= button_to button_title(title: 'Remove', disabled_with: 'Removing', icon: svg_icon('trash', class: 'w-6 h-6')), template_path(template), class: 'btn btn-outline btn-sm', method: :delete, data: { turbo_confirm: 'Are you sure?' } %>
<% end %>
@@ -14,4 +14,5 @@
</span>
</button>
<input id="upload_template" name="files[]" class="hidden" onchange="this.form.requestSubmit()" type="file" accept="image/*, application/pdf<%= ', .docx, .doc, .xlsx, .xls' if Docuseal.multitenant? %>" multiple>
<input hidden name="folder_name" value="<%= local_assigns[:folder_name] %>">
<% end %>
+14 -3
View File
@@ -1,12 +1,23 @@
<%= render 'shared/turbo_modal', title: @base_template ? 'Clone Template' : 'New Document Template' do %>
<%= form_for @template, data: { turbo_frame: :_top } do |f| %>
<%= form_for @template, data: { turbo_frame: :_top }, html: { autocomplete: :off } do |f| %>
<% if @base_template %>
<%= hidden_field_tag :base_template_id, @base_template.id %>
<% end %>
<div class="form-control my-6">
<div class="form-control mt-6">
<%= f.text_field :name, required: true, placeholder: 'Template Name', class: 'base-input' %>
</div>
<div class="form-control mt-4">
<div class="mt-3 mb-4 flex items-center justify-between">
<a href="#" onclick="[event.preventDefault(), window.folder_name.focus()]">
<%= svg_icon('folder', class: 'w-6 h-6') %>
</a>
<folder-autocomplete class="flex justify-between w-full">
<input id="folder_name" type="text" class="w-64 outline-none border-transparent focus:border-transparent focus:ring-0 bg-base-100 px-1 peer" name="folder_name" value="<%= params[:folder_name].presence || @base_template&.folder&.name || TemplateFolder::DEFAULT_NAME %>" onblur="window.folder_name.value = window.folder_name.value || 'Default'" autocomplete="off">
<a href="#" onclick="[event.preventDefault(), window.folder_name.value = '', window.folder_name.focus()]" class="link peer-focus:hidden mr-1.5">
Change Folder
</a>
</folder-autocomplete>
</div>
<div class="form-control">
<%= f.button button_title(title: @base_template ? 'Submit' : 'Create', disabled_with: 'Creating'), class: 'base-button' %>
</div>
<% end %>
Loaded 100 of 134 files, more files were not shown because too many files have changed in this diff. Show more