Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
79e158be43 | ||
|
|
41ee2dba6a | ||
|
|
8e1354175f | ||
|
|
2474adffc2 | ||
|
|
b8fd5a77d4 | ||
|
|
18bb57aa99 | ||
|
|
17ff193e34 | ||
|
|
20ac63e524 | ||
|
|
518bbe3d1c | ||
|
|
fa5e3e7163 | ||
|
|
2b8280168e | ||
|
|
82854b8ce8 | ||
|
|
276b4f4a5f | ||
|
|
b73a730390 | ||
|
|
2e98885f41 | ||
|
|
bfece574b9 | ||
|
|
0dd5bbe25a | ||
|
|
3975c963c5 | ||
|
|
1f41807412 | ||
|
|
872fbbc875 | ||
|
|
b98a874e20 | ||
|
|
50e123c221 | ||
|
|
7f97bfb3bd | ||
|
|
784665b549 | ||
|
|
c651709e45 | ||
|
|
881a2acbfc | ||
|
|
c49cb4b0c8 | ||
|
|
17b8354c40 | ||
|
|
dc7593c70c | ||
|
|
d2edb2fb69 | ||
|
|
154a30b60b | ||
|
|
392c5c1c54 | ||
|
|
cad0d0f371 | ||
|
|
cb879738c0 | ||
|
|
abfa64140f | ||
|
|
db1e2b9aa2 | ||
|
|
c1ab4fafb2 | ||
|
|
7ba3a604bd | ||
|
|
cf85bea0df | ||
|
|
5643094a7a | ||
|
|
380f553a17 | ||
|
|
f8712a9da9 | ||
|
|
ef8c3cfe7f | ||
|
|
9bcf5aacc0 | ||
|
|
54592e7264 | ||
|
|
f1f4de53c6 | ||
|
|
ee350324e5 | ||
|
|
dc02964d9b | ||
|
|
0424b93fe7 | ||
|
|
905e0b22ed |
No files matched your search
@@ -138,4 +138,5 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
bundle exec rake db:create
|
bundle exec rake db:create
|
||||||
bundle exec rake db:migrate
|
bundle exec rake db:migrate
|
||||||
|
bundle exec rake assets:precompile
|
||||||
bundle exec rspec
|
bundle exec rspec
|
||||||
@@ -13,6 +13,7 @@ gem 'devise-two-factor'
|
|||||||
gem 'dotenv', require: false
|
gem 'dotenv', require: false
|
||||||
gem 'email_typo'
|
gem 'email_typo'
|
||||||
gem 'faraday'
|
gem 'faraday'
|
||||||
|
gem 'faraday-follow_redirects'
|
||||||
gem 'google-cloud-storage', require: false
|
gem 'google-cloud-storage', require: false
|
||||||
gem 'hexapdf'
|
gem 'hexapdf'
|
||||||
gem 'image_processing'
|
gem 'image_processing'
|
||||||
|
|||||||
@@ -189,6 +189,8 @@ GEM
|
|||||||
faraday-em_http (1.0.0)
|
faraday-em_http (1.0.0)
|
||||||
faraday-em_synchrony (1.0.0)
|
faraday-em_synchrony (1.0.0)
|
||||||
faraday-excon (1.1.0)
|
faraday-excon (1.1.0)
|
||||||
|
faraday-follow_redirects (0.3.0)
|
||||||
|
faraday (>= 1, < 3)
|
||||||
faraday-httpclient (1.0.1)
|
faraday-httpclient (1.0.1)
|
||||||
faraday-multipart (1.0.4)
|
faraday-multipart (1.0.4)
|
||||||
multipart-post (~> 2)
|
multipart-post (~> 2)
|
||||||
@@ -580,6 +582,7 @@ DEPENDENCIES
|
|||||||
factory_bot_rails
|
factory_bot_rails
|
||||||
faker
|
faker
|
||||||
faraday
|
faraday
|
||||||
|
faraday-follow_redirects
|
||||||
google-cloud-storage
|
google-cloud-storage
|
||||||
hexapdf
|
hexapdf
|
||||||
image_processing
|
image_processing
|
||||||
|
|||||||
@@ -52,8 +52,8 @@ DocuSeal is an open source platform that provides secure and efficient digital d
|
|||||||
| [<img alt="Deploy on Heroku" src="https://www.herokucdn.com/deploy/button.svg" height="40">](https://heroku.com/deploy?template=https://github.com/docusealco/docuseal-heroku) | [<img alt="Deploy on Railway" src="https://railway.app/button.svg" height="40">](https://railway.app/template/IGoDnc?referralCode=ruU7JR)|
|
| [<img alt="Deploy on Heroku" src="https://www.herokucdn.com/deploy/button.svg" height="40">](https://heroku.com/deploy?template=https://github.com/docusealco/docuseal-heroku) | [<img alt="Deploy on Railway" src="https://railway.app/button.svg" height="40">](https://railway.app/template/IGoDnc?referralCode=ruU7JR)|
|
||||||
|**DigitalOcean**|**Render**|
|
|**DigitalOcean**|**Render**|
|
||||||
| [<img alt="Deploy on DigitalOcean" src="https://www.deploytodo.com/do-btn-blue.svg" height="40">](https://cloud.digitalocean.com/apps/new?repo=https://github.com/docusealco/docuseal-digitalocean/tree/master&refcode=421d50f53990) | [<img alt="Deploy to Render" src="https://render.com/images/deploy-to-render-button.svg" height="40">](https://render.com/deploy?repo=https://github.com/docusealco/docuseal-render)
|
| [<img alt="Deploy on DigitalOcean" src="https://www.deploytodo.com/do-btn-blue.svg" height="40">](https://cloud.digitalocean.com/apps/new?repo=https://github.com/docusealco/docuseal-digitalocean/tree/master&refcode=421d50f53990) | [<img alt="Deploy to Render" src="https://render.com/images/deploy-to-render-button.svg" height="40">](https://render.com/deploy?repo=https://github.com/docusealco/docuseal-render)
|
||||||
|**Koyeb**| |
|
|**Koyeb**|**Elestio**|
|
||||||
| [<img alt="Deploy on Koyeb" src="https://www.koyeb.com/static/images/deploy/button.svg" height="40">](https://app.koyeb.com/deploy?name=docuseal&type=docker&image=docker.io/docuseal/docuseal&env[PORT]=8000&env[DATABASE_URL]=CHANGE_ME&env[SECRET_KEY_BASE]=CHANGE_ME&ports=8000;http;/) | |
|
| [<img alt="Deploy on Koyeb" src="https://www.koyeb.com/static/images/deploy/button.svg" height="40">](https://app.koyeb.com/deploy?name=docuseal&type=docker&image=docker.io/docuseal/docuseal&env[PORT]=8000&env[DATABASE_URL]=CHANGE_ME&env[SECRET_KEY_BASE]=CHANGE_ME&ports=8000;http;/) | [<img alt="Deploy on Elestio" src="https://pub-da36157c854648669813f3f76c526c2b.r2.dev/deploy-on-elestio-black.png">](https://dash.elest.io/deploy?soft=DocuSeal&id=339) |
|
||||||
|
|
||||||
|
|
||||||
#### Docker
|
#### Docker
|
||||||
@@ -81,9 +81,9 @@ HOST=your-domain-name.com docker-compose up
|
|||||||
|
|
||||||
At DocuSeal we have expertise and technologies to make documents creation, filling, signing and processing seamlessly integrated with your product. We specialize in working with various industries, including **Banking, Healthcare, Transport, Real Estate, eCommerce, KYC, CRM, and other software products** that require bulk document signing. By leveraging DocuSeal, we can assist in reducing the overall cost of developing and processing electronic documents while ensuring security and compliance with local electronic document laws.
|
At DocuSeal we have expertise and technologies to make documents creation, filling, signing and processing seamlessly integrated with your product. We specialize in working with various industries, including **Banking, Healthcare, Transport, Real Estate, eCommerce, KYC, CRM, and other software products** that require bulk document signing. By leveraging DocuSeal, we can assist in reducing the overall cost of developing and processing electronic documents while ensuring security and compliance with local electronic document laws.
|
||||||
|
|
||||||
[](https://cal.com/docuseal)
|
[Book a Meeting](https://calendly.com/kriti-docuseal/30min)
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
Distributed under the AGPLv3 License. See [LICENSE](https://github.com/docusealco/docuseal/blob/master/LICENSE) for more information.
|
Distributed under the AGPLv3 License. See [LICENSE](https://github.com/docusealco/docuseal/blob/master/LICENSE) for more information.
|
||||||
Unless otherwise noted, all files © 2023 Oleksandr Turchyn.
|
Unless otherwise noted, all files © 2023 DocuSeal LLC.
|
||||||
@@ -3,6 +3,12 @@
|
|||||||
module Api
|
module Api
|
||||||
class ApiBaseController < ActionController::API
|
class ApiBaseController < ActionController::API
|
||||||
include ActiveStorage::SetCurrent
|
include ActiveStorage::SetCurrent
|
||||||
|
include Pagy::Backend
|
||||||
|
|
||||||
|
DEFAULT_LIMIT = 10
|
||||||
|
MAX_LIMIT = 100
|
||||||
|
|
||||||
|
wrap_parameters false
|
||||||
|
|
||||||
before_action :authenticate_user!
|
before_action :authenticate_user!
|
||||||
check_authorization
|
check_authorization
|
||||||
@@ -17,6 +23,16 @@ module Api
|
|||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
def paginate(relation)
|
||||||
|
result = relation.order(id: :desc)
|
||||||
|
.limit([params[: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
|
def current_account
|
||||||
current_user&.account
|
current_user&.account
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -2,78 +2,150 @@
|
|||||||
|
|
||||||
module Api
|
module Api
|
||||||
class SubmissionsController < ApiBaseController
|
class SubmissionsController < ApiBaseController
|
||||||
UnknownFieldName = Class.new(StandardError)
|
load_and_authorize_resource :template, only: :create
|
||||||
UnknownSubmitterName = Class.new(StandardError)
|
load_and_authorize_resource :submission, only: %i[show index]
|
||||||
|
|
||||||
load_and_authorize_resource :template
|
before_action only: :create do
|
||||||
|
|
||||||
before_action do
|
|
||||||
authorize!(:create, Submission)
|
authorize!(:create, Submission)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def index
|
||||||
|
submissions = Submissions.search(@submissions, params[:q])
|
||||||
|
submissions = submissions.where(template_id: params[:template_id]) if params[:template_id].present?
|
||||||
|
|
||||||
|
submissions = paginate(submissions.preload(:created_by_user, :template, :submitters))
|
||||||
|
|
||||||
|
render json: {
|
||||||
|
data: submissions.as_json(serialize_params),
|
||||||
|
pagination: {
|
||||||
|
count: submissions.size,
|
||||||
|
next: submissions.last&.id,
|
||||||
|
prev: submissions.first&.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def show
|
||||||
|
serialized_subbmitters =
|
||||||
|
@submission.submitters.preload(documents_attachments: :blob, attachments_attachments: :blob).map do |submitter|
|
||||||
|
Submissions::EnsureResultGenerated.call(submitter) if submitter.completed_at?
|
||||||
|
|
||||||
|
Submitters::SerializeForApi.call(submitter)
|
||||||
|
end
|
||||||
|
|
||||||
|
json = @submission.as_json(
|
||||||
|
serialize_params.deep_merge(
|
||||||
|
include: {
|
||||||
|
submission_events: {
|
||||||
|
only: %i[id submitter_id event_type event_timestamp]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
json[:submitters] = serialized_subbmitters
|
||||||
|
|
||||||
|
render json:
|
||||||
|
end
|
||||||
|
|
||||||
def create
|
def create
|
||||||
|
is_send_email = !params[:send_email].in?(['false', false])
|
||||||
|
|
||||||
submissions =
|
submissions =
|
||||||
if (emails = (params[:emails] || params[:email]).presence)
|
if (emails = (params[:emails] || params[:email]).presence) && params[:submission].blank?
|
||||||
Submissions.create_from_emails(template: @template,
|
Submissions.create_from_emails(template: @template,
|
||||||
user: current_user,
|
user: current_user,
|
||||||
source: :api,
|
source: :api,
|
||||||
mark_as_sent: params[:send_email] != 'false',
|
mark_as_sent: is_send_email,
|
||||||
emails:)
|
emails:)
|
||||||
else
|
else
|
||||||
submissions_attrs = normalize_submissions_params!(submissions_params[:submission], @template)
|
submissions_attrs, attachments = normalize_submissions_params!(submissions_params[:submission], @template)
|
||||||
|
|
||||||
Submissions.create_from_submitters(
|
Submissions.create_from_submitters(
|
||||||
template: @template,
|
template: @template,
|
||||||
user: current_user,
|
user: current_user,
|
||||||
source: :api,
|
source: :api,
|
||||||
mark_as_sent: params[:send_email] != 'false',
|
mark_as_sent: is_send_email,
|
||||||
submitters_order: params[:submitters_order] || 'preserved',
|
submitters_order: params[:submitters_order] || 'preserved',
|
||||||
submissions_attrs:
|
submissions_attrs:
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
Submissions.send_signature_requests(submissions, send_email: params[:send_email] != 'false')
|
Submissions.send_signature_requests(submissions, send_email: is_send_email)
|
||||||
|
|
||||||
render json: submissions.flat_map(&:submitters)
|
submitters = submissions.flat_map(&:submitters)
|
||||||
rescue UnknownFieldName, UnknownSubmitterName => e
|
|
||||||
|
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
|
render json: { error: e.message }, status: :unprocessable_entity
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def destroy
|
||||||
|
@submission.update!(deleted_at: Time.current)
|
||||||
|
|
||||||
|
render json: @submission.as_json(only: %i[id deleted_at])
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
def serialize_params
|
||||||
|
{
|
||||||
|
only: %i[id source submitters_order created_at updated_at],
|
||||||
|
include: {
|
||||||
|
submitters: { only: %i[id slug uuid name email phone
|
||||||
|
completed_at opened_at sent_at
|
||||||
|
created_at updated_at],
|
||||||
|
methods: %i[status] },
|
||||||
|
template: { only: %i[id name created_at updated_at] },
|
||||||
|
created_by_user: { only: %i[id email first_name last_name] }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
def submissions_params
|
def submissions_params
|
||||||
params.permit(submission: [{ submitters: [[:uuid, :name, :email, :role, :phone, { values: {} }]] }])
|
params.permit(submission: [{
|
||||||
|
submitters: [[:uuid, :name, :email, :role, :completed, :phone, { values: {} }]]
|
||||||
|
}])
|
||||||
end
|
end
|
||||||
|
|
||||||
def normalize_submissions_params!(submissions_params, template)
|
def normalize_submissions_params!(submissions_params, template)
|
||||||
submissions_params.each do |submission|
|
attachments = []
|
||||||
|
|
||||||
|
Array.wrap(submissions_params).each do |submission|
|
||||||
submission[:submitters].each_with_index do |submitter, index|
|
submission[:submitters].each_with_index do |submitter, index|
|
||||||
next if submitter[:values].blank?
|
next if submitter[:values].blank?
|
||||||
|
|
||||||
submitter[:values] =
|
values, new_attachments =
|
||||||
normalize_submitter_values(template,
|
Submitters::NormalizeValues.call(template,
|
||||||
submitter[:values],
|
submitter[:values],
|
||||||
submitter[:role] || template.submitters[index]['name'])
|
submitter[:role] || template.submitters[index]['name'])
|
||||||
|
|
||||||
|
attachments.push(*new_attachments)
|
||||||
|
|
||||||
|
submitter[:values] = values
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
submissions_params
|
[submissions_params, attachments]
|
||||||
end
|
end
|
||||||
|
|
||||||
def normalize_submitter_values(template, values, submitter_name)
|
def save_default_value_attachments!(attachments, submitters)
|
||||||
submitter =
|
return if attachments.blank?
|
||||||
template.submitters.find { |e| e['name'] == submitter_name } ||
|
|
||||||
raise(UnknownSubmitterName, "Unknown submitter: #{submitter_name}")
|
|
||||||
|
|
||||||
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'] }
|
submitters.each do |submitter|
|
||||||
fields_name_index = fields.index_by { |e| e['name'] }
|
submitter.values.to_a.each do |_, value|
|
||||||
|
attachment = attachments_index[value]
|
||||||
|
|
||||||
values.transform_keys do |key|
|
next unless attachment
|
||||||
next key if fields_uuid_index[key].present?
|
|
||||||
|
|
||||||
fields_name_index[key]&.dig('uuid') || raise(UnknownFieldName, "Unknown field: #{key}")
|
attachment.record = submitter
|
||||||
|
|
||||||
|
attachment.save!
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Api
|
||||||
|
class SubmittersAutocompleteController < ApiBaseController
|
||||||
|
load_and_authorize_resource :submitter, parent: false
|
||||||
|
|
||||||
|
SELECT_COLUMNS = %w[email phone name].freeze
|
||||||
|
LIMIT = 100
|
||||||
|
|
||||||
|
def index
|
||||||
|
submitters = search_submitters(@submitters)
|
||||||
|
|
||||||
|
values = submitters.limit(LIMIT).group(SELECT_COLUMNS.join(', ')).pluck(SELECT_COLUMNS.join(', '))
|
||||||
|
|
||||||
|
attrs = values.map { |row| SELECT_COLUMNS.zip(row).to_h }
|
||||||
|
attrs = attrs.uniq { |e| e[params[:field]] } if params[:field].present?
|
||||||
|
|
||||||
|
render json: attrs
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def search_submitters(submitters)
|
||||||
|
if SELECT_COLUMNS.include?(params[:field])
|
||||||
|
column = Submitter.arel_table[params[:field].to_sym]
|
||||||
|
|
||||||
|
term = "%#{params[:q].downcase}%"
|
||||||
|
|
||||||
|
submitters.where(column.lower.matches(term))
|
||||||
|
else
|
||||||
|
Submitters.search(submitters, params[:q])
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,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
|
||||||
@@ -5,22 +5,51 @@ module Api
|
|||||||
load_and_authorize_resource :template
|
load_and_authorize_resource :template
|
||||||
|
|
||||||
def index
|
def index
|
||||||
render json: @templates
|
templates = Templates.search(@templates, params[:q])
|
||||||
|
|
||||||
|
templates = params[:archived] ? templates.archived : templates.active
|
||||||
|
|
||||||
|
templates = paginate(templates.preload(:author, documents_attachments: :blob))
|
||||||
|
|
||||||
|
render json: {
|
||||||
|
data: templates.as_json(serialize_params),
|
||||||
|
pagination: {
|
||||||
|
count: templates.size,
|
||||||
|
next: templates.last&.id,
|
||||||
|
prev: templates.first&.id
|
||||||
|
}
|
||||||
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
def show
|
def show
|
||||||
render json: @template.as_json(include: { author: { only: %i[id email first_name last_name] },
|
render json: @template.as_json(serialize_params)
|
||||||
documents: { only: %i[id uuid], methods: %i[url filename] } })
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def update
|
def update
|
||||||
|
if (folder_name = params.dig(:template, :folder_name))
|
||||||
|
@template.folder = TemplateFolders.find_or_create_by_name(current_user, folder_name)
|
||||||
|
end
|
||||||
|
|
||||||
@template.update!(template_params)
|
@template.update!(template_params)
|
||||||
|
|
||||||
render :ok
|
render json: @template.as_json(only: %i[id updated_at])
|
||||||
|
end
|
||||||
|
|
||||||
|
def destroy
|
||||||
|
@template.update!(deleted_at: Time.current)
|
||||||
|
|
||||||
|
render json: @template.as_json(only: %i[id deleted_at])
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
def serialize_params
|
||||||
|
{
|
||||||
|
include: { author: { only: %i[id email first_name last_name] },
|
||||||
|
documents: { only: %i[id uuid], methods: %i[url filename] } }
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
def template_params
|
def template_params
|
||||||
params.require(:template).permit(:name,
|
params.require(:template).permit(:name,
|
||||||
schema: [%i[attachment_uuid name]],
|
schema: [%i[attachment_uuid name]],
|
||||||
|
|||||||
@@ -6,17 +6,56 @@ class DashboardController < ApplicationController
|
|||||||
before_action :maybe_redirect_product_url
|
before_action :maybe_redirect_product_url
|
||||||
before_action :maybe_render_landing
|
before_action :maybe_render_landing
|
||||||
|
|
||||||
|
load_and_authorize_resource :template_folder, parent: false
|
||||||
load_and_authorize_resource :template, parent: false
|
load_and_authorize_resource :template, parent: false
|
||||||
|
|
||||||
def index
|
SHOW_TEMPLATES_FOLDERS_THRESHOLD = 9
|
||||||
@templates = @templates.active.preload(:author).order(id: :desc)
|
TEMPLATES_PER_PAGE = 12
|
||||||
@templates = Templates.search(@templates, params[:q])
|
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
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
def filter_template_folders(template_folders)
|
||||||
|
rel = template_folders.joins(:active_templates)
|
||||||
|
.order(id: :desc)
|
||||||
|
.distinct
|
||||||
|
|
||||||
|
TemplateFolders.search(rel, params[:q])
|
||||||
|
end
|
||||||
|
|
||||||
|
def filter_templates(templates)
|
||||||
|
rel = templates.active.preload(:author).order(id: :desc)
|
||||||
|
rel = rel.where(folder_id: current_account.default_template_folder.id) if params[:q].blank?
|
||||||
|
|
||||||
|
Templates.search(rel, params[:q])
|
||||||
|
end
|
||||||
|
|
||||||
def maybe_redirect_product_url
|
def maybe_redirect_product_url
|
||||||
return if !Docuseal.multitenant? || signed_in?
|
return if !Docuseal.multitenant? || signed_in?
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ class StartFormController < ApplicationController
|
|||||||
else
|
else
|
||||||
@submitter.assign_attributes(
|
@submitter.assign_attributes(
|
||||||
uuid: @template.submitters.first['uuid'],
|
uuid: @template.submitters.first['uuid'],
|
||||||
opened_at: Time.current,
|
|
||||||
ip: request.remote_ip,
|
ip: request.remote_ip,
|
||||||
ua: request.user_agent
|
ua: request.user_agent
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -15,7 +15,11 @@ class SubmitFormController < ApplicationController
|
|||||||
|
|
||||||
return redirect_to submit_form_completed_path(@submitter.slug) if @submitter.completed_at?
|
return redirect_to submit_form_completed_path(@submitter.slug) if @submitter.completed_at?
|
||||||
|
|
||||||
|
Submitters::MaybeUpdateDefaultValues.call(@submitter, current_user)
|
||||||
|
|
||||||
cookies[:submitter_sid] = @submitter.signed_id
|
cookies[:submitter_sid] = @submitter.signed_id
|
||||||
|
|
||||||
|
render @submitter.submission.template.deleted_at? ? :archived : :show
|
||||||
end
|
end
|
||||||
|
|
||||||
def update
|
def update
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class TemplateFoldersController < ApplicationController
|
||||||
|
load_and_authorize_resource :template_folder
|
||||||
|
|
||||||
|
def show
|
||||||
|
@templates = @template_folder.templates.active.preload(:author).order(id: :desc)
|
||||||
|
@templates = Templates.search(@templates, params[:q])
|
||||||
|
|
||||||
|
@pagy, @templates = pagy(@templates, items: 12)
|
||||||
|
end
|
||||||
|
|
||||||
|
def edit; end
|
||||||
|
|
||||||
|
def update
|
||||||
|
if @template_folder != current_account.default_template_folder &&
|
||||||
|
@template_folder.update(template_folder_params)
|
||||||
|
redirect_to folder_path(@template_folder), notice: 'Folder name has been updated'
|
||||||
|
else
|
||||||
|
redirect_to folder_path(@template_folder), alert: 'Unable to rename folder'
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def template_folder_params
|
||||||
|
params.require(:template_folder).permit(:name)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -4,7 +4,7 @@ class TemplatesArchivedController < ApplicationController
|
|||||||
load_and_authorize_resource :template, parent: false
|
load_and_authorize_resource :template, parent: false
|
||||||
|
|
||||||
def index
|
def index
|
||||||
@templates = @templates.where.not(deleted_at: nil).preload(:author).order(id: :desc)
|
@templates = @templates.where.not(deleted_at: nil).preload(:author, :folder).order(id: :desc)
|
||||||
@templates = Templates.search(@templates, params[:q])
|
@templates = Templates.search(@templates, params[:q])
|
||||||
|
|
||||||
@pagy, @templates = pagy(@templates, items: 12)
|
@pagy, @templates = pagy(@templates, items: 12)
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ class TemplatesController < ApplicationController
|
|||||||
def create
|
def create
|
||||||
@template.account = current_account
|
@template.account = current_account
|
||||||
@template.author = current_user
|
@template.author = current_user
|
||||||
|
@template.folder = TemplateFolders.find_or_create_by_name(current_user, params[:folder_name])
|
||||||
@template.assign_attributes(@base_template.slice(:fields, :schema, :submitters)) if @base_template
|
@template.assign_attributes(@base_template.slice(:fields, :schema, :submitters)) if @base_template
|
||||||
|
|
||||||
if @template.save
|
if @template.save
|
||||||
|
|||||||
@@ -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
|
load_and_authorize_resource :template, parent: false
|
||||||
|
|
||||||
def create
|
def create
|
||||||
|
@template.account = current_account
|
||||||
@template.author = current_user
|
@template.author = current_user
|
||||||
|
@template.folder = TemplateFolders.find_or_create_by_name(current_user, params[:folder_name])
|
||||||
@template.name = File.basename(params[:files].first.original_filename, '.*')
|
@template.name = File.basename(params[:files].first.original_filename, '.*')
|
||||||
|
|
||||||
@template.save!
|
@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
|
||||||
@@ -27,7 +27,10 @@ class UsersController < ApplicationController
|
|||||||
def update
|
def update
|
||||||
return redirect_to settings_users_path, notice: 'Unable to update user.' if Docuseal.demo?
|
return redirect_to settings_users_path, notice: 'Unable to update user.' if Docuseal.demo?
|
||||||
|
|
||||||
if @user.update(user_params.compact_blank.except(current_user == @user ? :role : nil))
|
attrs = user_params.compact_blank
|
||||||
|
attrs.delete(:role) if User::ROLES.exclude?(attrs[:role])
|
||||||
|
|
||||||
|
if @user.update(attrs.except(current_user == @user ? :role : nil))
|
||||||
redirect_to settings_users_path, notice: 'User has been updated'
|
redirect_to settings_users_path, notice: 'User has been updated'
|
||||||
else
|
else
|
||||||
render turbo_stream: turbo_stream.replace(:modal, template: 'users/edit'), status: :unprocessable_entity
|
render turbo_stream: turbo_stream.replace(:modal, template: 'users/edit'), status: :unprocessable_entity
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ import DownloadButton from './elements/download_button'
|
|||||||
import SetOriginUrl from './elements/set_origin_url'
|
import SetOriginUrl from './elements/set_origin_url'
|
||||||
import SetTimezone from './elements/set_timezone'
|
import SetTimezone from './elements/set_timezone'
|
||||||
import AutoresizeTextarea from './elements/autoresize_textarea'
|
import AutoresizeTextarea from './elements/autoresize_textarea'
|
||||||
|
import SubmittersAutocomplete from './elements/submitter_autocomplete'
|
||||||
|
import FolderAutocomplete from './elements/folder_autocomplete'
|
||||||
|
import SignatureForm from './elements/signature_form'
|
||||||
|
|
||||||
import * as TurboInstantClick from './lib/turbo_instant_click'
|
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-origin-url', SetOriginUrl)
|
||||||
window.customElements.define('set-timezone', SetTimezone)
|
window.customElements.define('set-timezone', SetTimezone)
|
||||||
window.customElements.define('autoresize-textarea', AutoresizeTextarea)
|
window.customElements.define('autoresize-textarea', AutoresizeTextarea)
|
||||||
|
window.customElements.define('submitters-autocomplete', SubmittersAutocomplete)
|
||||||
|
window.customElements.define('folder-autocomplete', FolderAutocomplete)
|
||||||
|
window.customElements.define('signature-form', SignatureForm)
|
||||||
|
|
||||||
document.addEventListener('turbo:before-fetch-request', encodeMethodIntoRequestBody)
|
document.addEventListener('turbo:before-fetch-request', encodeMethodIntoRequestBody)
|
||||||
document.addEventListener('turbo:submit-end', async (event) => {
|
document.addEventListener('turbo:submit-end', async (event) => {
|
||||||
@@ -70,12 +76,12 @@ document.addEventListener('turbo:submit-end', async (event) => {
|
|||||||
window.customElements.define('template-builder', class extends HTMLElement {
|
window.customElements.define('template-builder', class extends HTMLElement {
|
||||||
connectedCallback () {
|
connectedCallback () {
|
||||||
this.appElem = document.createElement('div')
|
this.appElem = document.createElement('div')
|
||||||
this.appElem.classList.add('max-h-screen')
|
|
||||||
|
|
||||||
this.app = createApp(TemplateBuilder, {
|
this.app = createApp(TemplateBuilder, {
|
||||||
template: reactive(JSON.parse(this.dataset.template)),
|
template: reactive(JSON.parse(this.dataset.template)),
|
||||||
backgroundColor: '#faf7f5',
|
backgroundColor: '#faf7f5',
|
||||||
withPhone: this.dataset.withPhone === 'true',
|
withPhone: this.dataset.withPhone === 'true',
|
||||||
|
withLogo: this.dataset.withLogo !== 'false',
|
||||||
acceptFileTypes: this.dataset.acceptFileTypes,
|
acceptFileTypes: this.dataset.acceptFileTypes,
|
||||||
isDirectUpload: this.dataset.isDirectUpload === 'true'
|
isDirectUpload: this.dataset.isDirectUpload === 'true'
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -74,3 +74,54 @@ button[disabled] .enabled {
|
|||||||
.base-select {
|
.base-select {
|
||||||
@apply select base-input w-full font-normal;
|
@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);
|
||||||
|
}
|
||||||
@@ -91,11 +91,13 @@ export default actionable(targetable(class extends HTMLElement {
|
|||||||
this.append(input)
|
this.append(input)
|
||||||
})
|
})
|
||||||
|
|
||||||
if (this.dataset.submitOnUpload) {
|
if (this.dataset.submitOnUpload === 'true') {
|
||||||
this.closest('form').querySelector('button[type="submit"]').click()
|
this.closest('form').querySelector('button[type="submit"]').click()
|
||||||
}
|
}
|
||||||
}).finally(() => {
|
}).finally(() => {
|
||||||
this.toggleLoading()
|
if (this.dataset.submitOnUpload !== 'true') {
|
||||||
|
this.toggleLoading()
|
||||||
|
}
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
if (this.dataset.submitOnUpload) {
|
if (this.dataset.submitOnUpload) {
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import autocomplete from 'autocompleter'
|
||||||
|
|
||||||
|
export default class extends HTMLElement {
|
||||||
|
connectedCallback () {
|
||||||
|
autocomplete({
|
||||||
|
input: this.input,
|
||||||
|
preventSubmit: this.dataset.submitOnSelect === 'true' ? 0 : 1,
|
||||||
|
minLength: 0,
|
||||||
|
showOnFocus: true,
|
||||||
|
onSelect: this.onSelect,
|
||||||
|
render: this.render,
|
||||||
|
fetch: this.fetch
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
onSelect = (item) => {
|
||||||
|
this.input.value = item.name
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch = (text, resolve) => {
|
||||||
|
const queryParams = new URLSearchParams({ q: text })
|
||||||
|
|
||||||
|
fetch('/api/template_folders_autocomplete?' + queryParams).then(async (resp) => {
|
||||||
|
const items = await resp.json()
|
||||||
|
|
||||||
|
resolve(items)
|
||||||
|
}).catch(() => {
|
||||||
|
resolve([])
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
render = (item) => {
|
||||||
|
const div = document.createElement('div')
|
||||||
|
|
||||||
|
div.textContent = item.name
|
||||||
|
|
||||||
|
return div
|
||||||
|
}
|
||||||
|
|
||||||
|
get input () {
|
||||||
|
return this.querySelector('input')
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { target, targetable } from '@github/catalyst/lib/targetable'
|
||||||
|
import { cropCanvasAndExportToPNG } from '../submission_form/crop_canvas'
|
||||||
|
|
||||||
|
export default targetable(class extends HTMLElement {
|
||||||
|
static [target.static] = ['canvas', 'input', 'clear', 'button']
|
||||||
|
|
||||||
|
async connectedCallback () {
|
||||||
|
this.canvas.width = this.canvas.parentNode.parentNode.clientWidth
|
||||||
|
this.canvas.height = this.canvas.parentNode.parentNode.clientWidth / 3
|
||||||
|
|
||||||
|
const { default: SignaturePad } = await import('signature_pad')
|
||||||
|
|
||||||
|
this.pad = new SignaturePad(this.canvas)
|
||||||
|
|
||||||
|
this.clear.addEventListener('click', (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
|
||||||
|
this.pad.clear()
|
||||||
|
})
|
||||||
|
|
||||||
|
this.button.addEventListener('click', (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
|
||||||
|
this.button.disabled = true
|
||||||
|
|
||||||
|
this.submit()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async submit () {
|
||||||
|
const blob = await cropCanvasAndExportToPNG(this.canvas)
|
||||||
|
const file = new File([blob], 'signature.png', { type: 'image/png' })
|
||||||
|
|
||||||
|
const dataTransfer = new DataTransfer()
|
||||||
|
|
||||||
|
dataTransfer.items.add(file)
|
||||||
|
|
||||||
|
this.input.files = dataTransfer.files
|
||||||
|
|
||||||
|
if (this.input.webkitEntries.length) {
|
||||||
|
this.input.dataset.file = `${dataTransfer.files[0].name}`
|
||||||
|
}
|
||||||
|
|
||||||
|
this.closest('form').requestSubmit()
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import autocomplete from 'autocompleter'
|
||||||
|
|
||||||
|
export default class extends HTMLElement {
|
||||||
|
connectedCallback () {
|
||||||
|
autocomplete({
|
||||||
|
input: this.input,
|
||||||
|
preventSubmit: 1,
|
||||||
|
minLength: 1,
|
||||||
|
showOnFocus: true,
|
||||||
|
onSelect: this.onSelect,
|
||||||
|
render: this.render,
|
||||||
|
fetch: this.fetch
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
onSelect = (item) => {
|
||||||
|
const fields = ['email', 'name', 'phone']
|
||||||
|
const submitterItemEl = this.closest('submitter-item')
|
||||||
|
|
||||||
|
fields.forEach((field) => {
|
||||||
|
const input = submitterItemEl.querySelector(`submitters-autocomplete[data-field="${field}"] input`)
|
||||||
|
const textarea = submitterItemEl.querySelector(`submitters-autocomplete[data-field="${field}"] textarea`)
|
||||||
|
|
||||||
|
if (input && item[field]) {
|
||||||
|
input.value = item[field]
|
||||||
|
}
|
||||||
|
|
||||||
|
if (textarea && item[field]) {
|
||||||
|
textarea.value = textarea.value.replace(/[^;,\s]+$/, item[field] + ' ')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch = (text, resolve) => {
|
||||||
|
const q = text.split(/[;,\s]+/).pop().trim()
|
||||||
|
|
||||||
|
if (q) {
|
||||||
|
const queryParams = new URLSearchParams({ q, field: this.dataset.field })
|
||||||
|
|
||||||
|
fetch('/api/submitters_autocomplete?' + queryParams).then(async (resp) => {
|
||||||
|
const items = await resp.json()
|
||||||
|
|
||||||
|
if (q.length < 3) {
|
||||||
|
resolve(items.filter((e) => e[this.dataset.field].startsWith(q)))
|
||||||
|
} else {
|
||||||
|
resolve(items)
|
||||||
|
}
|
||||||
|
}).catch(() => {
|
||||||
|
resolve([])
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
resolve([])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
render = (item) => {
|
||||||
|
const div = document.createElement('div')
|
||||||
|
|
||||||
|
div.textContent = item[this.dataset.field]
|
||||||
|
|
||||||
|
return div
|
||||||
|
}
|
||||||
|
|
||||||
|
get input () {
|
||||||
|
return this.querySelector('input') || this.querySelector('textarea')
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,10 +13,12 @@ window.customElements.define('submission-form', class extends HTMLElement {
|
|||||||
authenticityToken: this.dataset.authenticityToken,
|
authenticityToken: this.dataset.authenticityToken,
|
||||||
canSendEmail: this.dataset.canSendEmail === 'true',
|
canSendEmail: this.dataset.canSendEmail === 'true',
|
||||||
isDirectUpload: this.dataset.isDirectUpload === 'true',
|
isDirectUpload: this.dataset.isDirectUpload === 'true',
|
||||||
|
goToLast: this.dataset.goToLast === 'true',
|
||||||
isDemo: this.dataset.isDemo === 'true',
|
isDemo: this.dataset.isDemo === 'true',
|
||||||
attribution: this.dataset.attribution !== 'false',
|
attribution: this.dataset.attribution !== 'false',
|
||||||
withConfetti: true,
|
withConfetti: true,
|
||||||
values: reactive(JSON.parse(this.dataset.values)),
|
values: reactive(JSON.parse(this.dataset.values)),
|
||||||
|
completedButton: JSON.parse(this.dataset.completedButton),
|
||||||
attachments: reactive(JSON.parse(this.dataset.attachments)),
|
attachments: reactive(JSON.parse(this.dataset.attachments)),
|
||||||
fields: JSON.parse(this.dataset.fields)
|
fields: JSON.parse(this.dataset.fields)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -47,6 +47,11 @@
|
|||||||
class="object-contain mx-auto"
|
class="object-contain mx-auto"
|
||||||
:src="signature.url"
|
:src="signature.url"
|
||||||
>
|
>
|
||||||
|
<img
|
||||||
|
v-else-if="field.type === 'initials' && initials"
|
||||||
|
class="object-contain mx-auto"
|
||||||
|
:src="initials.url"
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
v-else-if="field.type === 'file'"
|
v-else-if="field.type === 'file'"
|
||||||
class="px-0.5 flex flex-col justify-center"
|
class="px-0.5 flex flex-col justify-center"
|
||||||
@@ -113,7 +118,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<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 {
|
export default {
|
||||||
name: 'FieldArea',
|
name: 'FieldArea',
|
||||||
@@ -175,6 +180,7 @@ export default {
|
|||||||
signature: 'Signature',
|
signature: 'Signature',
|
||||||
date: 'Date',
|
date: 'Date',
|
||||||
image: 'Image',
|
image: 'Image',
|
||||||
|
initials: 'Initials',
|
||||||
file: 'File',
|
file: 'File',
|
||||||
select: 'Select',
|
select: 'Select',
|
||||||
checkbox: 'Checkbox',
|
checkbox: 'Checkbox',
|
||||||
@@ -189,6 +195,7 @@ export default {
|
|||||||
signature: IconWritingSign,
|
signature: IconWritingSign,
|
||||||
date: IconCalendarEvent,
|
date: IconCalendarEvent,
|
||||||
image: IconPhoto,
|
image: IconPhoto,
|
||||||
|
initials: IconLetterCaseUpper,
|
||||||
file: IconPaperclip,
|
file: IconPaperclip,
|
||||||
select: IconSelect,
|
select: IconSelect,
|
||||||
checkbox: IconCheckbox,
|
checkbox: IconCheckbox,
|
||||||
@@ -212,6 +219,13 @@ export default {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
initials () {
|
||||||
|
if (this.field.type === 'initials') {
|
||||||
|
return this.attachmentsIndex[this.modelValue]
|
||||||
|
} else {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
},
|
||||||
formattedDate () {
|
formattedDate () {
|
||||||
if (this.field.type === 'date' && this.modelValue) {
|
if (this.field.type === 'date' && this.modelValue) {
|
||||||
return new Intl.DateTimeFormat([], { year: 'numeric', month: 'long', day: 'numeric', timeZone: 'UTC' }).format(new Date(this.modelValue))
|
return new Intl.DateTimeFormat([], { year: 'numeric', month: 'long', day: 'numeric', timeZone: 'UTC' }).format(new Date(this.modelValue))
|
||||||
|
|||||||
@@ -11,6 +11,15 @@
|
|||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
<div class="space-y-3 mt-5">
|
<div class="space-y-3 mt-5">
|
||||||
|
<a
|
||||||
|
v-if="completedButton.url"
|
||||||
|
:href="completedButton.url"
|
||||||
|
class="white-button flex items-center w-full"
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
{{ completedButton.title || 'Back to Website' }}
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
<button
|
<button
|
||||||
v-if="canSendEmail && !isDemo"
|
v-if="canSendEmail && !isDemo"
|
||||||
class="white-button !h-auto flex items-center space-x-1 w-full"
|
class="white-button !h-auto flex items-center space-x-1 w-full"
|
||||||
@@ -27,6 +36,7 @@
|
|||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
v-if="!isWebView"
|
||||||
class="base-button flex items-center space-x-1 w-full"
|
class="base-button flex items-center space-x-1 w-full"
|
||||||
:disabled="isDownloading"
|
:disabled="isDownloading"
|
||||||
@click.prevent="download"
|
@click.prevent="download"
|
||||||
@@ -114,6 +124,11 @@ export default {
|
|||||||
type: Boolean,
|
type: Boolean,
|
||||||
required: false,
|
required: false,
|
||||||
default: false
|
default: false
|
||||||
|
},
|
||||||
|
completedButton: {
|
||||||
|
type: Object,
|
||||||
|
required: false,
|
||||||
|
default: () => ({})
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
data () {
|
data () {
|
||||||
@@ -122,6 +137,11 @@ export default {
|
|||||||
isDownloading: false
|
isDownloading: false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
computed: {
|
||||||
|
isWebView () {
|
||||||
|
return /webview|wv|ip((?!.*Safari)|(?=.*like Safari))/i.test(window.navigator.userAgent)
|
||||||
|
}
|
||||||
|
},
|
||||||
async mounted () {
|
async mounted () {
|
||||||
if (this.withConfetti) {
|
if (this.withConfetti) {
|
||||||
const { default: confetti } = await import('canvas-confetti')
|
const { default: confetti } = await import('canvas-confetti')
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
function cropCanvasAndExportToPNG (canvas) {
|
||||||
|
const ctx = canvas.getContext('2d')
|
||||||
|
|
||||||
|
const width = canvas.width
|
||||||
|
const height = canvas.height
|
||||||
|
|
||||||
|
let topmost = height
|
||||||
|
let bottommost = 0
|
||||||
|
let leftmost = width
|
||||||
|
let rightmost = 0
|
||||||
|
|
||||||
|
const imageData = ctx.getImageData(0, 0, width, height)
|
||||||
|
const pixels = imageData.data
|
||||||
|
|
||||||
|
for (let y = 0; y < height; y++) {
|
||||||
|
for (let x = 0; x < width; x++) {
|
||||||
|
const pixelIndex = (y * width + x) * 4
|
||||||
|
const alpha = pixels[pixelIndex + 3]
|
||||||
|
if (alpha !== 0) {
|
||||||
|
topmost = Math.min(topmost, y)
|
||||||
|
bottommost = Math.max(bottommost, y)
|
||||||
|
leftmost = Math.min(leftmost, x)
|
||||||
|
rightmost = Math.max(rightmost, x)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const croppedWidth = rightmost - leftmost + 1
|
||||||
|
const croppedHeight = bottommost - topmost + 1
|
||||||
|
|
||||||
|
const croppedCanvas = document.createElement('canvas')
|
||||||
|
croppedCanvas.width = croppedWidth
|
||||||
|
croppedCanvas.height = croppedHeight
|
||||||
|
const croppedCtx = croppedCanvas.getContext('2d')
|
||||||
|
|
||||||
|
croppedCtx.drawImage(canvas, leftmost, topmost, croppedWidth, croppedHeight, 0, 0, croppedWidth, croppedHeight)
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
croppedCanvas.toBlob((blob) => {
|
||||||
|
if (blob) {
|
||||||
|
resolve(blob)
|
||||||
|
} else {
|
||||||
|
reject(new Error('Failed to create a PNG blob.'))
|
||||||
|
}
|
||||||
|
}, 'image/png')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export { cropCanvasAndExportToPNG }
|
||||||
@@ -224,6 +224,9 @@
|
|||||||
:id="field.uuid"
|
:id="field.uuid"
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
class="base-checkbox !h-7 !w-7"
|
class="base-checkbox !h-7 !w-7"
|
||||||
|
:oninvalid="`this.setCustomValidity('${t('please_check_the_box_to_continue')}')`"
|
||||||
|
:onchange="`this.setCustomValidity(validity.valueMissing ? '${t('please_check_the_box_to_continue')}' : '');`"
|
||||||
|
:required="field.required"
|
||||||
:checked="!!values[field.uuid]"
|
:checked="!!values[field.uuid]"
|
||||||
@click="[$refs.areas.scrollIntoField(field), values[field.uuid] = !values[field.uuid]]"
|
@click="[$refs.areas.scrollIntoField(field), values[field.uuid] = !values[field.uuid]]"
|
||||||
>
|
>
|
||||||
@@ -256,6 +259,19 @@
|
|||||||
@start="$refs.areas.scrollIntoField(currentField)"
|
@start="$refs.areas.scrollIntoField(currentField)"
|
||||||
@minimize="isFormVisible = false"
|
@minimize="isFormVisible = false"
|
||||||
/>
|
/>
|
||||||
|
<InitialsStep
|
||||||
|
v-else-if="currentField.type === 'initials'"
|
||||||
|
ref="currentStep"
|
||||||
|
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
|
<AttachmentStep
|
||||||
v-else-if="currentField.type === 'file'"
|
v-else-if="currentField.type === 'file'"
|
||||||
v-model="values[currentField.uuid]"
|
v-model="values[currentField.uuid]"
|
||||||
@@ -304,6 +320,7 @@
|
|||||||
v-else
|
v-else
|
||||||
:is-demo="isDemo"
|
:is-demo="isDemo"
|
||||||
:attribution="attribution"
|
:attribution="attribution"
|
||||||
|
:completed-button="completedButton"
|
||||||
:with-confetti="withConfetti"
|
:with-confetti="withConfetti"
|
||||||
:can-send-email="canSendEmail && !!submitter.email"
|
:can-send-email="canSendEmail && !!submitter.email"
|
||||||
:submitter-slug="submitterSlug"
|
:submitter-slug="submitterSlug"
|
||||||
@@ -328,6 +345,7 @@
|
|||||||
import FieldAreas from './areas'
|
import FieldAreas from './areas'
|
||||||
import ImageStep from './image_step'
|
import ImageStep from './image_step'
|
||||||
import SignatureStep from './signature_step'
|
import SignatureStep from './signature_step'
|
||||||
|
import InitialsStep from './initials_step'
|
||||||
import AttachmentStep from './attachment_step'
|
import AttachmentStep from './attachment_step'
|
||||||
import MultiSelectStep from './multi_select_step'
|
import MultiSelectStep from './multi_select_step'
|
||||||
import PhoneStep from './phone_step'
|
import PhoneStep from './phone_step'
|
||||||
@@ -342,6 +360,7 @@ export default {
|
|||||||
ImageStep,
|
ImageStep,
|
||||||
SignatureStep,
|
SignatureStep,
|
||||||
AttachmentStep,
|
AttachmentStep,
|
||||||
|
InitialsStep,
|
||||||
MultiSelectStep,
|
MultiSelectStep,
|
||||||
IconInnerShadowTop,
|
IconInnerShadowTop,
|
||||||
IconArrowsDiagonal,
|
IconArrowsDiagonal,
|
||||||
@@ -400,6 +419,16 @@ export default {
|
|||||||
required: false,
|
required: false,
|
||||||
default: false
|
default: false
|
||||||
},
|
},
|
||||||
|
allowToSkip: {
|
||||||
|
type: Boolean,
|
||||||
|
required: false,
|
||||||
|
default: true
|
||||||
|
},
|
||||||
|
goToLast: {
|
||||||
|
type: Boolean,
|
||||||
|
required: false,
|
||||||
|
default: true
|
||||||
|
},
|
||||||
isDemo: {
|
isDemo: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
required: false,
|
required: false,
|
||||||
@@ -414,6 +443,11 @@ export default {
|
|||||||
type: Object,
|
type: Object,
|
||||||
required: false,
|
required: false,
|
||||||
default: () => ({})
|
default: () => ({})
|
||||||
|
},
|
||||||
|
completedButton: {
|
||||||
|
type: Object,
|
||||||
|
required: false,
|
||||||
|
default: () => ({})
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
data () {
|
data () {
|
||||||
@@ -422,6 +456,7 @@ export default {
|
|||||||
isFormVisible: true,
|
isFormVisible: true,
|
||||||
currentStep: 0,
|
currentStep: 0,
|
||||||
isSubmitting: false,
|
isSubmitting: false,
|
||||||
|
submittedValues: {},
|
||||||
recalculateButtonDisabledKey: ''
|
recalculateButtonDisabledKey: ''
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -439,7 +474,8 @@ export default {
|
|||||||
if (this.recalculateButtonDisabledKey) {
|
if (this.recalculateButtonDisabledKey) {
|
||||||
return this.isSubmitting ||
|
return this.isSubmitting ||
|
||||||
(this.currentField.required && ['image', 'file'].includes(this.currentField.type) && !this.values[this.currentField.uuid]?.length) ||
|
(this.currentField.required && ['image', 'file'].includes(this.currentField.type) && !this.values[this.currentField.uuid]?.length) ||
|
||||||
(this.currentField.required && this.currentField.type === 'signature' && !this.values[this.currentField.uuid]?.length && this.$refs.currentStep && !this.$refs.currentStep.isSignatureStarted)
|
(this.currentField.required && this.currentField.type === 'signature' && !this.values[this.currentField.uuid]?.length && this.$refs.currentStep && !this.$refs.currentStep.isSignatureStarted) ||
|
||||||
|
(this.currentField.required && this.currentField.type === 'initials' && !this.values[this.currentField.uuid]?.length && this.$refs.currentStep && !this.$refs.currentStep.isInitialsStarted)
|
||||||
} else {
|
} else {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -472,10 +508,14 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted () {
|
mounted () {
|
||||||
this.currentStep = Math.min(
|
this.submittedValues = JSON.parse(JSON.stringify(this.values))
|
||||||
this.stepFields.indexOf([...this.stepFields].reverse().find((fields) => fields.some((f) => !!this.values[f.uuid]))) + 1,
|
|
||||||
this.stepFields.length - 1
|
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)) {
|
if (/iPhone|iPad|iPod/i.test(navigator.userAgent)) {
|
||||||
this.$nextTick(() => {
|
this.$nextTick(() => {
|
||||||
@@ -494,7 +534,10 @@ export default {
|
|||||||
this.$nextTick(() => {
|
this.$nextTick(() => {
|
||||||
this.recalculateButtonDisabledKey = Math.random()
|
this.recalculateButtonDisabledKey = Math.random()
|
||||||
|
|
||||||
this.maybeTrackEmailClick().finally(() => {
|
Promise.all([
|
||||||
|
this.maybeTrackEmailClick(),
|
||||||
|
this.maybeTrackSmsClick()
|
||||||
|
]).finally(() => {
|
||||||
this.trackViewForm()
|
this.trackViewForm()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -525,6 +568,30 @@ export default {
|
|||||||
return Promise.resolve({})
|
return Promise.resolve({})
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
maybeTrackSmsClick () {
|
||||||
|
const queryParams = new URLSearchParams(window.location.search)
|
||||||
|
|
||||||
|
if (queryParams.has('c')) {
|
||||||
|
const c = queryParams.get('c')
|
||||||
|
|
||||||
|
queryParams.delete('c')
|
||||||
|
const newUrl = [window.location.pathname, queryParams.toString()].filter(Boolean).join('?')
|
||||||
|
window.history.replaceState({}, document.title, newUrl)
|
||||||
|
|
||||||
|
return fetch(this.baseUrl + '/api/submitter_sms_clicks', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
c,
|
||||||
|
submitter_slug: this.submitterSlug
|
||||||
|
})
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
return Promise.resolve({})
|
||||||
|
}
|
||||||
|
},
|
||||||
trackViewForm () {
|
trackViewForm () {
|
||||||
fetch(this.baseUrl + '/api/submitter_form_views', {
|
fetch(this.baseUrl + '/api/submitter_form_views', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -566,14 +633,18 @@ export default {
|
|||||||
async submitStep () {
|
async submitStep () {
|
||||||
this.isSubmitting = true
|
this.isSubmitting = true
|
||||||
|
|
||||||
const stepPromise = ['signature', 'phone'].includes(this.currentField.type)
|
const stepPromise = ['signature', 'phone', 'initials'].includes(this.currentField.type)
|
||||||
? this.$refs.currentStep.submit
|
? this.$refs.currentStep.submit
|
||||||
: () => Promise.resolve({})
|
: () => Promise.resolve({})
|
||||||
|
|
||||||
stepPromise().then(async () => {
|
stepPromise().then(async () => {
|
||||||
|
const emptyRequiredField = this.stepFields.find((fields, index) => {
|
||||||
|
return index < this.currentStep && fields[0].required && (fields[0].type === 'phone' || !this.allowToSkip) && !this.submittedValues[fields[0].uuid]
|
||||||
|
})
|
||||||
|
|
||||||
const formData = new FormData(this.$refs.form)
|
const formData = new FormData(this.$refs.form)
|
||||||
|
|
||||||
if (this.currentStep === this.stepFields.length - 1) {
|
if (this.currentStep === this.stepFields.length - 1 && !emptyRequiredField) {
|
||||||
formData.append('completed', 'true')
|
formData.append('completed', 'true')
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -586,10 +657,12 @@ export default {
|
|||||||
return Promise.reject(new Error(data.error))
|
return Promise.reject(new Error(data.error))
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextStep = this.stepFields[this.currentStep + 1]
|
this.submittedValues[this.currentField.uuid] = this.values[this.currentField.uuid]
|
||||||
|
|
||||||
|
const nextStep = emptyRequiredField || this.stepFields[this.currentStep + 1]
|
||||||
|
|
||||||
if (nextStep) {
|
if (nextStep) {
|
||||||
this.goToStep(this.stepFields[this.currentStep + 1], true)
|
this.goToStep(nextStep, true)
|
||||||
} else {
|
} else {
|
||||||
this.isCompleted = true
|
this.isCompleted = true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,9 +10,17 @@ const en = {
|
|||||||
or_drag_and_drop_files: 'or drag and drop files',
|
or_drag_and_drop_files: 'or drag and drop files',
|
||||||
send_copy_via_email: 'Send copy via email',
|
send_copy_via_email: 'Send copy via email',
|
||||||
download: 'Download',
|
download: 'Download',
|
||||||
|
signature: 'Signature',
|
||||||
|
initials: 'Initials',
|
||||||
|
clear: 'Clear',
|
||||||
|
redraw: 'Redraw',
|
||||||
|
draw_initials: 'Draw initials',
|
||||||
|
type_signature_here: 'Type signature here',
|
||||||
|
type_initial_here: 'Type initials here',
|
||||||
form_has_been_completed: 'Form has been completed!',
|
form_has_been_completed: 'Form has been completed!',
|
||||||
create_a_free_account: 'Create a Free Account',
|
create_a_free_account: 'Create a Free Account',
|
||||||
signed_with: 'Signed with',
|
signed_with: 'Signed with',
|
||||||
|
please_check_the_box_to_continue: 'Please check the box to continue',
|
||||||
open_source_documents_software: 'open source documents software',
|
open_source_documents_software: 'open source documents software',
|
||||||
verified_phone_number: 'Verify Phone Number',
|
verified_phone_number: 'Verify Phone Number',
|
||||||
use_international_format: 'Use internatioanl format: +1xxx',
|
use_international_format: 'Use internatioanl format: +1xxx',
|
||||||
@@ -36,9 +44,17 @@ const es = {
|
|||||||
or_drag_and_drop_files: 'o arrastra y suelta archivos',
|
or_drag_and_drop_files: 'o arrastra y suelta archivos',
|
||||||
send_copy_via_email: 'Enviar copia por correo electrónico',
|
send_copy_via_email: 'Enviar copia por correo electrónico',
|
||||||
download: 'Descargar',
|
download: 'Descargar',
|
||||||
|
signature: 'Firma',
|
||||||
|
initials: 'Iniciales',
|
||||||
|
clear: 'Borrar',
|
||||||
|
redraw: 'Redibujar',
|
||||||
|
draw_initials: 'Dibujar iniciales',
|
||||||
|
type_signature_here: 'Escribe la firma aquí',
|
||||||
|
type_initial_here: 'Escribe las iniciales aquí',
|
||||||
form_has_been_completed: '¡El formulario ha sido completado!',
|
form_has_been_completed: '¡El formulario ha sido completado!',
|
||||||
create_a_free_account: 'Crear una Cuenta Gratuita',
|
create_a_free_account: 'Crear una Cuenta Gratuita',
|
||||||
signed_with: 'Firmado con',
|
signed_with: 'Firmado con',
|
||||||
|
please_check_the_box_to_continue: 'Por favor marque la casilla para continuar',
|
||||||
open_source_documents_software: 'software de documentos de código abierto',
|
open_source_documents_software: 'software de documentos de código abierto',
|
||||||
verified_phone_number: 'Verificar número de teléfono',
|
verified_phone_number: 'Verificar número de teléfono',
|
||||||
use_international_format: 'Usar formato internacional: +1xxx',
|
use_international_format: 'Usar formato internacional: +1xxx',
|
||||||
@@ -62,9 +78,17 @@ const it = {
|
|||||||
or_drag_and_drop_files: 'oppure trascina e rilascia i file',
|
or_drag_and_drop_files: 'oppure trascina e rilascia i file',
|
||||||
send_copy_via_email: 'Invia copia via email',
|
send_copy_via_email: 'Invia copia via email',
|
||||||
download: 'Scarica',
|
download: 'Scarica',
|
||||||
|
signature: 'Firma',
|
||||||
|
initials: 'Iniziali',
|
||||||
|
clear: 'Cancella',
|
||||||
|
redraw: 'Ridisegna',
|
||||||
|
draw_initials: 'Disegna iniziali',
|
||||||
|
type_signature_here: 'Scrivi la firma qui',
|
||||||
|
type_initial_here: 'Scrivi le iniziali qui',
|
||||||
form_has_been_completed: 'Il modulo è stato completato!',
|
form_has_been_completed: 'Il modulo è stato completato!',
|
||||||
create_a_free_account: 'Crea un Account Gratuito',
|
create_a_free_account: 'Crea un Account Gratuito',
|
||||||
signed_with: 'Firmato con',
|
signed_with: 'Firmato con',
|
||||||
|
please_check_the_box_to_continue: 'Si prega di spuntare la casella per continuare',
|
||||||
open_source_documents_software: 'software di documenti open source',
|
open_source_documents_software: 'software di documenti open source',
|
||||||
verified_phone_number: 'Verifica numero di telefono',
|
verified_phone_number: 'Verifica numero di telefono',
|
||||||
use_international_format: 'Usa formato internazionale: +1xxx',
|
use_international_format: 'Usa formato internazionale: +1xxx',
|
||||||
@@ -88,9 +112,17 @@ const de = {
|
|||||||
or_drag_and_drop_files: 'oder Dateien hierher ziehen und ablegen',
|
or_drag_and_drop_files: 'oder Dateien hierher ziehen und ablegen',
|
||||||
send_copy_via_email: 'Kopie per E-Mail senden',
|
send_copy_via_email: 'Kopie per E-Mail senden',
|
||||||
download: 'Herunterladen',
|
download: 'Herunterladen',
|
||||||
|
signature: 'Unterschrift',
|
||||||
|
initials: 'Initialen',
|
||||||
|
clear: 'Löschen',
|
||||||
|
redraw: 'Neu zeichnen',
|
||||||
|
draw_initials: 'Initialen zeichnen',
|
||||||
|
type_signature_here: 'Unterschrift hier eingeben',
|
||||||
|
type_initial_here: 'Initialen hier eingeben',
|
||||||
form_has_been_completed: 'Formular wurde ausgefüllt!',
|
form_has_been_completed: 'Formular wurde ausgefüllt!',
|
||||||
create_a_free_account: 'Kostenloses Konto erstellen',
|
create_a_free_account: 'Kostenloses Konto erstellen',
|
||||||
signed_with: 'Unterschrieben mit',
|
signed_with: 'Unterschrieben mit',
|
||||||
|
please_check_the_box_to_continue: 'Bitte setzen Sie das Häkchen, um fortzufahren',
|
||||||
open_source_documents_software: 'Open-Source-Dokumentensoftware',
|
open_source_documents_software: 'Open-Source-Dokumentensoftware',
|
||||||
verified_phone_number: 'Telefonnummer überprüfen',
|
verified_phone_number: 'Telefonnummer überprüfen',
|
||||||
use_international_format: 'Internationales Format verwenden: +1xxx',
|
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',
|
or_drag_and_drop_files: 'ou faites glisser-déposer les fichiers',
|
||||||
send_copy_via_email: 'Envoyer une copie par e-mail',
|
send_copy_via_email: 'Envoyer une copie par e-mail',
|
||||||
download: 'Télécharger',
|
download: 'Télécharger',
|
||||||
|
signature: 'Signature',
|
||||||
|
initials: 'Initiales',
|
||||||
|
clear: 'Effacer',
|
||||||
|
redraw: 'Redessiner',
|
||||||
|
draw_initials: 'Dessiner les initiales',
|
||||||
|
type_signature_here: 'Tapez la signature ici',
|
||||||
|
type_initial_here: 'Tapez les initiales ici',
|
||||||
form_has_been_completed: 'Le formulaire a été complété !',
|
form_has_been_completed: 'Le formulaire a été complété !',
|
||||||
create_a_free_account: 'Créer un Compte Gratuit',
|
create_a_free_account: 'Créer un Compte Gratuit',
|
||||||
signed_with: 'Signé avec',
|
signed_with: 'Signé avec',
|
||||||
|
please_check_the_box_to_continue: 'Veuillez cocher la case pour continuer',
|
||||||
open_source_documents_software: 'logiciel de documents open source',
|
open_source_documents_software: 'logiciel de documents open source',
|
||||||
verified_phone_number: 'Vérifier le numéro de téléphone',
|
verified_phone_number: 'Vérifier le numéro de téléphone',
|
||||||
use_international_format: 'Utiliser le format international : +1xxx',
|
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',
|
or_drag_and_drop_files: 'lub przeciągnij i upuść pliki',
|
||||||
send_copy_via_email: 'Wyślij kopię drogą mailową',
|
send_copy_via_email: 'Wyślij kopię drogą mailową',
|
||||||
download: 'Pobierz',
|
download: 'Pobierz',
|
||||||
|
signature: 'Podpis',
|
||||||
|
initials: 'Inicjały',
|
||||||
|
clear: 'Wyczyść',
|
||||||
|
redraw: 'Przerysuj',
|
||||||
|
draw_initials: 'Narysuj inicjały',
|
||||||
|
type_signature_here: 'Wpisz podpis tutaj',
|
||||||
|
type_initial_here: 'Wpisz inicjały tutaj',
|
||||||
form_has_been_completed: 'Formularz został wypełniony!',
|
form_has_been_completed: 'Formularz został wypełniony!',
|
||||||
create_a_free_account: 'Utwórz darmowe konto',
|
create_a_free_account: 'Utwórz darmowe konto',
|
||||||
signed_with: 'Podpisane za pomocą',
|
signed_with: 'Podpisane za pomocą',
|
||||||
|
please_check_the_box_to_continue: 'Proszę zaznaczyć pole, aby kontynuować',
|
||||||
open_source_documents_software: 'oprogramowanie do dokumentów open source',
|
open_source_documents_software: 'oprogramowanie do dokumentów open source',
|
||||||
verified_phone_number: 'Zweryfikuj numer telefonu',
|
verified_phone_number: 'Zweryfikuj numer telefonu',
|
||||||
use_international_format: 'Użyj międzynarodowego formatu: +1xxx',
|
use_international_format: 'Użyj międzynarodowego formatu: +1xxx',
|
||||||
@@ -166,9 +214,17 @@ const uk = {
|
|||||||
or_drag_and_drop_files: 'або перетягніть файли сюди',
|
or_drag_and_drop_files: 'або перетягніть файли сюди',
|
||||||
send_copy_via_email: 'Надіслати копію електронною поштою',
|
send_copy_via_email: 'Надіслати копію електронною поштою',
|
||||||
download: 'Завантажити',
|
download: 'Завантажити',
|
||||||
|
signature: 'Підпис',
|
||||||
|
initials: 'Ініціали',
|
||||||
|
clear: 'Очистити',
|
||||||
|
redraw: 'Перемалювати',
|
||||||
|
draw_initials: 'Намалювати ініціали',
|
||||||
|
type_signature_here: 'Введіть підпис тут',
|
||||||
|
type_initial_here: 'Введіть ініціали тут',
|
||||||
form_has_been_completed: 'Форму заповнено!',
|
form_has_been_completed: 'Форму заповнено!',
|
||||||
create_a_free_account: 'Створити безкоштовний обліковий запис',
|
create_a_free_account: 'Створити безкоштовний обліковий запис',
|
||||||
signed_with: 'Підписано за допомогою',
|
signed_with: 'Підписано за допомогою',
|
||||||
|
please_check_the_box_to_continue: 'Будь ласка, позначте прапорець, щоб продовжити',
|
||||||
open_source_documents_software: 'відкритий програмний засіб для документів',
|
open_source_documents_software: 'відкритий програмний засіб для документів',
|
||||||
verified_phone_number: 'Підтвердіть номер телефону',
|
verified_phone_number: 'Підтвердіть номер телефону',
|
||||||
use_international_format: 'Використовуйте міжнародний формат: +1xxx',
|
use_international_format: 'Використовуйте міжнародний формат: +1xxx',
|
||||||
@@ -192,9 +248,17 @@ const cs = {
|
|||||||
or_drag_and_drop_files: 'nebo přetáhněte soubory sem',
|
or_drag_and_drop_files: 'nebo přetáhněte soubory sem',
|
||||||
send_copy_via_email: 'Odeslat kopii e-mailem',
|
send_copy_via_email: 'Odeslat kopii e-mailem',
|
||||||
download: 'Stáhnout',
|
download: 'Stáhnout',
|
||||||
|
signature: 'Podpis',
|
||||||
|
initials: 'Iniciály',
|
||||||
|
clear: 'Smazat',
|
||||||
|
redraw: 'Překreslit',
|
||||||
|
draw_initials: 'Nakreslit iniciály',
|
||||||
|
type_signature_here: 'Sem zadejte podpis',
|
||||||
|
type_initial_here: 'Sem zadejte iniciály',
|
||||||
form_has_been_completed: 'Formulář byl dokončen!',
|
form_has_been_completed: 'Formulář byl dokončen!',
|
||||||
create_a_free_account: 'Vytvořit bezplatný účet',
|
create_a_free_account: 'Vytvořit bezplatný účet',
|
||||||
signed_with: 'Podepsáno pomocí',
|
signed_with: 'Podepsáno pomocí',
|
||||||
|
please_check_the_box_to_continue: 'Prosím, zaškrtněte políčko pro pokračování',
|
||||||
open_source_documents_software: 'open source software pro dokumenty',
|
open_source_documents_software: 'open source software pro dokumenty',
|
||||||
verified_phone_number: 'Ověřte telefonní číslo',
|
verified_phone_number: 'Ověřte telefonní číslo',
|
||||||
use_international_format: 'Použijte mezinárodní formát: +1xxx',
|
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',
|
or_drag_and_drop_files: 'ou arraste e solte arquivos',
|
||||||
send_copy_via_email: 'Enviar cópia por e-mail',
|
send_copy_via_email: 'Enviar cópia por e-mail',
|
||||||
download: 'Baixar',
|
download: 'Baixar',
|
||||||
|
signature: 'Assinatura',
|
||||||
|
initials: 'Iniciais',
|
||||||
|
clear: 'Limpar',
|
||||||
|
redraw: 'Redesenhar',
|
||||||
|
draw_initials: 'Desenhar iniciais',
|
||||||
|
type_signature_here: 'Digite a assinatura aqui',
|
||||||
|
type_initial_here: 'Digite as iniciais aqui',
|
||||||
form_has_been_completed: 'O formulário foi concluído!',
|
form_has_been_completed: 'O formulário foi concluído!',
|
||||||
create_a_free_account: 'Criar uma Conta Gratuita',
|
create_a_free_account: 'Criar uma Conta Gratuita',
|
||||||
signed_with: 'Assinado com',
|
signed_with: 'Assinado com',
|
||||||
|
please_check_the_box_to_continue: 'Por favor, marque a caixa para continuar',
|
||||||
open_source_documents_software: 'software de documentos de código aberto',
|
open_source_documents_software: 'software de documentos de código aberto',
|
||||||
verified_phone_number: 'Verificar Número de Telefone',
|
verified_phone_number: 'Verificar Número de Telefone',
|
||||||
use_international_format: 'Use formato internacional: +1xxx',
|
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}][]`"
|
:name="`values[${field.uuid}][]`"
|
||||||
:value="option"
|
:value="option"
|
||||||
class="base-checkbox !h-7 !w-7"
|
class="base-checkbox !h-7 !w-7"
|
||||||
:checked="modelValue.includes(option)"
|
:checked="(modelValue || []).includes(option)"
|
||||||
@change="onChange"
|
@change="onChange"
|
||||||
>
|
>
|
||||||
<span class="text-xl">
|
<span class="text-xl">
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<div class="flex justify-between items-center w-full mb-2">
|
<div class="flex justify-between items-center w-full mb-2">
|
||||||
<label
|
<label
|
||||||
class="label text-2xl"
|
class="label text-2xl"
|
||||||
>{{ field.name || 'Signature' }}</label>
|
>{{ field.name || t('signature') }}</label>
|
||||||
<div class="space-x-2 flex">
|
<div class="space-x-2 flex">
|
||||||
<span
|
<span
|
||||||
class="tooltip"
|
class="tooltip"
|
||||||
@@ -40,7 +40,7 @@
|
|||||||
@click.prevent="remove"
|
@click.prevent="remove"
|
||||||
>
|
>
|
||||||
<IconReload :width="16" />
|
<IconReload :width="16" />
|
||||||
Redraw
|
{{ t('redraw') }}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
v-else
|
v-else
|
||||||
@@ -48,7 +48,7 @@
|
|||||||
@click.prevent="clear"
|
@click.prevent="clear"
|
||||||
>
|
>
|
||||||
<IconReload :width="16" />
|
<IconReload :width="16" />
|
||||||
Clear
|
{{ t('clear') }}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
title="Minimize"
|
title="Minimize"
|
||||||
@@ -83,7 +83,7 @@
|
|||||||
ref="textInput"
|
ref="textInput"
|
||||||
class="base-input !text-2xl w-full mt-6"
|
class="base-input !text-2xl w-full mt-6"
|
||||||
:required="field.required"
|
:required="field.required"
|
||||||
:placeholder="`Type signature here...`"
|
:placeholder="`${t('type_signature_here')}...`"
|
||||||
type="text"
|
type="text"
|
||||||
@input="updateWrittenSignature"
|
@input="updateWrittenSignature"
|
||||||
>
|
>
|
||||||
@@ -92,6 +92,7 @@
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
import { IconReload, IconCamera, IconTextSize, IconArrowsDiagonalMinimize2 } from '@tabler/icons-vue'
|
import { IconReload, IconCamera, IconTextSize, IconArrowsDiagonalMinimize2 } from '@tabler/icons-vue'
|
||||||
|
import { cropCanvasAndExportToPNG } from './crop_canvas'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'SignatureStep',
|
name: 'SignatureStep',
|
||||||
@@ -101,7 +102,7 @@ export default {
|
|||||||
IconTextSize,
|
IconTextSize,
|
||||||
IconArrowsDiagonalMinimize2
|
IconArrowsDiagonalMinimize2
|
||||||
},
|
},
|
||||||
inject: ['baseUrl'],
|
inject: ['baseUrl', 't'],
|
||||||
props: {
|
props: {
|
||||||
field: {
|
field: {
|
||||||
type: Object,
|
type: Object,
|
||||||
@@ -136,8 +137,10 @@ export default {
|
|||||||
},
|
},
|
||||||
async mounted () {
|
async mounted () {
|
||||||
this.$nextTick(() => {
|
this.$nextTick(() => {
|
||||||
this.$refs.canvas.width = this.$refs.canvas.parentNode.clientWidth
|
if (this.$refs.canvas) {
|
||||||
this.$refs.canvas.height = this.$refs.canvas.parentNode.clientWidth / 3
|
this.$refs.canvas.width = this.$refs.canvas.parentNode.clientWidth
|
||||||
|
this.$refs.canvas.height = this.$refs.canvas.parentNode.clientWidth / 3
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
if (this.isDirectUpload) {
|
if (this.isDirectUpload) {
|
||||||
@@ -146,13 +149,15 @@ export default {
|
|||||||
|
|
||||||
const { default: SignaturePad } = await import('signature_pad')
|
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.pad.addEventListener('beginStroke', () => {
|
||||||
this.isSignatureStarted = true
|
this.isSignatureStarted = true
|
||||||
|
|
||||||
this.$emit('start')
|
this.$emit('start')
|
||||||
})
|
})
|
||||||
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
remove () {
|
remove () {
|
||||||
@@ -244,60 +249,13 @@ export default {
|
|||||||
reader.readAsDataURL(file)
|
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 () {
|
async submit () {
|
||||||
if (this.modelValue) {
|
if (this.modelValue) {
|
||||||
return Promise.resolve({})
|
return Promise.resolve({})
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
this.cropCanvasAndExportToPNG(this.$refs.canvas).then(async (blob) => {
|
cropCanvasAndExportToPNG(this.$refs.canvas).then(async (blob) => {
|
||||||
const file = new File([blob], 'signature.png', { type: 'image/png' })
|
const file = new File([blob], 'signature.png', { type: 'image/png' })
|
||||||
|
|
||||||
if (this.isDirectUpload) {
|
if (this.isDirectUpload) {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
:style="positionStyle"
|
:style="positionStyle"
|
||||||
@pointerdown.stop
|
@pointerdown.stop
|
||||||
@mousedown.stop="startDrag"
|
@mousedown.stop="startDrag"
|
||||||
|
@touchstart="startTouchDrag"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
v-if="isSelected || isDraw"
|
v-if="isSelected || isDraw"
|
||||||
@@ -32,7 +33,7 @@
|
|||||||
<div
|
<div
|
||||||
v-if="field?.type"
|
v-if="field?.type"
|
||||||
class="absolute bg-white rounded-t border overflow-visible whitespace-nowrap group-hover:flex group-hover:z-10"
|
class="absolute bg-white rounded-t border overflow-visible whitespace-nowrap group-hover:flex group-hover:z-10"
|
||||||
:class="{ 'flex z-10': isNameFocus || isSelected, hidden: !isNameFocus && !isSelected }"
|
:class="{ 'flex z-10': isNameFocus || isSelected, invisible: !isNameFocus && !isSelected }"
|
||||||
style="top: -25px; height: 25px"
|
style="top: -25px; height: 25px"
|
||||||
@mousedown.stop
|
@mousedown.stop
|
||||||
@pointerdown.stop
|
@pointerdown.stop
|
||||||
@@ -108,12 +109,14 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
|
ref="touchTarget"
|
||||||
class="absolute top-0 bottom-0 right-0 left-0 cursor-pointer"
|
class="absolute top-0 bottom-0 right-0 left-0 cursor-pointer"
|
||||||
/>
|
/>
|
||||||
<span
|
<span
|
||||||
v-if="field?.type"
|
v-if="field?.type"
|
||||||
class="h-2.5 w-2.5 -right-1 rounded-full -bottom-1 border-gray-400 bg-white shadow-md border absolute cursor-nwse-resize"
|
class="h-4 w-4 md:h-2.5 md:w-2.5 -right-1 rounded-full -bottom-1 border-gray-400 bg-white shadow-md border absolute cursor-nwse-resize"
|
||||||
@mousedown.stop="startResize"
|
@mousedown.stop="startResize"
|
||||||
|
@touchstart="startTouchResize"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -186,7 +189,12 @@ export default {
|
|||||||
'border-sky-500',
|
'border-sky-500',
|
||||||
'border-emerald-500',
|
'border-emerald-500',
|
||||||
'border-yellow-300',
|
'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 () {
|
bgColors () {
|
||||||
@@ -195,7 +203,12 @@ export default {
|
|||||||
'bg-sky-100',
|
'bg-sky-100',
|
||||||
'bg-emerald-100',
|
'bg-emerald-100',
|
||||||
'bg-yellow-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 () {
|
isSelected () {
|
||||||
@@ -309,6 +322,47 @@ export default {
|
|||||||
|
|
||||||
this.$emit('start-drag')
|
this.$emit('start-drag')
|
||||||
},
|
},
|
||||||
|
startTouchDrag (e) {
|
||||||
|
if (e.target !== this.$refs.touchTarget) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.$refs?.name?.blur()
|
||||||
|
|
||||||
|
e.preventDefault()
|
||||||
|
|
||||||
|
this.isDragged = true
|
||||||
|
|
||||||
|
const rect = e.target.getBoundingClientRect()
|
||||||
|
|
||||||
|
this.selectedAreaRef.value = this.area
|
||||||
|
|
||||||
|
this.dragFrom = { x: rect.left - e.touches[0].clientX, y: rect.top - e.touches[0].clientY }
|
||||||
|
|
||||||
|
this.$el.getRootNode().addEventListener('touchmove', this.touchDrag)
|
||||||
|
this.$el.getRootNode().addEventListener('touchend', this.stopTouchDrag)
|
||||||
|
|
||||||
|
this.$emit('start-drag')
|
||||||
|
},
|
||||||
|
touchDrag (e) {
|
||||||
|
const page = this.$parent.$refs.mask.previousSibling
|
||||||
|
const rect = page.getBoundingClientRect()
|
||||||
|
|
||||||
|
this.area.x = (this.dragFrom.x + e.touches[0].clientX - rect.left) / rect.width
|
||||||
|
this.area.y = (this.dragFrom.y + e.touches[0].clientY - rect.top) / rect.height
|
||||||
|
},
|
||||||
|
stopTouchDrag () {
|
||||||
|
this.$el.getRootNode().removeEventListener('touchmove', this.touchDrag)
|
||||||
|
this.$el.getRootNode().removeEventListener('touchend', this.stopTouchDrag)
|
||||||
|
|
||||||
|
if (this.isDragged) {
|
||||||
|
this.save()
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isDragged = false
|
||||||
|
|
||||||
|
this.$emit('stop-drag')
|
||||||
|
},
|
||||||
stopDrag () {
|
stopDrag () {
|
||||||
this.$el.getRootNode().removeEventListener('mousemove', this.drag)
|
this.$el.getRootNode().removeEventListener('mousemove', this.drag)
|
||||||
this.$el.getRootNode().removeEventListener('mouseup', this.stopDrag)
|
this.$el.getRootNode().removeEventListener('mouseup', this.stopDrag)
|
||||||
@@ -335,6 +389,33 @@ export default {
|
|||||||
|
|
||||||
this.$emit('stop-resize')
|
this.$emit('stop-resize')
|
||||||
|
|
||||||
|
this.save()
|
||||||
|
},
|
||||||
|
startTouchResize (e) {
|
||||||
|
this.selectedAreaRef.value = this.area
|
||||||
|
|
||||||
|
this.$refs?.name?.blur()
|
||||||
|
|
||||||
|
e.preventDefault()
|
||||||
|
|
||||||
|
this.$el.getRootNode().addEventListener('touchmove', this.touchResize)
|
||||||
|
this.$el.getRootNode().addEventListener('touchend', this.stopTouchResize)
|
||||||
|
|
||||||
|
this.$emit('start-resize', 'nwse')
|
||||||
|
},
|
||||||
|
touchResize (e) {
|
||||||
|
const page = this.$parent.$refs.mask.previousSibling
|
||||||
|
const rect = page.getBoundingClientRect()
|
||||||
|
|
||||||
|
this.area.w = (e.touches[0].clientX - rect.left) / rect.width - this.area.x
|
||||||
|
this.area.h = (e.touches[0].clientY - rect.top) / rect.height - this.area.y
|
||||||
|
},
|
||||||
|
stopTouchResize () {
|
||||||
|
this.$el.getRootNode().removeEventListener('touchmove', this.touchResize)
|
||||||
|
this.$el.getRootNode().removeEventListener('touchend', this.stopTouchResize)
|
||||||
|
|
||||||
|
this.$emit('stop-resize')
|
||||||
|
|
||||||
this.save()
|
this.save()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,19 @@
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
style="max-width: 1600px"
|
style="max-width: 1600px"
|
||||||
class="mx-auto pl-4 h-full"
|
class="mx-auto pl-3 md:pl-4 h-full"
|
||||||
>
|
>
|
||||||
<div class="flex justify-between py-1.5 items-center pr-4">
|
<div
|
||||||
|
class="flex justify-between py-1.5 items-center pr-4 sticky top-0 z-10"
|
||||||
|
:style="{ backgroundColor }"
|
||||||
|
>
|
||||||
<div class="flex space-x-3">
|
<div class="flex space-x-3">
|
||||||
<a
|
<a
|
||||||
v-if="withLogoLink"
|
v-if="withLogo"
|
||||||
href="/"
|
href="/"
|
||||||
>
|
>
|
||||||
<Logo />
|
<Logo />
|
||||||
</a>
|
</a>
|
||||||
<Logo v-else />
|
|
||||||
<Contenteditable
|
<Contenteditable
|
||||||
:model-value="template.name"
|
:model-value="template.name"
|
||||||
class="text-3xl font-semibold focus:text-clip"
|
class="text-3xl font-semibold focus:text-clip"
|
||||||
@@ -28,7 +30,7 @@
|
|||||||
<a
|
<a
|
||||||
:href="`/templates/${template.id}/submissions/new`"
|
:href="`/templates/${template.id}/submissions/new`"
|
||||||
data-turbo-frame="modal"
|
data-turbo-frame="modal"
|
||||||
class="btn btn-primary"
|
class="btn btn-primary text-base"
|
||||||
>
|
>
|
||||||
<IconUsersPlus
|
<IconUsersPlus
|
||||||
width="20"
|
width="20"
|
||||||
@@ -46,12 +48,12 @@
|
|||||||
>
|
>
|
||||||
<IconInnerShadowTop
|
<IconInnerShadowTop
|
||||||
v-if="isSaving"
|
v-if="isSaving"
|
||||||
width="20"
|
width="22"
|
||||||
class="animate-spin"
|
class="animate-spin"
|
||||||
/>
|
/>
|
||||||
<IconDeviceFloppy
|
<IconDeviceFloppy
|
||||||
v-else
|
v-else
|
||||||
width="20"
|
width="22"
|
||||||
/>
|
/>
|
||||||
<span class="hidden md:inline">
|
<span class="hidden md:inline">
|
||||||
Save
|
Save
|
||||||
@@ -60,10 +62,7 @@
|
|||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div class="flex md:max-h-[calc(100vh-60px)]">
|
||||||
class="flex"
|
|
||||||
style="max-height: calc(100% - 60px)"
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
ref="previews"
|
ref="previews"
|
||||||
:style="{ 'display': isBreakpointLg ? 'none' : 'initial' }"
|
:style="{ 'display': isBreakpointLg ? 'none' : 'initial' }"
|
||||||
@@ -98,7 +97,7 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-full overflow-y-auto overflow-x-hidden mt-0.5 pt-0.5">
|
<div class="w-full overflow-y-hidden md:overflow-y-auto overflow-x-hidden mt-0.5 pt-0.5">
|
||||||
<div
|
<div
|
||||||
ref="documents"
|
ref="documents"
|
||||||
class="pr-3.5 pl-0.5"
|
class="pr-3.5 pl-0.5"
|
||||||
@@ -153,17 +152,54 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<span
|
||||||
v-if="sortedDocuments.length"
|
v-if="drawField"
|
||||||
class="sticky md:hidden"
|
class="fixed text-center w-full left-1/2 bottom-0 transform -translate-x-1/2"
|
||||||
style="bottom: 100px"
|
|
||||||
>
|
>
|
||||||
<div class="px-4 py-3 rounded-2xl bg-base-200 flex items-center justify-between ml-4 mr-6">
|
<span
|
||||||
<span class="w-full text-center text-lg">
|
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"
|
||||||
You need a larger screen to use builder tools.
|
>
|
||||||
|
<component
|
||||||
|
:is="fieldIcons[drawField.type]"
|
||||||
|
:width="20"
|
||||||
|
:height="20"
|
||||||
|
class="inline"
|
||||||
|
:stroke-width="1.6"
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
Draw {{ fieldNames[drawField.type] }} Field
|
||||||
</span>
|
</span>
|
||||||
</div>
|
<a
|
||||||
</div>
|
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>
|
||||||
<div
|
<div
|
||||||
class="relative w-80 flex-none mt-1 pr-4 pl-0.5 hidden md:block"
|
class="relative w-80 flex-none mt-1 pr-4 pl-0.5 hidden md:block"
|
||||||
@@ -215,7 +251,8 @@ import Logo from './logo'
|
|||||||
import Contenteditable from './contenteditable'
|
import Contenteditable from './contenteditable'
|
||||||
import DocumentPreview from './preview'
|
import DocumentPreview from './preview'
|
||||||
import DocumentControls from './controls'
|
import DocumentControls from './controls'
|
||||||
import { IconUsersPlus, IconDeviceFloppy, IconInnerShadowTop } from '@tabler/icons-vue'
|
import FieldType from './field_type'
|
||||||
|
import { IconUsersPlus, IconDeviceFloppy, IconInnerShadowTop, IconPlus, IconX } from '@tabler/icons-vue'
|
||||||
import { v4 } from 'uuid'
|
import { v4 } from 'uuid'
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
|
|
||||||
@@ -225,6 +262,9 @@ export default {
|
|||||||
Upload,
|
Upload,
|
||||||
Document,
|
Document,
|
||||||
Fields,
|
Fields,
|
||||||
|
IconPlus,
|
||||||
|
FieldType,
|
||||||
|
IconX,
|
||||||
Logo,
|
Logo,
|
||||||
Dropzone,
|
Dropzone,
|
||||||
DocumentPreview,
|
DocumentPreview,
|
||||||
@@ -269,7 +309,7 @@ export default {
|
|||||||
required: false,
|
required: false,
|
||||||
default: ''
|
default: ''
|
||||||
},
|
},
|
||||||
withLogoLink: {
|
withLogo: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
required: false,
|
required: false,
|
||||||
default: true
|
default: true
|
||||||
@@ -301,6 +341,8 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
|
fieldIcons: FieldType.computed.fieldIcons,
|
||||||
|
fieldNames: FieldType.computed.fieldNames,
|
||||||
selectedAreaRef: () => ref(),
|
selectedAreaRef: () => ref(),
|
||||||
fieldAreasIndex () {
|
fieldAreasIndex () {
|
||||||
const areas = {}
|
const areas = {}
|
||||||
@@ -330,16 +372,21 @@ export default {
|
|||||||
this.selectedSubmitter = this.template.submitters[0]
|
this.selectedSubmitter = this.template.submitters[0]
|
||||||
},
|
},
|
||||||
mounted () {
|
mounted () {
|
||||||
|
this.undoStack = [JSON.stringify(this.template)]
|
||||||
|
this.redoStack = []
|
||||||
|
|
||||||
this.$nextTick(() => {
|
this.$nextTick(() => {
|
||||||
this.onWindowResize()
|
this.onWindowResize()
|
||||||
})
|
})
|
||||||
|
|
||||||
document.addEventListener('keyup', this.onKeyUp)
|
document.addEventListener('keyup', this.onKeyUp)
|
||||||
|
window.addEventListener('keydown', this.onKeyDown)
|
||||||
|
|
||||||
window.addEventListener('resize', this.onWindowResize)
|
window.addEventListener('resize', this.onWindowResize)
|
||||||
},
|
},
|
||||||
unmounted () {
|
unmounted () {
|
||||||
document.removeEventListener('keyup', this.onKeyUp)
|
document.removeEventListener('keyup', this.onKeyUp)
|
||||||
|
window.removeEventListener('keydown', this.onKeyDown)
|
||||||
|
|
||||||
window.removeEventListener('resize', this.onWindowResize)
|
window.removeEventListener('resize', this.onWindowResize)
|
||||||
},
|
},
|
||||||
@@ -347,6 +394,52 @@ export default {
|
|||||||
this.documentRefs = []
|
this.documentRefs = []
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
startFieldDraw (type) {
|
||||||
|
const field = {
|
||||||
|
name: '',
|
||||||
|
uuid: v4(),
|
||||||
|
required: type !== 'checkbox',
|
||||||
|
areas: [],
|
||||||
|
submitter_uuid: this.selectedSubmitter.uuid,
|
||||||
|
type
|
||||||
|
}
|
||||||
|
|
||||||
|
if (['select', 'multiple', 'radio'].includes(type)) {
|
||||||
|
field.options = ['']
|
||||||
|
}
|
||||||
|
|
||||||
|
this.drawField = field
|
||||||
|
},
|
||||||
|
undo () {
|
||||||
|
if (this.undoStack.length > 1) {
|
||||||
|
this.undoStack.pop()
|
||||||
|
const stringData = this.undoStack[this.undoStack.length - 1]
|
||||||
|
const currentStringData = JSON.stringify(this.template)
|
||||||
|
|
||||||
|
if (stringData && stringData !== currentStringData) {
|
||||||
|
this.redoStack.push(currentStringData)
|
||||||
|
|
||||||
|
Object.assign(this.template, JSON.parse(stringData))
|
||||||
|
|
||||||
|
this.save()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
redo () {
|
||||||
|
const stringData = this.redoStack.pop()
|
||||||
|
this.lastRedoData = stringData
|
||||||
|
const currentStringData = JSON.stringify(this.template)
|
||||||
|
|
||||||
|
if (stringData && stringData !== currentStringData) {
|
||||||
|
if (this.undoStack[this.undoStack.length - 1] !== currentStringData) {
|
||||||
|
this.undoStack.push(currentStringData)
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.assign(this.template, JSON.parse(stringData))
|
||||||
|
|
||||||
|
this.save()
|
||||||
|
}
|
||||||
|
},
|
||||||
onWindowResize (e) {
|
onWindowResize (e) {
|
||||||
const breakpointLg = 1024
|
const breakpointLg = 1024
|
||||||
|
|
||||||
@@ -374,6 +467,19 @@ export default {
|
|||||||
this.selectedAreaRef.value = null
|
this.selectedAreaRef.value = null
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
onKeyDown (event) {
|
||||||
|
if ((event.metaKey && event.shiftKey && event.key === 'z') || (event.ctrlKey && event.key === 'Z')) {
|
||||||
|
event.stopImmediatePropagation()
|
||||||
|
event.preventDefault()
|
||||||
|
|
||||||
|
this.redo()
|
||||||
|
} else if ((event.ctrlKey || event.metaKey) && event.key === 'z') {
|
||||||
|
event.stopImmediatePropagation()
|
||||||
|
event.preventDefault()
|
||||||
|
|
||||||
|
this.undo()
|
||||||
|
}
|
||||||
|
},
|
||||||
removeArea (area) {
|
removeArea (area) {
|
||||||
const field = this.template.fields.find((f) => f.areas?.includes(area))
|
const field = this.template.fields.find((f) => f.areas?.includes(area))
|
||||||
|
|
||||||
@@ -385,12 +491,31 @@ export default {
|
|||||||
|
|
||||||
this.save()
|
this.save()
|
||||||
},
|
},
|
||||||
|
pushUndo () {
|
||||||
|
const stringData = JSON.stringify(this.template)
|
||||||
|
|
||||||
|
if (this.undoStack[this.undoStack.length - 1] !== stringData) {
|
||||||
|
this.undoStack.push(stringData)
|
||||||
|
|
||||||
|
if (this.lastRedoData !== stringData) {
|
||||||
|
this.redoStack = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
onDraw (area) {
|
onDraw (area) {
|
||||||
if (this.drawField) {
|
if (this.drawField) {
|
||||||
this.drawField.areas ||= []
|
this.drawField.areas ||= []
|
||||||
this.drawField.areas.push(area)
|
this.drawField.areas.push(area)
|
||||||
|
|
||||||
|
if (this.template.fields.indexOf(this.drawField) === -1) {
|
||||||
|
this.template.fields.push(this.drawField)
|
||||||
|
}
|
||||||
|
|
||||||
this.drawField = null
|
this.drawField = null
|
||||||
|
|
||||||
|
this.selectedAreaRef.value = area
|
||||||
|
|
||||||
|
this.save()
|
||||||
} else {
|
} else {
|
||||||
const documentRef = this.documentRefs.find((e) => e.document.uuid === area.attachment_uuid)
|
const documentRef = this.documentRefs.find((e) => e.document.uuid === area.attachment_uuid)
|
||||||
const pageMask = documentRef.pageRefs[area.page].$refs.mask
|
const pageMask = documentRef.pageRefs[area.page].$refs.mask
|
||||||
@@ -401,33 +526,37 @@ export default {
|
|||||||
const previousField = [...this.template.fields].reverse().find((f) => f.type === type)
|
const previousField = [...this.template.fields].reverse().find((f) => f.type === type)
|
||||||
const previousArea = previousField?.areas?.[previousField.areas.length - 1]
|
const previousArea = previousField?.areas?.[previousField.areas.length - 1]
|
||||||
|
|
||||||
const areaW = previousArea?.w || (30 / pageMask.clientWidth)
|
if (previousArea || area.w) {
|
||||||
const areaH = previousArea?.h || (30 / pageMask.clientHeight)
|
const areaW = previousArea?.w || (30 / pageMask.clientWidth)
|
||||||
|
const areaH = previousArea?.h || (30 / pageMask.clientHeight)
|
||||||
|
|
||||||
if ((pageMask.clientWidth * area.w) < 5) {
|
if ((pageMask.clientWidth * area.w) < 5) {
|
||||||
area.x = area.x - (areaW / 2)
|
area.x = area.x - (areaW / 2)
|
||||||
area.y = area.y - (areaH / 2)
|
area.y = area.y - (areaH / 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
area.w = areaW
|
||||||
|
area.h = areaH
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (area.w) {
|
||||||
|
const field = {
|
||||||
|
name: '',
|
||||||
|
uuid: v4(),
|
||||||
|
required: type !== 'checkbox',
|
||||||
|
type,
|
||||||
|
submitter_uuid: this.selectedSubmitter.uuid,
|
||||||
|
areas: [area]
|
||||||
}
|
}
|
||||||
|
|
||||||
area.w = areaW
|
this.template.fields.push(field)
|
||||||
area.h = areaH
|
|
||||||
}
|
|
||||||
|
|
||||||
const field = {
|
this.selectedAreaRef.value = area
|
||||||
name: '',
|
|
||||||
uuid: v4(),
|
|
||||||
required: type !== 'checkbox',
|
|
||||||
type,
|
|
||||||
submitter_uuid: this.selectedSubmitter.uuid,
|
|
||||||
areas: [area]
|
|
||||||
}
|
|
||||||
|
|
||||||
this.template.fields.push(field)
|
this.save()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.selectedAreaRef.value = area
|
|
||||||
|
|
||||||
this.save()
|
|
||||||
},
|
},
|
||||||
onDropfield (area) {
|
onDropfield (area) {
|
||||||
const field = {
|
const field = {
|
||||||
@@ -473,6 +602,11 @@ export default {
|
|||||||
w: area.maskW / 5 / area.maskW,
|
w: area.maskW / 5 / area.maskW,
|
||||||
h: (area.maskW / 5 / area.maskW) * (area.maskW / area.maskH) / 2
|
h: (area.maskW / 5 / area.maskW) * (area.maskW / area.maskH) / 2
|
||||||
}
|
}
|
||||||
|
} else if (this.dragFieldType === 'initials') {
|
||||||
|
baseArea = {
|
||||||
|
w: area.maskW / 10 / area.maskW,
|
||||||
|
h: area.maskW / 35 / area.maskW
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
baseArea = {
|
baseArea = {
|
||||||
w: area.maskW / 5 / area.maskW,
|
w: area.maskW / 5 / area.maskW,
|
||||||
@@ -584,6 +718,8 @@ export default {
|
|||||||
this.$el.closest('template-builder').dataset.template = JSON.stringify(this.template)
|
this.$el.closest('template-builder').dataset.template = JSON.stringify(this.template)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.pushUndo()
|
||||||
|
|
||||||
return this.baseFetch(`/api/templates/${this.template.id}`, {
|
return this.baseFetch(`/api/templates/${this.template.id}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
|
|||||||
@@ -30,7 +30,7 @@
|
|||||||
:class="{ 'ml-1': !withRequired, 'absolute': !iconInline, 'inline align-bottom': iconInline }"
|
:class="{ 'ml-1': !withRequired, 'absolute': !iconInline, 'inline align-bottom': iconInline }"
|
||||||
:width="iconWidth"
|
:width="iconWidth"
|
||||||
:stroke-width="iconStrokeWidth"
|
:stroke-width="iconStrokeWidth"
|
||||||
@click="focusContenteditable"
|
@click="[focusContenteditable(), selectOnEditClick && selectContent()]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -64,6 +64,11 @@ export default {
|
|||||||
required: false,
|
required: false,
|
||||||
default: false
|
default: false
|
||||||
},
|
},
|
||||||
|
selectOnEditClick: {
|
||||||
|
type: Boolean,
|
||||||
|
required: false,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
iconStrokeWidth: {
|
iconStrokeWidth: {
|
||||||
type: Number,
|
type: Number,
|
||||||
required: false,
|
required: false,
|
||||||
@@ -85,6 +90,19 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
selectContent () {
|
||||||
|
const el = this.$refs.contenteditable
|
||||||
|
|
||||||
|
const range = document.createRange()
|
||||||
|
|
||||||
|
range.selectNodeContents(el)
|
||||||
|
|
||||||
|
const sel = window.getSelection()
|
||||||
|
|
||||||
|
sel.removeAllRanges()
|
||||||
|
|
||||||
|
sel.addRange(range)
|
||||||
|
},
|
||||||
onBlur (e) {
|
onBlur (e) {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
this.value = this.$refs.contenteditable.innerText.trim() || this.modelValue
|
this.value = this.$refs.contenteditable.innerText.trim() || this.modelValue
|
||||||
|
|||||||
@@ -31,21 +31,19 @@
|
|||||||
v-if="isNameFocus"
|
v-if="isNameFocus"
|
||||||
class="flex items-center relative"
|
class="flex items-center relative"
|
||||||
>
|
>
|
||||||
<template v-if="field.type !== 'checkbox'">
|
<input
|
||||||
<input
|
:id="`required-checkbox-${field.uuid}`"
|
||||||
:id="`required-checkbox-${field.uuid}`"
|
v-model="field.required"
|
||||||
v-model="field.required"
|
type="checkbox"
|
||||||
type="checkbox"
|
class="checkbox checkbox-xs no-animation rounded"
|
||||||
class="checkbox checkbox-xs no-animation rounded"
|
@mousedown.prevent
|
||||||
@mousedown.prevent
|
>
|
||||||
>
|
<label
|
||||||
<label
|
:for="`required-checkbox-${field.uuid}`"
|
||||||
:for="`required-checkbox-${field.uuid}`"
|
class="label text-xs"
|
||||||
class="label text-xs"
|
@click.prevent="field.required = !field.required"
|
||||||
@click.prevent="field.required = !field.required"
|
@mousedown.prevent
|
||||||
@mousedown.prevent
|
>Required</label>
|
||||||
>Required</label>
|
|
||||||
</template>
|
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-else
|
v-else
|
||||||
@@ -99,6 +97,19 @@
|
|||||||
Draw New Area
|
Draw New Area
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
<li v-if="field.areas?.length === 1 && ['date', 'signature', 'initials', 'text', 'cells'].includes(field.type)">
|
||||||
|
<a
|
||||||
|
href="#"
|
||||||
|
class="text-sm py-1 px-2"
|
||||||
|
@click.prevent="copyToAllPages(field)"
|
||||||
|
>
|
||||||
|
<IconCopy
|
||||||
|
:width="20"
|
||||||
|
:stroke-width="1.6"
|
||||||
|
/>
|
||||||
|
Copy to All Pages
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
@@ -183,7 +194,7 @@
|
|||||||
<script>
|
<script>
|
||||||
import Contenteditable from './contenteditable'
|
import Contenteditable from './contenteditable'
|
||||||
import FieldType from './field_type'
|
import FieldType from './field_type'
|
||||||
import { IconShape, IconNewSection, IconTrashX } from '@tabler/icons-vue'
|
import { IconShape, IconNewSection, IconTrashX, IconCopy } from '@tabler/icons-vue'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'TemplateField',
|
name: 'TemplateField',
|
||||||
@@ -192,6 +203,7 @@ export default {
|
|||||||
IconShape,
|
IconShape,
|
||||||
IconNewSection,
|
IconNewSection,
|
||||||
IconTrashX,
|
IconTrashX,
|
||||||
|
IconCopy,
|
||||||
FieldType
|
FieldType
|
||||||
},
|
},
|
||||||
inject: ['template', 'save'],
|
inject: ['template', 'save'],
|
||||||
@@ -221,6 +233,23 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
copyToAllPages (field) {
|
||||||
|
const areaString = JSON.stringify(field.areas[0])
|
||||||
|
|
||||||
|
this.template.documents.forEach((attachment) => {
|
||||||
|
attachment.preview_images.forEach((page) => {
|
||||||
|
if (!field.areas.find((area) => area.attachment_uuid === attachment.uuid && area.page === parseInt(page.filename))) {
|
||||||
|
field.areas.push({ ...JSON.parse(areaString), page: parseInt(page.filename) })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
this.$nextTick(() => {
|
||||||
|
this.$emit('scroll-to', this.field.areas[this.field.areas.length - 1])
|
||||||
|
})
|
||||||
|
|
||||||
|
this.save()
|
||||||
|
},
|
||||||
onNameFocus (e) {
|
onNameFocus (e) {
|
||||||
this.isNameFocus = true
|
this.isNameFocus = true
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,7 @@
|
|||||||
v-model="selectedSubmitter.name"
|
v-model="selectedSubmitter.name"
|
||||||
class="cursor-text"
|
class="cursor-text"
|
||||||
:icon-inline="true"
|
:icon-inline="true"
|
||||||
|
:select-on-edit-click="true"
|
||||||
:icon-width="18"
|
:icon-width="18"
|
||||||
@update:model-value="$emit('name-change', selectedSubmitter)"
|
@update:model-value="$emit('name-change', selectedSubmitter)"
|
||||||
/>
|
/>
|
||||||
@@ -69,7 +70,7 @@
|
|||||||
</button>
|
</button>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li v-if="submitters.length < 5">
|
<li v-if="submitters.length < 10">
|
||||||
<a
|
<a
|
||||||
href="#"
|
href="#"
|
||||||
class="flex px-2"
|
class="flex px-2"
|
||||||
@@ -129,7 +130,12 @@ export default {
|
|||||||
'bg-sky-500',
|
'bg-sky-500',
|
||||||
'bg-emerald-500',
|
'bg-emerald-500',
|
||||||
'bg-yellow-300',
|
'bg-yellow-300',
|
||||||
'bg-purple-600'
|
'bg-purple-600',
|
||||||
|
'bg-pink-500',
|
||||||
|
'bg-cyan-500',
|
||||||
|
'bg-orange-500',
|
||||||
|
'bg-lime-500',
|
||||||
|
'bg-indigo-500'
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
names () {
|
names () {
|
||||||
@@ -138,7 +144,12 @@ export default {
|
|||||||
'Second Submitter',
|
'Second Submitter',
|
||||||
'Third Submitter',
|
'Third Submitter',
|
||||||
'Fourth Submitter',
|
'Fourth Submitter',
|
||||||
'Fifth Submitter'
|
'Fifth Submitter',
|
||||||
|
'Sixth Submitter',
|
||||||
|
'Seventh Submitter',
|
||||||
|
'Eighth Submitter',
|
||||||
|
'Ninth Submitter',
|
||||||
|
'Tenth Submitter'
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
selectedSubmitter () {
|
selectedSubmitter () {
|
||||||
|
|||||||
@@ -1,20 +1,22 @@
|
|||||||
<template>
|
<template>
|
||||||
<span class="dropdown">
|
<span class="dropdown">
|
||||||
<label
|
<slot>
|
||||||
tabindex="0"
|
<label
|
||||||
:title="fieldNames[modelValue]"
|
tabindex="0"
|
||||||
class="cursor-pointer"
|
:title="fieldNames[modelValue]"
|
||||||
>
|
class="cursor-pointer"
|
||||||
<component
|
>
|
||||||
:is="fieldIcons[modelValue]"
|
<component
|
||||||
:width="buttonWidth"
|
:is="fieldIcons[modelValue]"
|
||||||
:class="buttonClasses"
|
:width="buttonWidth"
|
||||||
:stroke-width="1.6"
|
:class="buttonClasses"
|
||||||
/>
|
:stroke-width="1.6"
|
||||||
</label>
|
/>
|
||||||
|
</label>
|
||||||
|
</slot>
|
||||||
<ul
|
<ul
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
class="dropdown-content menu menu-xs p-2 shadow rounded-box w-52 z-10"
|
class="dropdown-content menu menu-xs p-2 shadow rounded-box w-52 z-10 mb-3"
|
||||||
:class="menuClasses"
|
:class="menuClasses"
|
||||||
@click="closeDropdown"
|
@click="closeDropdown"
|
||||||
>
|
>
|
||||||
@@ -43,7 +45,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<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 {
|
export default {
|
||||||
name: 'FiledTypeDropdown',
|
name: 'FiledTypeDropdown',
|
||||||
@@ -75,6 +77,7 @@ export default {
|
|||||||
return {
|
return {
|
||||||
text: 'Text',
|
text: 'Text',
|
||||||
signature: 'Signature',
|
signature: 'Signature',
|
||||||
|
initials: 'Initials',
|
||||||
date: 'Date',
|
date: 'Date',
|
||||||
image: 'Image',
|
image: 'Image',
|
||||||
file: 'File',
|
file: 'File',
|
||||||
@@ -90,14 +93,15 @@ export default {
|
|||||||
return {
|
return {
|
||||||
text: IconTextSize,
|
text: IconTextSize,
|
||||||
signature: IconWritingSign,
|
signature: IconWritingSign,
|
||||||
|
initials: IconLetterCaseUpper,
|
||||||
date: IconCalendarEvent,
|
date: IconCalendarEvent,
|
||||||
image: IconPhoto,
|
image: IconPhoto,
|
||||||
file: IconPaperclip,
|
file: IconPaperclip,
|
||||||
select: IconSelect,
|
select: IconSelect,
|
||||||
checkbox: IconCheckbox,
|
checkbox: IconCheckbox,
|
||||||
cells: IconColumns3,
|
|
||||||
multiple: IconChecks,
|
|
||||||
radio: IconCircleDot,
|
radio: IconCircleDot,
|
||||||
|
multiple: IconChecks,
|
||||||
|
cells: IconColumns3,
|
||||||
phone: IconPhoneCheck
|
phone: IconPhoneCheck
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,7 +73,7 @@
|
|||||||
</button>
|
</button>
|
||||||
<div
|
<div
|
||||||
v-else
|
v-else
|
||||||
class="tooltip flex"
|
class="tooltip tooltip-bottom-end flex"
|
||||||
data-tip="Unlock SMS-verified phone number field with paid plan. Use text field for phone numbers without verification."
|
data-tip="Unlock SMS-verified phone number field with paid plan. Use text field for phone numbers without verification."
|
||||||
>
|
>
|
||||||
<a
|
<a
|
||||||
@@ -107,9 +107,6 @@
|
|||||||
<li>
|
<li>
|
||||||
Draw a text field on the page with a mouse
|
Draw a text field on the page with a mouse
|
||||||
</li>
|
</li>
|
||||||
<li>
|
|
||||||
Single click on the page to add a checkbox
|
|
||||||
</li>
|
|
||||||
<li>
|
<li>
|
||||||
Drag & drop any other field type on the page
|
Drag & drop any other field type on the page
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="relative cursor-crosshair select-none">
|
<div
|
||||||
|
class="relative cursor-crosshair select-none"
|
||||||
|
:style="drawField ? 'touch-action: none' : ''"
|
||||||
|
>
|
||||||
<img
|
<img
|
||||||
ref="image"
|
ref="image"
|
||||||
:src="image.url"
|
:src="image.url"
|
||||||
@@ -32,12 +35,13 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-show="resizeDirection || isMove || isDrag || showMask"
|
v-show="resizeDirection || isMove || isDrag || showMask || (drawField && isMobile)"
|
||||||
id="mask"
|
id="mask"
|
||||||
ref="mask"
|
ref="mask"
|
||||||
class="top-0 bottom-0 left-0 right-0 absolute z-10"
|
class="top-0 bottom-0 left-0 right-0 absolute z-10"
|
||||||
:class="{ 'cursor-grab': isDrag || isMove, 'cursor-nwse-resize': drawField, [resizeDirectionClasses[resizeDirection]]: !!resizeDirectionClasses }"
|
:class="{ 'cursor-grab': isDrag || isMove, 'cursor-nwse-resize': drawField, [resizeDirectionClasses[resizeDirection]]: !!resizeDirectionClasses }"
|
||||||
@pointermove="onPointermove"
|
@pointermove="onPointermove"
|
||||||
|
@pointerdown="onStartDraw"
|
||||||
@dragover.prevent
|
@dragover.prevent
|
||||||
@drop="onDrop"
|
@drop="onDrop"
|
||||||
@pointerup="onPointerup"
|
@pointerup="onPointerup"
|
||||||
@@ -93,6 +97,9 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
|
isMobile () {
|
||||||
|
return /android|iphone|ipad/i.test(navigator.userAgent)
|
||||||
|
},
|
||||||
resizeDirectionClasses () {
|
resizeDirectionClasses () {
|
||||||
return {
|
return {
|
||||||
nwse: 'cursor-nwse-resize',
|
nwse: 'cursor-nwse-resize',
|
||||||
@@ -125,6 +132,10 @@ export default {
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
onStartDraw (e) {
|
onStartDraw (e) {
|
||||||
|
if (this.isMobile && !this.drawField) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
this.showMask = true
|
this.showMask = true
|
||||||
|
|
||||||
this.$nextTick(() => {
|
this.$nextTick(() => {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ class SendFormCompletedWebhookRequestJob < ApplicationJob
|
|||||||
USER_AGENT = 'DocuSeal.co Webhook'
|
USER_AGENT = 'DocuSeal.co Webhook'
|
||||||
|
|
||||||
def perform(submitter)
|
def perform(submitter)
|
||||||
config = submitter.submission.account.encrypted_configs.find_by(key: EncryptedConfig::WEBHOOK_URL_KEY)
|
config = Accounts.load_webhook_configs(submitter.submission.account)
|
||||||
|
|
||||||
return if config.blank? || config.value.blank?
|
return if config.blank? || config.value.blank?
|
||||||
|
|
||||||
@@ -15,7 +15,7 @@ class SendFormCompletedWebhookRequestJob < ApplicationJob
|
|||||||
Faraday.post(config.value,
|
Faraday.post(config.value,
|
||||||
{
|
{
|
||||||
event_type: 'form.completed',
|
event_type: 'form.completed',
|
||||||
timestamp: Time.current.iso8601,
|
timestamp: Time.current,
|
||||||
data: Submitters::SerializeForWebhook.call(submitter)
|
data: Submitters::SerializeForWebhook.call(submitter)
|
||||||
}.to_json,
|
}.to_json,
|
||||||
'Content-Type' => 'application/json',
|
'Content-Type' => 'application/json',
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ class SendFormStartedWebhookRequestJob < ApplicationJob
|
|||||||
USER_AGENT = 'DocuSeal.co Webhook'
|
USER_AGENT = 'DocuSeal.co Webhook'
|
||||||
|
|
||||||
def perform(submitter)
|
def perform(submitter)
|
||||||
config = submitter.submission.account.encrypted_configs.find_by(key: EncryptedConfig::WEBHOOK_URL_KEY)
|
config = Accounts.load_webhook_configs(submitter.submission.account)
|
||||||
|
|
||||||
return if config.blank? || config.value.blank?
|
return if config.blank? || config.value.blank?
|
||||||
|
|
||||||
@@ -13,7 +13,7 @@ class SendFormStartedWebhookRequestJob < ApplicationJob
|
|||||||
Faraday.post(config.value,
|
Faraday.post(config.value,
|
||||||
{
|
{
|
||||||
event_type: 'form.started',
|
event_type: 'form.started',
|
||||||
timestamp: Time.current.iso8601,
|
timestamp: Time.current,
|
||||||
data: Submitters::SerializeForWebhook.call(submitter)
|
data: Submitters::SerializeForWebhook.call(submitter)
|
||||||
}.to_json,
|
}.to_json,
|
||||||
'Content-Type' => 'application/json',
|
'Content-Type' => 'application/json',
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ class SendFormViewedWebhookRequestJob < ApplicationJob
|
|||||||
USER_AGENT = 'DocuSeal.co Webhook'
|
USER_AGENT = 'DocuSeal.co Webhook'
|
||||||
|
|
||||||
def perform(submitter)
|
def perform(submitter)
|
||||||
config = submitter.submission.account.encrypted_configs.find_by(key: EncryptedConfig::WEBHOOK_URL_KEY)
|
config = Accounts.load_webhook_configs(submitter.submission.account)
|
||||||
|
|
||||||
return if config.blank? || config.value.blank?
|
return if config.blank? || config.value.blank?
|
||||||
|
|
||||||
@@ -13,7 +13,7 @@ class SendFormViewedWebhookRequestJob < ApplicationJob
|
|||||||
Faraday.post(config.value,
|
Faraday.post(config.value,
|
||||||
{
|
{
|
||||||
event_type: 'form.viewed',
|
event_type: 'form.viewed',
|
||||||
timestamp: Time.current.iso8601,
|
timestamp: Time.current,
|
||||||
data: Submitters::SerializeForWebhook.call(submitter)
|
data: Submitters::SerializeForWebhook.call(submitter)
|
||||||
}.to_json,
|
}.to_json,
|
||||||
'Content-Type' => 'application/json',
|
'Content-Type' => 'application/json',
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ class Account < ApplicationRecord
|
|||||||
has_many :encrypted_configs, dependent: :destroy
|
has_many :encrypted_configs, dependent: :destroy
|
||||||
has_many :account_configs, dependent: :destroy
|
has_many :account_configs, dependent: :destroy
|
||||||
has_many :templates, dependent: :destroy
|
has_many :templates, dependent: :destroy
|
||||||
|
has_many :template_folders, dependent: :destroy
|
||||||
|
has_one :default_template_folder, -> { where(name: TemplateFolder::DEFAULT_NAME) },
|
||||||
|
class_name: 'TemplateFolder', dependent: :destroy, inverse_of: :account
|
||||||
has_many :submissions, through: :templates
|
has_many :submissions, through: :templates
|
||||||
has_many :submitters, through: :submissions
|
has_many :submitters, through: :submissions
|
||||||
has_many :active_users, -> { active }, dependent: :destroy,
|
has_many :active_users, -> { active }, dependent: :destroy,
|
||||||
@@ -23,4 +26,9 @@ class Account < ApplicationRecord
|
|||||||
|
|
||||||
attribute :timezone, :string, default: 'UTC'
|
attribute :timezone, :string, default: 'UTC'
|
||||||
attribute :locale, :string, default: 'en-US'
|
attribute :locale, :string, default: 'en-US'
|
||||||
|
|
||||||
|
def default_template_folder
|
||||||
|
super || build_default_template_folder(name: TemplateFolder::DEFAULT_NAME,
|
||||||
|
author_id: users.minimum(:id)).tap(&:save!)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
@@ -26,6 +26,7 @@ class AccountConfig < ApplicationRecord
|
|||||||
SUBMITTER_DOCUMENTS_COPY_EMAIL_KEY = 'submitter_documents_copy_email'
|
SUBMITTER_DOCUMENTS_COPY_EMAIL_KEY = 'submitter_documents_copy_email'
|
||||||
BCC_EMAILS = 'bcc_emails'
|
BCC_EMAILS = 'bcc_emails'
|
||||||
SUBMITTER_REMAILERS = 'submitter_reminders'
|
SUBMITTER_REMAILERS = 'submitter_reminders'
|
||||||
|
FORM_COMPLETED_BUTTON_KEY = 'form_completed_button'
|
||||||
|
|
||||||
DEFAULT_VALUES = {
|
DEFAULT_VALUES = {
|
||||||
SUBMITTER_INVITATION_EMAIL_KEY => {
|
SUBMITTER_INVITATION_EMAIL_KEY => {
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ class SubmissionEvent < ApplicationRecord
|
|||||||
open_email: 'open_email',
|
open_email: 'open_email',
|
||||||
click_email: 'click_email',
|
click_email: 'click_email',
|
||||||
click_sms: 'click_sms',
|
click_sms: 'click_sms',
|
||||||
|
phone_verified: 'phone_verified',
|
||||||
start_form: 'start_form',
|
start_form: 'start_form',
|
||||||
view_form: 'view_form',
|
view_form: 'view_form',
|
||||||
complete_form: 'complete_form'
|
complete_form: 'complete_form'
|
||||||
|
|||||||
@@ -16,23 +16,29 @@
|
|||||||
# updated_at :datetime not null
|
# updated_at :datetime not null
|
||||||
# account_id :bigint not null
|
# account_id :bigint not null
|
||||||
# author_id :bigint not null
|
# author_id :bigint not null
|
||||||
|
# folder_id :bigint not null
|
||||||
#
|
#
|
||||||
# Indexes
|
# Indexes
|
||||||
#
|
#
|
||||||
# index_templates_on_account_id (account_id)
|
# index_templates_on_account_id (account_id)
|
||||||
# index_templates_on_author_id (author_id)
|
# index_templates_on_author_id (author_id)
|
||||||
|
# index_templates_on_folder_id (folder_id)
|
||||||
# index_templates_on_slug (slug) UNIQUE
|
# index_templates_on_slug (slug) UNIQUE
|
||||||
#
|
#
|
||||||
# Foreign Keys
|
# Foreign Keys
|
||||||
#
|
#
|
||||||
# fk_rails_... (account_id => accounts.id)
|
# fk_rails_... (account_id => accounts.id)
|
||||||
# fk_rails_... (author_id => users.id)
|
# fk_rails_... (author_id => users.id)
|
||||||
|
# fk_rails_... (folder_id => template_folders.id)
|
||||||
#
|
#
|
||||||
class Template < ApplicationRecord
|
class Template < ApplicationRecord
|
||||||
DEFAULT_SUBMITTER_NAME = 'First Submitter'
|
DEFAULT_SUBMITTER_NAME = 'First Submitter'
|
||||||
|
|
||||||
belongs_to :author, class_name: 'User'
|
belongs_to :author, class_name: 'User'
|
||||||
belongs_to :account
|
belongs_to :account
|
||||||
|
belongs_to :folder, class_name: 'TemplateFolder'
|
||||||
|
|
||||||
|
before_validation :maybe_set_default_folder, on: :create
|
||||||
|
|
||||||
attribute :fields, :string, default: -> { [] }
|
attribute :fields, :string, default: -> { [] }
|
||||||
attribute :schema, :string, default: -> { [] }
|
attribute :schema, :string, default: -> { [] }
|
||||||
@@ -52,4 +58,11 @@ class Template < ApplicationRecord
|
|||||||
has_many :submissions, dependent: :destroy
|
has_many :submissions, dependent: :destroy
|
||||||
|
|
||||||
scope :active, -> { where(deleted_at: nil) }
|
scope :active, -> { where(deleted_at: nil) }
|
||||||
|
scope :archived, -> { where.not(deleted_at: nil) }
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def maybe_set_default_folder
|
||||||
|
self.folder ||= account.default_template_folder
|
||||||
|
end
|
||||||
end
|
end
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
# == Schema Information
|
||||||
|
#
|
||||||
|
# Table name: template_folders
|
||||||
|
#
|
||||||
|
# id :bigint not null, primary key
|
||||||
|
# deleted_at :datetime
|
||||||
|
# name :string not null
|
||||||
|
# created_at :datetime not null
|
||||||
|
# updated_at :datetime not null
|
||||||
|
# account_id :bigint not null
|
||||||
|
# author_id :bigint not null
|
||||||
|
#
|
||||||
|
# Indexes
|
||||||
|
#
|
||||||
|
# index_template_folders_on_account_id (account_id)
|
||||||
|
# index_template_folders_on_author_id (author_id)
|
||||||
|
#
|
||||||
|
# Foreign Keys
|
||||||
|
#
|
||||||
|
# fk_rails_... (account_id => accounts.id)
|
||||||
|
# fk_rails_... (author_id => users.id)
|
||||||
|
#
|
||||||
|
class TemplateFolder < ApplicationRecord
|
||||||
|
DEFAULT_NAME = 'Default'
|
||||||
|
|
||||||
|
belongs_to :author, class_name: 'User'
|
||||||
|
belongs_to :account
|
||||||
|
|
||||||
|
has_many :templates, dependent: :destroy, foreign_key: :folder_id, inverse_of: :folder
|
||||||
|
has_many :active_templates, -> { where(deleted_at: nil) },
|
||||||
|
class_name: 'Template', dependent: :destroy, foreign_key: :folder_id, inverse_of: :folder
|
||||||
|
|
||||||
|
scope :active, -> { where(deleted_at: nil) }
|
||||||
|
|
||||||
|
def default?
|
||||||
|
name == DEFAULT_NAME
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -52,6 +52,7 @@ class User < ApplicationRecord
|
|||||||
belongs_to :account
|
belongs_to :account
|
||||||
has_one :access_token, dependent: :destroy
|
has_one :access_token, dependent: :destroy
|
||||||
has_many :templates, dependent: :destroy, foreign_key: :author_id, inverse_of: :author
|
has_many :templates, dependent: :destroy, foreign_key: :author_id, inverse_of: :author
|
||||||
|
has_many :user_configs, dependent: :destroy
|
||||||
|
|
||||||
devise :two_factor_authenticatable, :recoverable, :rememberable, :validatable, :trackable
|
devise :two_factor_authenticatable, :recoverable, :rememberable, :validatable, :trackable
|
||||||
devise :registerable, :omniauthable, omniauth_providers: [:google_oauth2] if Docuseal.multitenant?
|
devise :registerable, :omniauthable, omniauth_providers: [:google_oauth2] if Docuseal.multitenant?
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
# == Schema Information
|
||||||
|
#
|
||||||
|
# Table name: user_configs
|
||||||
|
#
|
||||||
|
# id :bigint not null, primary key
|
||||||
|
# key :string not null
|
||||||
|
# value :text not null
|
||||||
|
# created_at :datetime not null
|
||||||
|
# updated_at :datetime not null
|
||||||
|
# user_id :bigint not null
|
||||||
|
#
|
||||||
|
# Indexes
|
||||||
|
#
|
||||||
|
# index_user_configs_on_user_id (user_id)
|
||||||
|
# index_user_configs_on_user_id_and_key (user_id,key) UNIQUE
|
||||||
|
#
|
||||||
|
# Foreign Keys
|
||||||
|
#
|
||||||
|
# fk_rails_... (user_id => users.id)
|
||||||
|
#
|
||||||
|
class UserConfig < ApplicationRecord
|
||||||
|
SIGNATURE_KEY = 'signature'
|
||||||
|
|
||||||
|
belongs_to :user
|
||||||
|
|
||||||
|
serialize :value, JSON
|
||||||
|
end
|
||||||
@@ -21,8 +21,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
<% unless Docuseal.multitenant? %>
|
<% encrypted_config = @encrypted_config || EncryptedConfig.find_or_initialize_by(account: current_account, key: EncryptedConfig::APP_URL_KEY) %>
|
||||||
<%= f.fields_for @encrypted_config || EncryptedConfig.find_or_initialize_by(account: current_account, key: EncryptedConfig::APP_URL_KEY) do |ff| %>
|
<% if !Docuseal.multitenant? && can?(:manage, encrypted_config) %>
|
||||||
|
<%= f.fields_for encrypted_config do |ff| %>
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<%= ff.label :value, 'App URL', class: 'label' %>
|
<%= ff.label :value, 'App URL', class: 'label' %>
|
||||||
<%= ff.text_field :value, autocomplete: 'off', class: 'base-input' %>
|
<%= ff.text_field :value, autocomplete: 'off', class: 'base-input' %>
|
||||||
|
|||||||
@@ -12,32 +12,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-4 mt-4">
|
<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">
|
<div class="collapse collapse-plus bg-base-200 px-1">
|
||||||
<input type="checkbox">
|
<input type="checkbox">
|
||||||
<div class="collapse-title text-xl font-medium">
|
<div class="collapse-title text-xl font-medium">
|
||||||
@@ -66,7 +40,7 @@
|
|||||||
"Form Text Field Name": "Default Value"
|
"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>
|
||||||
</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">
|
<div class="collapse collapse-plus bg-base-200 px-1">
|
||||||
<input type="checkbox">
|
<input type="checkbox">
|
||||||
<div class="collapse-title text-xl font-medium">
|
<div class="collapse-title text-xl font-medium">
|
||||||
@@ -101,5 +101,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="text-center">
|
||||||
|
<%= link_to 'Open Full API Reference', "#{Docuseal::PRODUCT_URL}/docs/api", class: 'btn btn-warning text-base mt-4 px-8', target: '_blank', rel: 'noopener' %>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
<% if Docuseal.demo? %><%= render 'shared/demo_alert' %><% end %>
|
<% if Docuseal.demo? %><%= render 'shared/demo_alert' %><% end %>
|
||||||
<% if @pagy.count > 0 || params[:q].present? %>
|
<% if @pagy.count > 0 || params[:q].present? || @template_folders.present? %>
|
||||||
<div class="flex justify-between mb-4 items-center">
|
<div class="flex justify-between mb-4 items-center">
|
||||||
<h1 class="text-4xl font-bold"><span class="hidden md:inline">Document</span> Templates</h1>
|
<h1 class="text-4xl font-bold"><span class="hidden md:inline">Document</span> Templates</h1>
|
||||||
<div class="flex space-x-2">
|
<div class="flex space-x-2">
|
||||||
<% if params[:q].present? || @pagy.pages > 1 %>
|
<% if params[:q].present? || @pagy.pages > 1 || @template_folders.present? %>
|
||||||
<%= render 'shared/search_input' %>
|
<%= render 'shared/search_input' %>
|
||||||
<% end %>
|
<% end %>
|
||||||
<% if can?(:create, ::Template) %>
|
<% if can?(:create, ::Template) %>
|
||||||
@@ -15,17 +15,55 @@
|
|||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<% if @pagy.count > 0 %>
|
<% view_archived_html = capture do %>
|
||||||
|
<% if current_account.templates.where.not(deleted_at: nil).exists? %>
|
||||||
|
<div>
|
||||||
|
<a href="<%= templates_archived_index_path %>" class="link text-sm">View Archived</a>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
<% end %>
|
||||||
|
<% if @template_folders.present? %>
|
||||||
|
<div class="grid gap-4 md:grid-cols-3 <%= 'mb-6' if @templates.present? %>">
|
||||||
|
<%= render partial: 'template_folders/folder', collection: @template_folders, as: :folder %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
<% if @templates.present? %>
|
||||||
<div class="grid gap-4 md:grid-cols-3">
|
<div class="grid gap-4 md:grid-cols-3">
|
||||||
<%= render partial: 'templates/template', collection: @templates %>
|
<%= render partial: 'templates/template', collection: @templates %>
|
||||||
</div>
|
</div>
|
||||||
<% view_archived_html = capture do %>
|
<% end %>
|
||||||
<% if current_account.templates.where.not(deleted_at: nil).exists? %>
|
<% if params[:q].blank? && @pagy.pages == 1 && ((@template_folders.size < 10 && @templates.size.zero?) || (@template_folders.size < 7 && @templates.size < 4) || (@template_folders.size < 4 && @templates.size < 7)) %>
|
||||||
<div>
|
<%= form_for '', url: templates_upload_path, method: :post, class: 'mt-8 block', html: { enctype: 'multipart/form-data' } do %>
|
||||||
<a href="<%= templates_archived_index_path %>" class="link text-sm">View Archived</a>
|
<button type="submit" class="hidden"></button>
|
||||||
</div>
|
<file-dropzone data-submit-on-upload="true" class="w-full">
|
||||||
<% end %>
|
<label for="file_dropzone_input" class="w-full block h-52 relative hover:bg-base-200/30 rounded-xl border border-2 border-base-300 border-dashed">
|
||||||
|
<div class="absolute top-0 right-0 left-0 bottom-0 flex items-center justify-center">
|
||||||
|
<div class="flex flex-col items-center">
|
||||||
|
<span data-target="file-dropzone.icon" class="flex flex-col items-center">
|
||||||
|
<span>
|
||||||
|
<%= svg_icon('cloud_upload', class: 'w-10 h-10') %>
|
||||||
|
</span>
|
||||||
|
<div class="font-medium mb-1">
|
||||||
|
Upload New Document
|
||||||
|
</div>
|
||||||
|
<div class="text-xs">
|
||||||
|
<span class="font-medium">Click to upload</span> or drag and drop
|
||||||
|
</div>
|
||||||
|
</span>
|
||||||
|
<span data-target="file-dropzone.loading" class="flex flex-col items-center hidden">
|
||||||
|
<%= svg_icon('loader', class: 'w-10 h-10 animate-spin') %>
|
||||||
|
<div class="font-medium mb-1">
|
||||||
|
Uploading...
|
||||||
|
</div>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<input id="file_dropzone_input" name="files[]" class="hidden" data-action="change:file-dropzone#onSelectFiles" data-target="file-dropzone.input" type="file" accept="image/*, application/pdf<%= ', .docx, .doc, .xlsx, .xls' if Docuseal.multitenant? %>" multiple>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</file-dropzone>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
<% end %>
|
||||||
|
<% if @templates.present? || params[:q].blank? %>
|
||||||
<% if @pagy.pages > 1 %>
|
<% if @pagy.pages > 1 %>
|
||||||
<%= render 'shared/pagination', pagy: @pagy, items_name: 'templates', left_additional_html: view_archived_html %>
|
<%= render 'shared/pagination', pagy: @pagy, items_name: 'templates', left_additional_html: view_archived_html %>
|
||||||
<% else %>
|
<% else %>
|
||||||
|
|||||||
@@ -18,11 +18,11 @@
|
|||||||
<div class="grid md:grid-cols-2 gap-4">
|
<div class="grid md:grid-cols-2 gap-4">
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<%= ff.label :username, class: 'label' %>
|
<%= ff.label :username, class: 'label' %>
|
||||||
<%= ff.text_field :username, value: value['username'], required: true, class: 'base-input' %>
|
<%= ff.text_field :username, value: value['username'], class: 'base-input' %>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<%= ff.label :password, class: 'label' %>
|
<%= ff.label :password, class: 'label' %>
|
||||||
<%= ff.password_field :password, value: value['password'], required: true, class: 'base-input' %>
|
<%= ff.password_field :password, value: value['password'], class: 'base-input' %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="grid md:grid-cols-2 gap-4">
|
<div class="grid md:grid-cols-2 gap-4">
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<div class="flex flex-wrap space-y-4 md:flex-nowrap md:space-y-0 md:space-x-10">
|
<div class="flex-wrap space-y-4 md:flex md:flex-nowrap md:space-y-0 md:space-x-10">
|
||||||
<%= render 'shared/settings_nav' %>
|
<%= render 'shared/settings_nav' %>
|
||||||
<div class="flex-grow">
|
<div class="md:flex-grow">
|
||||||
<div class="max-w-xl">
|
<div class="max-w-xl">
|
||||||
<h1 class="text-4xl font-bold mb-4">PDF Signature</h1>
|
<h1 class="text-4xl font-bold mb-4">PDF Signature</h1>
|
||||||
<div id="result">
|
<div id="result">
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="<%= local_assigns[:class] %>" width="44" height="44" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
|
||||||
|
<path d="M17 7l-10 10"></path>
|
||||||
|
<path d="M8 7l9 0l0 9"></path>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 354 B |
@@ -0,0 +1,11 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="<%= local_assigns[:class] %>" width="44" height="44" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
|
||||||
|
<path d="M3 21l18 0"></path>
|
||||||
|
<path d="M9 8l1 0"></path>
|
||||||
|
<path d="M9 12l1 0"></path>
|
||||||
|
<path d="M9 16l1 0"></path>
|
||||||
|
<path d="M14 8l1 0"></path>
|
||||||
|
<path d="M14 12l1 0"></path>
|
||||||
|
<path d="M14 16l1 0"></path>
|
||||||
|
<path d="M5 21v-16a2 2 0 0 1 2 -2h10a2 2 0 0 1 2 2v16"></path>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 565 B |
@@ -0,0 +1,4 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="<%= local_assigns[:class] %>" width="44" height="44" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
|
||||||
|
<path d="M5 4h4l3 3h7a2 2 0 0 1 2 2v8a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2v-11a2 2 0 0 1 2 -2"></path>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 391 B |
@@ -0,0 +1,6 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="<%= local_assigns[:class] %>" width="44" height="44" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
|
||||||
|
<path d="M13 19h-8a2 2 0 0 1 -2 -2v-11a2 2 0 0 1 2 -2h4l3 3h7a2 2 0 0 1 2 2v4"></path>
|
||||||
|
<path d="M16 22l5 -5"></path>
|
||||||
|
<path d="M21 21.5v-4.5h-4.5"></path>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 448 B |
@@ -0,0 +1,9 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="<%= local_assigns[:class] %>" width="44" height="44" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
|
||||||
|
<path d="M12 3a3 3 0 0 0 -3 3v12a3 3 0 0 0 3 3"></path>
|
||||||
|
<path d="M6 3a3 3 0 0 1 3 3v12a3 3 0 0 1 -3 3"></path>
|
||||||
|
<path d="M13 7h7a1 1 0 0 1 1 1v8a1 1 0 0 1 -1 1h-7"></path>
|
||||||
|
<path d="M5 7h-1a1 1 0 0 0 -1 1v8a1 1 0 0 0 1 1h1"></path>
|
||||||
|
<path d="M17 12h.01"></path>
|
||||||
|
<path d="M13 12h.01"></path>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 588 B |
@@ -0,0 +1,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 |
@@ -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 |
@@ -0,0 +1,4 @@
|
|||||||
|
<title>
|
||||||
|
DocuSeal | Open Source Document Filling and Signing
|
||||||
|
</title>
|
||||||
|
<%= render 'shared/meta' %>
|
||||||
@@ -1,10 +1,7 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html data-theme="docuseal" lang="en">
|
<html data-theme="docuseal" lang="en">
|
||||||
<head>
|
<head>
|
||||||
<title>
|
<%= render 'layouts/head_tags' %>
|
||||||
DocuSeal | Open Source Document Filling and Signing
|
|
||||||
</title>
|
|
||||||
<%= render 'shared/meta' %>
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<%= csrf_meta_tags %>
|
<%= csrf_meta_tags %>
|
||||||
<%= csp_meta_tag %>
|
<%= csp_meta_tag %>
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html data-theme="docuseal" lang="en">
|
<html data-theme="docuseal" lang="en">
|
||||||
<head>
|
<head>
|
||||||
<title>
|
<%= render 'layouts/head_tags' %>
|
||||||
DocuSeal | Open Source Document Filling and Signing
|
|
||||||
</title>
|
|
||||||
<%= render 'shared/meta' %>
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<%= csrf_meta_tags %>
|
<%= csrf_meta_tags %>
|
||||||
<%= csp_meta_tag %>
|
<%= csp_meta_tag %>
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html data-theme="docuseal" lang="en">
|
<html data-theme="docuseal" lang="en">
|
||||||
<head>
|
<head>
|
||||||
<title>
|
<%= render 'layouts/head_tags' %>
|
||||||
DocuSeal | Open Source Document Filling and Signing
|
|
||||||
</title>
|
|
||||||
<%= render 'shared/meta' %>
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<%= csrf_meta_tags %>
|
<%= csrf_meta_tags %>
|
||||||
<%= csp_meta_tag %>
|
<%= csp_meta_tag %>
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<h3 class="mb-4 text-2xl font-semibold">Easy to Start</h3>
|
<h3 class="mb-4 text-2xl font-semibold">Easy to Start</h3>
|
||||||
<p class="text-base text-gray-500">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -70,4 +70,4 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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>
|
</div>
|
||||||
<p class="text-4xl font-bold mb-4 mt-8">Company Logo</p>
|
<p class="text-4xl font-bold mb-4 mt-8">Company Logo</p>
|
||||||
<%= render 'logo_form' %>
|
<%= render 'logo_form' %>
|
||||||
|
<p class="text-4xl font-bold mb-4 mt-8">Submitter Form</p>
|
||||||
|
<%= render 'form_completed_button_form' %>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-0 md:w-52"></div>
|
<div class="w-0 md:w-52"></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -21,6 +21,14 @@
|
|||||||
<%= f.button button_title(title: 'Update', disabled_with: 'Updating'), class: 'base-button' %>
|
<%= f.button button_title(title: 'Update', disabled_with: 'Updating'), class: 'base-button' %>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% 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>
|
<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| %>
|
<%= 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">
|
<div class="form-control">
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
<%= render 'shared/powered_by' %>
|
<%= render 'shared/powered_by', with_counter: local_assigns[:with_counter] %>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<% 'stats stat stat-figure stat-title stat-value text-accent' %>
|
||||||
@@ -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>
|
||||||
@@ -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="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">
|
<div class="flex items-center space-x-4">
|
||||||
<a href="<%= root_path %>" class="text-2xl font-bold items-center flex space-x-2">
|
<a href="<%= root_path %>" class="text-2xl font-bold items-center flex space-x-2">
|
||||||
<%= render 'shared/logo' %>
|
<%= render 'shared/title' %>
|
||||||
<span>DocuSeal</span>
|
|
||||||
</a>
|
</a>
|
||||||
<% unless Docuseal.demo? %>
|
<%= render 'shared/github' %>
|
||||||
<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 %>
|
|
||||||
</div>
|
</div>
|
||||||
<% if signed_in? %>
|
<% if signed_in? %>
|
||||||
<div class="space-x-4 flex items-center">
|
<div class="space-x-4 flex items-center">
|
||||||
@@ -16,11 +11,13 @@
|
|||||||
<%= render 'shared/github_button' %>
|
<%= render 'shared/github_button' %>
|
||||||
<% else %>
|
<% else %>
|
||||||
<div class="flex items-center justify-center space-x-4 mr-1">
|
<div class="flex items-center justify-center space-x-4 mr-1">
|
||||||
<%= 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 %>
|
<% if can?(:manage, EncryptedConfig) && !can?(:manage, :tenants) %>
|
||||||
Console
|
<%= 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 %>
|
||||||
<span class="badge badge-warning ml-1">New</span>
|
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 %>
|
<% 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' %>
|
<%= link_to 'Settings', settings_profile_index_path, class: 'hidden md:inline-flex font-medium text-lg' %>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
@@ -35,7 +32,7 @@
|
|||||||
<span class="mr-1">Profile</span>
|
<span class="mr-1">Profile</span>
|
||||||
<% end %>
|
<% end %>
|
||||||
</li>
|
</li>
|
||||||
<% unless Docuseal.demo? %>
|
<% if !Docuseal.demo? && can?(:manage, EncryptedConfig) %>
|
||||||
<li>
|
<li>
|
||||||
<%= link_to Docuseal.multitenant? ? console_redirect_index_path : Docuseal::CONSOLE_URL, data: { prefetch: false }, class: 'flex items-center' do %>
|
<%= 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') %>
|
<%= svg_icon('terminal', class: 'w-5 h-5 stroke-2') %>
|
||||||
@@ -43,12 +40,14 @@
|
|||||||
<% end %>
|
<% end %>
|
||||||
</li>
|
</li>
|
||||||
<% end %>
|
<% end %>
|
||||||
<li>
|
<% if can?(:read, EncryptedConfig.new(key: EncryptedConfig::ESIGN_CERTS_KEY, account: current_account)) %>
|
||||||
<%= link_to settings_esign_path, class: 'flex items-center' do %>
|
<li>
|
||||||
<%= svg_icon('zoom_check', class: 'w-5 h-5 stroke-2') %>
|
<%= link_to settings_esign_path, class: 'flex items-center' do %>
|
||||||
<span class="mr-1">Verify PDF</span>
|
<%= svg_icon('zoom_check', class: 'w-5 h-5 stroke-2') %>
|
||||||
<% end %>
|
<span class="mr-1">Verify PDF</span>
|
||||||
</li>
|
<% end %>
|
||||||
|
</li>
|
||||||
|
<% end %>
|
||||||
<li>
|
<li>
|
||||||
<%= button_to destroy_user_session_path, method: :delete, data: { turbo: false }, class: 'flex items-center' do %>
|
<%= 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') %>
|
<%= svg_icon('logout', class: 'w-5 h-5 stroke-2 mr-2 inline') %>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<% if @pagy.pages > 1 %>
|
<% if @pagy.pages > 1 %>
|
||||||
<div class="flex my-6 justify-center md:justify-between">
|
<div class="flex my-6 justify-center md:justify-between">
|
||||||
<div class="hidden md:block text-sm">
|
<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] %>
|
<%= local_assigns[:left_additional_html] %>
|
||||||
</div>
|
</div>
|
||||||
<div class="join">
|
<div class="join">
|
||||||
|
|||||||
@@ -1,4 +1,13 @@
|
|||||||
<div class="text-center px-2">
|
<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
|
<a href="<%= Docuseal::PRODUCT_URL %>" class="underline"><%= Docuseal::PRODUCT_NAME %></a> - open source documents software
|
||||||
</div>
|
</div>
|
||||||
@@ -1,5 +1,12 @@
|
|||||||
<form action="<%= request.path %>" method="get" class="items-center hidden md:flex">
|
<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">
|
||||||
|
×
|
||||||
|
</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()]">
|
<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">
|
<span class="enabled">
|
||||||
<%= svg_icon('search', class: 'w-6 h-6 stroke-2') %>
|
<%= svg_icon('search', class: 'w-6 h-6 stroke-2') %>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
<%= link_to 'Account', settings_account_path, class: 'text-base hover:bg-base-300' %>
|
<%= link_to 'Account', settings_account_path, class: 'text-base hover:bg-base-300' %>
|
||||||
</li>
|
</li>
|
||||||
<% unless Docuseal.multitenant? %>
|
<% 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>
|
<li>
|
||||||
<%= link_to 'Email', settings_email_index_path, class: 'text-base hover:bg-base-300' %>
|
<%= link_to 'Email', settings_email_index_path, class: 'text-base hover:bg-base-300' %>
|
||||||
</li>
|
</li>
|
||||||
@@ -58,7 +58,8 @@
|
|||||||
<%= link_to 'Personalization', settings_personalization_path, class: 'text-base hover:bg-base-300' %>
|
<%= link_to 'Personalization', settings_personalization_path, class: 'text-base hover:bg-base-300' %>
|
||||||
</li>
|
</li>
|
||||||
<% end %>
|
<% end %>
|
||||||
<% unless Docuseal.demo? %>
|
<%= render 'shared/settings_nav_extra' %>
|
||||||
|
<% if !Docuseal.demo? && can?(:manage, EncryptedConfig) %>
|
||||||
<li>
|
<li>
|
||||||
<%= link_to Docuseal.multitenant? ? console_redirect_index_path : Docuseal::CONSOLE_URL, class: 'text-base hover:bg-base-300', data: { prefetch: false } do %>
|
<%= link_to Docuseal.multitenant? ? console_redirect_index_path : Docuseal::CONSOLE_URL, class: 'text-base hover:bg-base-300', data: { prefetch: false } do %>
|
||||||
Console
|
Console
|
||||||
@@ -68,27 +69,31 @@
|
|||||||
<% end %>
|
<% end %>
|
||||||
</ul>
|
</ul>
|
||||||
</menu-active>
|
</menu-active>
|
||||||
<div class="mx-4 border-t border-base-300 hidden md:block">
|
<% if !can?(:manage, :tenants) %>
|
||||||
<div class="text-sm mt-3">
|
<div class="mx-4 border-t border-base-300 hidden md:block">
|
||||||
Need help? Ask a question:
|
<div class="text-sm mt-3">
|
||||||
</div>
|
Need help? Ask a question:
|
||||||
<div class="flex mt-3 space-x-3">
|
</div>
|
||||||
<a href="<%= Docuseal::GITHUB_URL %>" target="_blank" class="btn btn-circle btn-primary btn-md">
|
<div class="flex mt-3 space-x-3">
|
||||||
<%= svg_icon('brand_github', class: 'w-8 h-8') %>
|
<div class="tooltip" data-tip="GitHub">
|
||||||
</a>
|
<a href="<%= Docuseal::GITHUB_URL %>" target="_blank" class="btn btn-circle btn-primary btn-md">
|
||||||
<a href="<%= Docuseal::DISCORD_URL %>" target="_blank" class="btn btn-circle btn-primary btn-md">
|
<%= svg_icon('brand_github', class: 'w-8 h-8') %>
|
||||||
<%= svg_icon('brand_discord', class: 'w-8 h-8') %>
|
</a>
|
||||||
</a>
|
</div>
|
||||||
<a href="<%= Docuseal::TWITTER_URL %>" target="_blank" class="btn btn-circle btn-primary btn-md">
|
<div class="tooltip" data-tip="Discord Community">
|
||||||
<%= svg_icon('brand_twitter', class: 'w-8 h-8') %>
|
<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>
|
</a>
|
||||||
</div>
|
</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">
|
<% end %>
|
||||||
<%= 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>
|
|
||||||
</div>
|
</div>
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
<%= render 'shared/logo' %>
|
||||||
|
<span>DocuSeal</span>
|
||||||
@@ -3,7 +3,9 @@
|
|||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<div class="text-center w-full space-y-6">
|
<div class="text-center w-full space-y-6">
|
||||||
<%= render 'banner' %>
|
<%= 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>
|
||||||
<div class="flex items-center bg-base-200 rounded-xl p-4 mb-4">
|
<div class="flex items-center bg-base-200 rounded-xl p-4 mb-4">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
@@ -12,19 +14,25 @@
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p class="text-lg font-bold mb-1"><%= @template.name %></p>
|
<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>
|
||||||
</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| %>
|
<% unless @template.deleted_at? %>
|
||||||
<div class="form-control !mt-0">
|
<%= form_for @submitter, url: start_form_path(@template.slug), data: { turbo_frame: :_top }, method: :put, html: { class: 'space-y-4' } do |f| %>
|
||||||
<%= f.label :email, class: 'label' %>
|
<div class="form-control !mt-0">
|
||||||
<%= f.email_field :email, required: true, class: 'base-input', placeholder: 'Provide your email to start' %>
|
<%= f.label :email, class: 'label' %>
|
||||||
</div>
|
<%= f.email_field :email, value: current_user&.email, required: true, class: 'base-input', placeholder: 'Provide your email to start' %>
|
||||||
<div class="form-control">
|
</div>
|
||||||
<%= f.button button_title(title: 'Start', disabled_with: 'Starting'), class: 'base-button' %>
|
<div class="form-control">
|
||||||
</div>
|
<%= f.button button_title(title: 'Start', disabled_with: 'Starting'), class: 'base-button' %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,19 +10,25 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="grid <%= 'md:grid-cols-2' if template.submitters.size > 1 %> gap-4">
|
<div class="grid <%= 'md:grid-cols-2' if template.submitters.size > 1 %> gap-4">
|
||||||
<% template.submitters.each do |item| %>
|
<% template.submitters.each do |item| %>
|
||||||
<div class="form-control">
|
<submitter-item class="form-control">
|
||||||
<% if template.submitters.size > 1 %>
|
<% if template.submitters.size > 1 %>
|
||||||
<label class="label pt-0 pb-1 text-xs">
|
<label class="label pt-0 pb-1 text-xs">
|
||||||
<span class="label-text"> <%= item['name'] %></span>
|
<span class="label-text"> <%= item['name'] %></span>
|
||||||
</label>
|
</label>
|
||||||
<% end %>
|
<% end %>
|
||||||
<input type="hidden" name="submission[1][submitters][][uuid]" value="<%= item['uuid'] %>">
|
<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 %>">
|
<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)">
|
<submitters-autocomplete data-field="email">
|
||||||
<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)">
|
<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>
|
||||||
</div>
|
</submitter-item>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
<%= form_for '', url: template_submissions_path(template), html: { class: 'space-y-4', autocomplete: 'off' }, data: { turbo_frame: :_top } do |f| %>
|
<%= 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 %>
|
<% if template.submitters.size == 1 %>
|
||||||
<div class="form-control">
|
<submitter-item class="form-control">
|
||||||
<autoresize-textarea>
|
<submitters-autocomplete data-field="email">
|
||||||
<%= f.text_area :emails, required: true, class: 'base-textarea w-full', placeholder: 'Type emails here...' %>
|
<autoresize-textarea>
|
||||||
</autoresize-textarea>
|
<%= f.text_area :emails, required: true, class: 'base-textarea w-full', placeholder: 'Type emails here...' %>
|
||||||
</div>
|
</autoresize-textarea>
|
||||||
|
</submitters-autocomplete>
|
||||||
|
</submitter-item>
|
||||||
<% else %>
|
<% else %>
|
||||||
<dynamic-list class="space-y-4">
|
<dynamic-list class="space-y-4">
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
@@ -17,13 +19,15 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="grid md:grid-cols-2 gap-4">
|
<div class="grid md:grid-cols-2 gap-4">
|
||||||
<% template.submitters.each do |item| %>
|
<% template.submitters.each do |item| %>
|
||||||
<div class="form-control">
|
<submitter-item class="form-control">
|
||||||
<label class="label pt-0 pb-1 text-xs">
|
<label class="label pt-0 pb-1 text-xs">
|
||||||
<span class="label-text"> <%= item['name'] %></span>
|
<span class="label-text"> <%= item['name'] %></span>
|
||||||
</label>
|
</label>
|
||||||
<input type="hidden" name="submission[1][submitters][][uuid]" value="<%= item['uuid'] %>">
|
<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>
|
<submitters-autocomplete data-field="email">
|
||||||
</div>
|
<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 %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,25 +8,33 @@
|
|||||||
<%= svg_icon('trash', class: 'w-4 h-4') %>
|
<%= svg_icon('trash', class: 'w-4 h-4') %>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</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| %>
|
<% template.submitters.each do |item| %>
|
||||||
<div class="form-control">
|
<submitter-item class="grid <%= template.submitters.size > 1 ? 'gap-4' : 'md:grid-cols-2 gap-1' %>">
|
||||||
<% if template.submitters.size > 1 %>
|
<div class="form-control">
|
||||||
<label class="label pt-0 pb-1 text-xs">
|
<% if template.submitters.size > 1 %>
|
||||||
<span class="label-text"> <%= item['name'] %></span>
|
<label class="label pt-0 pb-1 text-xs">
|
||||||
</label>
|
<span class="label-text"> <%= item['name'] %></span>
|
||||||
<% end %>
|
</label>
|
||||||
<input type="hidden" name="submission[1][submitters][][uuid]" value="<%= item['uuid'] %>">
|
<% end %>
|
||||||
<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>
|
<input type="hidden" name="submission[1][submitters][][uuid]" value="<%= item['uuid'] %>">
|
||||||
<% if template.submitters.size > 1 %>
|
<submitters-autocomplete data-field="phone">
|
||||||
<input type="text" name="submission[1][submitters][][name]" autocomplete="off" class="input input-sm input-bordered mt-1.5" placeholder="Name (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 w-full" placeholder="Phone" required>
|
||||||
<% end %>
|
</submitters-autocomplete>
|
||||||
</div>
|
<% if template.submitters.size > 1 %>
|
||||||
<% if template.submitters.size == 1 %>
|
<submitters-autocomplete data-field="name">
|
||||||
<div class="form-control flex">
|
<input type="text" name="submission[1][submitters][][name]" autocomplete="off" class="input input-sm input-bordered mt-1.5 w-full" placeholder="Name (optional)">
|
||||||
<input type="text" name="submission[1][submitters][][name]" autocomplete="off" class="input input-sm input-bordered" placeholder="Name (optional)">
|
</submitters-autocomplete>
|
||||||
|
<% end %>
|
||||||
</div>
|
</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 %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -37,8 +45,10 @@
|
|||||||
<span>Add New</span>
|
<span>Add New</span>
|
||||||
</a>
|
</a>
|
||||||
</dynamic-list>
|
</dynamic-list>
|
||||||
<%= render 'send_sms', f: %>
|
<div>
|
||||||
<%= render('submitters_order', f:, template:) if Accounts.can_send_emails?(current_account) %>
|
<%= render('submitters_order', f:, template:) if Accounts.can_send_emails?(current_account) %>
|
||||||
|
<%= render 'send_sms', f: %>
|
||||||
|
</div>
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<%= f.button button_title(title: 'Add Recipients'), class: 'base-button' %>
|
<%= f.button button_title(title: 'Add Recipients'), class: 'base-button' %>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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 %>%">
|
<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">
|
<img class="object-contain mx-auto" src="<%= attachments_index[value].url %>" loading="lazy">
|
||||||
<% elsif field['type'] == 'file' %>
|
<% elsif field['type'] == 'file' %>
|
||||||
<div class="px-0.5 flex flex-col justify-center">
|
<div class="px-0.5 flex flex-col justify-center">
|
||||||
|
|||||||
@@ -66,7 +66,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="hidden md:block relative w-80 flex-none pt-0.5 pr-4 pl-0.5 overflow-auto space">
|
<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'] } %>
|
<% 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| %>
|
<% (@submission.template_submitters || @submission.template.submitters).each_with_index do |item, index| %>
|
||||||
<% submitter = @submission.submitters.find { |e| e.uuid == item['uuid'] } %>
|
<% 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']]}" %>
|
<%= field['name'].presence || "#{field['type'].titleize} Field #{submitter_field_counters[field['type']]}" %>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<% if field['type'] == 'signature' %>
|
<% if field['type'].in?(%w[signature initials]) %>
|
||||||
<div class="w-full bg-base-300">
|
<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">
|
<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>
|
</div>
|
||||||
<% elsif field['type'] == 'image' %>
|
<% 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_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 %>
|
<% 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>
|
||||||
@@ -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? %>
|
<% if @email_config || @body.present? %>
|
||||||
<%= auto_link(simple_format(h(ReplaceEmailVariables.call(@body.presence || @email_config.value['body'], submitter: @submitter)))) %>
|
<%= 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 %>
|
<% else %>
|
||||||
<p>Hi there,</p>
|
<p>Hi there,</p>
|
||||||
<p>You have been invited to submit the "<%= @submitter.submission.template.name %>" form</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>
|
||||||
@@ -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 %>
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
<div>
|
||||||
|
<%= link_to root_path do %>
|
||||||
|
←
|
||||||
|
<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 %>
|
||||||
@@ -39,7 +39,7 @@
|
|||||||
<% end %>
|
<% end %>
|
||||||
<span class="btn btn-outline btn-sm w-20 md:w-24">View</span>
|
<span class="btn btn-outline btn-sm w-20 md:w-24">View</span>
|
||||||
<% if !submission.deleted_at? && can?(:destroy, submission) %>
|
<% 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 %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
<% else %>
|
<% else %>
|
||||||
@@ -113,7 +113,7 @@
|
|||||||
<% end %>
|
<% end %>
|
||||||
<span class="btn btn-outline btn-sm w-20 md:w-24">View</span>
|
<span class="btn btn-outline btn-sm w-20 md:w-24">View</span>
|
||||||
<% unless submission.deleted_at? %>
|
<% 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 %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|||||||
@@ -8,33 +8,58 @@
|
|||||||
<%= svg_icon('user', class: 'w-4 h-4') %>
|
<%= svg_icon('user', class: 'w-4 h-4') %>
|
||||||
<span><%= template.author.full_name.presence || template.author.email %></span>
|
<span><%= template.author.full_name.presence || template.author.email %></span>
|
||||||
</p>
|
</p>
|
||||||
<p class="flex items-center space-x-1 text-xs text-base-content/60">
|
<p class="flex text-xs text-base-content/60">
|
||||||
<%= svg_icon('calendar', class: 'w-4 h-4') %>
|
<span class="flex items-center space-x-1 w-1/2">
|
||||||
<span><%= l(template.created_at.in_time_zone(current_account.timezone), format: :short, locale: current_account.locale) %></span>
|
<%= 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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
<div class="absolute top-0 bottom-0 w-0 pt-7 space-y-1.5 hidden md:group-hover:block" style="right: 40px">
|
<div class="absolute top-0 bottom-0 w-0 flex items-center hidden md:group-hover:flex" style="right: 37px">
|
||||||
<% if template.deleted_at? && can?(:update, template) %>
|
<div class="space-y-1">
|
||||||
<%= button_to template_restore_index_path(template), class: 'btn btn-xs hover:btn-outline bg-base-200 btn-circle' do %>
|
<% if can?(:update, template) && !template.deleted_at? %>
|
||||||
<%= svg_icon('rotate', class: 'w-4 h-4 enabled') %>
|
<span class="tooltip tooltip-left" data-tip="Move">
|
||||||
<%= svg_icon('loader', class: 'w-4 h-4 animate-spin disabled') %>
|
<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 %>
|
<% end %>
|
||||||
<% elsif can?(:update, template) %>
|
<% if template.deleted_at? && can?(:update, template) %>
|
||||||
<a href="<%= edit_template_path(template) %>" class="btn btn-xs hover:btn-outline bg-base-200 btn-circle">
|
<span class="tooltip tooltip-left" data-tip="Restore">
|
||||||
<%= svg_icon('pencil', class: 'w-4 h-4') %>
|
<%= button_to template_restore_index_path(template), class: 'btn btn-xs hover:btn-outline bg-base-200 btn-circle' do %>
|
||||||
</a>
|
<%= svg_icon('rotate', class: 'w-4 h-4 enabled') %>
|
||||||
<% end %>
|
<%= svg_icon('loader', class: 'w-4 h-4 animate-spin disabled') %>
|
||||||
<% if can?(:create, template) %>
|
<% end %>
|
||||||
<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">
|
</span>
|
||||||
<%= svg_icon('copy', class: 'w-4 h-4') %>
|
<% elsif can?(:update, template) %>
|
||||||
</a>
|
<span class="tooltip tooltip-left" data-tip="Edit">
|
||||||
<% end %>
|
<a href="<%= edit_template_path(template) %>" class="btn btn-xs hover:btn-outline bg-base-200 btn-circle">
|
||||||
<% if !template.deleted_at? && can?(:destroy, template) %>
|
<%= svg_icon('pencil', class: 'w-4 h-4') %>
|
||||||
<%= 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 %>
|
</a>
|
||||||
<%= svg_icon('trash', class: 'w-4 h-4 enabled') %>
|
</span>
|
||||||
<%= svg_icon('loader', class: 'w-4 h-4 animate-spin disabled') %>
|
|
||||||
<% end %>
|
<% 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>
|
||||||
</div>
|
</div>
|
||||||
@@ -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">
|
<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">
|
||||||
<h1 class="text-4xl font-semibold mr-4" style="overflow: hidden; display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2;">
|
<div>
|
||||||
<%= template.name %>
|
<h1 class="text-4xl font-semibold mr-4" style="overflow: hidden; display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2;">
|
||||||
<% if template.deleted_at? %>
|
<%= template.name %>
|
||||||
<span class="badge badge-outline badge-lg align-middle">Archived</span>
|
<% if template.deleted_at? %>
|
||||||
<% end %>
|
<span class="badge badge-outline badge-lg align-middle">Archived</span>
|
||||||
</h1>
|
<% end %>
|
||||||
<div class="flex md:justify-between space-x-2 flex-none">
|
</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) %>
|
<% 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?' } %>
|
<%= 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 %>
|
<% end %>
|
||||||
|
|||||||
@@ -14,4 +14,5 @@
|
|||||||
</span>
|
</span>
|
||||||
</button>
|
</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 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 %>
|
<% end %>
|
||||||
@@ -1,12 +1,23 @@
|
|||||||
<%= render 'shared/turbo_modal', title: @base_template ? 'Clone Template' : 'New Document Template' do %>
|
<%= 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 %>
|
<% if @base_template %>
|
||||||
<%= hidden_field_tag :base_template_id, @base_template.id %>
|
<%= hidden_field_tag :base_template_id, @base_template.id %>
|
||||||
<% end %>
|
<% 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' %>
|
<%= f.text_field :name, required: true, placeholder: 'Template Name', class: 'base-input' %>
|
||||||
</div>
|
</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' %>
|
<%= f.button button_title(title: @base_template ? 'Submit' : 'Create', disabled_with: 'Creating'), class: 'base-button' %>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|||||||