mirror of
https://github.com/docusealco/docuseal
synced 2026-11-04 14:42:32 -05:00
Compare commits
152
Commits
3.2.6
...
c6a7555f54
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c6a7555f54 | ||
|
|
0772880bbc | ||
|
|
73339d8cd9 | ||
|
|
25504e8cb1 | ||
|
|
63a874528a | ||
|
|
c1c99621ff | ||
|
|
5c168f4a29 | ||
|
|
bef825c4e0 | ||
|
|
a990d22831 | ||
|
|
1687a372ae | ||
|
|
9b267d4a07 | ||
|
|
2189c2e9fe | ||
|
|
1e43fe8969 | ||
|
|
f77a716658 | ||
|
|
a70ac571e3 | ||
|
|
4f5e72f983 | ||
|
|
8f47ec6d4e | ||
|
|
9e1bbf2944 | ||
|
|
aa7d6e9aaa | ||
|
|
57c8be33ce | ||
|
|
ed7109bfc6 | ||
|
|
9e2590489b | ||
|
|
d793f00b37 | ||
|
|
5896c9a2aa | ||
|
|
78b0ce9828 | ||
|
|
e187644716 | ||
|
|
a847e7aa9c | ||
|
|
0905e7d215 | ||
|
|
91147d2f48 | ||
|
|
acce6195b0 | ||
|
|
ce9ab691ec | ||
|
|
4190544edc | ||
|
|
db8e5efbd5 | ||
|
|
a5c6f4e40c | ||
|
|
7537d05d1f | ||
|
|
44dcc7f312 | ||
|
|
218c22da21 | ||
|
|
a8433e9bd3 | ||
|
|
800101f747 | ||
|
|
f637ee907c | ||
|
|
6a9b123cdc | ||
|
|
0a2f8dfab3 | ||
|
|
3dc00ed7fc | ||
|
|
af160b3f3f | ||
|
|
1a9e4026d9 | ||
|
|
5ef32bcf9e | ||
|
|
2d6f4c521e | ||
|
|
439f95d87e | ||
|
|
ca7b9671f2 | ||
|
|
20ec614d3a | ||
|
|
887ebbbcf6 | ||
|
|
4225295295 | ||
|
|
89367bc905 | ||
|
|
4bacaa26e1 | ||
|
|
0f9fcf3fdc | ||
|
|
437f61dc26 | ||
|
|
e55542a000 | ||
|
|
e004ac2d40 | ||
|
|
92bfedd01d | ||
|
|
e7fd1e4006 | ||
|
|
56bba60d3f | ||
|
|
6b6a917f60 | ||
|
|
df7b3f9dfe | ||
|
|
1b21b0c65b | ||
|
|
258133d65c | ||
|
|
8f33c8d8b6 | ||
|
|
e11f4bddbe | ||
|
|
d071e8c429 | ||
|
|
79aa034183 | ||
|
|
f5dad673de | ||
|
|
7e8375aee8 | ||
|
|
1999611b26 | ||
|
|
188c3a8c84 | ||
|
|
d35538e2ba | ||
|
|
a86f896694 | ||
|
|
35eeeba0c3 | ||
|
|
e3e5c237d4 | ||
|
|
9e89cb92a9 | ||
|
|
827850646d | ||
|
|
eafae4cda2 | ||
|
|
e012b7f5ac | ||
|
|
da9e7c6366 | ||
|
|
20cc5d1e2e | ||
|
|
ae2cedbd70 | ||
|
|
1f46f74400 | ||
|
|
930445258d | ||
|
|
be533e492e | ||
|
|
66758d45bc | ||
|
|
6f31bd7af8 | ||
|
|
f4d2fa4c28 | ||
|
|
5d3369d556 | ||
|
|
5ea55c733f | ||
|
|
547bc9aa94 | ||
|
|
53ca45a67a | ||
|
|
ef28b80589 | ||
|
|
05fa75f9a2 | ||
|
|
87b57279a4 | ||
|
|
f09732df90 | ||
|
|
a588c2f4a4 | ||
|
|
960d36f628 | ||
|
|
456dbae1cd | ||
|
|
0fc1c0d1ed | ||
|
|
714324ea99 | ||
|
|
5a29ed22db | ||
|
|
061580594c | ||
|
|
8723ce7f7a | ||
|
|
522df7f476 | ||
|
|
026637c5ab | ||
|
|
fde993a579 | ||
|
|
a960ea268a | ||
|
|
d59776e8f5 | ||
|
|
27e5c2c81e | ||
|
|
0609a70544 | ||
|
|
3ed15e383b | ||
|
|
d29be7c2cf | ||
|
|
79f72c92b3 | ||
|
|
7e6331d1ad | ||
|
|
0986dfba73 | ||
|
|
7cf0b5aaf7 | ||
|
|
ae5f87f8fb | ||
|
|
8259ebdb6c | ||
|
|
a4ca0abba1 | ||
|
|
ca009252ac | ||
|
|
9678cd1286 | ||
|
|
8cb0a7a020 | ||
|
|
d3cc2e1ca8 | ||
|
|
08ac60c7e6 | ||
|
|
49afb2e87c | ||
|
|
be621339c3 | ||
|
|
5f5b49a1ba | ||
|
|
c0dd4b85ed | ||
|
|
95f7cd5220 | ||
|
|
f0753de99e | ||
|
|
38ecda82fa | ||
|
|
073859a4b7 | ||
|
|
44972fcc39 | ||
|
|
1232dfb248 | ||
|
|
5de334522c | ||
|
|
7a6b4acea6 | ||
|
|
198a599a2b | ||
|
|
d36f40cafd | ||
|
|
9abdb68bc5 | ||
|
|
359f695eb5 | ||
|
|
2b9e5a86b6 | ||
|
|
c07d76e2a4 | ||
|
|
f5b29642ee | ||
|
|
3f6cdcd15b | ||
|
|
bde8097288 | ||
|
|
617d75a4ed | ||
|
|
aa1874cdb4 | ||
|
|
22b2e7475a | ||
|
|
ad3367dae0 |
No files matched your search
@@ -1,6 +1,11 @@
|
||||
---
|
||||
EnableDefaultLinters: true
|
||||
linters:
|
||||
AllowedScriptType:
|
||||
enabled: true
|
||||
allowed_types:
|
||||
- text/javascript
|
||||
- application/json
|
||||
ErbSafety:
|
||||
enabled: false
|
||||
HardCodedString:
|
||||
|
||||
+1
-1
@@ -497,7 +497,7 @@ GEM
|
||||
ruby-vips (2.3.0)
|
||||
ffi (~> 1.12)
|
||||
logger
|
||||
rubyzip (3.2.2)
|
||||
rubyzip (3.7.0)
|
||||
securerandom (0.4.1)
|
||||
semantic_range (3.1.1)
|
||||
shakapacker (9.7.0)
|
||||
|
||||
@@ -40,7 +40,7 @@ DocuSeal is an open source platform that provides secure and efficient digital d
|
||||
- Files storage on disk or AWS S3, Google Storage, Azure Cloud
|
||||
- Automatic PDF eSignature
|
||||
- PDF signature verification
|
||||
- Users management
|
||||
- User management
|
||||
- Mobile-optimized
|
||||
- 7 UI languages with signing available in 14 languages
|
||||
- API and Webhooks for integrations
|
||||
|
||||
@@ -98,7 +98,7 @@ module Api
|
||||
|
||||
render json: build_create_json(submissions)
|
||||
rescue Submitters::NormalizeValues::BaseError, Submissions::CreateFromSubmitters::BaseError,
|
||||
DownloadUtils::UnableToDownload => e
|
||||
Submitters::SubmitValues::ValidationError, DownloadUtils::UnableToDownload => e
|
||||
Rollbar.warning(e) if defined?(Rollbar)
|
||||
|
||||
render json: { error: e.message }, status: :unprocessable_content
|
||||
@@ -173,7 +173,6 @@ module Api
|
||||
end
|
||||
|
||||
def filter_submissions(submissions, params)
|
||||
submissions = submissions.where(template_id: params[:template_id]) if params[:template_id].present?
|
||||
submissions = submissions.where(slug: params[:slug]) if params[:slug].present?
|
||||
|
||||
if params[:template_folder].present?
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Api
|
||||
class SubmitterEmailClicksController < ApiBaseController
|
||||
skip_before_action :authenticate_user!
|
||||
skip_authorization_check
|
||||
|
||||
class SubmitterEmailClicksController < ActionController::API
|
||||
def create
|
||||
@submitter = Submitter.find_by!(slug: params[:submitter_slug])
|
||||
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Api
|
||||
class SubmitterFormViewsController < ApiBaseController
|
||||
skip_before_action :authenticate_user!
|
||||
skip_authorization_check
|
||||
|
||||
class SubmitterFormViewsController < ActionController::API
|
||||
def create
|
||||
@submitter = Submitter.find_by!(slug: params[:submitter_slug])
|
||||
|
||||
|
||||
@@ -78,7 +78,8 @@ module Api
|
||||
|
||||
render json: Submitters::SerializeForApi.call(@submitter, with_template: false, with_urls: true,
|
||||
with_events: false, params:)
|
||||
rescue Submitters::NormalizeValues::BaseError, DownloadUtils::UnableToDownload => e
|
||||
rescue Submitters::NormalizeValues::BaseError, Submitters::SubmitValues::ValidationError,
|
||||
DownloadUtils::UnableToDownload => e
|
||||
Rollbar.warning(e) if defined?(Rollbar)
|
||||
|
||||
render json: { error: e.message }, status: :unprocessable_content
|
||||
|
||||
@@ -13,13 +13,17 @@ class ApplicationController < ActionController::Base
|
||||
before_action :maybe_redirect_to_setup, unless: :signed_in?
|
||||
before_action :authenticate_user!, unless: :devise_controller?
|
||||
|
||||
before_action :set_csp, if: -> { request.get? && !request.headers['HTTP_X_TURBO'] }
|
||||
before_action :set_csp, if: lambda {
|
||||
request.get? && !request.headers['HTTP_X_TURBO_REQUEST_ID'] && !turbo_infinite_scroll?
|
||||
}
|
||||
|
||||
helper_method :button_title,
|
||||
:current_account,
|
||||
:true_ability,
|
||||
:form_link_host,
|
||||
:svg_icon
|
||||
:svg_icon,
|
||||
:turbo_native_ios?,
|
||||
:turbo_native_android?
|
||||
|
||||
impersonates :user, with: ->(uuid) { User.find_by(uuid:) }
|
||||
|
||||
@@ -64,6 +68,28 @@ class ApplicationController < ActionController::Base
|
||||
|
||||
private
|
||||
|
||||
def turbo_infinite_scroll?
|
||||
request.headers['HTTP_X_TURBO_INFINITE_SCROLL'].present?
|
||||
end
|
||||
|
||||
def turbo_native_app?
|
||||
return @turbo_native_app if defined?(@turbo_native_app)
|
||||
|
||||
@turbo_native_app = request.user_agent.to_s.include?('Hotwire Native')
|
||||
end
|
||||
|
||||
def turbo_native_ios?
|
||||
return @turbo_native_ios if defined?(@turbo_native_ios)
|
||||
|
||||
@turbo_native_ios = request.user_agent.to_s.include?('Hotwire Native iOS')
|
||||
end
|
||||
|
||||
def turbo_native_android?
|
||||
return @turbo_native_android if defined?(@turbo_native_android)
|
||||
|
||||
@turbo_native_android = request.user_agent.to_s.include?('Hotwire Native Android')
|
||||
end
|
||||
|
||||
def with_locale(&)
|
||||
return yield unless current_account
|
||||
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class EnquiriesController < ApplicationController
|
||||
skip_before_action :authenticate_user!
|
||||
skip_authorization_check
|
||||
|
||||
def create
|
||||
if params[:talk_to_sales] == 'on'
|
||||
Faraday.post(Docuseal::ENQUIRIES_URL,
|
||||
enquiry_params.merge(type: :talk_to_sales).to_json,
|
||||
'Content-Type' => 'application/json')
|
||||
end
|
||||
|
||||
head :ok
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def enquiry_params
|
||||
params.require(:user).permit(:email)
|
||||
end
|
||||
end
|
||||
@@ -33,7 +33,7 @@ class MfaSetupController < ApplicationController
|
||||
def destroy
|
||||
RateLimit.call("mfa-setup-otp-#{current_user.id}", limit: 5, ttl: 5.minutes, enabled: true)
|
||||
|
||||
if current_user.validate_and_consume_otp!(params[:otp_attempt])
|
||||
if current_user.valid_for_authentication? { current_user.validate_and_consume_otp!(params[:otp_attempt]) }
|
||||
current_user.update!(otp_required_for_login: false, otp_secret: nil)
|
||||
|
||||
redirect_to settings_profile_index_path, notice: I18n.t('2fa_has_been_removed')
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class SearchController < ApplicationController
|
||||
load_and_authorize_resource :template_folder, parent: false
|
||||
load_and_authorize_resource :template, parent: false
|
||||
load_and_authorize_resource :submission, parent: false
|
||||
load_and_authorize_resource :template, instance_name: :filter_template, id_param: :template_id, parent: true
|
||||
|
||||
FOLDERS_LIMIT = 4
|
||||
LIMIT = 12
|
||||
|
||||
def index
|
||||
@query = params[:q].to_s.squish
|
||||
|
||||
return render_infinite_scroll(@template_folders, @templates, @submissions, @query) if turbo_infinite_scroll?
|
||||
|
||||
if @query.blank?
|
||||
@template_folders = @templates = @submissions = []
|
||||
elsif params[:scope] == 'folders'
|
||||
@folders_pagy, @template_folders =
|
||||
pagy(:countless, filter_folders(@template_folders, @templates, @query), limit: LIMIT)
|
||||
|
||||
@templates = @submissions = []
|
||||
else
|
||||
folders = filter_folders(@template_folders, @templates, @query)
|
||||
|
||||
@pagy, @template_folders, @templates, @submissions =
|
||||
if params[:scope] == 'submissions'
|
||||
load_submissions_page(folders, @templates, @submissions, @query)
|
||||
else
|
||||
load_templates_page(folders, @templates, @submissions, @query)
|
||||
end
|
||||
|
||||
if @templates.blank? && @submissions.blank? && params[:template_id].blank?
|
||||
@folders_pagy, @template_folders = pagy(:countless, folders, limit: LIMIT)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def load_submissions_page(folders, templates, submissions, query)
|
||||
pagy, submissions = pagy(:countless, filter_submissions(submissions, query), limit: LIMIT)
|
||||
|
||||
return [pagy, [], [], submissions] if pagy.next || params[:template_id].present?
|
||||
|
||||
[pagy, folders.limit(FOLDERS_LIMIT).to_a, filter_templates(templates, query).limit(LIMIT).to_a, submissions]
|
||||
end
|
||||
|
||||
def load_templates_page(folders, templates, submissions, query)
|
||||
pagy, templates = pagy(:countless, filter_templates(templates, query), limit: LIMIT)
|
||||
|
||||
submissions = pagy.next ? [] : filter_submissions(submissions, query).limit(LIMIT).to_a
|
||||
|
||||
[pagy, folders.limit(FOLDERS_LIMIT).to_a, templates, submissions]
|
||||
end
|
||||
|
||||
def render_infinite_scroll(folders, templates, submissions, query)
|
||||
target, partial, collection =
|
||||
case params[:scope]
|
||||
when 'folders'
|
||||
['search_folders', 'search/folder', filter_folders(folders, templates, query)]
|
||||
when 'submissions'
|
||||
['search_submissions', 'search/submission', filter_submissions(submissions, query)]
|
||||
else
|
||||
['search_templates', 'search/template', filter_templates(templates, query)]
|
||||
end
|
||||
|
||||
pagy, records = pagy(:countless, collection, limit: LIMIT)
|
||||
|
||||
render turbo_stream: [
|
||||
turbo_stream.append(target, partial:, collection: records),
|
||||
turbo_stream.replace('infinite_scroll', partial: 'shared/infinite_scroll', locals: { pagy: })
|
||||
]
|
||||
end
|
||||
|
||||
def filter_folders(folders, templates, query)
|
||||
return folders.none if params[:archived] == 'true'
|
||||
|
||||
folders = folders.where.not(id: current_account.default_template_folder.id)
|
||||
|
||||
folders = folders.where(parent_folder_id: filter_folder_ids(params[:folder])) if params[:folder].present?
|
||||
folders = folders.where(author_id: filter_author_id(params[:author])) if params[:author].present?
|
||||
folders = TemplateFolders.filter_active_folders(folders, templates)
|
||||
folders = TemplateFolders.search(folders, query)
|
||||
|
||||
folders.preload(:parent_folder).order(:name)
|
||||
end
|
||||
|
||||
def filter_templates(templates, query)
|
||||
templates = params[:archived] == 'true' ? templates.where.not(archived_at: nil) : templates.active
|
||||
templates = templates.select_for_list.preload(:author, :template_accesses, folder: :parent_folder)
|
||||
|
||||
templates = templates.where(folder_id: filter_folder_ids(params[:folder])) if params[:folder].present?
|
||||
templates = templates.where(author_id: filter_author_id(params[:author])) if params[:author].present?
|
||||
|
||||
Templates.search(current_user, templates, query).order(id: :desc)
|
||||
end
|
||||
|
||||
def filter_folder_ids(folder_name)
|
||||
TemplateFolders.filter_by_full_name(current_account.template_folders, folder_name)
|
||||
.preload(:subfolders)
|
||||
.flat_map { |folder| [folder.id, *folder.subfolders.map(&:id)] }
|
||||
end
|
||||
|
||||
def filter_author_id(email)
|
||||
current_account.users.find_by(email:)&.id || -1
|
||||
end
|
||||
|
||||
def filter_submissions(submissions, query)
|
||||
submissions = submissions.left_joins(:template)
|
||||
|
||||
submissions =
|
||||
if params[:archived] == 'true'
|
||||
submissions.where.not(archived_at: nil).or(submissions.where.not(templates: { archived_at: nil }))
|
||||
else
|
||||
submissions.where(archived_at: nil).where(templates: { archived_at: nil })
|
||||
end
|
||||
|
||||
submissions = Submissions.search(current_user, submissions, query, search_template: true)
|
||||
submissions = Submissions::Filter.call(submissions, current_user, params)
|
||||
|
||||
submissions =
|
||||
if params[:status] == 'completed' || params[:completed_at_from].present? || params[:completed_at_to].present?
|
||||
submissions.order(completed_at: :desc)
|
||||
else
|
||||
submissions.order(id: :desc)
|
||||
end
|
||||
|
||||
submissions.select_for_list.preload(:submitters, :template)
|
||||
end
|
||||
end
|
||||
@@ -3,6 +3,10 @@
|
||||
class SessionsController < Devise::SessionsController
|
||||
before_action :configure_permitted_parameters
|
||||
|
||||
skip_before_action :verify_authenticity_token, if: -> { request.xhr? && request.format.json? }
|
||||
|
||||
respond_to :json, only: :create
|
||||
|
||||
around_action :with_browser_locale
|
||||
|
||||
def create
|
||||
@@ -11,12 +15,26 @@ class SessionsController < Devise::SessionsController
|
||||
if Docuseal.multitenant? && !User.exists?(email:)
|
||||
Rollbar.warning('Sign in new user') if defined?(Rollbar)
|
||||
|
||||
return redirect_to new_registration_path(sign_up: true, user: sign_in_params.slice(:email)),
|
||||
notice: I18n.t('create_a_new_account')
|
||||
respond_to do |format|
|
||||
format.html do
|
||||
redirect_to new_registration_path(sign_up: true, user: sign_in_params.slice(:email)),
|
||||
notice: t('create_a_new_account')
|
||||
end
|
||||
format.json do
|
||||
render json: { sign_up: true, notice: t('create_a_new_account') }, status: :unprocessable_content
|
||||
end
|
||||
end
|
||||
|
||||
return
|
||||
end
|
||||
|
||||
if User.exists?(email:, otp_required_for_login: true) && sign_in_params[:otp_attempt].blank?
|
||||
return render :otp, locals: { resource: User.new(sign_in_params) }, status: :unprocessable_content
|
||||
respond_to do |format|
|
||||
format.html { render :otp, locals: { resource: User.new(sign_in_params) }, status: :unprocessable_content }
|
||||
format.json { render json: { otp_required: true, email_otp: false }, status: :unprocessable_content }
|
||||
end
|
||||
|
||||
return
|
||||
end
|
||||
|
||||
super
|
||||
|
||||
@@ -29,5 +29,17 @@ class SubmissionsArchivedController < ApplicationController
|
||||
|
||||
ActiveRecord::Associations::Preloader.new(records: @submissions.filter_map(&:template),
|
||||
associations: :author).call
|
||||
|
||||
render_infinite_scroll(@pagy, @submissions) if turbo_infinite_scroll?
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def render_infinite_scroll(pagy, submissions)
|
||||
render turbo_stream: [
|
||||
turbo_stream.before('infinite_scroll', partial: 'templates/submission', collection: submissions,
|
||||
locals: { with_template: true, archived: true }),
|
||||
turbo_stream.replace('infinite_scroll', partial: 'shared/infinite_scroll', locals: { pagy: })
|
||||
]
|
||||
end
|
||||
end
|
||||
@@ -18,6 +18,16 @@ class SubmissionsController < ApplicationController
|
||||
'payment' => 'credit_card', 'verification' => 'id', 'kba' => 'user_scan'
|
||||
}.freeze
|
||||
|
||||
STATUS_BADGES = {
|
||||
'awaiting' => 'badge-info', 'sent' => 'badge-info', 'completed' => 'badge-success',
|
||||
'opened' => 'badge-warning', 'declined' => 'badge-error'
|
||||
}.freeze
|
||||
|
||||
STATUS_ICONS = {
|
||||
'awaiting' => 'clock', 'sent' => 'send', 'completed' => 'circle_check',
|
||||
'opened' => 'mail_opened', 'declined' => 'x_circle'
|
||||
}.freeze
|
||||
|
||||
def show
|
||||
@submission = Submissions.preload_with_pages(@submission)
|
||||
|
||||
@@ -83,7 +93,14 @@ class SubmissionsController < ApplicationController
|
||||
I18n.t('submission_has_been_archived')
|
||||
end
|
||||
|
||||
redirect_back(fallback_location: @submission.template_id ? template_path(@submission.template) : root_path, notice:)
|
||||
if turbo_native_app? && request.format.turbo_stream?
|
||||
flash.now[:notice] = notice
|
||||
|
||||
render turbo_stream: [turbo_stream.remove(@submission), turbo_stream.append_all('body', partial: 'shared/flash')]
|
||||
else
|
||||
redirect_back(fallback_location: @submission.template_id ? template_path(@submission.template) : root_path,
|
||||
notice:)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
@@ -30,5 +30,17 @@ class SubmissionsDashboardController < ApplicationController
|
||||
|
||||
ActiveRecord::Associations::Preloader.new(records: @submissions.filter_map(&:template),
|
||||
associations: :author).call
|
||||
|
||||
render_infinite_scroll(@pagy, @submissions) if turbo_infinite_scroll?
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def render_infinite_scroll(pagy, submissions)
|
||||
render turbo_stream: [
|
||||
turbo_stream.before('infinite_scroll', partial: 'templates/submission', collection: submissions,
|
||||
locals: { with_template: true }),
|
||||
turbo_stream.replace('infinite_scroll', partial: 'shared/infinite_scroll', locals: { pagy: })
|
||||
]
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,13 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class SubmitFormClickEmailController < ActionController::API
|
||||
def create
|
||||
@submitter = Submitter.find_by!(slug: params[:submit_form_slug])
|
||||
|
||||
if params[:t].present? && params[:t] == SubmissionEvents.build_tracking_param(@submitter, 'click_email')
|
||||
SubmissionEvents.create_with_tracking_data(@submitter, 'click_email', request)
|
||||
end
|
||||
|
||||
render json: {}
|
||||
end
|
||||
end
|
||||
@@ -29,7 +29,9 @@ class SubmitFormCompletedDownloadController < ApplicationController
|
||||
Submissions::EnsureResultGenerated.call(last_submitter)
|
||||
|
||||
if !signature_valid && !current_user_submitter?(last_submitter)
|
||||
return head :not_found unless Submitters::AuthorizedForForm.call(@submitter, current_user, request)
|
||||
unless Submitters::AuthorizedForForm.call(@submitter, current_user, request, with_order: true)
|
||||
return head :not_found
|
||||
end
|
||||
|
||||
if last_submitter.completed_at < TTL.ago
|
||||
Rollbar.info("TTL: #{last_submitter.id}") if defined?(Rollbar)
|
||||
|
||||
@@ -31,24 +31,22 @@ class SubmitFormController < ApplicationController
|
||||
|
||||
Submissions.preload_with_pages(submission)
|
||||
|
||||
Submitters::MaybeUpdateDefaultValues.call(@submitter, current_user)
|
||||
|
||||
@attachments_index = build_attachments_index(submission)
|
||||
|
||||
if current_user && current_user.email == @submitter.email
|
||||
@current_user_data = current_user.as_json(only: %i[first_name last_name], methods: %i[full_name])
|
||||
|
||||
initials = UserConfigs.load_initials(current_user)
|
||||
|
||||
@prefill_initials = Submitters.build_prefill_attachment_data(@submitter, initials) if initials
|
||||
end
|
||||
|
||||
return unless @form_configs[:prefill_signature]
|
||||
|
||||
if (user_signature = UserConfigs.load_signature(current_user))
|
||||
@signature_attachment = ActiveStorage::Attachment.find_or_create_by!(
|
||||
blob_id: user_signature.blob_id,
|
||||
name: 'attachments',
|
||||
record: @submitter
|
||||
)
|
||||
end
|
||||
signature = UserConfigs.load_signature(current_user) ||
|
||||
Submitters::FindRememberedSignature.call(@submitter, params, cookies)
|
||||
|
||||
@signature_attachment ||=
|
||||
Submitters::MaybeAssignDefaultBrowserSignature.call(@submitter, params, cookies, @attachments_index.values)
|
||||
|
||||
@attachments_index[@signature_attachment.uuid] = @signature_attachment if @signature_attachment
|
||||
@prefill_signature = Submitters.build_prefill_attachment_data(@submitter, signature) if signature
|
||||
end
|
||||
|
||||
def update
|
||||
|
||||
@@ -15,7 +15,8 @@ class SubmitFormDelegateController < ApplicationController
|
||||
@submitter.viewer? ||
|
||||
!Submitters::AuthorizedForForm.call(@submitter,
|
||||
current_user,
|
||||
request)
|
||||
request,
|
||||
with_order: true)
|
||||
|
||||
@submitter.account.account_configs.find_by!(key: AccountConfig::ALLOW_TO_DELEGATE_KEY, value: true)
|
||||
|
||||
|
||||
@@ -19,7 +19,8 @@ class SubmitFormDownloadController < ApplicationController
|
||||
AccountConfig.exists?(account_id: @submitter.account_id,
|
||||
key: AccountConfig::ALLOW_TO_PARTIAL_DOWNLOAD_KEY,
|
||||
value: false) ||
|
||||
!Submitters::AuthorizedForForm.call(@submitter, current_user, request)
|
||||
!Submitters::AuthorizedForForm.call(@submitter, current_user, request,
|
||||
with_order: true)
|
||||
|
||||
last_completed_submitter = @submitter.submission.submitters
|
||||
.where.not(id: @submitter.id)
|
||||
|
||||
@@ -15,7 +15,7 @@ class SubmitFormDrawSignatureController < ApplicationController
|
||||
return redirect_to submit_form_path(@submitter.slug) if @submitter.viewer?
|
||||
|
||||
if @submitter.submission.template&.archived_at? || @submitter.submission.archived_at? ||
|
||||
!Submitters::AuthorizedForForm.call(@submitter, current_user, request)
|
||||
!Submitters::AuthorizedForForm.call(@submitter, current_user, request, with_order: true)
|
||||
return redirect_to submit_form_path(@submitter.slug)
|
||||
end
|
||||
|
||||
|
||||
@@ -11,9 +11,9 @@ class SubmitFormEmail2fasController < ApplicationController
|
||||
def create
|
||||
RateLimit.call("verify-2fa-code-#{@submitter.id}", limit: 2, ttl: 45.seconds, enabled: true)
|
||||
|
||||
value = [@submitter.email.downcase.strip, @submitter.slug].join(':')
|
||||
value = [@submitter.email.downcase.squish, @submitter.slug].join(':')
|
||||
|
||||
if EmailVerificationCodes.verify(params[:one_time_code].to_s.gsub(/\D/, ''), value)
|
||||
if OtpChallenges.verify(params[:one_time_code].to_s.gsub(/\D/, ''), value, purpose: 'form_email_2fa')
|
||||
SubmissionEvents.create_with_tracking_data(@submitter, 'email_verified', request, { email: @submitter.email })
|
||||
|
||||
cookies.encrypted[:email_2fa_slug] =
|
||||
|
||||
@@ -17,12 +17,12 @@ class SubmitFormMetadataController < ApplicationController
|
||||
submission.schema_documents.find { |a| a.uuid == item['attachment_uuid'] }
|
||||
end
|
||||
|
||||
ActiveRecord::Associations::Preloader.new(records: documents, associations: %i[blob record]).call
|
||||
ActiveRecord::Associations::Preloader.new(records: documents, associations: :blob).call
|
||||
|
||||
text_runs = documents.to_h do |document|
|
||||
[
|
||||
document.uuid,
|
||||
DocumentMetadatas.find_or_create_for_document(document, account_id: document.record.account_id).text_runs
|
||||
DocumentMetadatas.find_or_create_for_document(document, account_id: submission.account_id).text_runs
|
||||
]
|
||||
end
|
||||
|
||||
@@ -39,6 +39,6 @@ class SubmitFormMetadataController < ApplicationController
|
||||
!submitter.submission.expired? &&
|
||||
!submitter.submission.template&.archived_at? &&
|
||||
!submitter.account.archived_at? &&
|
||||
Submitters::AuthorizedForForm.call(submitter, current_user, request)
|
||||
Submitters::AuthorizedForForm.call(submitter, current_user, request, with_order: true)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,33 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class SubmitFormPrefillAttachmentsController < ActionController::API
|
||||
include ActiveStorage::SetCurrent
|
||||
|
||||
def create
|
||||
@submitter = Submitter.find_by!(slug: params[:submit_form_slug])
|
||||
|
||||
unless can_upload?(@submitter)
|
||||
return render json: { error: I18n.t('form_has_been_archived') }, status: :unprocessable_content
|
||||
end
|
||||
|
||||
uuid, slug = ApplicationRecord.signed_id_verifier.verify(params[:prefill_token], purpose: :prefill_attachment)
|
||||
|
||||
raise ActiveRecord::RecordNotFound if slug != @submitter.slug
|
||||
|
||||
source_attachment = ActiveStorage::Attachment.find_by!(uuid:)
|
||||
|
||||
attachment = @submitter.attachments_attachments.find_or_create_by!(blob_id: source_attachment.blob_id)
|
||||
|
||||
render json: attachment.as_json(only: %i[uuid created_at], methods: %i[url filename content_type])
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def can_upload?(submitter)
|
||||
!submitter.declined_at? &&
|
||||
!submitter.completed_at? &&
|
||||
!submitter.submission.archived_at? &&
|
||||
!submitter.submission.expired? &&
|
||||
!submitter.submission.template&.archived_at?
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,86 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class SubmitFormUploadController < ActionController::API
|
||||
include ActionController::Cookies
|
||||
include ActiveStorage::SetCurrent
|
||||
|
||||
before_action :load_submitter
|
||||
before_action :authorize_upload!
|
||||
|
||||
COOKIE_STORE_LIMIT = 10
|
||||
|
||||
def create
|
||||
file = params[:file]
|
||||
|
||||
if params[:type].in?(%w[initials signature])
|
||||
image = ImageUtils.load_vips(file.read, content_type: file.content_type)
|
||||
|
||||
if ImageUtils.blank?(image)
|
||||
Rollbar.error("Empty signature: #{@submitter.id}") if defined?(Rollbar)
|
||||
|
||||
return render json: { error: "#{params[:type]} is empty" }, status: :unprocessable_content
|
||||
end
|
||||
|
||||
if ImageUtils.error?(image)
|
||||
Rollbar.error("Error signature: #{@submitter.id}") if defined?(Rollbar)
|
||||
|
||||
return render json: { error: "#{params[:type]} error, try to sign on another device" },
|
||||
status: :unprocessable_content
|
||||
end
|
||||
|
||||
metadata = { analyzed: true, identified: true, width: image.width, height: image.height }
|
||||
end
|
||||
|
||||
attachment = Submitters.create_attachment!(@submitter, file, metadata:)
|
||||
|
||||
if params[:remember_signature] == 'true' && @submitter.email.present?
|
||||
cookies.encrypted[:signature_uuids] = build_new_cookie_signatures_json(@submitter, attachment)
|
||||
end
|
||||
|
||||
render json: attachment.as_json(only: %i[uuid created_at], methods: %i[url filename content_type])
|
||||
rescue Submitters::MaliciousFileExtension => e
|
||||
Rollbar.error(e) if defined?(Rollbar)
|
||||
|
||||
render json: { error: e.message }, status: :unprocessable_content
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def load_submitter
|
||||
@submitter = Submitter.find_by!(slug: params[:submit_form_slug])
|
||||
end
|
||||
|
||||
def authorize_upload!
|
||||
unless can_upload?(@submitter)
|
||||
return render json: { error: I18n.t('form_has_been_archived') }, status: :unprocessable_content
|
||||
end
|
||||
|
||||
return if Submitters::AuthorizedForForm.call(@submitter, current_user, request)
|
||||
|
||||
render json: { error: I18n.t('verification_required_refresh_the_page_and_pass_2fa') },
|
||||
status: :unprocessable_content
|
||||
end
|
||||
|
||||
def can_upload?(submitter)
|
||||
!submitter.declined_at? &&
|
||||
!submitter.completed_at? &&
|
||||
!submitter.submission.archived_at? &&
|
||||
!submitter.submission.expired? &&
|
||||
!submitter.submission.template&.archived_at?
|
||||
end
|
||||
|
||||
def build_new_cookie_signatures_json(submitter, attachment)
|
||||
values =
|
||||
begin
|
||||
JSON.parse(cookies.encrypted[:signature_uuids].presence || '{}')
|
||||
rescue JSON::ParserError
|
||||
{}
|
||||
end
|
||||
|
||||
values[submitter.email] = attachment.uuid
|
||||
|
||||
values = values.to_a.last(COOKIE_STORE_LIMIT).to_h if values.size > COOKIE_STORE_LIMIT
|
||||
|
||||
values.to_json
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,18 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class SubmitFormViewController < ActionController::API
|
||||
def create
|
||||
@submitter = Submitter.find_by!(slug: params[:submit_form_slug])
|
||||
|
||||
return head :forbidden if Submitter.signed_id_verifier.verified(params[:v], purpose: :view_form) != @submitter.slug
|
||||
|
||||
@submitter.opened_at = Time.current
|
||||
@submitter.save
|
||||
|
||||
SubmissionEvents.create_with_tracking_data(@submitter, 'view_form', request)
|
||||
|
||||
WebhookUrls.enqueue_events(@submitter, 'form.viewed')
|
||||
|
||||
render json: {}
|
||||
end
|
||||
end
|
||||
@@ -6,7 +6,17 @@ class TemplateDocumentsController < ApplicationController
|
||||
FILES_TTL = 5.minutes
|
||||
|
||||
def index
|
||||
render json: @template.schema_documents.map { |d| ActiveStorage::Blob.proxy_path(d.blob, expires_at: FILES_TTL.from_now.to_i) }
|
||||
documents = @template.schema.map do |item|
|
||||
if item['dynamic']
|
||||
dynamic_document = @template.schema_dynamic_documents.find { |e| e.uuid == item['attachment_uuid'] }
|
||||
|
||||
DynamicDocuments::EnsureVersionGenerated.call(dynamic_document).document_attachment
|
||||
else
|
||||
@template.schema_documents.find { |e| e.uuid == item['attachment_uuid'] }
|
||||
end
|
||||
end
|
||||
|
||||
render json: documents.map { |d| ActiveStorage::Blob.proxy_path(d.blob, expires_at: FILES_TTL.from_now.to_i) }
|
||||
end
|
||||
|
||||
def create
|
||||
|
||||
@@ -13,10 +13,7 @@ class TemplateFoldersController < ApplicationController
|
||||
.where(folder: [@template_folder, *(params[:q].present? ? @template_folder.subfolders : [])])
|
||||
.preload(:author, :template_accesses)
|
||||
|
||||
@template_folders =
|
||||
@template_folder.subfolders.where(id: Template.accessible_by(current_ability).active.select(:folder_id))
|
||||
|
||||
@template_folders = TemplateFolders.search(@template_folders, params[:q])
|
||||
@template_folders = filter_folders(@template_folder)
|
||||
@template_folders = TemplateFolders.sort(@template_folders, current_user, selected_order)
|
||||
|
||||
if @templates.exists?
|
||||
@@ -40,6 +37,8 @@ class TemplateFoldersController < ApplicationController
|
||||
|
||||
@templates = @templates.none
|
||||
end
|
||||
|
||||
render_infinite_scroll(@pagy, @templates, @template_folders) if turbo_infinite_scroll?
|
||||
end
|
||||
|
||||
def edit; end
|
||||
@@ -55,6 +54,27 @@ class TemplateFoldersController < ApplicationController
|
||||
|
||||
private
|
||||
|
||||
def filter_folders(template_folder)
|
||||
template_folders =
|
||||
template_folder.subfolders.where(id: Template.accessible_by(current_ability).active.select(:folder_id))
|
||||
|
||||
TemplateFolders.search(template_folders, params[:q])
|
||||
end
|
||||
|
||||
def render_infinite_scroll(pagy, templates, template_folders)
|
||||
render_params =
|
||||
if templates.present?
|
||||
{ partial: 'templates/template', collection: templates }
|
||||
else
|
||||
{ partial: 'template_folders/folder', collection: template_folders, as: :folder }
|
||||
end
|
||||
|
||||
render turbo_stream: [
|
||||
turbo_stream.before('infinite_scroll', **render_params),
|
||||
turbo_stream.replace('infinite_scroll', partial: 'shared/infinite_scroll', locals: { pagy: })
|
||||
]
|
||||
end
|
||||
|
||||
def selected_order
|
||||
@selected_order ||=
|
||||
if can?(:manage, :countless)
|
||||
|
||||
@@ -12,13 +12,23 @@ class TemplatesArchivedController < ApplicationController
|
||||
|
||||
@pagy, @templates = pagy_auto(@templates.select_for_list, limit: 12)
|
||||
|
||||
return unless params[:q].present? && @templates.blank?
|
||||
if params[:q].present? && @templates.blank?
|
||||
@related_submissions_pagy, @related_submissions = load_related_submissions
|
||||
end
|
||||
|
||||
@related_submissions_pagy, @related_submissions = load_related_submissions
|
||||
render_infinite_scroll(@pagy, @templates) if turbo_infinite_scroll?
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def render_infinite_scroll(pagy, templates)
|
||||
render turbo_stream: [
|
||||
turbo_stream.before('infinite_scroll', partial: 'templates/template', collection: templates,
|
||||
locals: { with_folder: true }),
|
||||
turbo_stream.replace('infinite_scroll', partial: 'shared/infinite_scroll', locals: { pagy: })
|
||||
]
|
||||
end
|
||||
|
||||
def load_related_submissions
|
||||
related_submissions =
|
||||
Submission.accessible_by(current_ability)
|
||||
|
||||
@@ -16,7 +16,19 @@ class TemplatesArchivedSubmissionsController < ApplicationController
|
||||
end
|
||||
|
||||
@pagy, @submissions = pagy_auto(@submissions.select_for_list.preload(submitters: :start_form_submission_events))
|
||||
|
||||
render_infinite_scroll(@pagy, @submissions, @template) if turbo_infinite_scroll?
|
||||
rescue ActiveRecord::RecordNotFound
|
||||
redirect_to root_path
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def render_infinite_scroll(pagy, submissions, template)
|
||||
render turbo_stream: [
|
||||
turbo_stream.before('infinite_scroll', partial: 'templates/submission', collection: submissions,
|
||||
locals: { template:, archived: true }),
|
||||
turbo_stream.replace('infinite_scroll', partial: 'shared/infinite_scroll', locals: { pagy: })
|
||||
]
|
||||
end
|
||||
end
|
||||
@@ -21,6 +21,8 @@ class TemplatesController < ApplicationController
|
||||
|
||||
@pagy, @submissions =
|
||||
pagy_auto(submissions.select_for_list.preload(:template_accesses, submitters: :start_form_submission_events))
|
||||
|
||||
render_infinite_scroll(@pagy, @submissions, @template) if turbo_infinite_scroll?
|
||||
rescue ActiveRecord::RecordNotFound
|
||||
redirect_to root_path
|
||||
end
|
||||
@@ -81,11 +83,23 @@ class TemplatesController < ApplicationController
|
||||
I18n.t('template_has_been_archived')
|
||||
end
|
||||
|
||||
redirect_back(fallback_location: root_path, notice:)
|
||||
if @template.destroyed? && turbo_native_app?
|
||||
redirect_to templates_archived_index_path, notice:
|
||||
else
|
||||
redirect_back(fallback_location: root_path, notice:)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def render_infinite_scroll(pagy, submissions, template)
|
||||
render turbo_stream: [
|
||||
turbo_stream.before('infinite_scroll', partial: 'templates/submission', collection: submissions,
|
||||
locals: { template:, archived: template.archived_at? }),
|
||||
turbo_stream.replace('infinite_scroll', partial: 'shared/infinite_scroll', locals: { pagy: })
|
||||
]
|
||||
end
|
||||
|
||||
def template_params
|
||||
params.require(:template).permit(
|
||||
:name,
|
||||
|
||||
@@ -43,10 +43,26 @@ class TemplatesDashboardController < ApplicationController
|
||||
@related_submissions_pagy, @related_submissions = load_related_submissions
|
||||
end
|
||||
end
|
||||
|
||||
render_infinite_scroll(@pagy, @templates, @template_folders) if turbo_infinite_scroll?
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def render_infinite_scroll(pagy, templates, template_folders)
|
||||
render_params =
|
||||
if templates.present?
|
||||
{ partial: 'templates/template', collection: templates }
|
||||
else
|
||||
{ partial: 'template_folders/folder', collection: template_folders, as: :folder }
|
||||
end
|
||||
|
||||
render turbo_stream: [
|
||||
turbo_stream.before('infinite_scroll', **render_params),
|
||||
turbo_stream.replace('infinite_scroll', partial: 'shared/infinite_scroll', locals: { pagy: })
|
||||
]
|
||||
end
|
||||
|
||||
def load_templates(templates, folders_count, show_shared_inline: false)
|
||||
templates = templates.preload(:author, :template_accesses)
|
||||
|
||||
@@ -94,16 +110,16 @@ class TemplatesDashboardController < ApplicationController
|
||||
|
||||
pinned_count = (show_default_in_grid ? 1 : 0) + (show_shared_in_grid ? 1 : 0)
|
||||
|
||||
pagy = Pagy::Offset.new(count: folders_count + pinned_count,
|
||||
page: show_inline_folders ? 1 : [params[:page].to_s.to_i, 1].max,
|
||||
limit: FOLDERS_PER_PAGE,
|
||||
raise_range_error: true)
|
||||
pagy, = pagy(template_folders, count: folders_count + pinned_count,
|
||||
page: show_inline_folders ? 1 : [params[:page].to_s.to_i, 1].max,
|
||||
limit: FOLDERS_PER_PAGE,
|
||||
raise_range_error: true)
|
||||
|
||||
show_default_folder = show_default_in_grid && pagy.page == 1
|
||||
show_shared_folder = show_shared_in_grid && pagy.page == 1
|
||||
|
||||
folder_offset = pagy.page == 1 ? 0 : pagy.offset - pinned_count
|
||||
folder_limit = pagy.page == 1 ? FOLDERS_PER_PAGE - pinned_count : FOLDERS_PER_PAGE
|
||||
folder_limit = pagy.page == 1 ? pagy.limit - pinned_count : pagy.limit
|
||||
|
||||
template_folders = template_folders.offset(folder_offset).limit(folder_limit)
|
||||
|
||||
|
||||
@@ -19,13 +19,22 @@ class TemplatesSharedController < ApplicationController
|
||||
|
||||
@pagy, @templates = pagy_auto(@templates.select_for_list, limit: 12)
|
||||
|
||||
return unless params[:q].present? && @templates.blank?
|
||||
if params[:q].present? && @templates.blank?
|
||||
@related_submissions_pagy, @related_submissions = load_related_submissions(is_archived: @is_archived)
|
||||
end
|
||||
|
||||
@related_submissions_pagy, @related_submissions = load_related_submissions(is_archived: @is_archived)
|
||||
render_infinite_scroll(@pagy, @templates) if turbo_infinite_scroll?
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def render_infinite_scroll(pagy, templates)
|
||||
render turbo_stream: [
|
||||
turbo_stream.before('infinite_scroll', partial: 'templates/template', collection: templates),
|
||||
turbo_stream.replace('infinite_scroll', partial: 'shared/infinite_scroll', locals: { pagy: })
|
||||
]
|
||||
end
|
||||
|
||||
def load_related_submissions(is_archived:)
|
||||
shared_templates = Templates.shared(current_user)
|
||||
shared_templates = is_archived ? shared_templates.archived : shared_templates.active
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
class TestingApiSettingsController < ApplicationController
|
||||
def index
|
||||
raise CanCan::AccessDenied unless current_account.testing?
|
||||
|
||||
authorize!(:manage, current_user.access_token)
|
||||
|
||||
@webhook_url = current_account.webhook_urls.first_or_initialize
|
||||
|
||||
@@ -20,7 +20,7 @@ class UsersController < ApplicationController
|
||||
|
||||
respond_to do |format|
|
||||
format.html do
|
||||
@pagy, @users = pagy(@users)
|
||||
@pagy, @users = pagy(@users, limit: turbo_native_app? ? 1_000 : Pagy::OPTIONS[:limit])
|
||||
end
|
||||
|
||||
if current_ability.can?(:manage, current_account)
|
||||
|
||||
+189
-38
@@ -1,5 +1,5 @@
|
||||
import '@hotwired/turbo'
|
||||
import { encodeMethodIntoRequestBody } from '@hotwired/turbo-rails/app/javascript/turbo/fetch_requests'
|
||||
import '@hotwired/turbo-rails'
|
||||
import './native'
|
||||
|
||||
import { createApp, reactive } from 'vue'
|
||||
import TemplateBuilder from './template_builder/builder'
|
||||
@@ -9,6 +9,15 @@ import ToggleVisible from './elements/toggle_visible'
|
||||
import ToggleCookies from './elements/toggle_cookies'
|
||||
import DisableHidden from './elements/disable_hidden'
|
||||
import TurboModal from './elements/turbo_modal'
|
||||
import NativeAction from './elements/native_action'
|
||||
import NativeMenu from './elements/native_menu'
|
||||
import NativeSearch from './elements/native_search'
|
||||
import NativeModal from './elements/native_modal'
|
||||
import NativeEvent from './elements/native_event'
|
||||
import NativeUpload from './elements/native_upload'
|
||||
import FormPrompt from './elements/form_prompt'
|
||||
import InfiniteScroll from './elements/infinite_scroll'
|
||||
import ModalButton from './elements/modal_button'
|
||||
import FileDropzone from './elements/file_dropzone'
|
||||
import MenuActive from './elements/menu_active'
|
||||
import ClipboardCopy from './elements/clipboard_copy'
|
||||
@@ -59,10 +68,8 @@ import ConfirmUpload from './elements/confirm_upload'
|
||||
import ScrollFade from './elements/scroll_fade'
|
||||
import OpenModalMobile from './elements/open_modal_mobile'
|
||||
import HistoryBack from './elements/history_back'
|
||||
|
||||
import * as TurboInstantClick from './lib/turbo_instant_click'
|
||||
|
||||
TurboInstantClick.start()
|
||||
import DatePlaceholder from './elements/date_placeholder'
|
||||
import FlashMessage from './elements/flash_message'
|
||||
|
||||
document.addEventListener('turbo:before-cache', () => {
|
||||
window.flash?.remove()
|
||||
@@ -74,10 +81,18 @@ document.addEventListener('keyup', (e) => {
|
||||
}
|
||||
})
|
||||
|
||||
document.addEventListener('turbo:before-fetch-request', encodeMethodIntoRequestBody)
|
||||
document.addEventListener('turbo:before-fetch-request', (event) => {
|
||||
event.detail.fetchOptions.headers['X-Turbo'] = 'true'
|
||||
document.addEventListener('turbo:morph', () => {
|
||||
if (document.activeElement?.closest('.dropdown')) {
|
||||
document.activeElement.blur()
|
||||
}
|
||||
})
|
||||
|
||||
document.addEventListener('turbo:before-fetch-response', (event) => {
|
||||
if (event.detail.fetchResponse.header('content-disposition')?.includes('attachment')) {
|
||||
event.preventDefault()
|
||||
}
|
||||
})
|
||||
|
||||
document.addEventListener('turbo:submit-end', async (event) => {
|
||||
const resp = event.detail?.formSubmission?.result?.fetchResponse?.response
|
||||
|
||||
@@ -85,11 +100,23 @@ document.addEventListener('turbo:submit-end', async (event) => {
|
||||
return
|
||||
}
|
||||
|
||||
const filename = decodeURIComponent(resp.headers.get('content-disposition').split('"')[1])
|
||||
const download = window.webkit?.messageHandlers?.download
|
||||
|
||||
if (download) {
|
||||
const reader = new FileReader()
|
||||
|
||||
reader.onload = () => download.postMessage({ name: filename, data: reader.result.split(',')[1] })
|
||||
reader.readAsDataURL(await resp.blob())
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const url = URL.createObjectURL(await resp.blob())
|
||||
const link = document.createElement('a')
|
||||
|
||||
link.href = url
|
||||
link.setAttribute('download', decodeURIComponent(resp.headers.get('content-disposition').split('"')[1]))
|
||||
link.setAttribute('download', filename)
|
||||
|
||||
document.body.appendChild(link)
|
||||
|
||||
@@ -105,6 +132,15 @@ const safeRegisterElement = (name, element, options = {}) => !window.customEleme
|
||||
safeRegisterElement('toggle-visible', ToggleVisible)
|
||||
safeRegisterElement('disable-hidden', DisableHidden)
|
||||
safeRegisterElement('turbo-modal', TurboModal)
|
||||
safeRegisterElement('native-action', NativeAction)
|
||||
safeRegisterElement('native-menu', NativeMenu)
|
||||
safeRegisterElement('native-search', NativeSearch)
|
||||
safeRegisterElement('native-modal', NativeModal)
|
||||
safeRegisterElement('native-event', NativeEvent)
|
||||
safeRegisterElement('native-upload', NativeUpload)
|
||||
safeRegisterElement('form-prompt', FormPrompt)
|
||||
safeRegisterElement('infinite-scroll', InfiniteScroll)
|
||||
safeRegisterElement('modal-button', ModalButton)
|
||||
safeRegisterElement('file-dropzone', FileDropzone)
|
||||
safeRegisterElement('menu-active', MenuActive)
|
||||
safeRegisterElement('clipboard-copy', ClipboardCopy)
|
||||
@@ -156,16 +192,30 @@ safeRegisterElement('confirm-upload', ConfirmUpload)
|
||||
safeRegisterElement('scroll-fade', ScrollFade)
|
||||
safeRegisterElement('open-modal-mobile', OpenModalMobile)
|
||||
safeRegisterElement('history-back', HistoryBack)
|
||||
safeRegisterElement('date-placeholder', DatePlaceholder)
|
||||
safeRegisterElement('flash-message', FlashMessage)
|
||||
|
||||
safeRegisterElement('template-builder', class extends HTMLElement {
|
||||
connectedCallback () {
|
||||
document.addEventListener('turbo:submit-end', this.onSubmit)
|
||||
document.addEventListener('template-builder:update', this.onSheetSubmit)
|
||||
document.addEventListener('template-builder:sync', this.onSync)
|
||||
document.addEventListener('template-builder:scroll-to', this.onScrollTo)
|
||||
document.addEventListener('template-builder:select-field', this.onSelectField)
|
||||
document.addEventListener('template-builder:select-submitter', this.onSelectSubmitter)
|
||||
document.addEventListener('template-builder:sync-custom-fields', this.onSyncCustomFields)
|
||||
document.addEventListener('template-builder:draw-field', this.onDrawField)
|
||||
document.addEventListener('native:builder-view', this.onBuilderView)
|
||||
document.addEventListener('turbo:before-cache', this.onBeforeCache)
|
||||
|
||||
this.appElem = document.createElement('div')
|
||||
|
||||
this.appElem.classList.add('md:h-screen')
|
||||
|
||||
if (this.dataset.nativeView) {
|
||||
this.appElem.classList.add('min-w-0')
|
||||
}
|
||||
|
||||
const template = reactive(JSON.parse(this.dataset.template))
|
||||
|
||||
this.app = createApp(TemplateBuilder, {
|
||||
@@ -203,7 +253,9 @@ safeRegisterElement('template-builder', class extends HTMLElement {
|
||||
withDownload: true,
|
||||
currencies: (this.dataset.currencies || '').split(',').filter(Boolean),
|
||||
acceptFileTypes: this.dataset.acceptFileTypes,
|
||||
showTourStartForm: this.dataset.showTourStartForm === 'true'
|
||||
showTourStartForm: this.dataset.showTourStartForm === 'true',
|
||||
nativePlatform: this.dataset.nativePlatform,
|
||||
nativeView: this.dataset.nativeView
|
||||
})
|
||||
|
||||
this.component = this.app.mount(this.appElem)
|
||||
@@ -211,39 +263,130 @@ safeRegisterElement('template-builder', class extends HTMLElement {
|
||||
this.appendChild(this.appElem)
|
||||
}
|
||||
|
||||
onSubmit = (e) => {
|
||||
if (e.detail.success) {
|
||||
if (e.detail?.formSubmission?.formElement?.id === 'submitters_form') {
|
||||
e.detail.fetchResponse.response.json().then((data) => {
|
||||
this.component.template.submitters = data.submitters
|
||||
})
|
||||
onSubmit = async (e) => {
|
||||
const form = e.detail.formSubmission?.formElement
|
||||
|
||||
if (!e.detail.success || !form) return
|
||||
|
||||
const data = {}
|
||||
|
||||
new FormData(form).forEach((value, key) => { data[key] = value })
|
||||
|
||||
this.applySubmission(form.id, form.action, await e.detail.fetchResponse.responseText, data)
|
||||
}
|
||||
|
||||
onSheetSubmit = (e) => {
|
||||
this.applySubmission(e.detail.form, e.detail.action, e.detail.body, {})
|
||||
this.component.syncNative()
|
||||
}
|
||||
|
||||
onSync = (e) => {
|
||||
const { schema, documents, fields, submitters, pendingFieldAttachmentUuids } = e.detail
|
||||
const selectedAreas = this.component.selectedAreasRef.value.map((area) => {
|
||||
const field = this.component.template.fields.find((f) => f.areas?.includes(area))
|
||||
|
||||
return field && { uuid: field.uuid, index: field.areas.indexOf(area) }
|
||||
})
|
||||
const selectedSubmitterUuid = this.component.selectedSubmitter?.uuid
|
||||
|
||||
Object.assign(this.component.template, { schema, documents, fields, submitters })
|
||||
|
||||
this.component.selectedAreasRef.value = selectedAreas.map((item) => item && fields.find((f) => f.uuid === item.uuid)?.areas?.[item.index]).filter(Boolean)
|
||||
this.component.selectedSubmitter = submitters.find((s) => s.uuid === selectedSubmitterUuid) || submitters[0]
|
||||
this.component.pendingFieldAttachmentUuids = pendingFieldAttachmentUuids
|
||||
}
|
||||
|
||||
onScrollTo = (e) => {
|
||||
this.component.scrollIntoDocument(e.detail)
|
||||
}
|
||||
|
||||
onSyncCustomFields = (e) => {
|
||||
this.component.customFields.splice(0, this.component.customFields.length, ...e.detail)
|
||||
}
|
||||
|
||||
onSelectSubmitter = (e) => {
|
||||
const submitter = this.component.template.submitters.find((s) => s.uuid === e.detail.uuid)
|
||||
|
||||
if (submitter) this.component.selectedSubmitter = submitter
|
||||
}
|
||||
|
||||
onSelectField = (e) => {
|
||||
const { uuid, attachment_uuid: attachmentUuid, page, x, y } = e.detail
|
||||
const field = this.component.template.fields.find((f) => f.uuid === uuid)
|
||||
const areas = field?.areas || []
|
||||
const area = areas.find((a) => a.attachment_uuid === attachmentUuid && a.page === page && a.x === x && a.y === y) || areas[0]
|
||||
|
||||
if (area) this.component.scrollToArea(area)
|
||||
}
|
||||
|
||||
onDrawField = (e) => {
|
||||
const { uuid, type, option, custom_field_uuid: customFieldUuid } = e.detail
|
||||
|
||||
if (customFieldUuid) {
|
||||
const field = this.component.customFields.find((f) => f.uuid === customFieldUuid)
|
||||
|
||||
if (field) {
|
||||
this.component.drawCustomField = field
|
||||
this.component.showDrawField = true
|
||||
}
|
||||
} else if (uuid) {
|
||||
const field = this.component.template.fields.find((f) => f.uuid === uuid)
|
||||
|
||||
if (e.detail?.formSubmission?.formElement?.action?.endsWith('/prefillable_fields')) {
|
||||
e.detail.fetchResponse.response.text().then((data) => {
|
||||
const doc = new DOMParser().parseFromString(data, 'text/html')
|
||||
const fragment = doc.querySelector('turbo-stream template').content
|
||||
|
||||
const prefillableUuidsIndex = {}
|
||||
|
||||
fragment.querySelectorAll('[name="field_uuid"]').forEach((field) => {
|
||||
prefillableUuidsIndex[field.value] = true
|
||||
})
|
||||
|
||||
this.component.template.fields.forEach((field) => {
|
||||
if (prefillableUuidsIndex[field.uuid]) {
|
||||
field.prefillable = true
|
||||
field.readonly = true
|
||||
} else if (field.prefillable) {
|
||||
delete field.prefillable
|
||||
delete field.readonly
|
||||
}
|
||||
})
|
||||
})
|
||||
if (field) {
|
||||
this.component.drawField = field
|
||||
this.component.drawOption = option || null
|
||||
}
|
||||
} else if (type) {
|
||||
this.component.startFieldDraw({ type })
|
||||
}
|
||||
}
|
||||
|
||||
applySubmission (formId, action, body, data) {
|
||||
if (formId === 'submitters_form') {
|
||||
this.component.template.submitters = JSON.parse(body).submitters
|
||||
} else if (action.endsWith('/prefillable_fields')) {
|
||||
const doc = new DOMParser().parseFromString(body, 'text/html')
|
||||
const fragment = doc.querySelector('turbo-stream template').content
|
||||
|
||||
const prefillableUuidsIndex = {}
|
||||
|
||||
fragment.querySelectorAll('[name="field_uuid"]').forEach((field) => {
|
||||
prefillableUuidsIndex[field.value] = true
|
||||
})
|
||||
|
||||
this.component.template.fields.forEach((field) => {
|
||||
if (prefillableUuidsIndex[field.uuid]) {
|
||||
field.prefillable = true
|
||||
field.readonly = true
|
||||
} else if (field.prefillable) {
|
||||
delete field.prefillable
|
||||
delete field.readonly
|
||||
}
|
||||
})
|
||||
} else if (data['template[name]']) {
|
||||
this.component.template.name = data['template[name]']
|
||||
|
||||
document.title = data['template[name]']
|
||||
}
|
||||
}
|
||||
|
||||
onBuilderView = (event) => {
|
||||
const root = document.documentElement.cloneNode(true)
|
||||
const builder = root.querySelector('template-builder')
|
||||
const style = document.createElement('style')
|
||||
|
||||
builder.innerHTML = ''
|
||||
builder.setAttribute('data-native-view', event.detail.view)
|
||||
|
||||
root.querySelector('meta[name="viewport"]')?.setAttribute('content', 'width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no')
|
||||
|
||||
style.textContent = 'html { overflow-x: hidden; overscroll-behavior-x: none; } html, body { max-width: 100%; } body { -webkit-user-select: none; user-select: none; } input, textarea, [contenteditable] { -webkit-user-select: text; user-select: text; }'
|
||||
|
||||
root.querySelector('head').appendChild(style)
|
||||
|
||||
event.detail.result = '<!DOCTYPE html>' + root.outerHTML
|
||||
}
|
||||
|
||||
onBeforeCache = () => {
|
||||
this.app?.unmount()
|
||||
this.appElem?.remove()
|
||||
@@ -251,6 +394,14 @@ safeRegisterElement('template-builder', class extends HTMLElement {
|
||||
|
||||
disconnectedCallback () {
|
||||
document.removeEventListener('turbo:submit-end', this.onSubmit)
|
||||
document.removeEventListener('template-builder:update', this.onSheetSubmit)
|
||||
document.removeEventListener('template-builder:sync', this.onSync)
|
||||
document.removeEventListener('template-builder:scroll-to', this.onScrollTo)
|
||||
document.removeEventListener('template-builder:select-field', this.onSelectField)
|
||||
document.removeEventListener('template-builder:select-submitter', this.onSelectSubmitter)
|
||||
document.removeEventListener('template-builder:sync-custom-fields', this.onSyncCustomFields)
|
||||
document.removeEventListener('template-builder:draw-field', this.onDrawField)
|
||||
document.removeEventListener('native:builder-view', this.onBuilderView)
|
||||
document.removeEventListener('turbo:before-cache', this.onBeforeCache)
|
||||
|
||||
this.app?.unmount()
|
||||
|
||||
@@ -166,3 +166,71 @@ button[disabled] .enabled, button.btn-disabled .enabled {
|
||||
markdown-editor [contenteditable] p {
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
native-action {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.table-stack,
|
||||
.table-stack tbody,
|
||||
.table-stack tr,
|
||||
.table-stack td {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.table-stack,
|
||||
div:has(> .table-stack) {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.table-stack thead {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.table-stack tr {
|
||||
@apply bg-base-200 rounded-2xl py-3 mb-3 border-0;
|
||||
}
|
||||
|
||||
.table-stack td {
|
||||
@apply px-4 py-1;
|
||||
}
|
||||
|
||||
.table-stack td[data-label] {
|
||||
@apply flow-root text-right;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.table-stack td[data-label]::before {
|
||||
content: attr(data-label);
|
||||
@apply float-left mr-4 text-sm leading-6 text-base-content/60;
|
||||
}
|
||||
|
||||
.table-stack td:first-child {
|
||||
@apply font-semibold;
|
||||
}
|
||||
|
||||
.table-stack td:last-child {
|
||||
@apply grid grid-cols-2 gap-2 pt-3;
|
||||
}
|
||||
|
||||
.table-stack td:last-child:has(> .dropdown) {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr)) auto;
|
||||
}
|
||||
|
||||
.table-stack td:last-child:not(:has(*)) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.table-stack td:last-child > * {
|
||||
margin-left: 0 !important;
|
||||
}
|
||||
|
||||
.table-stack td:last-child > :last-child:nth-child(odd):not(.dropdown) {
|
||||
@apply col-span-2;
|
||||
}
|
||||
|
||||
.table-stack td:last-child .btn {
|
||||
@apply btn-sm w-full;
|
||||
}
|
||||
}
|
||||
@@ -126,11 +126,10 @@ window.customElements.define('draw-signature', class extends HTMLElement {
|
||||
const formData = new FormData()
|
||||
|
||||
formData.append('file', file)
|
||||
formData.append('submitter_slug', this.dataset.slug)
|
||||
formData.append('name', 'attachments')
|
||||
formData.append('remember_signature', 'true')
|
||||
|
||||
return fetch('/api/attachments', {
|
||||
return fetch(`/s/${this.dataset.slug}/upload`, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
}).then(resp => resp.json())
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
export default class extends HTMLElement {
|
||||
connectedCallback () {
|
||||
this.id = `autoresize-textarea-${Math.random().toString(32).split('.')[1]}`
|
||||
|
||||
this.resize()
|
||||
|
||||
this.textarea.addEventListener('input', () => this.resize())
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
export default class extends HTMLElement {
|
||||
connectedCallback () {
|
||||
this.id = `bar-chart-${Math.random().toString(32).split('.')[1]}`
|
||||
|
||||
this.chartLabels = JSON.parse(this.dataset.labels || '[]')
|
||||
this.chartDatasets = JSON.parse(this.dataset.datasets || '[]')
|
||||
|
||||
|
||||
@@ -4,9 +4,18 @@ export default class extends HTMLElement {
|
||||
|
||||
this.addEventListener('click', (e) => {
|
||||
const text = this.dataset.text || this.innerText.trim()
|
||||
const download = window.webkit?.messageHandlers?.download
|
||||
|
||||
if (download && /^https?:\/\//.test(text)) {
|
||||
e.preventDefault()
|
||||
|
||||
return download.postMessage({ url: text })
|
||||
}
|
||||
|
||||
if (navigator.clipboard) {
|
||||
navigator.clipboard.writeText(text)
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
window.webkit?.messageHandlers?.flash?.postMessage({ style: 'notice', message: this.dataset.copied })
|
||||
})
|
||||
} else {
|
||||
if (e.target.tagName !== 'INPUT') {
|
||||
alert(`Clipboard not available. Make sure you're using https://\nCopy text: ${text}`)
|
||||
|
||||
@@ -59,10 +59,10 @@ export async function convertImagesInInput (input) {
|
||||
|
||||
export default class extends HTMLElement {
|
||||
connectedCallback () {
|
||||
const input = this.querySelector('input[type="file"]')
|
||||
const form = input.form
|
||||
this.addEventListener('change', async (e) => {
|
||||
const input = e.target
|
||||
const form = input.form
|
||||
|
||||
input.addEventListener('change', async () => {
|
||||
await convertImagesInInput(input)
|
||||
|
||||
form.querySelector('[type="submit"]')?.setAttribute('disabled', true)
|
||||
|
||||
@@ -26,17 +26,41 @@ export default targetable(class extends HTMLElement {
|
||||
|
||||
window.addEventListener('dragleave', this.onWindowDragleave)
|
||||
|
||||
this.fileDropzone?.addEventListener('drop', this.onDropFile)
|
||||
this.folderDropzone?.addEventListener('drop', this.onDropNewFolder)
|
||||
this.addEventListener('drop', this.onDrop)
|
||||
this.addEventListener('dragstart', this.onDragstart)
|
||||
this.addEventListener('dragover', this.onDragover)
|
||||
this.addEventListener('dragleave', this.onDragleave)
|
||||
}
|
||||
|
||||
this.folderCards.forEach((el) => el.addEventListener('drop', (e) => this.onDropFolder(e, el)))
|
||||
this.templateCards.forEach((el) => el.addEventListener('drop', this.onDropTemplate))
|
||||
this.templateCards.forEach((el) => el.addEventListener('dragstart', this.onTemplateDragStart))
|
||||
dropTarget (e) {
|
||||
return e.target.closest([
|
||||
'[data-target="dashboard-dropzone.fileDropzone"]',
|
||||
'[data-target="dashboard-dropzone.folderDropzone"]',
|
||||
'[data-targets="dashboard-dropzone.folderCards"]',
|
||||
'[data-targets="dashboard-dropzone.templateCards"]'
|
||||
].join(', '))
|
||||
}
|
||||
|
||||
return [this.fileDropzone, this.folderDropzone, ...this.folderCards, ...this.templateCards].forEach((el) => {
|
||||
el?.addEventListener('dragover', this.onDragover)
|
||||
el?.addEventListener('dragleave', this.onDragleave)
|
||||
})
|
||||
onDrop = (e) => {
|
||||
const el = this.dropTarget(e)
|
||||
|
||||
if (!el) return
|
||||
|
||||
if (el === this.fileDropzone) {
|
||||
this.onDropFile(e)
|
||||
} else if (el === this.folderDropzone) {
|
||||
this.onDropNewFolder(e)
|
||||
} else if (el.dataset.targets === 'dashboard-dropzone.folderCards') {
|
||||
this.onDropFolder(e, el)
|
||||
} else {
|
||||
this.onDropTemplate(e, el)
|
||||
}
|
||||
}
|
||||
|
||||
onDragstart = (e) => {
|
||||
if (e.target.closest('[data-targets="dashboard-dropzone.templateCards"]')) {
|
||||
this.onTemplateDragStart(e)
|
||||
}
|
||||
}
|
||||
|
||||
disconnectedCallback () {
|
||||
@@ -122,13 +146,13 @@ export default targetable(class extends HTMLElement {
|
||||
}
|
||||
}).finally(() => {
|
||||
window.Turbo.cache.clear()
|
||||
window.Turbo.visit(location.href)
|
||||
window.Turbo.visit(location.href, { action: 'replace' })
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onDropTemplate = (e) => {
|
||||
onDropTemplate = (e, el) => {
|
||||
e.preventDefault()
|
||||
|
||||
if (e.dataTransfer.files.length) {
|
||||
@@ -136,10 +160,10 @@ export default targetable(class extends HTMLElement {
|
||||
loading.classList.add('bottom-5', 'left-0', 'flex', 'justify-center', 'w-full', 'absolute')
|
||||
loading.innerHTML = loadingIconHtml
|
||||
|
||||
e.target.appendChild(loading)
|
||||
e.target.classList.add('opacity-50')
|
||||
el.appendChild(loading)
|
||||
el.classList.add('opacity-50')
|
||||
|
||||
const id = e.target.href.split('/').pop()
|
||||
const id = el.href.split('/').pop()
|
||||
|
||||
this.uploadFiles(e.dataTransfer.files, `/templates/${id}/clone_and_replace`)
|
||||
}
|
||||
@@ -173,16 +197,20 @@ export default targetable(class extends HTMLElement {
|
||||
}
|
||||
}
|
||||
|
||||
onDragover (e) {
|
||||
if (e.dataTransfer?.types?.includes('Files') || this.dataset.targets !== 'dashboard-dropzone.templateCards') {
|
||||
this.style.backgroundColor = '#F7F3F0'
|
||||
onDragover = (e) => {
|
||||
const el = this.dropTarget(e)
|
||||
|
||||
if (this.classList.contains('before:border-base-300')) {
|
||||
this.classList.remove('before:border-base-300')
|
||||
this.classList.add('before:border-base-content/30')
|
||||
} else if (this.classList.contains('border-base-300')) {
|
||||
this.classList.remove('border-base-300')
|
||||
this.classList.add('border-base-content/30')
|
||||
if (!el) return
|
||||
|
||||
if (e.dataTransfer?.types?.includes('Files') || el.dataset.targets !== 'dashboard-dropzone.templateCards') {
|
||||
el.style.backgroundColor = '#F7F3F0'
|
||||
|
||||
if (el.classList.contains('before:border-base-300')) {
|
||||
el.classList.remove('before:border-base-300')
|
||||
el.classList.add('before:border-base-content/30')
|
||||
} else if (el.classList.contains('border-base-300')) {
|
||||
el.classList.remove('border-base-300')
|
||||
el.classList.add('border-base-content/30')
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -205,15 +233,19 @@ export default targetable(class extends HTMLElement {
|
||||
a.remove()
|
||||
}
|
||||
|
||||
onDragleave () {
|
||||
this.style.backgroundColor = null
|
||||
onDragleave = (e) => {
|
||||
const el = this.dropTarget(e)
|
||||
|
||||
if (this.classList.contains('before:border-base-content/30')) {
|
||||
this.classList.remove('before:border-base-content/30')
|
||||
this.classList.add('before:border-base-300')
|
||||
} else if (this.classList.contains('border-base-content/30')) {
|
||||
this.classList.remove('border-base-content/30')
|
||||
this.classList.add('border-base-300')
|
||||
if (!el) return
|
||||
|
||||
el.style.backgroundColor = null
|
||||
|
||||
if (el.classList.contains('before:border-base-content/30')) {
|
||||
el.classList.remove('before:border-base-content/30')
|
||||
el.classList.add('before:border-base-300')
|
||||
} else if (el.classList.contains('border-base-content/30')) {
|
||||
el.classList.remove('border-base-content/30')
|
||||
el.classList.add('border-base-300')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
export default class extends HTMLElement {
|
||||
connectedCallback () {
|
||||
const input = this.querySelector('input')
|
||||
const placeholder = this.querySelector('span')
|
||||
|
||||
input.addEventListener('change', () => {
|
||||
placeholder.classList.toggle('hidden', !!input.value)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,18 @@ export default class extends HTMLElement {
|
||||
static observedAttributes = ['class']
|
||||
|
||||
connectedCallback () {
|
||||
this.addEventListener('turbo:morph-element', this.onMorph)
|
||||
|
||||
this.trigger()
|
||||
}
|
||||
|
||||
disconnectedCallback () {
|
||||
this.removeEventListener('turbo:morph-element', this.onMorph)
|
||||
}
|
||||
|
||||
onMorph = (event) => {
|
||||
if (event.target !== this) return
|
||||
|
||||
this.trigger()
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,16 @@ export default targetable(class extends HTMLElement {
|
||||
downloadFiles () {
|
||||
if (!this.dataset.src) return
|
||||
|
||||
const download = window.webkit?.messageHandlers?.download
|
||||
|
||||
if (download) {
|
||||
this.toggleState()
|
||||
|
||||
document.addEventListener('native:download-end', () => this.toggleState(), { once: true })
|
||||
|
||||
return download.postMessage({ src: this.dataset.src })
|
||||
}
|
||||
|
||||
this.toggleState()
|
||||
|
||||
fetch(this.dataset.src).then(async (response) => {
|
||||
|
||||
@@ -39,6 +39,8 @@ export default targetable(class extends HTMLElement {
|
||||
]
|
||||
|
||||
connectedCallback () {
|
||||
this.id = `email-editor-${Math.random().toString(32).split('.')[1]}`
|
||||
|
||||
this.mount()
|
||||
|
||||
if (this.input.value) {
|
||||
|
||||
@@ -35,7 +35,7 @@ export default class extends HTMLElement {
|
||||
counter.innerText = `${count} / ${this.dataset.limit}`
|
||||
}
|
||||
|
||||
if (this.dataset.bulkEnabled !== 'true') {
|
||||
if (this.dataset.bulkEnabled !== 'true' && !/Hotwire Native iOS/.test(navigator.userAgent)) {
|
||||
if (!bulkMessage) {
|
||||
bulkMessage = document.createElement('span')
|
||||
|
||||
|
||||
@@ -14,6 +14,15 @@ export default actionable(targetable(class extends HTMLElement {
|
||||
this.addEventListener('dragover', (e) => e.preventDefault())
|
||||
this.addEventListener('drop', this.onDrop)
|
||||
document.addEventListener('turbo:submit-end', this.toggleLoading)
|
||||
|
||||
if (this.area) {
|
||||
this.initArea()
|
||||
} else {
|
||||
queueMicrotask(() => this.initArea())
|
||||
}
|
||||
}
|
||||
|
||||
initArea () {
|
||||
this.area?.addEventListener('dragover', this.onDragover)
|
||||
this.area?.addEventListener('dragleave', this.onDragleave)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
export default class extends HTMLElement {
|
||||
connectedCallback () {
|
||||
this.addEventListener('turbo:morph-element', this.onMorph)
|
||||
|
||||
this.show()
|
||||
}
|
||||
|
||||
disconnectedCallback () {
|
||||
this.removeEventListener('turbo:morph-element', this.onMorph)
|
||||
|
||||
clearTimeout(this.closeTimeout)
|
||||
}
|
||||
|
||||
onMorph = (event) => {
|
||||
if (event.target !== this) return
|
||||
|
||||
this.show()
|
||||
}
|
||||
|
||||
show () {
|
||||
clearTimeout(this.closeTimeout)
|
||||
|
||||
this.hideAnimation?.cancel()
|
||||
|
||||
this.animate([
|
||||
{ transform: 'translateY(-100%)', opacity: 0 },
|
||||
{ transform: 'translateY(0)', opacity: 1 }
|
||||
], { duration: 200, easing: 'cubic-bezier(0.34, 1.56, 0.64, 1)' })
|
||||
|
||||
this.closeTimeout = setTimeout(this.close, 2000)
|
||||
}
|
||||
|
||||
close = () => {
|
||||
clearTimeout(this.closeTimeout)
|
||||
|
||||
this.hideAnimation = this.animate([
|
||||
{ transform: 'translateY(0)', opacity: 1 },
|
||||
{ transform: 'translateY(-100%)', opacity: 0 }
|
||||
], { duration: 150, easing: 'ease-in', fill: 'forwards' })
|
||||
|
||||
this.hideAnimation.onfinish = () => this.remove()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
export default class extends HTMLElement {
|
||||
connectedCallback () {
|
||||
this.addEventListener('click', this.prompt)
|
||||
document.addEventListener('turbo:submit-end', this.onSubmitEnd)
|
||||
}
|
||||
|
||||
disconnectedCallback () {
|
||||
this.removeEventListener('click', this.prompt)
|
||||
document.removeEventListener('turbo:submit-end', this.onSubmitEnd)
|
||||
}
|
||||
|
||||
get form () {
|
||||
return this.querySelector('form')
|
||||
}
|
||||
|
||||
prompt = () => {
|
||||
const label = this.form.querySelector('label')
|
||||
const input = this.form.querySelector(`#${CSS.escape(label.htmlFor)}`)
|
||||
const value = window.prompt(label.textContent.trim(), input.value)?.trim()
|
||||
|
||||
if (!value || value === input.value) return
|
||||
|
||||
input.value = value
|
||||
|
||||
this.form.requestSubmit()
|
||||
}
|
||||
|
||||
onSubmitEnd = (e) => {
|
||||
if (e.detail.success && e.detail.formSubmission?.formElement === this.form && this.dataset.refresh !== 'false') {
|
||||
window.Turbo.visit(window.location.href, { action: 'replace' })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -458,6 +458,8 @@ export default actionable(targetable(class extends HTMLElement {
|
||||
async connectedCallback () {
|
||||
if (!this.textarea || !this.editorElement) return
|
||||
|
||||
this.id = `html-editor-${Math.random().toString(32).split('.')[1]}`
|
||||
|
||||
this.textarea.style.display = 'none'
|
||||
this.adjustShortcutsForPlatform()
|
||||
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { renderStreamMessage, session } from '@hotwired/turbo'
|
||||
|
||||
export default class extends HTMLElement {
|
||||
connectedCallback () {
|
||||
if (this.dataset.limit) this.replaceLimitParam()
|
||||
|
||||
if (!this.dataset.src) {
|
||||
this.remove()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
this.observer = new IntersectionObserver((entries) => {
|
||||
if (entries.some((entry) => entry.isIntersecting)) {
|
||||
this.load()
|
||||
}
|
||||
}, { rootMargin: '400px' })
|
||||
|
||||
this.observer.observe(this)
|
||||
}
|
||||
|
||||
disconnectedCallback () {
|
||||
this.observer?.disconnect()
|
||||
}
|
||||
|
||||
replaceLimitParam () {
|
||||
const url = new URL(window.location.href)
|
||||
|
||||
url.searchParams.set('limit', this.dataset.limit)
|
||||
|
||||
session.history.replace(url, session.history.restorationIdentifier)
|
||||
session.view.lastRenderedLocation = url
|
||||
}
|
||||
|
||||
load () {
|
||||
if (this.loading || !this.dataset.src) return
|
||||
|
||||
this.loading = true
|
||||
|
||||
fetch(this.dataset.src, {
|
||||
headers: { 'X-Turbo-Infinite-Scroll': 'true' }
|
||||
}).then(async (response) => {
|
||||
if (!response.ok) return
|
||||
|
||||
delete this.dataset.src
|
||||
|
||||
renderStreamMessage(await response.text())
|
||||
}).finally(() => {
|
||||
this.loading = false
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -143,6 +143,8 @@ export default actionable(targetable(class extends HTMLElement {
|
||||
async connectedCallback () {
|
||||
if (!this.textarea || !this.editorElement) return
|
||||
|
||||
this.id = `markdown-editor-${Math.random().toString(32).split('.')[1]}`
|
||||
|
||||
this.textarea.style.display = 'none'
|
||||
this.adjustShortcutsForPlatform()
|
||||
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
export default class extends HTMLElement {
|
||||
connectedCallback () {
|
||||
const maskedToken = this.input.value
|
||||
|
||||
this.input.addEventListener('focus', () => {
|
||||
this.maskedToken = this.input.value
|
||||
this.input.value = this.dataset.token
|
||||
this.input.select()
|
||||
})
|
||||
|
||||
this.input.addEventListener('focusout', () => {
|
||||
this.input.value = maskedToken
|
||||
this.input.value = this.maskedToken
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
export default class extends HTMLElement {
|
||||
connectedCallback () {
|
||||
this.id = `menu-active-${Math.random().toString(32).split('.')[1]}`
|
||||
|
||||
this.querySelectorAll('a').forEach((link) => {
|
||||
if (document.location.pathname.startsWith(link.pathname) && !link.getAttribute('href').startsWith('http')) {
|
||||
link.classList.add('bg-base-300')
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { nativeIcon } from '../native'
|
||||
|
||||
export default class extends HTMLElement {
|
||||
connectedCallback () {
|
||||
const bridge = window.webkit?.messageHandlers?.native
|
||||
|
||||
if (!bridge) return
|
||||
|
||||
const target = this.querySelector('a, button, [role="button"]') || this.firstElementChild
|
||||
|
||||
if (!target && !this.dataset.native) return
|
||||
|
||||
this.nativeId ||= `native-action-${Math.random().toString(32).split('.')[1]}`
|
||||
this.id = this.nativeId
|
||||
|
||||
if (target) target.dataset.nativeId = this.nativeId
|
||||
|
||||
bridge.postMessage({
|
||||
type: 'action',
|
||||
op: 'add',
|
||||
id: this.nativeId,
|
||||
title: this.dataset.label || target?.textContent.trim(),
|
||||
icon: nativeIcon(this.dataset.icon),
|
||||
placement: this.dataset.placement || 'menu',
|
||||
side: this.dataset.side,
|
||||
view: this.dataset.view,
|
||||
section: this.dataset.section,
|
||||
menu: this.dataset.menu,
|
||||
menuIcon: nativeIcon(this.dataset.menuIcon),
|
||||
native: this.dataset.native,
|
||||
url: this.dataset.url,
|
||||
accept: this.dataset.accept,
|
||||
destructive: this.dataset.destructive === 'true',
|
||||
selected: this.dataset.selected === 'true',
|
||||
haptic: this.dataset.haptic === 'true'
|
||||
})
|
||||
}
|
||||
|
||||
disconnectedCallback () {
|
||||
if (this.nativeId) {
|
||||
window.webkit?.messageHandlers?.native?.postMessage({ type: 'action', op: 'remove', id: this.nativeId })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
export default class extends HTMLElement {
|
||||
connectedCallback () {
|
||||
if (this.dataset.on === 'submit') {
|
||||
document.addEventListener('turbo:submit-end', this.onSubmitEnd)
|
||||
this.addEventListener('turbo:before-fetch-response', this.onBeforeFetchResponse)
|
||||
} else {
|
||||
this.addEventListener('click', this.dispatch)
|
||||
}
|
||||
}
|
||||
|
||||
disconnectedCallback () {
|
||||
document.removeEventListener('turbo:submit-end', this.onSubmitEnd)
|
||||
this.removeEventListener('turbo:before-fetch-response', this.onBeforeFetchResponse)
|
||||
this.removeEventListener('click', this.dispatch)
|
||||
}
|
||||
|
||||
onBeforeFetchResponse = (e) => {
|
||||
const response = e.detail.fetchResponse
|
||||
|
||||
if (window.webkit?.messageHandlers?.modal && response.succeeded && !response.redirected && !response.isHTML) {
|
||||
e.preventDefault()
|
||||
}
|
||||
}
|
||||
|
||||
onSubmitEnd = async (e) => {
|
||||
const form = e.detail.formSubmission?.formElement
|
||||
|
||||
if (!e.detail.success || !form || !this.contains(form)) return
|
||||
|
||||
this.post(JSON.stringify({ form: form.id, action: form.action, body: await e.detail.fetchResponse.responseText }))
|
||||
}
|
||||
|
||||
dispatch = async () => {
|
||||
if (this.dataset.loading) return
|
||||
|
||||
let detail = this.dataset.detail || 'null'
|
||||
|
||||
if (this.dataset.src) {
|
||||
this.dataset.loading = 'true'
|
||||
|
||||
try {
|
||||
const response = await fetch(this.dataset.src, { headers: { Accept: 'application/json' } })
|
||||
|
||||
if (!response.ok) return
|
||||
|
||||
detail = await response.text()
|
||||
} catch {
|
||||
return
|
||||
} finally {
|
||||
delete this.dataset.loading
|
||||
}
|
||||
}
|
||||
|
||||
this.post(detail)
|
||||
}
|
||||
|
||||
post (detail) {
|
||||
window.webkit?.messageHandlers?.modal?.postMessage({ action: 'dispatch', name: this.dataset.name, detail, dismiss: this.dataset.dismiss || 'true' })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { nativeIcon } from '../native'
|
||||
|
||||
export default class extends HTMLElement {
|
||||
connectedCallback () {
|
||||
if (window.webkit?.messageHandlers?.native) {
|
||||
this.addEventListener('click', this.onClick)
|
||||
} else {
|
||||
document.addEventListener('pointerdown', this.onPointerDown)
|
||||
}
|
||||
}
|
||||
|
||||
disconnectedCallback () {
|
||||
this.removeEventListener('click', this.onClick)
|
||||
document.removeEventListener('pointerdown', this.onPointerDown)
|
||||
}
|
||||
|
||||
onPointerDown = (event) => {
|
||||
if (this.contains(document.activeElement) && !this.contains(event.target)) {
|
||||
document.activeElement.blur()
|
||||
}
|
||||
}
|
||||
|
||||
onClick = (event) => {
|
||||
if (event.target.closest('native-menu-action')) return
|
||||
|
||||
const rect = this.firstElementChild.getBoundingClientRect()
|
||||
|
||||
const items = [...this.querySelectorAll('native-menu-action')].map((action) => {
|
||||
const target = action.querySelector('a, button, [role="button"]') || action.firstElementChild
|
||||
|
||||
target.dataset.nativeId ||= `native-menu-${Math.random().toString(32).split('.')[1]}`
|
||||
|
||||
return {
|
||||
id: target.dataset.nativeId,
|
||||
title: action.dataset.label || target.textContent.trim(),
|
||||
icon: nativeIcon(action.dataset.icon),
|
||||
destructive: action.dataset.destructive === 'true',
|
||||
selected: action.dataset.selected === 'true',
|
||||
haptic: action.dataset.haptic === 'true'
|
||||
}
|
||||
})
|
||||
|
||||
window.webkit.messageHandlers.native.postMessage({
|
||||
type: 'menu',
|
||||
rect: { x: rect.left + window.scrollX, y: rect.top + window.scrollY, width: rect.width, height: rect.height },
|
||||
items
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
export default class extends HTMLElement {
|
||||
connectedCallback () {
|
||||
document.addEventListener('turbo:submit-end', this.onSubmit)
|
||||
this.addEventListener('click', this.onClick)
|
||||
}
|
||||
|
||||
disconnectedCallback () {
|
||||
document.removeEventListener('turbo:submit-end', this.onSubmit)
|
||||
}
|
||||
|
||||
onSubmit = async (e) => {
|
||||
if (!e.detail.success) return
|
||||
if (e.detail.fetchResponse?.redirected && e.detail.fetchResponse.location.href !== window.location.href) return
|
||||
if (e.detail.formSubmission?.formElement?.method === 'get') return
|
||||
if (this.dataset.closeAfterSubmit === 'false') return
|
||||
if (e.detail.formSubmission?.formElement?.dataset?.closeOnSubmit === 'false') return
|
||||
if (e.detail.formSubmission?.formElement?.closest('native-event[data-on="submit"]')) return
|
||||
|
||||
const flash = await this.flashFrom(e.detail.fetchResponse)
|
||||
|
||||
window.webkit?.messageHandlers?.modal?.postMessage({ action: 'submitted', ...flash })
|
||||
}
|
||||
|
||||
async flashFrom (fetchResponse) {
|
||||
const html = await fetchResponse?.responseHTML
|
||||
const flash = html && new DOMParser().parseFromString(html, 'text/html').getElementById('native_flash')
|
||||
|
||||
if (flash?.dataset.alert) return { alert: flash.dataset.alert }
|
||||
if (flash?.dataset.notice) return { notice: flash.dataset.notice }
|
||||
|
||||
return {}
|
||||
}
|
||||
|
||||
onClick = () => {
|
||||
setTimeout(() => {
|
||||
if (document.documentElement.scrollHeight > window.innerHeight) {
|
||||
window.webkit?.messageHandlers?.modal?.postMessage({ action: 'expand' })
|
||||
}
|
||||
}, 300)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
export default class extends HTMLElement {
|
||||
connectedCallback () {
|
||||
document.addEventListener('native:search', this.onSearch)
|
||||
document.addEventListener('turbo:before-cache', this.onBeforeCache)
|
||||
this.addEventListener('touchstart', this.onTouchStart, { passive: true })
|
||||
this.addEventListener('scroll', this.onScroll, { capture: true, passive: true })
|
||||
|
||||
const scroller = this.querySelector('[data-scroll-top]')
|
||||
|
||||
if (scroller) scroller.scrollTop = scroller.dataset.scrollTop
|
||||
}
|
||||
|
||||
disconnectedCallback () {
|
||||
document.removeEventListener('native:search', this.onSearch)
|
||||
document.removeEventListener('turbo:before-cache', this.onBeforeCache)
|
||||
}
|
||||
|
||||
onBeforeCache = () => {
|
||||
if (this.scroller) this.scroller.dataset.scrollTop = this.scroller.scrollTop
|
||||
}
|
||||
|
||||
onTouchStart = () => {
|
||||
this.isTouching = true
|
||||
}
|
||||
|
||||
onScroll = (event) => {
|
||||
this.scroller = event.target
|
||||
|
||||
if (!this.isTouching) return
|
||||
|
||||
this.isTouching = false
|
||||
|
||||
window.webkit?.messageHandlers?.native?.postMessage({ type: 'dismiss-keyboard' })
|
||||
}
|
||||
|
||||
onSearch = (event) => {
|
||||
const query = (event.detail?.q || '').trim()
|
||||
|
||||
if (query === (this.dataset.q || '')) return
|
||||
|
||||
this.dataset.q = query
|
||||
|
||||
const url = new URL(this.frame.src || this.dataset.src, window.location.href)
|
||||
|
||||
url.searchParams.delete('page')
|
||||
|
||||
if (query) {
|
||||
url.searchParams.set('q', query)
|
||||
} else {
|
||||
url.searchParams.delete('q')
|
||||
}
|
||||
|
||||
this.frame.src = url.toString()
|
||||
}
|
||||
|
||||
get frame () {
|
||||
return this.querySelector('turbo-frame')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { convertImagesInInput } from './convert_upload'
|
||||
|
||||
export default class extends HTMLElement {
|
||||
connectedCallback () {
|
||||
this.files = []
|
||||
|
||||
document.addEventListener('turbo:submit-end', this.onSubmitEnd)
|
||||
document.addEventListener('native:upload-start', this.onUploadStart)
|
||||
document.addEventListener('native:upload-chunk', this.onUploadChunk)
|
||||
document.addEventListener('native:upload-submit', this.onUploadSubmit)
|
||||
}
|
||||
|
||||
disconnectedCallback () {
|
||||
document.removeEventListener('turbo:submit-end', this.onSubmitEnd)
|
||||
document.removeEventListener('native:upload-start', this.onUploadStart)
|
||||
document.removeEventListener('native:upload-chunk', this.onUploadChunk)
|
||||
document.removeEventListener('native:upload-submit', this.onUploadSubmit)
|
||||
}
|
||||
|
||||
get form () {
|
||||
return this.querySelector('form')
|
||||
}
|
||||
|
||||
onUploadStart = (event) => {
|
||||
this.files = []
|
||||
|
||||
event.detail.result = true
|
||||
}
|
||||
|
||||
onUploadChunk = ({ detail: { index, name, type, chunk } }) => {
|
||||
const file = this.files[index] ||= { name, type, chunks: [] }
|
||||
|
||||
file.chunks.push(Uint8Array.from(atob(chunk), (char) => char.charCodeAt(0)))
|
||||
}
|
||||
|
||||
onUploadSubmit = (event) => {
|
||||
event.detail.result = this.submit(event.detail.url)
|
||||
}
|
||||
|
||||
async submit (url) {
|
||||
const dataTransfer = new DataTransfer()
|
||||
|
||||
this.files.forEach(({ name, type, chunks }) => dataTransfer.items.add(new File(chunks, name, { type })))
|
||||
this.files = []
|
||||
|
||||
const input = this.form.querySelector('[type="file"]')
|
||||
|
||||
input.files = dataTransfer.files
|
||||
|
||||
await convertImagesInInput(input)
|
||||
|
||||
this.form.action = url
|
||||
this.form.requestSubmit()
|
||||
}
|
||||
|
||||
onSubmitEnd = (e) => {
|
||||
if (e.detail.formSubmission?.formElement !== this.form) return
|
||||
|
||||
window.webkit?.messageHandlers?.native?.postMessage({ type: 'upload', success: e.detail.success })
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,17 @@
|
||||
export default class extends HTMLElement {
|
||||
connectedCallback () {
|
||||
this.addEventListener('pointerdown', this.onPointerdown)
|
||||
}
|
||||
|
||||
onPointerdown = (event) => {
|
||||
if (this.dataset.disabled === 'true') return
|
||||
if (/Hotwire Native/.test(navigator.userAgent)) return
|
||||
if (!this.isMobile()) return
|
||||
if (window.innerWidth >= 768) return
|
||||
|
||||
this.querySelectorAll('a[data-turbo-frame]').forEach((link) => {
|
||||
link.dataset.turboFrame = '_top'
|
||||
})
|
||||
const link = event.target.closest('a[data-turbo-frame]')
|
||||
|
||||
if (link) link.dataset.turboFrame = '_top'
|
||||
}
|
||||
|
||||
isMobile () {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
export default class extends HTMLElement {
|
||||
connectedCallback () {
|
||||
this.id = `scroll-fade-${Math.random().toString(32).split('.')[1]}`
|
||||
|
||||
this.addEventListener('scroll', this.updateFade, { passive: true })
|
||||
window.addEventListener('resize', this.updateFade)
|
||||
|
||||
|
||||
@@ -1,20 +1,29 @@
|
||||
export default class extends HTMLElement {
|
||||
connectedCallback () {
|
||||
this.button.addEventListener('click', (event) => {
|
||||
if (!this.input.value && document.activeElement !== this.input) {
|
||||
event.preventDefault()
|
||||
|
||||
this.input.focus()
|
||||
}
|
||||
})
|
||||
this.addEventListener('click', this.onClick)
|
||||
|
||||
document.addEventListener('turbo:before-cache', this.onBeforeCache)
|
||||
|
||||
if (this.input?.value) {
|
||||
this.input.focus()
|
||||
this.input.setSelectionRange(this.input.value.length, this.input.value.length)
|
||||
}
|
||||
}
|
||||
|
||||
disconnectedCallback () {
|
||||
document.removeEventListener('turbo:before-cache', this.onBeforeCache)
|
||||
}
|
||||
|
||||
onClick = (event) => {
|
||||
if (!event.target.closest('button')) return
|
||||
|
||||
if (!this.input.value && document.activeElement !== this.input) {
|
||||
event.preventDefault()
|
||||
|
||||
this.input.focus()
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeCache = () => {
|
||||
this.input.value = this.input.getAttribute('value') || ''
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
export default class extends HTMLElement {
|
||||
connectedCallback () {
|
||||
this.id = `set-timezone-${Math.random().toString(32).split('.')[1]}`
|
||||
|
||||
if (this.dataset.inputId) {
|
||||
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@ export default targetable(class extends HTMLElement {
|
||||
static [target.static] = ['canvas', 'input', 'clear', 'button']
|
||||
|
||||
async connectedCallback () {
|
||||
this.lockSwipe(true)
|
||||
|
||||
const { default: SignaturePad } = await import('signature_pad')
|
||||
|
||||
this.setCanvasSize()
|
||||
@@ -61,11 +63,17 @@ export default targetable(class extends HTMLElement {
|
||||
}
|
||||
|
||||
disconnectedCallback () {
|
||||
this.lockSwipe(false)
|
||||
|
||||
if (this.resizeObserver) {
|
||||
this.resizeObserver.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
lockSwipe (locked) {
|
||||
window.webkit?.messageHandlers?.native?.postMessage({ type: 'swipe', op: locked ? 'lock' : 'unlock' })
|
||||
}
|
||||
|
||||
setCanvasSize () {
|
||||
const scale = 3
|
||||
|
||||
|
||||
@@ -1,5 +1,19 @@
|
||||
export default class extends HTMLElement {
|
||||
connectedCallback () {
|
||||
if (this.lastElementChild) {
|
||||
this.init()
|
||||
} else {
|
||||
queueMicrotask(() => this.init())
|
||||
}
|
||||
}
|
||||
|
||||
disconnectedCallback () {
|
||||
if (this.interval) {
|
||||
clearInterval(this.interval)
|
||||
}
|
||||
}
|
||||
|
||||
init () {
|
||||
const form = this.querySelector('form') || (this.querySelector('input, button, select') || this.lastElementChild).form
|
||||
|
||||
if (this.dataset.interval) {
|
||||
@@ -24,10 +38,4 @@ export default class extends HTMLElement {
|
||||
form.requestSubmit()
|
||||
}
|
||||
}
|
||||
|
||||
disconnectedCallback () {
|
||||
if (this.interval) {
|
||||
clearInterval(this.interval)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,23 @@
|
||||
export default class extends HTMLElement {
|
||||
connectedCallback () {
|
||||
this.addEventListener('click', this.onClick)
|
||||
}
|
||||
|
||||
onClick = (event) => {
|
||||
const button = this.querySelector('a, button, label')
|
||||
|
||||
if (!button?.contains(event.target)) return
|
||||
|
||||
const target = this.dataset.targetId ? document.getElementById(this.dataset.targetId) : button
|
||||
|
||||
button.addEventListener('click', () => {
|
||||
this.dataset.classes.split(' ').forEach((cls) => {
|
||||
if (this.dataset.action === 'remove') {
|
||||
target.classList.remove(cls)
|
||||
} else if (this.dataset.action === 'add') {
|
||||
target.classList.add(cls)
|
||||
} else {
|
||||
target.classList.toggle(cls)
|
||||
}
|
||||
})
|
||||
this.dataset.classes.split(' ').forEach((cls) => {
|
||||
if (this.dataset.action === 'remove') {
|
||||
target.classList.remove(cls)
|
||||
} else if (this.dataset.action === 'add') {
|
||||
target.classList.add(cls)
|
||||
} else {
|
||||
target.classList.toggle(cls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,27 +1,21 @@
|
||||
export default class extends HTMLElement {
|
||||
connectedCallback () {
|
||||
document.addEventListener('turbo:submit-end', this.onSubmitEnd)
|
||||
|
||||
this.form.addEventListener('submit', this.onSubmit)
|
||||
this.form.addEventListener('turbo:before-fetch-response', this.onFetchResponse)
|
||||
}
|
||||
|
||||
disconnectedCallback () {
|
||||
document.removeEventListener('turbo:submit-end', this.onSubmitEnd)
|
||||
|
||||
this.form.removeEventListener('submit', this.onSubmit)
|
||||
this.form.removeEventListener('turbo:before-fetch-response', this.onFetchResponse)
|
||||
}
|
||||
|
||||
onSubmit = () => {
|
||||
this.element.classList.add('invisible')
|
||||
}
|
||||
|
||||
onSubmitEnd = (event) => {
|
||||
if (event.target === this.form) {
|
||||
const resp = event.detail?.formSubmission?.result?.fetchResponse?.response
|
||||
|
||||
if (resp?.status / 100 === 2) {
|
||||
this.element.classList.remove('invisible')
|
||||
}
|
||||
onFetchResponse = (event) => {
|
||||
if (event.detail.fetchResponse.succeeded) {
|
||||
this.element.classList.remove('invisible')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import './native'
|
||||
|
||||
import { createApp, reactive } from 'vue'
|
||||
|
||||
import Form from './submission_form/form'
|
||||
@@ -8,6 +10,7 @@ import ScrollButtons from './elements/scroll_buttons'
|
||||
import PageContainer from './elements/page_container'
|
||||
import SubmitForm from './elements/submit_form'
|
||||
import ModalButton from './elements/modal_button'
|
||||
import NativeAction from './elements/native_action'
|
||||
|
||||
const safeRegisterElement = (name, element, options = {}) => !window.customElements.get(name) && window.customElements.define(name, element, options)
|
||||
|
||||
@@ -18,6 +21,7 @@ safeRegisterElement('scroll-buttons', ScrollButtons)
|
||||
safeRegisterElement('page-container', PageContainer)
|
||||
safeRegisterElement('submit-form', SubmitForm)
|
||||
safeRegisterElement('modal-button', ModalButton)
|
||||
safeRegisterElement('native-action', NativeAction)
|
||||
safeRegisterElement('submission-form', class extends HTMLElement {
|
||||
connectedCallback () {
|
||||
this.appElem = document.createElement('div')
|
||||
@@ -28,7 +32,9 @@ safeRegisterElement('submission-form', class extends HTMLElement {
|
||||
optionalInviteSubmitters: JSON.parse(this.dataset.optionalInviteSubmitters),
|
||||
schema: JSON.parse(this.dataset.schema),
|
||||
canSendEmail: this.dataset.canSendEmail === 'true',
|
||||
previousSignatureValue: this.dataset.previousSignatureValue,
|
||||
prefillSignature: this.dataset.prefillSignature ? JSON.parse(this.dataset.prefillSignature) : null,
|
||||
prefillInitials: this.dataset.prefillInitials ? JSON.parse(this.dataset.prefillInitials) : null,
|
||||
currentUser: this.dataset.currentUser ? JSON.parse(this.dataset.currentUser) : null,
|
||||
goToLast: this.dataset.goToLast === 'true',
|
||||
isDemo: this.dataset.isDemo === 'true',
|
||||
attribution: this.dataset.attribution !== 'false',
|
||||
@@ -37,10 +43,12 @@ safeRegisterElement('submission-form', class extends HTMLElement {
|
||||
language: this.dataset.language,
|
||||
paymentProvider: this.dataset.paymentProvider,
|
||||
dryRun: this.dataset.dryRun === 'true',
|
||||
viewToken: this.dataset.viewToken,
|
||||
expand: ['true', 'false'].includes(this.dataset.expand) ? this.dataset.expand === 'true' : null,
|
||||
withSignatureId: this.dataset.withSignatureId === 'true',
|
||||
requireSigningReason: this.dataset.requireSigningReason === 'true',
|
||||
withConfetti: this.dataset.withConfetti === 'true',
|
||||
nativePlatform: this.dataset.nativePlatform,
|
||||
withFieldLabels: this.dataset.withFieldLabels !== 'false',
|
||||
withDisclosure: this.dataset.withDisclosure === 'true',
|
||||
reuseSignature: this.dataset.reuseSignature !== 'false',
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
const requestCache = new Map()
|
||||
const cacheTtl = 10 * 1000
|
||||
|
||||
function isPreloadable (linkElement) {
|
||||
const href = linkElement.getAttribute('href')
|
||||
|
||||
if (!href || href === '#' || linkElement.dataset.turbo === 'false' || linkElement.dataset.prefetch === 'false') {
|
||||
return
|
||||
}
|
||||
|
||||
if (linkElement.origin !== document.location.origin) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!['http:', 'https:'].includes(linkElement.protocol)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (linkElement.pathname + linkElement.search === document.location.pathname + document.location.search) {
|
||||
return
|
||||
}
|
||||
|
||||
if (linkElement.dataset.turboMethod && linkElement.dataset.turboMethod !== 'get') {
|
||||
return
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
function mouseoverListener (event) {
|
||||
let linkElement
|
||||
|
||||
if (event.target.tagName === 'A') {
|
||||
linkElement = event.target
|
||||
} else {
|
||||
linkElement = event.target.closest('a')
|
||||
}
|
||||
|
||||
if (!linkElement) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!isPreloadable(linkElement)) {
|
||||
return
|
||||
}
|
||||
|
||||
const url = linkElement.getAttribute('href')
|
||||
const loc = new URL(url, location.protocol + '//' + location.host)
|
||||
const absoluteUrl = loc.toString()
|
||||
|
||||
const cached = requestCache.get(absoluteUrl)
|
||||
|
||||
if (cached && cached.ttl > new Date()) {
|
||||
return
|
||||
}
|
||||
|
||||
const requestOptions = {
|
||||
credentials: 'same-origin',
|
||||
headers: { Accept: 'text/html, application/xhtml+xml', 'VND.PREFETCH': 'true', 'X-Turbo': 'true' },
|
||||
method: 'GET',
|
||||
redirect: 'follow'
|
||||
}
|
||||
|
||||
if (linkElement.dataset.turboFrame && linkElement.dataset.turboFrame !== '_top') {
|
||||
requestOptions.headers['Turbo-Frame'] = linkElement.dataset.turboFrame
|
||||
} else if (linkElement.dataset.turboFrame !== '_top') {
|
||||
const turboFrame = linkElement.closest('turbo-frame')
|
||||
|
||||
if (turboFrame) {
|
||||
requestOptions.headers['Turbo-Frame'] = turboFrame.id
|
||||
}
|
||||
}
|
||||
|
||||
requestCache.set(absoluteUrl, { request: fetch(absoluteUrl, requestOptions), ttl: new Date(new Date().getTime() + cacheTtl) })
|
||||
}
|
||||
|
||||
function turboBeforeFetchRequest (event) {
|
||||
if (event.target.tagName !== 'FORM' && event.detail.fetchOptions.method === 'GET') {
|
||||
const cached = requestCache.get(event.detail.url.toString())
|
||||
|
||||
if (cached && cached.ttl > new Date()) {
|
||||
event.detail.response = cached.request
|
||||
}
|
||||
}
|
||||
|
||||
requestCache.clear()
|
||||
}
|
||||
|
||||
function start () {
|
||||
if (!window.turboInstantClickEnabled) {
|
||||
document.addEventListener('turbo:before-fetch-request', turboBeforeFetchRequest)
|
||||
document.addEventListener('mouseover', mouseoverListener, { capture: true, passive: true })
|
||||
}
|
||||
|
||||
window.turboInstantClickEnabled = true
|
||||
}
|
||||
|
||||
export { start }
|
||||
@@ -0,0 +1,208 @@
|
||||
const ICONS = {
|
||||
ios: {
|
||||
link: 'link',
|
||||
archive: 'archivebox',
|
||||
folder: 'folder',
|
||||
trash: 'trash',
|
||||
rotate: 'arrow.counterclockwise',
|
||||
copy: 'doc.on.doc',
|
||||
pencil: 'pencil',
|
||||
logs: 'list.bullet.rectangle',
|
||||
external_link: 'arrow.up.right.square',
|
||||
download: 'arrow.down.circle',
|
||||
mail_forward: 'paperplane',
|
||||
send: 'paperplane',
|
||||
writing: 'signature',
|
||||
adjustments_horizontal: 'slider.horizontal.3',
|
||||
file_text: 'doc.text',
|
||||
files: 'doc.on.doc',
|
||||
plus: 'plus',
|
||||
eye: 'eye',
|
||||
check: 'checkmark',
|
||||
code: 'chevron.left.forwardslash.chevron.right',
|
||||
history: 'clock.arrow.circlepath',
|
||||
user: 'person',
|
||||
users: 'person.2',
|
||||
users_plus: 'person.badge.plus',
|
||||
photo: 'photo',
|
||||
settings: 'gearshape',
|
||||
logout: 'rectangle.portrait.and.arrow.right',
|
||||
upload: 'arrow.up.doc',
|
||||
scan: 'doc.viewfinder',
|
||||
pencil_plus: 'square.and.pencil',
|
||||
arrow_sort: 'arrow.up.arrow.down',
|
||||
sort_descending_numbers: 'calendar',
|
||||
sort_descending_small_big: 'clock.arrow.circlepath',
|
||||
sort_ascending_letters: 'textformat.abc',
|
||||
calendar: 'calendar',
|
||||
calendar_check: 'calendar.badge.checkmark',
|
||||
info_circle: 'info.circle',
|
||||
list: 'list.bullet',
|
||||
clock: 'clock',
|
||||
circle_check: 'checkmark.circle'
|
||||
}
|
||||
}
|
||||
|
||||
const platform = navigator.userAgent.includes('Hotwire Native Android') ? 'android' : 'ios'
|
||||
|
||||
export function nativeIcon (name) {
|
||||
return ICONS[platform]?.[name] || name
|
||||
}
|
||||
|
||||
const handlers = window.webkit?.messageHandlers
|
||||
const isNativeLoaded = window.isNativeLoaded
|
||||
|
||||
if (handlers?.native && window === window.top && !isNativeLoaded) {
|
||||
window.isNativeLoaded = true
|
||||
|
||||
handlers.native.postMessage({ type: 'reset' })
|
||||
|
||||
const reportPathConfiguration = () => {
|
||||
const element = document.getElementById('native_path_configuration')
|
||||
|
||||
if (!element) return
|
||||
|
||||
const config = JSON.parse(element.textContent)
|
||||
|
||||
config.rules.forEach(({ properties }) => {
|
||||
if (properties.native_actions) properties.native_actions = properties.native_actions.map(nativeIcon)
|
||||
})
|
||||
|
||||
handlers.native.postMessage({ type: 'path-configuration', json: JSON.stringify(config) })
|
||||
}
|
||||
|
||||
document.addEventListener('turbo:load', reportPathConfiguration)
|
||||
|
||||
document.addEventListener('turbo:before-cache', () => {
|
||||
document.getElementById('native_path_configuration')?.remove()
|
||||
})
|
||||
|
||||
reportPathConfiguration()
|
||||
|
||||
const reportFlash = (element) => {
|
||||
if (!element) return
|
||||
|
||||
if (element.dataset.notice) handlers.flash.postMessage({ style: 'notice', message: element.dataset.notice })
|
||||
if (element.dataset.alert) handlers.flash.postMessage({ style: 'alert', message: element.dataset.alert })
|
||||
|
||||
element.remove()
|
||||
}
|
||||
|
||||
const reportPageFlash = () => reportFlash(document.getElementById('native_flash'))
|
||||
|
||||
new MutationObserver(reportPageFlash).observe(document.documentElement, { childList: true, subtree: true })
|
||||
|
||||
document.addEventListener('turbo:load', reportPageFlash)
|
||||
|
||||
document.addEventListener('native:flash', (event) => {
|
||||
reportFlash(new DOMParser().parseFromString(event.detail.html, 'text/html').getElementById('native_flash'))
|
||||
})
|
||||
|
||||
reportPageFlash()
|
||||
|
||||
document.addEventListener('click', (event) => {
|
||||
const link = event.target.closest('a[href]')
|
||||
|
||||
if (!link) return
|
||||
|
||||
const action = link.closest('native-action, native-menu-action')
|
||||
const title = link.dataset.nativeTitle || link.getAttribute('aria-label') || link.closest('[data-native-title]')?.dataset.nativeTitle || action?.dataset.label || action?.textContent.replace(/\s+/g, ' ').trim()
|
||||
|
||||
if (!title) return
|
||||
|
||||
const actions = link.closest('[data-native-actions]')?.dataset.nativeActions
|
||||
|
||||
handlers.native.postMessage({ type: 'title', url: link.href, title, actions: actions ? actions.split(',').map(nativeIcon) : null })
|
||||
}, true)
|
||||
|
||||
document.addEventListener('click', (event) => {
|
||||
if (document.querySelector('native-modal')) return
|
||||
|
||||
const link = event.target.closest('a[data-turbo-frame="modal"], a[data-turbo-frame="drawer"]')
|
||||
|
||||
if (!link?.href) return
|
||||
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
handlers.modal.postMessage({ action: 'open', url: link.href, modal: link.closest('[data-native-modal]')?.dataset.nativeModal || '' })
|
||||
}, true)
|
||||
|
||||
let isTouchPrefetch = false
|
||||
|
||||
document.addEventListener('touchstart', (event) => {
|
||||
isTouchPrefetch = true
|
||||
event.target.closest('a[href]')?.dispatchEvent(new MouseEvent('mouseenter'))
|
||||
isTouchPrefetch = false
|
||||
}, { passive: true })
|
||||
|
||||
document.addEventListener('turbo:before-prefetch', (event) => {
|
||||
if (!isTouchPrefetch || event.target.closest('a[data-turbo-frame="modal"], a[data-turbo-frame="drawer"]')) {
|
||||
event.preventDefault()
|
||||
}
|
||||
})
|
||||
|
||||
document.addEventListener('native:action', (event) => {
|
||||
document.querySelector(`[data-native-id="${event.detail.id}"]`)?.click()
|
||||
})
|
||||
|
||||
document.addEventListener('native:title-bottom', (event) => {
|
||||
const rect = document.querySelector('h1, h2')?.getBoundingClientRect()
|
||||
|
||||
event.detail.result = rect?.height ? rect.bottom - document.documentElement.getBoundingClientRect().top : null
|
||||
})
|
||||
}
|
||||
|
||||
if (handlers?.native && window.Turbo && !isNativeLoaded) {
|
||||
const staleSnapshots = new Set()
|
||||
let isRestoring = false
|
||||
|
||||
window.Turbo.session.view.clearSnapshotCache = function () {
|
||||
this.snapshotCache.keys.forEach((key) => staleSnapshots.add(key))
|
||||
}
|
||||
|
||||
window.Turbo.session.navigator.linkPrefetchingIsEnabledForLocation = () => !document.querySelector('native-modal')
|
||||
|
||||
document.addEventListener('turbo:submit-end', (e) => {
|
||||
if (e.detail.success && !e.detail.formSubmission.isSafe) {
|
||||
handlers.native.postMessage({ type: 'stale' })
|
||||
}
|
||||
})
|
||||
|
||||
document.addEventListener('native:pull-to-refresh', (event) => {
|
||||
const { session } = window.Turbo
|
||||
const url = new URL(document.baseURI)
|
||||
|
||||
url.searchParams.delete('limit')
|
||||
|
||||
session.history.replace(url, session.history.restorationIdentifier)
|
||||
session.view.lastRenderedLocation = url
|
||||
|
||||
event.detail.result = new Promise((resolve) => {
|
||||
document.addEventListener('turbo:render', () => resolve(true), { once: true })
|
||||
document.addEventListener('turbo:fetch-request-error', () => resolve(true), { once: true })
|
||||
|
||||
session.refresh(document.baseURI)
|
||||
})
|
||||
})
|
||||
|
||||
document.addEventListener('native:refresh', () => {
|
||||
window.Turbo.session.refresh(document.baseURI)
|
||||
})
|
||||
|
||||
document.addEventListener('native:turbo-stream', (e) => {
|
||||
if (!e.detail.body?.trimStart().startsWith('<turbo-stream')) return
|
||||
|
||||
window.Turbo.renderStreamMessage(e.detail.body)
|
||||
})
|
||||
|
||||
document.addEventListener('turbo:visit', (e) => {
|
||||
isRestoring = e.detail.action === 'restore'
|
||||
})
|
||||
|
||||
document.addEventListener('turbo:load', () => {
|
||||
if (staleSnapshots.delete(window.location.href.split('#')[0]) && isRestoring && window.location.pathname !== '/search') {
|
||||
setTimeout(() => window.Turbo.session.refresh(document.baseURI))
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -125,6 +125,11 @@ export default {
|
||||
required: false,
|
||||
default: () => []
|
||||
},
|
||||
fieldsUuidIndex: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: () => ({})
|
||||
},
|
||||
values: {
|
||||
type: Object,
|
||||
required: false,
|
||||
@@ -195,7 +200,7 @@ export default {
|
||||
},
|
||||
isMobileContainer: FieldAreas.computed.isMobileContainer,
|
||||
isInlineSize: FieldAreas.computed.isInlineSize,
|
||||
fieldsUuidIndex () {
|
||||
formulaFieldsUuidIndex () {
|
||||
return this.formulaFields.reduce((acc, field) => {
|
||||
acc[field.uuid] = field
|
||||
|
||||
@@ -289,6 +294,7 @@ export default {
|
||||
methods: {
|
||||
normalizeFormula: FormulaAreas.methods.normalizeFormula,
|
||||
calculateFormula: FormulaAreas.methods.calculateFormula,
|
||||
dateFormulaValue: FormulaAreas.methods.dateFormulaValue,
|
||||
evalTextFormula: FormulaAreas.methods.evalTextFormula,
|
||||
scrollInContainer: FieldAreas.methods.scrollInContainer,
|
||||
scrollIntoArea: FieldAreas.methods.scrollIntoArea,
|
||||
|
||||
@@ -633,6 +633,32 @@ defineFunction('contains', {
|
||||
return String(v[0]).indexOf(String(v[1])) !== -1
|
||||
}
|
||||
})
|
||||
defineFunction('today', {
|
||||
min: 0,
|
||||
max: 0,
|
||||
call: () => {
|
||||
const now = new Date()
|
||||
return Date.UTC(now.getFullYear(), now.getMonth(), now.getDate()) / 86400000
|
||||
}
|
||||
})
|
||||
defineFunction('datedif', {
|
||||
min: 3,
|
||||
max: 3,
|
||||
call: (v) => {
|
||||
if (v[0] == null || v[1] == null) return null
|
||||
const start = toNumber(v[0])
|
||||
const end = toNumber(v[1])
|
||||
const unit = String(v[2]).toLowerCase()
|
||||
if (unit === 'd') return end - start
|
||||
const from = new Date(Math.min(start, end) * 86400000)
|
||||
const to = new Date(Math.max(start, end) * 86400000)
|
||||
const months = (to.getUTCFullYear() - from.getUTCFullYear()) * 12 + to.getUTCMonth() - from.getUTCMonth() - (to.getUTCDate() < from.getUTCDate() ? 1 : 0)
|
||||
const sign = end < start ? -1 : 1
|
||||
if (unit === 'm') return sign * months
|
||||
if (unit === 'y') return sign * Math.floor(months / 12)
|
||||
return null
|
||||
}
|
||||
})
|
||||
|
||||
const OP_CLASSES = {
|
||||
add: Addition,
|
||||
|
||||
@@ -56,7 +56,7 @@
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="!isWebView && withDownloadButton"
|
||||
v-if="(!isWebView || isNativeApp) && withDownloadButton"
|
||||
class="base-button flex items-center space-x-1 w-full completed-form-download-button"
|
||||
:disabled="isDownloading"
|
||||
@click.prevent="download"
|
||||
@@ -186,6 +186,11 @@ export default {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: () => ({})
|
||||
},
|
||||
nativePlatform: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: ''
|
||||
}
|
||||
},
|
||||
data () {
|
||||
@@ -195,6 +200,9 @@ export default {
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
isNativeApp () {
|
||||
return !!this.nativePlatform && !!window.webkit?.messageHandlers?.download
|
||||
},
|
||||
isWebView () {
|
||||
return /webview|wv|ip((?!.*Safari)|(?=.*like Safari))/i.test(window.navigator.userAgent)
|
||||
}
|
||||
@@ -230,6 +238,12 @@ export default {
|
||||
download () {
|
||||
this.isDownloading = true
|
||||
|
||||
if (this.isNativeApp) {
|
||||
document.addEventListener('native:download-end', () => { this.isDownloading = false }, { once: true })
|
||||
|
||||
return window.webkit.messageHandlers.download.postMessage({ src: this.baseUrl + `/s/${this.submitterSlug}/documents` })
|
||||
}
|
||||
|
||||
fetch(this.baseUrl + `/s/${this.submitterSlug}/documents`, {
|
||||
method: 'GET',
|
||||
...this.fetchOptions
|
||||
|
||||
@@ -87,7 +87,7 @@ export default {
|
||||
IconCloudUpload,
|
||||
IconInnerShadowTop
|
||||
},
|
||||
inject: ['baseUrl', 't'],
|
||||
inject: ['baseUrl', 'fetchOptions', 't'],
|
||||
props: {
|
||||
message: {
|
||||
type: String,
|
||||
@@ -197,9 +197,20 @@ export default {
|
||||
formData.append('submitter_slug', this.submitterSlug)
|
||||
formData.append('name', 'attachments')
|
||||
|
||||
return fetch(this.baseUrl + '/api/attachments', {
|
||||
return fetch(this.baseUrl + `/s/${this.submitterSlug}/upload`, {
|
||||
method: 'POST',
|
||||
headers: { ...this.fetchOptions.headers },
|
||||
body: formData
|
||||
}).then((resp) => {
|
||||
if (resp.status === 404) {
|
||||
return fetch(this.baseUrl + '/api/attachments', {
|
||||
method: 'POST',
|
||||
headers: { ...this.fetchOptions.headers },
|
||||
body: formData
|
||||
})
|
||||
} else {
|
||||
return resp
|
||||
}
|
||||
}).then(async (resp) => {
|
||||
const data = await resp.json()
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
:readonly-conditional-fields="readonlyConditionalFields"
|
||||
:readonly-conditional-field-values="readonlyConditionalFieldValues"
|
||||
:formula-fields="formulaFields"
|
||||
:fields-uuid-index="fieldsUuidIndex"
|
||||
:values="values"
|
||||
:readonly-values="readonlyFieldValues"
|
||||
:submitter="submitter"
|
||||
@@ -71,6 +72,7 @@
|
||||
<FormulaFieldAreas
|
||||
v-if="!withAccessibilityAreas && !isAccessibilityMode && formulaFields.length"
|
||||
:fields="formulaFields"
|
||||
:fields-uuid-index="fieldsUuidIndex"
|
||||
:readonly-values="readonlyFieldValues"
|
||||
:values="values"
|
||||
/>
|
||||
@@ -489,8 +491,8 @@
|
||||
:reason="values[currentField.preferences?.reason_field_uuid]"
|
||||
:field="currentField"
|
||||
:values="values"
|
||||
:previous-value="previousSignatureValueFor(currentField) || previousSignatureValue"
|
||||
:touch-attachment-uuid="previousSignatureValue"
|
||||
:previous-value="previousSignatureValueFor(currentField)"
|
||||
:prefill-data="prefillSignature"
|
||||
:with-typed-signature="withTypedSignature"
|
||||
:remember-signature="rememberSignature"
|
||||
:attachments-index="attachmentsIndex"
|
||||
@@ -503,8 +505,8 @@
|
||||
:with-qr-button="withQrButton"
|
||||
:submitter="submitter"
|
||||
:show-field-names="showFieldNames"
|
||||
:native-platform="nativePlatform"
|
||||
@update:reason="values[currentField.preferences?.reason_field_uuid] = $event"
|
||||
@touch-attachment="attachmentsIndex[previousSignatureValue] ? attachmentsIndex[previousSignatureValue].created_at = new Date() : null"
|
||||
@attached="attachments.push($event)"
|
||||
@start="scrollIntoField(currentField)"
|
||||
@minimize="minimizeForm"
|
||||
@@ -518,9 +520,11 @@
|
||||
:dry-run="dryRun"
|
||||
:submitter="submitter"
|
||||
:previous-value="previousInitialsValue"
|
||||
:prefill-data="prefillInitials"
|
||||
:attachments-index="attachmentsIndex"
|
||||
:show-field-names="showFieldNames"
|
||||
:submitter-slug="submitterSlug"
|
||||
:native-platform="nativePlatform"
|
||||
@attached="attachments.push($event)"
|
||||
@start="scrollIntoField(currentField)"
|
||||
@focus="scrollIntoField(currentField)"
|
||||
@@ -558,6 +562,7 @@
|
||||
:field="currentField"
|
||||
:submitter-slug="submitterSlug"
|
||||
:fields="formulaFields"
|
||||
:fields-uuid-index="fieldsUuidIndex"
|
||||
:values="values"
|
||||
:readonly-values="readonlyFieldValues"
|
||||
:fetch-options="fetchOptions"
|
||||
@@ -652,6 +657,7 @@
|
||||
:with-confetti="withConfetti"
|
||||
:can-send-email="canSendEmail && !!submitter.email"
|
||||
:submitter-slug="submitterSlug"
|
||||
:native-platform="nativePlatform"
|
||||
/>
|
||||
<nav
|
||||
v-if="stepFields.length < 80"
|
||||
@@ -772,6 +778,7 @@ export default {
|
||||
provide () {
|
||||
return {
|
||||
baseUrl: this.baseUrl,
|
||||
fetchOptions: this.fetchOptions,
|
||||
scrollIntoArea: this.scrollIntoArea,
|
||||
scrollIntoField: this.scrollIntoField,
|
||||
t: this.t
|
||||
@@ -909,6 +916,11 @@ export default {
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
nativePlatform: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: ''
|
||||
},
|
||||
autoscrollFields: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
@@ -969,10 +981,20 @@ export default {
|
||||
required: false,
|
||||
default: ''
|
||||
},
|
||||
previousSignatureValue: {
|
||||
type: String,
|
||||
prefillSignature: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: ''
|
||||
default: null
|
||||
},
|
||||
prefillInitials: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: null
|
||||
},
|
||||
currentUser: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: null
|
||||
},
|
||||
allowToSkip: {
|
||||
type: Boolean,
|
||||
@@ -994,6 +1016,11 @@ export default {
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
viewToken: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: ''
|
||||
},
|
||||
attribution: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
@@ -1329,6 +1356,30 @@ export default {
|
||||
|
||||
screen?.orientation?.addEventListener('change', this.onOrientationChange)
|
||||
|
||||
if (this.currentUser) {
|
||||
this.fields.forEach((field) => {
|
||||
if (field.readonly || field.type !== 'text') {
|
||||
return
|
||||
}
|
||||
|
||||
const fieldName = (field.name || '').toLowerCase()
|
||||
|
||||
let value
|
||||
|
||||
if (fieldName === 'full name' || fieldName === 'legal name') {
|
||||
value = this.currentUser.full_name
|
||||
} else if (fieldName === 'first name') {
|
||||
value = this.currentUser.first_name
|
||||
} else if (fieldName === 'last name') {
|
||||
value = this.currentUser.last_name
|
||||
}
|
||||
|
||||
if (value) {
|
||||
this.values[field.uuid] ??= value
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
this.fields.forEach((field) => {
|
||||
if (field.default_value && !field.readonly) {
|
||||
this.values[field.uuid] ||= field.default_value
|
||||
@@ -1523,15 +1574,10 @@ export default {
|
||||
const newUrl = [window.location.pathname, queryParams.toString()].filter(Boolean).join('?')
|
||||
window.history.replaceState({}, document.title, newUrl)
|
||||
|
||||
return fetch(this.baseUrl + '/api/submitter_email_clicks', {
|
||||
return fetch(this.baseUrl + `/s/${this.submitterSlug}/click_email`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
t,
|
||||
submitter_slug: this.submitterSlug
|
||||
})
|
||||
headers: { 'Content-Type': 'application/json', ...this.fetchOptions.headers },
|
||||
body: JSON.stringify({ t })
|
||||
})
|
||||
} else {
|
||||
return Promise.resolve({})
|
||||
@@ -1547,29 +1593,28 @@ export default {
|
||||
const newUrl = [window.location.pathname, queryParams.toString()].filter(Boolean).join('?')
|
||||
window.history.replaceState({}, document.title, newUrl)
|
||||
|
||||
return fetch(this.baseUrl + '/api/submitter_sms_clicks', {
|
||||
return fetch(this.baseUrl + `/s/${this.submitterSlug}/click_sms`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
c,
|
||||
submitter_slug: this.submitterSlug
|
||||
})
|
||||
headers: { 'Content-Type': 'application/json', ...this.fetchOptions.headers },
|
||||
body: JSON.stringify({ c })
|
||||
})
|
||||
} else {
|
||||
return Promise.resolve({})
|
||||
}
|
||||
},
|
||||
trackViewForm () {
|
||||
fetch(this.baseUrl + '/api/submitter_form_views', {
|
||||
fetch(this.baseUrl + `/s/${this.submitterSlug}/view`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
submitter_slug: this.submitterSlug
|
||||
})
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ v: this.viewToken })
|
||||
}).then((resp) => {
|
||||
if (resp.status === 404) {
|
||||
return fetch(this.baseUrl + '/api/submitter_form_views', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ submitter_slug: this.submitterSlug })
|
||||
})
|
||||
}
|
||||
})
|
||||
},
|
||||
previousSignatureValueFor (field) {
|
||||
@@ -1767,6 +1812,10 @@ export default {
|
||||
this.isCompleted = true
|
||||
this.isFormVisible = true
|
||||
|
||||
if (this.nativePlatform) {
|
||||
window.webkit?.messageHandlers?.native?.postMessage({ type: 'stale' })
|
||||
}
|
||||
|
||||
if (resp?.text) {
|
||||
const respData = await resp.text()
|
||||
|
||||
|
||||
@@ -48,6 +48,11 @@ export default {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: () => ({})
|
||||
},
|
||||
fieldsUuidIndex: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: () => ({})
|
||||
}
|
||||
},
|
||||
data () {
|
||||
@@ -59,7 +64,7 @@ export default {
|
||||
isInlineSize () {
|
||||
return CSS.supports('container-type: size')
|
||||
},
|
||||
fieldsUuidIndex () {
|
||||
formulaFieldsUuidIndex () {
|
||||
return this.fields.reduce((acc, field) => {
|
||||
acc[field.uuid] = field
|
||||
|
||||
@@ -82,8 +87,8 @@ export default {
|
||||
if (depth > 10) return formula
|
||||
|
||||
return formula.replace(/{{(.*?)}}/g, (match, uuid) => {
|
||||
if (this.fieldsUuidIndex[uuid]) {
|
||||
return `(${this.normalizeFormula(this.fieldsUuidIndex[uuid].preferences.formula, depth + 1)})`
|
||||
if (this.formulaFieldsUuidIndex[uuid]) {
|
||||
return `(${this.normalizeFormula(this.formulaFieldsUuidIndex[uuid].preferences.formula, depth + 1)})`
|
||||
} else {
|
||||
return match
|
||||
}
|
||||
@@ -91,16 +96,36 @@ export default {
|
||||
},
|
||||
calculateFormula (field) {
|
||||
const transformedFormula = this.normalizeFormula(field.preferences.formula).replace(/{{(.*?)}}/g, (match, uuid) => {
|
||||
return this.readonlyValues[uuid] || this.values[uuid] || 0.0
|
||||
const value = this.readonlyValues[uuid] || this.values[uuid]
|
||||
|
||||
if (this.fieldsUuidIndex[uuid]?.type === 'date') {
|
||||
return this.dateFormulaValue(value)
|
||||
}
|
||||
|
||||
return value || 0.0
|
||||
})
|
||||
|
||||
return this.math.evaluate(transformedFormula.toLowerCase())
|
||||
const value = this.math.evaluate(transformedFormula.toLowerCase())
|
||||
|
||||
if (field.type === 'date') {
|
||||
return Number.isFinite(value) ? new Date(Math.floor(value) * 86400000).toISOString().slice(0, 10) : ''
|
||||
}
|
||||
|
||||
return value
|
||||
},
|
||||
dateFormulaValue (value) {
|
||||
if (!value) return 'null'
|
||||
if (value === '{{date}}') return 'today()'
|
||||
|
||||
const [year, month, day = 1] = value.slice(0, 10).split('-').map(Number)
|
||||
|
||||
return Date.UTC(year, month - 1, day) / 86400000
|
||||
},
|
||||
evalTextFormula (field, depth = 0) {
|
||||
if (depth > 10) return ''
|
||||
|
||||
return field.preferences.formula.replace(/{{(.*?)}}/g, (match, uuid) => {
|
||||
const formulaField = this.fieldsUuidIndex[uuid]
|
||||
const formulaField = this.formulaFieldsUuidIndex[uuid]
|
||||
|
||||
if (formulaField?.preferences?.formula) {
|
||||
if (formulaField.type === 'text') {
|
||||
|
||||
@@ -13,6 +13,7 @@ const en = {
|
||||
must_be_valid_email: 'Must be a valid email address',
|
||||
must_be_valid_url: 'Must be a valid URL (starting with http:// or https://)',
|
||||
must_be_valid_zip: 'Must be a valid ZIP code (XXXXX or XXXXX-XXXX)',
|
||||
must_be_valid_iban: 'Must be a valid IBAN',
|
||||
must_contain_numbers_only: 'Must contain numbers only',
|
||||
must_contain_letters_only: 'Must contain letters only',
|
||||
complete_all_required_fields_to_proceed_with_identity_verification: 'Complete all required fields to proceed with identity verification.',
|
||||
@@ -132,6 +133,7 @@ const es = {
|
||||
must_be_valid_email: 'Debe ser una dirección de correo electrónico válida',
|
||||
must_be_valid_url: 'Debe ser una URL válida (que comience con http:// o https://)',
|
||||
must_be_valid_zip: 'Debe ser un ZIP válido (XXXXX o XXXXX-XXXX)',
|
||||
must_be_valid_iban: 'Debe ser un IBAN válido',
|
||||
must_contain_numbers_only: 'Debe contener solo números',
|
||||
must_contain_letters_only: 'Debe contener solo letras',
|
||||
complete_all_required_fields_to_proceed_with_identity_verification: 'Complete todos los campos requeridos para continuar con la verificación de identidad.',
|
||||
@@ -251,6 +253,7 @@ const it = {
|
||||
must_be_valid_email: 'Deve essere un indirizzo email valido',
|
||||
must_be_valid_url: 'Deve essere un URL valido (che inizia con http:// o https://)',
|
||||
must_be_valid_zip: 'Deve essere uno ZIP valido (XXXXX o XXXXX-XXXX)',
|
||||
must_be_valid_iban: 'Deve essere un IBAN valido',
|
||||
must_contain_numbers_only: 'Deve contenere solo numeri',
|
||||
must_contain_letters_only: 'Deve contenere solo lettere',
|
||||
complete_all_required_fields_to_proceed_with_identity_verification: "Compila tutti i campi obbligatori per procedere con la verifica dell'identità.",
|
||||
@@ -370,6 +373,7 @@ const de = {
|
||||
must_be_valid_email: 'Muss eine gültige E-Mail-Adresse sein',
|
||||
must_be_valid_url: 'Muss eine gültige URL sein (beginnend mit http:// oder https://)',
|
||||
must_be_valid_zip: 'Muss eine gültige ZIP sein (XXXXX oder XXXXX-XXXX)',
|
||||
must_be_valid_iban: 'Muss eine gültige IBAN sein',
|
||||
must_contain_numbers_only: 'Darf nur Zahlen enthalten',
|
||||
must_contain_letters_only: 'Darf nur Buchstaben enthalten',
|
||||
complete_all_required_fields_to_proceed_with_identity_verification: 'Füllen Sie alle Pflichtfelder aus, um mit der Identitätsprüfung fortzufahren.',
|
||||
@@ -489,6 +493,7 @@ const fr = {
|
||||
must_be_valid_email: 'Doit être une adresse e-mail valide',
|
||||
must_be_valid_url: 'Doit être une URL valide (commençant par http:// ou https://)',
|
||||
must_be_valid_zip: 'Doit être un ZIP valide (XXXXX ou XXXXX-XXXX)',
|
||||
must_be_valid_iban: 'Doit être un IBAN valide',
|
||||
must_contain_numbers_only: 'Doit contenir uniquement des chiffres',
|
||||
must_contain_letters_only: 'Doit contenir uniquement des lettres',
|
||||
complete_all_required_fields_to_proceed_with_identity_verification: "Veuillez remplir tous les champs obligatoires pour poursuivre la vérification d'identité.",
|
||||
@@ -608,6 +613,7 @@ const pl = {
|
||||
must_be_valid_email: 'Musi być prawidłowym adresem e-mail',
|
||||
must_be_valid_url: 'Musi być prawidłowym adresem URL (zaczynającym się od http:// lub https://)',
|
||||
must_be_valid_zip: 'Musi być prawidłowym ZIP (XXXXX lub XXXXX-XXXX)',
|
||||
must_be_valid_iban: 'Musi być prawidłowym numerem IBAN',
|
||||
must_contain_numbers_only: 'Może zawierać tylko cyfry',
|
||||
must_contain_letters_only: 'Może zawierać tylko litery',
|
||||
complete_all_required_fields_to_proceed_with_identity_verification: 'Uzupełnij wszystkie wymagane pola, aby kontynuować weryfikację tożsamości.',
|
||||
@@ -727,6 +733,7 @@ const uk = {
|
||||
must_be_valid_email: 'Має бути дійсною адресою електронної пошти',
|
||||
must_be_valid_url: 'Має бути дійсним URL (починаючи з http:// або https://)',
|
||||
must_be_valid_zip: 'Має бути дійсним ZIP (XXXXX або XXXXX-XXXX)',
|
||||
must_be_valid_iban: 'Має бути дійсним IBAN',
|
||||
must_contain_numbers_only: 'Має містити лише цифри',
|
||||
must_contain_letters_only: 'Має містити лише літери',
|
||||
complete_all_required_fields_to_proceed_with_identity_verification: "Заповніть всі обов'язкові поля, щоб продовжити перевірку особи.",
|
||||
@@ -846,6 +853,7 @@ const cs = {
|
||||
must_be_valid_email: 'Musí být platná e-mailová adresa',
|
||||
must_be_valid_url: 'Musí být platná URL (začínající http:// nebo https://)',
|
||||
must_be_valid_zip: 'Musí být platný ZIP (XXXXX nebo XXXXX-XXXX)',
|
||||
must_be_valid_iban: 'Musí být platný IBAN',
|
||||
must_contain_numbers_only: 'Musí obsahovat pouze čísla',
|
||||
must_contain_letters_only: 'Musí obsahovat pouze písmena',
|
||||
complete_all_required_fields_to_proceed_with_identity_verification: 'Vyplňte všechna povinná pole, abyste mohli pokračovat v ověření identity.',
|
||||
@@ -965,6 +973,7 @@ const pt = {
|
||||
must_be_valid_email: 'Deve ser um endereço de e-mail válido',
|
||||
must_be_valid_url: 'Deve ser uma URL válida (começando com http:// ou https://)',
|
||||
must_be_valid_zip: 'Deve ser um ZIP válido (XXXXX ou XXXXX-XXXX)',
|
||||
must_be_valid_iban: 'Deve ser um IBAN válido',
|
||||
must_contain_numbers_only: 'Deve conter apenas números',
|
||||
must_contain_letters_only: 'Deve conter apenas letras',
|
||||
complete_all_required_fields_to_proceed_with_identity_verification: 'Preencha todos os campos obrigatórios para prosseguir com a verificação de identidade.',
|
||||
@@ -1084,6 +1093,7 @@ const he = {
|
||||
must_be_valid_email: 'חייבת להיות כתובת אימייל חוקית',
|
||||
must_be_valid_url: 'חייבת להיות כתובת URL חוקית (המתחילה ב-http:// או https://)',
|
||||
must_be_valid_zip: 'חייב להיות ZIP חוקי (XXXXX או XXXXX-XXXX)',
|
||||
must_be_valid_iban: 'חייב להיות IBAN חוקי',
|
||||
must_contain_numbers_only: 'חייב להכיל מספרים בלבד',
|
||||
must_contain_letters_only: 'חייב להכיל אותיות בלבד',
|
||||
complete_all_required_fields_to_proceed_with_identity_verification: 'מלא את כל השדות הנדרשים כדי להמשיך עם אימות זהות.',
|
||||
@@ -1203,6 +1213,7 @@ const nl = {
|
||||
must_be_valid_email: 'Moet een geldig e-mailadres zijn',
|
||||
must_be_valid_url: 'Moet een geldige URL zijn (beginnend met http:// of https://)',
|
||||
must_be_valid_zip: 'Moet een geldige ZIP zijn (XXXXX of XXXXX-XXXX)',
|
||||
must_be_valid_iban: 'Moet een geldig IBAN zijn',
|
||||
must_contain_numbers_only: 'Mag alleen cijfers bevatten',
|
||||
must_contain_letters_only: 'Mag alleen letters bevatten',
|
||||
complete_all_required_fields_to_proceed_with_identity_verification: 'Vul alle verplichte velden in om door te gaan met de identiteitsverificatie.',
|
||||
@@ -1322,6 +1333,7 @@ const ar = {
|
||||
must_be_valid_email: 'يجب أن يكون عنوان بريد إلكتروني صالحًا',
|
||||
must_be_valid_url: 'يجب أن يكون عنوان URL صالحًا (يبدأ بـ http:// أو https://)',
|
||||
must_be_valid_zip: 'يجب أن يكون ZIP صالحًا (XXXXX أو XXXXX-XXXX)',
|
||||
must_be_valid_iban: 'يجب أن يكون IBAN صالحًا',
|
||||
must_contain_numbers_only: 'يجب أن يحتوي على أرقام فقط',
|
||||
must_contain_letters_only: 'يجب أن يحتوي على أحرف فقط',
|
||||
complete_all_required_fields_to_proceed_with_identity_verification: 'أكمل جميع الحقول المطلوبة للمتابعة في التحقق من الهوية.',
|
||||
@@ -1441,6 +1453,7 @@ const ko = {
|
||||
must_be_valid_email: '유효한 이메일 주소여야 합니다',
|
||||
must_be_valid_url: '유효한 URL이어야 합니다 (http:// 또는 https://로 시작)',
|
||||
must_be_valid_zip: '유효한 ZIP이어야 합니다 (XXXXX 또는 XXXXX-XXXX)',
|
||||
must_be_valid_iban: '유효한 IBAN이어야 합니다',
|
||||
must_contain_numbers_only: '숫자만 포함해야 합니다',
|
||||
must_contain_letters_only: '문자만 포함해야 합니다',
|
||||
complete_all_required_fields_to_proceed_with_identity_verification: '신원 확인을 진행하려면 모든 필수 필드를 작성하십시오.',
|
||||
@@ -1560,6 +1573,7 @@ const ja = {
|
||||
must_be_valid_email: '有効なメールアドレスである必要があります',
|
||||
must_be_valid_url: '有効なURLである必要があります (http://またはhttps://で始まる)',
|
||||
must_be_valid_zip: '有効なZIPである必要があります (XXXXX または XXXXX-XXXX)',
|
||||
must_be_valid_iban: '有効なIBANである必要があります',
|
||||
must_contain_numbers_only: '数字のみを含む必要があります',
|
||||
must_contain_letters_only: '文字のみを含む必要があります',
|
||||
complete_all_required_fields_to_proceed_with_identity_verification: '本人確認を進めるには、すべての必須項目を入力してください。',
|
||||
|
||||
@@ -87,7 +87,7 @@
|
||||
</button>
|
||||
</span>
|
||||
<button
|
||||
v-if="modelValue || computedPreviousValue"
|
||||
v-if="modelValue || computedPreviousValue || computedPrefillData"
|
||||
type="button"
|
||||
class="btn font-medium btn-outline btn-sm clear-canvas-button"
|
||||
@click="remove"
|
||||
@@ -139,8 +139,8 @@
|
||||
:name="`values[${field.uuid}]`"
|
||||
>
|
||||
<img
|
||||
v-if="modelValue || computedPreviousValue"
|
||||
:src="attachmentsIndex[modelValue || computedPreviousValue].url"
|
||||
v-if="modelValue || computedPreviousValue || computedPrefillData"
|
||||
:src="previousValueUrl"
|
||||
:alt="field.name || t('initials')"
|
||||
class="mx-auto bg-white border border-base-300 rounded max-h-44"
|
||||
>
|
||||
@@ -150,20 +150,23 @@
|
||||
class="absolute top-0 right-0 left-0 bottom-0"
|
||||
/>
|
||||
<label
|
||||
v-if="!isDrawInitials && !modelValue && !computedPreviousValue"
|
||||
v-if="!isDrawInitials && !modelValue && !computedPreviousValue && !computedPrefillData"
|
||||
for="initials_text_input"
|
||||
class="absolute top-0 right-0 left-0 bottom-0"
|
||||
/>
|
||||
<canvas
|
||||
v-show="!modelValue && !computedPreviousValue"
|
||||
v-show="!modelValue && !computedPreviousValue && !computedPrefillData"
|
||||
ref="canvas"
|
||||
role="img"
|
||||
:aria-label="t('initials_drawing_area')"
|
||||
class="bg-white border border-base-300 rounded-2xl w-full draw-canvas"
|
||||
@touchstart.passive="lockSwipe"
|
||||
@touchend.passive="unlockSwipe"
|
||||
@touchcancel.passive="unlockSwipe"
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
v-if="!isDrawInitials && !modelValue && !computedPreviousValue"
|
||||
v-if="!isDrawInitials && !modelValue && !computedPreviousValue && !computedPrefillData"
|
||||
id="initials_text_input"
|
||||
ref="textInput"
|
||||
class="base-input !text-2xl w-full mt-6 text-center"
|
||||
@@ -199,7 +202,7 @@ export default {
|
||||
MarkdownContent,
|
||||
IconArrowsDiagonalMinimize2
|
||||
},
|
||||
inject: ['baseUrl', 't'],
|
||||
inject: ['baseUrl', 'fetchOptions', 't'],
|
||||
props: {
|
||||
field: {
|
||||
type: Object,
|
||||
@@ -233,6 +236,16 @@ export default {
|
||||
required: false,
|
||||
default: ''
|
||||
},
|
||||
prefillData: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: null
|
||||
},
|
||||
nativePlatform: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: ''
|
||||
},
|
||||
modelValue: {
|
||||
type: String,
|
||||
required: false,
|
||||
@@ -255,10 +268,24 @@ export default {
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
},
|
||||
computedPrefillData () {
|
||||
if (this.isUsePreviousValue && this.field.required === true && !this.previousValue && !this.modelValue) {
|
||||
return this.prefillData
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
},
|
||||
previousValueUrl () {
|
||||
if (this.computedPrefillData) {
|
||||
return this.computedPrefillData.url
|
||||
} else {
|
||||
return this.attachmentsIndex[this.modelValue || this.computedPreviousValue].url
|
||||
}
|
||||
}
|
||||
},
|
||||
created () {
|
||||
this.isInitialsStarted = !!this.computedPreviousValue
|
||||
this.isInitialsStarted = !!(this.computedPreviousValue || this.computedPrefillData)
|
||||
},
|
||||
async mounted () {
|
||||
this.$nextTick(() => {
|
||||
@@ -311,6 +338,8 @@ export default {
|
||||
this.intersectionObserver?.disconnect()
|
||||
},
|
||||
methods: {
|
||||
lockSwipe: SignatureStep.methods.lockSwipe,
|
||||
unlockSwipe: SignatureStep.methods.unlockSwipe,
|
||||
drawOnCanvas: SignatureStep.methods.drawOnCanvas,
|
||||
drawImage (event) {
|
||||
this.remove()
|
||||
@@ -397,7 +426,48 @@ export default {
|
||||
this.updateWrittenInitials({ target: this.$refs.textInput })
|
||||
}
|
||||
},
|
||||
submitPrefillAttachment () {
|
||||
if (this.dryRun) {
|
||||
const attachment = { uuid: Math.random().toString(), url: this.prefillData.url }
|
||||
|
||||
this.$emit('attached', attachment)
|
||||
this.$emit('update:model-value', attachment.uuid)
|
||||
|
||||
return Promise.resolve(attachment)
|
||||
}
|
||||
|
||||
const formData = new FormData()
|
||||
|
||||
formData.append('prefill_token', this.prefillData.token)
|
||||
|
||||
return fetch(this.baseUrl + `/s/${this.submitterSlug}/prefill_attachments`, {
|
||||
method: 'POST',
|
||||
headers: { ...this.fetchOptions.headers },
|
||||
body: formData
|
||||
}).then(async (resp) => {
|
||||
if (!resp.ok) {
|
||||
const data = await resp.json()
|
||||
|
||||
return Promise.reject(new Error(data.error))
|
||||
}
|
||||
|
||||
const attachment = await resp.json()
|
||||
|
||||
this.$emit('attached', attachment)
|
||||
this.$emit('update:model-value', attachment.uuid)
|
||||
|
||||
return attachment
|
||||
}).catch((error) => {
|
||||
this.remove()
|
||||
|
||||
return Promise.reject(error)
|
||||
})
|
||||
},
|
||||
async submit () {
|
||||
if (this.computedPrefillData) {
|
||||
return this.submitPrefillAttachment()
|
||||
}
|
||||
|
||||
if (this.modelValue || this.computedPreviousValue) {
|
||||
if (this.computedPreviousValue) {
|
||||
this.$emit('update:model-value', this.computedPreviousValue)
|
||||
@@ -431,9 +501,20 @@ export default {
|
||||
formData.append('name', 'attachments')
|
||||
formData.append('type', 'initials')
|
||||
|
||||
return fetch(this.baseUrl + '/api/attachments', {
|
||||
return fetch(this.baseUrl + `/s/${this.submitterSlug}/upload`, {
|
||||
method: 'POST',
|
||||
headers: { ...this.fetchOptions.headers },
|
||||
body: formData
|
||||
}).then((resp) => {
|
||||
if (resp.status === 404) {
|
||||
return fetch(this.baseUrl + '/api/attachments', {
|
||||
method: 'POST',
|
||||
headers: { ...this.fetchOptions.headers },
|
||||
body: formData
|
||||
})
|
||||
} else {
|
||||
return resp
|
||||
}
|
||||
}).then(async (resp) => {
|
||||
if (resp.status === 422 || resp.status === 500) {
|
||||
const data = await resp.json()
|
||||
|
||||
@@ -483,7 +483,7 @@ export default {
|
||||
this.clearCountdown()
|
||||
|
||||
try {
|
||||
const payload = { ...this.form, submitter_slug: this.submitterSlug }
|
||||
const payload = { ...this.form }
|
||||
|
||||
if (payload.dob) {
|
||||
payload.dob = payload.dob.replace(/-/g, '')
|
||||
@@ -497,7 +497,7 @@ export default {
|
||||
payload.phone = payload.phone.replace(/^\+1/, '')
|
||||
}
|
||||
|
||||
const resp = await fetch(this.baseUrl + '/api/kba', {
|
||||
const resp = await fetch(this.baseUrl + `/s/${this.submitterSlug}/kba`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
...this.fetchOptions,
|
||||
@@ -548,13 +548,12 @@ export default {
|
||||
}, {})
|
||||
|
||||
try {
|
||||
const resp = await fetch(this.baseUrl + `/api/kba/${this.field.uuid}`, {
|
||||
const resp = await fetch(this.baseUrl + `/s/${this.submitterSlug}/kba/${this.field.uuid}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
token: this.token,
|
||||
answers: formattedAnswers,
|
||||
reference: this.reference,
|
||||
submitter_slug: this.submitterSlug
|
||||
reference: this.reference
|
||||
}),
|
||||
...this.fetchOptions,
|
||||
headers: { 'Content-Type': 'application/json', ...this.fetchOptions.headers }
|
||||
|
||||
@@ -104,6 +104,11 @@ export default {
|
||||
required: false,
|
||||
default: () => ({})
|
||||
},
|
||||
fieldsUuidIndex: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: () => ({})
|
||||
},
|
||||
values: {
|
||||
type: Object,
|
||||
required: true
|
||||
@@ -137,7 +142,7 @@ export default {
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
fieldsUuidIndex () {
|
||||
formulaFieldsUuidIndex () {
|
||||
return this.fields.reduce((acc, field) => {
|
||||
acc[field.uuid] = field
|
||||
|
||||
@@ -151,7 +156,7 @@ export default {
|
||||
return this.provider === 'paypal'
|
||||
},
|
||||
paymentEndpoint () {
|
||||
return this.isPaypal ? '/api/paypal_payments' : '/api/stripe_payments'
|
||||
return `/s/${this.submitterSlug}/${this.isPaypal ? 'paypal_payments' : 'stripe_payments'}`
|
||||
},
|
||||
sessionId () {
|
||||
return this.queryParams.get('stripe_session_id') || this.queryParams.get('paypal_order_id') || (this.isPaypal && this.queryParams.get('token'))
|
||||
@@ -216,17 +221,31 @@ export default {
|
||||
},
|
||||
calculateFormula () {
|
||||
const transformedFormula = this.normalizeFormula(this.field.preferences.formula).replace(/{{(.*?)}}/g, (match, uuid) => {
|
||||
return this.readonlyValues[uuid] || this.values[uuid] || 0.0
|
||||
const value = this.readonlyValues[uuid] || this.values[uuid]
|
||||
|
||||
if (this.fieldsUuidIndex[uuid]?.type === 'date') {
|
||||
return this.dateFormulaValue(value)
|
||||
}
|
||||
|
||||
return value || 0.0
|
||||
})
|
||||
|
||||
return this.math.evaluate(transformedFormula.toLowerCase())
|
||||
},
|
||||
dateFormulaValue (value) {
|
||||
if (!value) return 'null'
|
||||
if (value === '{{date}}') return 'today()'
|
||||
|
||||
const [year, month, day = 1] = value.slice(0, 10).split('-').map(Number)
|
||||
|
||||
return Date.UTC(year, month - 1, day) / 86400000
|
||||
},
|
||||
normalizeFormula (formula, depth = 0) {
|
||||
if (depth > 10) return formula
|
||||
|
||||
return formula.replace(/{{(.*?)}}/g, (match, uuid) => {
|
||||
if (this.fieldsUuidIndex[uuid]) {
|
||||
return `(${this.normalizeFormula(this.fieldsUuidIndex[uuid].preferences.formula, depth + 1)})`
|
||||
if (this.formulaFieldsUuidIndex[uuid]) {
|
||||
return `(${this.normalizeFormula(this.formulaFieldsUuidIndex[uuid].preferences.formula, depth + 1)})`
|
||||
} else {
|
||||
return match
|
||||
}
|
||||
@@ -236,9 +255,6 @@ export default {
|
||||
if (this.sessionId) {
|
||||
return fetch(this.baseUrl + this.paymentEndpoint + '/' + this.sessionId, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
submitter_slug: this.submitterSlug
|
||||
}),
|
||||
...this.fetchOptions,
|
||||
headers: { 'Content-Type': 'application/json', ...this.fetchOptions.headers }
|
||||
}).then(async (resp) => {
|
||||
@@ -275,7 +291,6 @@ export default {
|
||||
fetch(this.baseUrl + this.paymentEndpoint, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
submitter_slug: this.submitterSlug,
|
||||
field_uuid: this.field.uuid,
|
||||
check_status: checkStatus,
|
||||
referer: this.normalizedPaymentUrl()
|
||||
|
||||
@@ -285,7 +285,7 @@ export default {
|
||||
}, 1000)
|
||||
},
|
||||
sendVerificationCode () {
|
||||
return fetch(this.baseUrl + '/api/send_phone_verification_code', {
|
||||
const options = {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
submitter_slug: this.submitterSlug,
|
||||
@@ -293,6 +293,14 @@ export default {
|
||||
phone: this.fullInternationalPhoneValue
|
||||
}),
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
}
|
||||
|
||||
return fetch(this.baseUrl + `/s/${this.submitterSlug}/send_phone_verification_code`, options).then((resp) => {
|
||||
if (resp.status === 404) {
|
||||
return fetch(this.baseUrl + '/api/send_phone_verification_code', options)
|
||||
} else {
|
||||
return resp
|
||||
}
|
||||
}).then(async (resp) => {
|
||||
if ([422, 429].includes(resp.status)) {
|
||||
const data = await resp.json()
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
<span
|
||||
v-else-if="withTypedSignature && format !== 'drawn_or_upload' && format !== 'typed_or_upload' && format !== 'typed' && format !== 'drawn' && format !== 'upload'"
|
||||
class="md:tooltip ml-2"
|
||||
:class="{ 'hidden sm:inline': modelValue || computedPreviousValue }"
|
||||
:class="{ 'hidden sm:inline': modelValue || computedPreviousValue || computedPrefillData }"
|
||||
:data-tip="t('type_text')"
|
||||
>
|
||||
<button
|
||||
@@ -68,7 +68,7 @@
|
||||
<span
|
||||
v-if="format !== 'typed' && format !== 'drawn' && format !== 'upload' && format !== 'drawn_or_typed'"
|
||||
class="md:tooltip"
|
||||
:class="{ 'hidden sm:inline': modelValue || computedPreviousValue }"
|
||||
:class="{ 'hidden sm:inline': modelValue || computedPreviousValue || computedPrefillData }"
|
||||
:data-tip="t('take_photo')"
|
||||
>
|
||||
<button
|
||||
@@ -95,7 +95,7 @@
|
||||
</button>
|
||||
</span>
|
||||
<button
|
||||
v-if="modelValue || computedPreviousValue"
|
||||
v-if="modelValue || computedPreviousValue || computedPrefillData"
|
||||
type="button"
|
||||
class="btn btn-outline btn-sm font-medium reupload-button"
|
||||
@click="remove"
|
||||
@@ -107,7 +107,7 @@
|
||||
{{ t(format === 'upload' ? 'reupload' : 'redraw') }}
|
||||
</button>
|
||||
<span
|
||||
v-if="withQrButton && !modelValue && !computedPreviousValue && format !== 'typed_or_upload' && format !== 'typed' && format !== 'upload'"
|
||||
v-if="withQrButton && !modelValue && !computedPreviousValue && !computedPrefillData && format !== 'typed_or_upload' && format !== 'typed' && format !== 'upload'"
|
||||
class="md:tooltip before:translate-x-[-90%]"
|
||||
:data-tip="t('sign_on_the_touchscreen')"
|
||||
>
|
||||
@@ -154,20 +154,14 @@
|
||||
type="hidden"
|
||||
:name="`values[${field.uuid}]`"
|
||||
>
|
||||
<input
|
||||
v-if="isTouchAttachment"
|
||||
:value="touchAttachmentUuid"
|
||||
type="hidden"
|
||||
name="touch_attachment_uuid"
|
||||
>
|
||||
<img
|
||||
v-if="modelValue || computedPreviousValue"
|
||||
:src="attachmentsIndex[modelValue || computedPreviousValue].url"
|
||||
v-if="modelValue || computedPreviousValue || computedPrefillData"
|
||||
:src="previousValueUrl"
|
||||
:alt="field.name || t('signature')"
|
||||
class="mx-auto bg-white border border-base-300 rounded max-h-44"
|
||||
>
|
||||
<FileDropzone
|
||||
v-if="format === 'upload' && !modelValue && !computedPreviousValue"
|
||||
v-if="format === 'upload' && !modelValue && !computedPreviousValue && !computedPrefillData"
|
||||
:message="`${t('upload')} ${field.name || t('signature')}`"
|
||||
:submitter-slug="submitterSlug"
|
||||
:dry-run="dryRun"
|
||||
@@ -179,7 +173,7 @@
|
||||
class="relative select-none"
|
||||
>
|
||||
<div
|
||||
v-if="!modelValue && !computedPreviousValue && !isShowQr && !isTextSignature && isSignatureStarted"
|
||||
v-if="!modelValue && !computedPreviousValue && !computedPrefillData && !isShowQr && !isTextSignature && isSignatureStarted"
|
||||
class="absolute top-0.5 right-0.5"
|
||||
>
|
||||
<button
|
||||
@@ -199,12 +193,15 @@
|
||||
class="absolute top-0 right-0 left-0 bottom-0"
|
||||
/>
|
||||
<canvas
|
||||
v-show="!modelValue && !computedPreviousValue"
|
||||
v-show="!modelValue && !computedPreviousValue && !computedPrefillData"
|
||||
ref="canvas"
|
||||
role="img"
|
||||
:aria-label="t('signature_drawing_area')"
|
||||
style="padding: 1px; 0"
|
||||
class="bg-white border border-base-300 rounded-2xl w-full draw-canvas"
|
||||
@touchstart.passive="lockSwipe"
|
||||
@touchend.passive="unlockSwipe"
|
||||
@touchcancel.passive="unlockSwipe"
|
||||
/>
|
||||
<div
|
||||
v-if="isShowQr"
|
||||
@@ -241,7 +238,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
v-if="isTextSignature && !modelValue && !computedPreviousValue"
|
||||
v-if="isTextSignature && !modelValue && !computedPreviousValue && !computedPrefillData"
|
||||
id="signature_text_input"
|
||||
ref="textInput"
|
||||
class="base-input !text-2xl w-full mt-6"
|
||||
@@ -373,7 +370,7 @@ export default {
|
||||
IconSignature,
|
||||
IconArrowsDiagonalMinimize2
|
||||
},
|
||||
inject: ['baseUrl', 't'],
|
||||
inject: ['baseUrl', 'fetchOptions', 't'],
|
||||
props: {
|
||||
field: {
|
||||
type: Object,
|
||||
@@ -437,10 +434,10 @@ export default {
|
||||
required: false,
|
||||
default: ''
|
||||
},
|
||||
touchAttachmentUuid: {
|
||||
type: String,
|
||||
prefillData: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: ''
|
||||
default: null
|
||||
},
|
||||
reason: {
|
||||
type: String,
|
||||
@@ -457,20 +454,24 @@ export default {
|
||||
required: false,
|
||||
default: ''
|
||||
},
|
||||
nativePlatform: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: ''
|
||||
},
|
||||
modelValue: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: ''
|
||||
}
|
||||
},
|
||||
emits: ['attached', 'update:model-value', 'start', 'minimize', 'update:reason', 'touch-attachment'],
|
||||
emits: ['attached', 'update:model-value', 'start', 'minimize', 'update:reason'],
|
||||
data () {
|
||||
return {
|
||||
isSignatureStarted: false,
|
||||
isShowQr: false,
|
||||
isOtherReason: false,
|
||||
isUsePreviousValue: true,
|
||||
isTouchAttachment: false,
|
||||
isTextSignature: !this.signatureSrc && (!!this.signatureText || this.field.preferences?.format === 'typed' || this.field.preferences?.format === 'typed_or_upload'),
|
||||
uploadImageInputKey: Math.random().toString()
|
||||
}
|
||||
@@ -498,10 +499,24 @@ export default {
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
},
|
||||
computedPrefillData () {
|
||||
if (this.isUsePreviousValue && this.field.required === true && !this.previousValue && !this.modelValue) {
|
||||
return this.prefillData
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
},
|
||||
previousValueUrl () {
|
||||
if (this.computedPrefillData) {
|
||||
return this.computedPrefillData.url
|
||||
} else {
|
||||
return this.attachmentsIndex[this.modelValue || this.computedPreviousValue].url
|
||||
}
|
||||
}
|
||||
},
|
||||
created () {
|
||||
this.isSignatureStarted = !!this.computedPreviousValue
|
||||
this.isSignatureStarted = !!(this.computedPreviousValue || this.computedPrefillData)
|
||||
|
||||
if (this.withSigningReason) {
|
||||
this.field.preferences ||= {}
|
||||
@@ -576,6 +591,16 @@ export default {
|
||||
this.stopCheckSignature()
|
||||
},
|
||||
methods: {
|
||||
lockSwipe () {
|
||||
if (this.nativePlatform) {
|
||||
window.webkit?.messageHandlers?.native?.postMessage({ type: 'swipe', op: 'lock' })
|
||||
}
|
||||
},
|
||||
unlockSwipe () {
|
||||
if (this.nativePlatform) {
|
||||
window.webkit?.messageHandlers?.native?.postMessage({ type: 'swipe', op: 'unlock' })
|
||||
}
|
||||
},
|
||||
setCanvasSize () {
|
||||
const canvas = this.$refs.canvas
|
||||
|
||||
@@ -872,15 +897,49 @@ export default {
|
||||
console.error(e)
|
||||
}
|
||||
},
|
||||
async submit () {
|
||||
if (this.modelValue || this.computedPreviousValue) {
|
||||
if (this.touchAttachmentUuid && this.computedPreviousValue === this.touchAttachmentUuid && !Object.values(this.values).includes(this.touchAttachmentUuid)) {
|
||||
this.isTouchAttachment = true
|
||||
this.$emit('touch-attachment', this.touchAttachmentUuid)
|
||||
} else {
|
||||
this.isTouchAttachment = false
|
||||
submitPrefillAttachment () {
|
||||
if (this.dryRun) {
|
||||
const attachment = { uuid: Math.random().toString(), url: this.prefillData.url }
|
||||
|
||||
this.$emit('attached', attachment)
|
||||
this.$emit('update:model-value', attachment.uuid)
|
||||
|
||||
return Promise.resolve(attachment)
|
||||
}
|
||||
|
||||
const formData = new FormData()
|
||||
|
||||
formData.append('prefill_token', this.prefillData.token)
|
||||
|
||||
return fetch(this.baseUrl + `/s/${this.submitterSlug}/prefill_attachments`, {
|
||||
method: 'POST',
|
||||
headers: { ...this.fetchOptions.headers },
|
||||
body: formData
|
||||
}).then(async (resp) => {
|
||||
if (!resp.ok) {
|
||||
const data = await resp.json()
|
||||
|
||||
return Promise.reject(new Error(data.error))
|
||||
}
|
||||
|
||||
const attachment = await resp.json()
|
||||
|
||||
this.$emit('attached', attachment)
|
||||
this.$emit('update:model-value', attachment.uuid)
|
||||
|
||||
return attachment
|
||||
}).catch((error) => {
|
||||
this.remove()
|
||||
|
||||
return Promise.reject(error)
|
||||
})
|
||||
},
|
||||
async submit () {
|
||||
if (this.computedPrefillData) {
|
||||
return this.submitPrefillAttachment()
|
||||
}
|
||||
|
||||
if (this.modelValue || this.computedPreviousValue) {
|
||||
if (this.computedPreviousValue) {
|
||||
this.$emit('update:model-value', this.computedPreviousValue)
|
||||
}
|
||||
@@ -932,9 +991,20 @@ export default {
|
||||
formData.append('remember_signature', this.rememberSignature)
|
||||
formData.append('type', 'signature')
|
||||
|
||||
return fetch(this.baseUrl + '/api/attachments', {
|
||||
return fetch(this.baseUrl + `/s/${this.submitterSlug}/upload`, {
|
||||
method: 'POST',
|
||||
headers: { ...this.fetchOptions.headers },
|
||||
body: formData
|
||||
}).then((resp) => {
|
||||
if (resp.status === 404) {
|
||||
return fetch(this.baseUrl + '/api/attachments', {
|
||||
method: 'POST',
|
||||
headers: { ...this.fetchOptions.headers },
|
||||
body: formData
|
||||
})
|
||||
} else {
|
||||
return resp
|
||||
}
|
||||
}).then(async (resp) => {
|
||||
if (resp.status === 422 || resp.status === 500) {
|
||||
const data = await resp.json()
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
<input
|
||||
v-if="!isTextArea"
|
||||
:id="field.uuid"
|
||||
ref="input"
|
||||
v-model="text"
|
||||
:maxlength="cellsMaxLegth"
|
||||
dir="auto"
|
||||
@@ -46,6 +47,7 @@
|
||||
:placeholder="`${t('type_here_')}${field.required ? '' : ` (${t('optional')})`}`"
|
||||
type="text"
|
||||
:name="`values[${field.uuid}]`"
|
||||
@input="validateValue($event.target)"
|
||||
@focus="$emit('focus')"
|
||||
>
|
||||
<textarea
|
||||
@@ -137,6 +139,9 @@ export default {
|
||||
return null
|
||||
}
|
||||
},
|
||||
isIbanValidation () {
|
||||
return this.patternMessageKeys[this.field.validation?.pattern] === 'must_be_valid_iban'
|
||||
},
|
||||
patternMessageKeys () {
|
||||
return {
|
||||
'^[0-9]{3}-[0-9]{2}-[0-9]{4}$': 'must_be_valid_ssn',
|
||||
@@ -144,6 +149,7 @@ export default {
|
||||
'^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$': 'must_be_valid_email',
|
||||
'^https?://.*': 'must_be_valid_url',
|
||||
'^[0-9]{5}(?:-[0-9]{4})?$': 'must_be_valid_zip',
|
||||
'^[A-Z]{2}[0-9]{2}(?: ?[A-Z0-9]){11,30}$': 'must_be_valid_iban',
|
||||
'^[0-9]+$': 'must_contain_numbers_only',
|
||||
'^[a-zA-Z]+$': 'must_contain_letters_only'
|
||||
}
|
||||
@@ -186,8 +192,21 @@ export default {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
this.validateValue(this.$refs.input)
|
||||
},
|
||||
methods: {
|
||||
validateValue (input) {
|
||||
if (this.isIbanValidation) {
|
||||
input.setCustomValidity(this.isValidIban(input.value) ? '' : this.t('must_be_valid_iban'))
|
||||
}
|
||||
},
|
||||
isValidIban (value) {
|
||||
const iban = value.replace(/\s/g, '')
|
||||
const digits = (iban.slice(4) + iban.slice(0, 4)).replace(/[A-Z]/g, (char) => char.charCodeAt(0) - 55)
|
||||
|
||||
return !iban || digits.split('').reduce((remainder, digit) => (remainder * 10 + parseInt(digit)) % 97, 0) === 1
|
||||
},
|
||||
resizeTextarea () {
|
||||
const textarea = this.$refs.textarea
|
||||
|
||||
|
||||
@@ -162,10 +162,9 @@ export default {
|
||||
},
|
||||
methods: {
|
||||
start () {
|
||||
return fetch(this.baseUrl + `/api/identity_verification/${this.field.uuid}`, {
|
||||
return fetch(this.baseUrl + `/s/${this.submitterSlug}/identity_verification/${this.field.uuid}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
submitter_slug: this.submitterSlug,
|
||||
redirect_url: document.location.href
|
||||
}),
|
||||
...this.fetchOptions,
|
||||
@@ -206,11 +205,8 @@ export default {
|
||||
})
|
||||
},
|
||||
async submit () {
|
||||
const resp = await fetch(this.baseUrl + '/api/identity_verification', {
|
||||
const resp = await fetch(this.baseUrl + `/s/${this.submitterSlug}/identity_verification`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
submitter_slug: this.submitterSlug
|
||||
}),
|
||||
...this.fetchOptions,
|
||||
headers: { 'Content-Type': 'application/json', ...this.fetchOptions.headers }
|
||||
})
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="field?.type && (isSelected || isNameFocus) && !isInMultiSelection"
|
||||
class="absolute bg-white rounded-t border overflow-visible whitespace-nowrap flex z-10 field-area-controls"
|
||||
class="absolute bg-white rounded-t border overflow-visible whitespace-nowrap flex field-area-controls"
|
||||
style="top: -25px; height: 25px"
|
||||
:style="{ zIndex: isMobile ? 5 : 10 }"
|
||||
@mousedown.stop="$emit('click-title')"
|
||||
@pointerdown.stop
|
||||
>
|
||||
@@ -40,7 +41,7 @@
|
||||
@blur="onNameBlur"
|
||||
>{{ optionIndexText }} {{ (defaultField ? (defaultField.title || field.title || field.name) : field.name) || defaultName }}</span>
|
||||
<div
|
||||
v-if="isSettingsFocus || isSelectInput || (isValueInput && field.type !== 'heading') || (isNameFocus && !['checkbox', 'phone'].includes(field.type))"
|
||||
v-if="isShowControls"
|
||||
class="flex items-center ml-1.5"
|
||||
>
|
||||
<input
|
||||
@@ -74,62 +75,115 @@
|
||||
@click.prevent="field.readonly = !(field.readonly ?? true)"
|
||||
@mousedown.prevent
|
||||
>{{ t('editable') }}</label>
|
||||
<span
|
||||
v-if="field.type !== 'payment' && !isValueInput"
|
||||
class="dropdown dropdown-end field-area-settings-dropdown"
|
||||
@mouseenter="renderDropdown = true"
|
||||
@touchstart="renderDropdown = true"
|
||||
>
|
||||
<label
|
||||
ref="settingsButton"
|
||||
tabindex="0"
|
||||
:title="t('settings')"
|
||||
class="cursor-pointer flex items-center"
|
||||
style="height: 25px"
|
||||
@focus="isSettingsFocus = true"
|
||||
@blur="maybeBlurSettings"
|
||||
>
|
||||
<IconDotsVertical class="w-5 h-5" />
|
||||
</label>
|
||||
<ul
|
||||
v-if="renderDropdown"
|
||||
ref="settingsDropdown"
|
||||
tabindex="0"
|
||||
class="dropdown-content menu menu-xs px-2 pb-2 pt-1 shadow rounded-box w-52 z-10 rounded-t-none"
|
||||
:style="{ backgroundColor: 'white' }"
|
||||
@dragstart.prevent.stop
|
||||
@click="closeDropdown"
|
||||
@focusout="maybeBlurSettings"
|
||||
>
|
||||
<FieldSettings
|
||||
v-if="isMobile"
|
||||
:field="field"
|
||||
:default-field="defaultField"
|
||||
:editable="editable"
|
||||
:background-color="'white'"
|
||||
:with-required="false"
|
||||
:with-areas="false"
|
||||
:with-signature-id="withSignatureId"
|
||||
:with-prefillable="withPrefillable"
|
||||
@click-formula="isShowFormulaModal = true"
|
||||
@click-font="isShowFontModal = true"
|
||||
@click-description="isShowDescriptionModal = true"
|
||||
@add-custom-field="$emit('add-custom-field')"
|
||||
@click-condition="isShowConditionsModal = true"
|
||||
@save="$emit('change')"
|
||||
@scroll-to="[selectedAreasRef.value = [$event], $emit('scroll-to', $event)]"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="whitespace-normal"
|
||||
>
|
||||
The dots menu is retired in favor of the field context menu. Right-click the field to access field settings. Double-click the field to set a default value.
|
||||
</div>
|
||||
</ul>
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
v-if="isShowSettingsMenu"
|
||||
class="dropdown dropdown-end field-area-settings-dropdown"
|
||||
:class="isMobile ? '-ml-1' : { 'ml-1.5': !isShowControls }"
|
||||
@mouseenter="renderDropdown = true"
|
||||
@touchstart="renderDropdown = true"
|
||||
>
|
||||
<label
|
||||
ref="settingsButton"
|
||||
tabindex="0"
|
||||
:title="t('settings')"
|
||||
class="cursor-pointer flex items-center"
|
||||
:class="{ 'pr-0.5': isMobile }"
|
||||
style="height: 24px"
|
||||
@focus="onSettingsFocus"
|
||||
@blur="maybeBlurSettings"
|
||||
>
|
||||
<IconDotsVertical class="w-5 h-5" />
|
||||
</label>
|
||||
<ul
|
||||
v-if="renderDropdown"
|
||||
ref="settingsDropdown"
|
||||
tabindex="0"
|
||||
class="dropdown-content menu menu-xs px-2 pb-2 pt-1 shadow rounded-box w-52 z-10 rounded-t-none"
|
||||
:style="{ backgroundColor: 'white' }"
|
||||
@dragstart.prevent.stop
|
||||
@click="closeDropdown"
|
||||
@focusout="maybeBlurSettings"
|
||||
>
|
||||
<FieldSettings
|
||||
v-if="isMobile"
|
||||
:field="field"
|
||||
:default-field="defaultField"
|
||||
:editable="editable"
|
||||
:background-color="'white'"
|
||||
:with-required="false"
|
||||
:with-areas="false"
|
||||
:with-signature-id="withSignatureId"
|
||||
:with-prefillable="withPrefillable"
|
||||
@click-formula="openFormulaModal"
|
||||
@click-font="openFontModal"
|
||||
@click-description="openDescriptionModal"
|
||||
@add-custom-field="$emit('add-custom-field')"
|
||||
@click-condition="openConditionsModal"
|
||||
@save="$emit('change')"
|
||||
@scroll-to="[selectedAreasRef.value = [$event], $emit('scroll-to', $event)]"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="whitespace-normal"
|
||||
>
|
||||
The dots menu is retired in favor of the field context menu. Right-click the field to access field settings. Double-click the field to set a default value.
|
||||
</div>
|
||||
<li
|
||||
v-if="isMobile && editable"
|
||||
class="field-settings-remove"
|
||||
>
|
||||
<a
|
||||
href="#"
|
||||
class="text-sm py-1 px-2 text-red-600"
|
||||
@click.prevent="$emit('remove')"
|
||||
>
|
||||
<IconTrashX
|
||||
:width="20"
|
||||
:stroke-width="1.6"
|
||||
/>
|
||||
{{ t('remove') }}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</span>
|
||||
<PaymentSettings
|
||||
v-else-if="isMobile && editable && field.type === 'payment'"
|
||||
class="-ml-1"
|
||||
:field="field"
|
||||
:with-force-open="false"
|
||||
:background-color="'white'"
|
||||
:menu-classes="'rounded-t-none pt-1'"
|
||||
@click-condition="openConditionsModal"
|
||||
@click-description="openDescriptionModal"
|
||||
@click-formula="openFormulaModal"
|
||||
>
|
||||
<label
|
||||
tabindex="0"
|
||||
:title="t('settings')"
|
||||
class="cursor-pointer flex items-center pr-0.5"
|
||||
style="height: 24px"
|
||||
>
|
||||
<IconDotsVertical class="w-5 h-5" />
|
||||
</label>
|
||||
<template #menu>
|
||||
<li class="field-settings-remove">
|
||||
<a
|
||||
href="#"
|
||||
class="text-sm py-1 px-2 text-red-600"
|
||||
@click.prevent="$emit('remove')"
|
||||
>
|
||||
<IconTrashX
|
||||
:width="20"
|
||||
:stroke-width="1.6"
|
||||
/>
|
||||
{{ t('remove') }}
|
||||
</a>
|
||||
</li>
|
||||
</template>
|
||||
</PaymentSettings>
|
||||
<button
|
||||
v-else-if="editable"
|
||||
v-else-if="editable && !isShowControls"
|
||||
class="pr-1"
|
||||
:title="t('remove')"
|
||||
@click.prevent="$emit('remove')"
|
||||
@@ -195,18 +249,21 @@ import FieldSubmitter from './field_submitter'
|
||||
import FieldType from './field_type'
|
||||
import Field from './field'
|
||||
import FieldSettings from './field_settings'
|
||||
import PaymentSettings from './payment_settings'
|
||||
import FormulaModal from './formula_modal'
|
||||
import FontModal from './font_modal'
|
||||
import ConditionsModal from './conditions_modal'
|
||||
import DescriptionModal from './description_modal'
|
||||
import { IconX, IconDotsVertical } from '@tabler/icons-vue'
|
||||
import { IconX, IconDotsVertical, IconTrashX } from '@tabler/icons-vue'
|
||||
import { v4 } from 'uuid'
|
||||
|
||||
export default {
|
||||
name: 'AreaTitle',
|
||||
components: {
|
||||
IconTrashX,
|
||||
FieldType,
|
||||
FieldSettings,
|
||||
PaymentSettings,
|
||||
FormulaModal,
|
||||
FontModal,
|
||||
IconDotsVertical,
|
||||
@@ -215,7 +272,7 @@ export default {
|
||||
FieldSubmitter,
|
||||
IconX
|
||||
},
|
||||
inject: ['t'],
|
||||
inject: ['t', 'nativePlatform'],
|
||||
props: {
|
||||
template: {
|
||||
type: Object,
|
||||
@@ -292,6 +349,17 @@ export default {
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
isShowControls () {
|
||||
if (this.isMobile) return false
|
||||
|
||||
return this.isSettingsFocus || this.isSelectInput || (this.isValueInput && this.field.type !== 'heading') || (this.isNameFocus && !['checkbox', 'phone'].includes(this.field.type))
|
||||
},
|
||||
isShowSettingsMenu () {
|
||||
if (this.field.type === 'payment' || this.isValueInput) return false
|
||||
if (this.isMobile) return this.editable
|
||||
|
||||
return this.isShowControls
|
||||
},
|
||||
fieldNames: FieldType.computed.fieldNames,
|
||||
fieldLabels: FieldType.computed.fieldLabels,
|
||||
submitter () {
|
||||
@@ -318,6 +386,34 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
openFormulaModal () {
|
||||
if (this.nativePlatform && window.webkit?.messageHandlers?.modal) {
|
||||
window.webkit.messageHandlers.modal.postMessage({ action: 'sheet', name: 'formula', detent: 'medium', uuid: this.field.uuid })
|
||||
} else {
|
||||
this.isShowFormulaModal = true
|
||||
}
|
||||
},
|
||||
openFontModal () {
|
||||
if (this.nativePlatform && window.webkit?.messageHandlers?.modal) {
|
||||
window.webkit.messageHandlers.modal.postMessage({ action: 'sheet', name: 'font', detent: 'medium', uuid: this.field.uuid })
|
||||
} else {
|
||||
this.isShowFontModal = true
|
||||
}
|
||||
},
|
||||
openConditionsModal () {
|
||||
if (this.nativePlatform && window.webkit?.messageHandlers?.modal) {
|
||||
window.webkit.messageHandlers.modal.postMessage({ action: 'sheet', name: 'conditions', detent: 'medium', uuid: this.field.uuid })
|
||||
} else {
|
||||
this.isShowConditionsModal = true
|
||||
}
|
||||
},
|
||||
openDescriptionModal () {
|
||||
if (this.nativePlatform && window.webkit?.messageHandlers?.modal) {
|
||||
window.webkit.messageHandlers.modal.postMessage({ action: 'sheet', name: 'description', detent: 'medium', uuid: this.field.uuid })
|
||||
} else {
|
||||
this.isShowDescriptionModal = true
|
||||
}
|
||||
},
|
||||
buildDefaultName: Field.methods.buildDefaultName,
|
||||
closeDropdown () {
|
||||
this.$el.getRootNode().activeElement.blur()
|
||||
@@ -327,6 +423,19 @@ export default {
|
||||
this.isSettingsFocus = false
|
||||
}
|
||||
},
|
||||
onSettingsFocus () {
|
||||
this.isSettingsFocus = true
|
||||
|
||||
if (this.isMobile) {
|
||||
this.$nextTick(() => {
|
||||
const dropdown = this.$refs.settingsDropdown
|
||||
const padding = parseFloat(getComputedStyle(dropdown).paddingLeft) + 6
|
||||
const left = dropdown.parentElement.getBoundingClientRect().right - dropdown.offsetWidth - padding
|
||||
|
||||
dropdown.style.right = left < 0 ? `${left}px` : ''
|
||||
})
|
||||
}
|
||||
},
|
||||
onNameFocus (e) {
|
||||
this.selectedAreasRef.value = [this.area]
|
||||
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,13 +1,17 @@
|
||||
<template>
|
||||
<div
|
||||
class="modal modal-open items-start !animate-none overflow-y-auto"
|
||||
:class="{ 'modal modal-open items-start !animate-none overflow-y-auto': !inline }"
|
||||
>
|
||||
<div
|
||||
v-if="!inline"
|
||||
class="absolute top-0 bottom-0 right-0 left-0"
|
||||
@click.prevent="$emit('close')"
|
||||
/>
|
||||
<div class="modal-box pt-4 pb-6 px-6 mt-20 w-full max-w-xl">
|
||||
<div class="flex justify-between items-center border-b pb-2 mb-2 font-medium">
|
||||
<div :class="inline ? 'px-4 pt-3 pb-6' : 'modal-box pt-4 pb-6 px-6 mt-20 w-full max-w-xl'">
|
||||
<div
|
||||
v-if="!inline"
|
||||
class="flex justify-between items-center border-b pb-2 mb-2 font-medium"
|
||||
>
|
||||
<span class="modal-title">
|
||||
{{ t('condition') }} - {{ (defaultField ? (defaultField.title || item.title || item.name) : item.name) || buildDefaultName(item) }}
|
||||
</span>
|
||||
@@ -183,6 +187,11 @@ export default {
|
||||
type: Array,
|
||||
required: false,
|
||||
default: () => []
|
||||
},
|
||||
inline: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
}
|
||||
},
|
||||
emits: ['close', 'save'],
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
:data-placeholder="placeholder"
|
||||
:data-empty="isEmpty"
|
||||
:style="{ minWidth }"
|
||||
:class="[iconInline ? (isEmpty ? 'inline-block' : 'inline') : 'block', hideIcon ? 'focus:block' : '']"
|
||||
:class="[iconInline ? (isEmpty ? 'inline-block' : 'inline') : 'block', hideIcon ? 'focus:block' : '', contenteditableClasses]"
|
||||
class="peer relative inline-block outline-none before:pointer-events-none before:absolute before:left-0 before:top-0 before:select-none before:whitespace-pre before:text-neutral-400 before:content-[attr(data-placeholder)] before:opacity-0 data-[empty=true]:before:opacity-100"
|
||||
@paste.prevent="onPaste"
|
||||
@keydown.enter.prevent="blurContenteditable"
|
||||
@@ -23,15 +23,15 @@
|
||||
{{ value }}
|
||||
</span>
|
||||
<span
|
||||
v-if="withButton"
|
||||
v-if="withButton && (!isMobile || editableOnButton)"
|
||||
class="relative inline"
|
||||
:class="{ 'peer-focus:hidden': hideIcon, 'peer-focus:invisible': !hideIcon }"
|
||||
>
|
||||
<IconPencil
|
||||
class="cursor-pointer flex-none opacity-0 group-hover/contenteditable-container:opacity-100 group-hover/contenteditable:opacity-100 align-middle pl-1"
|
||||
class="cursor-pointer flex-none align-middle pl-1"
|
||||
:style="iconInline ? {} : { right: -(1.1 * iconWidth) + 'px' }"
|
||||
:title="t('edit')"
|
||||
:class="{ invisible: !editable, 'absolute top-1/2 -translate-y-1/2': !iconInline || floatIcon, 'inline align-bottom': iconInline, 'left-0': floatIcon }"
|
||||
:class="{ invisible: !editable, 'opacity-0 group-hover/contenteditable-container:opacity-100 group-hover/contenteditable:opacity-100': !isMobile, 'absolute top-1/2 -translate-y-1/2': !iconInline || floatIcon, 'inline align-bottom': iconInline, 'left-0': floatIcon }"
|
||||
:width="iconWidth + 4"
|
||||
:stroke-width="iconStrokeWidth"
|
||||
@click="clickEdit"
|
||||
@@ -48,7 +48,7 @@ export default {
|
||||
components: {
|
||||
IconPencil
|
||||
},
|
||||
inject: ['t'],
|
||||
inject: ['t', 'isMobile'],
|
||||
props: {
|
||||
modelValue: {
|
||||
type: String,
|
||||
@@ -109,6 +109,11 @@ export default {
|
||||
type: Number,
|
||||
required: false,
|
||||
default: 2
|
||||
},
|
||||
contenteditableClasses: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: ''
|
||||
}
|
||||
},
|
||||
emits: ['update:model-value', 'focus', 'blur', 'click-contenteditable'],
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
<template>
|
||||
<div class="flex space-x-2">
|
||||
<Contenteditable
|
||||
class="w-full block mr-6 document-preview-name"
|
||||
:model-value="item.name"
|
||||
:icon-width="16"
|
||||
@update:model-value="onUpdateName"
|
||||
/>
|
||||
<ReplaceButton
|
||||
v-if="withReplaceButton"
|
||||
:template-id="template.id"
|
||||
:accept-file-types="acceptFileTypes"
|
||||
:authenticity-token="authenticityToken"
|
||||
:with-google-drive="withGoogleDrive"
|
||||
:google-drive-file-id="item.google_drive_file_id"
|
||||
@click.stop
|
||||
@success="$emit('replace', { replaceSchemaItem: item, ...$event })"
|
||||
/>
|
||||
<button
|
||||
v-if="withArrows"
|
||||
class="btn border-base-200 bg-white text-base-content btn-xs rounded hover:text-base-100 hover:bg-base-content hover:border-base-content w-full transition-colors document-control-button"
|
||||
style="width: 24px; height: 24px"
|
||||
@click.stop="$emit('up', item)"
|
||||
>
|
||||
↑
|
||||
</button>
|
||||
<button
|
||||
v-if="withArrows"
|
||||
class="btn border-base-200 bg-white text-base-content btn-xs rounded hover:text-base-100 hover:bg-base-content hover:border-base-content w-full transition-colors document-control-button"
|
||||
style="width: 24px; height: 24px"
|
||||
@click.stop="$emit('down', item)"
|
||||
>
|
||||
↓
|
||||
</button>
|
||||
<button
|
||||
class="btn border-base-200 bg-white text-base-content btn-xs rounded hover:text-base-100 hover:bg-base-content hover:border-base-content w-full transition-colors document-control-button"
|
||||
style="width: 24px; height: 24px"
|
||||
@click.stop="$emit('remove', item)"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Contenteditable from './contenteditable'
|
||||
import Upload from './upload'
|
||||
import ReplaceButton from './replace'
|
||||
|
||||
export default {
|
||||
name: 'DocumentControls',
|
||||
components: {
|
||||
Contenteditable,
|
||||
ReplaceButton
|
||||
},
|
||||
props: {
|
||||
item: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
template: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
document: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
acceptFileTypes: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: 'image/*, application/pdf, application/zip, application/json'
|
||||
},
|
||||
withReplaceButton: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
default: true
|
||||
},
|
||||
withGoogleDrive: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
},
|
||||
authenticityToken: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: ''
|
||||
},
|
||||
withArrows: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: true
|
||||
}
|
||||
},
|
||||
emits: ['change', 'remove', 'up', 'down', 'replace'],
|
||||
methods: {
|
||||
upload: Upload.methods.upload,
|
||||
onUpdateName (value) {
|
||||
this.item.name = value
|
||||
|
||||
this.$emit('change')
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -47,10 +47,11 @@
|
||||
<PaymentSettings
|
||||
v-if="field.type === 'payment' && !isNew"
|
||||
:field="field"
|
||||
:background-color="dropdownBgColor"
|
||||
:with-condition="false"
|
||||
:with-force-open="false"
|
||||
@click-description="isShowDescriptionModal = true"
|
||||
@click-formula="isShowFormulaModal = true"
|
||||
@click-description="openDescriptionModal"
|
||||
@click-formula="openFormulaModal"
|
||||
/>
|
||||
<span
|
||||
v-else-if="!isNew"
|
||||
@@ -61,9 +62,17 @@
|
||||
<label
|
||||
tabindex="0"
|
||||
:title="t('settings')"
|
||||
class="cursor-pointer text-transparent group-hover:text-base-content"
|
||||
class="cursor-pointer flex items-center"
|
||||
:class="[iconColorClass, { 'pr-1': isMobile }]"
|
||||
>
|
||||
<IconDotsVertical
|
||||
v-if="isMobile"
|
||||
:width="18"
|
||||
:height="18"
|
||||
:stroke-width="1.6"
|
||||
/>
|
||||
<IconSettings
|
||||
v-else
|
||||
:width="18"
|
||||
:stroke-width="1.6"
|
||||
/>
|
||||
@@ -85,11 +94,27 @@
|
||||
:with-areas="false"
|
||||
:with-copy-to-all-pages="false"
|
||||
:with-condition="false"
|
||||
@click-formula="isShowFormulaModal = true"
|
||||
@click-font="isShowFontModal = true"
|
||||
@click-description="isShowDescriptionModal = true"
|
||||
@click-formula="openFormulaModal"
|
||||
@click-font="openFontModal"
|
||||
@click-description="openDescriptionModal"
|
||||
@save="$emit('save')"
|
||||
/>
|
||||
<li
|
||||
v-if="isMobile"
|
||||
class="field-settings-remove"
|
||||
>
|
||||
<a
|
||||
href="#"
|
||||
class="text-sm py-1 px-2 text-red-600"
|
||||
@click.prevent="$emit('remove', field)"
|
||||
>
|
||||
<IconTrashX
|
||||
:width="20"
|
||||
:stroke-width="1.6"
|
||||
/>
|
||||
{{ t('remove') }}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</span>
|
||||
<button
|
||||
@@ -104,8 +129,9 @@
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
v-if="isNew || !isMobile || field.type === 'payment'"
|
||||
class="relative group-hover:text-base-content pr-1 field-remove-button"
|
||||
:class="isNew ? 'text-base-content' : 'text-transparent group-hover:text-base-content'"
|
||||
:class="isNew ? 'text-base-content' : iconColorClass"
|
||||
:title="t('remove')"
|
||||
@click="onRemoveClick"
|
||||
>
|
||||
@@ -164,7 +190,7 @@ import FieldSettings from './field_settings'
|
||||
import FormulaModal from './formula_modal'
|
||||
import FontModal from './font_modal'
|
||||
import DescriptionModal from './description_modal'
|
||||
import { IconTrashX, IconSettings, IconCheck } from '@tabler/icons-vue'
|
||||
import { IconTrashX, IconSettings, IconCheck, IconDotsVertical } from '@tabler/icons-vue'
|
||||
import IconDrag from './icon_drag'
|
||||
|
||||
export default {
|
||||
@@ -172,6 +198,7 @@ export default {
|
||||
components: {
|
||||
Contenteditable,
|
||||
IconSettings,
|
||||
IconDotsVertical,
|
||||
IconCheck,
|
||||
FieldSettings,
|
||||
PaymentSettings,
|
||||
@@ -182,7 +209,7 @@ export default {
|
||||
IconTrashX,
|
||||
FieldType
|
||||
},
|
||||
inject: ['backgroundColor', 't'],
|
||||
inject: ['backgroundColor', 't', 'isMobile', 'nativePlatform'],
|
||||
props: {
|
||||
field: {
|
||||
type: Object,
|
||||
@@ -217,6 +244,9 @@ export default {
|
||||
dropdownBgColor () {
|
||||
return ['', null, 'transparent'].includes(this.backgroundColor) ? 'white' : this.backgroundColor
|
||||
},
|
||||
iconColorClass () {
|
||||
return this.isMobile ? 'text-base-content' : 'text-transparent group-hover:text-base-content'
|
||||
},
|
||||
modalContainerEl () {
|
||||
return this.$el.getRootNode().querySelector('#docuseal_modal_container')
|
||||
}
|
||||
@@ -230,6 +260,27 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
openFormulaModal () {
|
||||
if (this.nativePlatform && window.webkit?.messageHandlers?.modal) {
|
||||
window.webkit.messageHandlers.modal.postMessage({ action: 'sheet', name: 'formula', detent: 'medium', custom_field_uuid: this.field.uuid })
|
||||
} else {
|
||||
this.isShowFormulaModal = true
|
||||
}
|
||||
},
|
||||
openFontModal () {
|
||||
if (this.nativePlatform && window.webkit?.messageHandlers?.modal) {
|
||||
window.webkit.messageHandlers.modal.postMessage({ action: 'sheet', name: 'font', detent: 'medium', custom_field_uuid: this.field.uuid })
|
||||
} else {
|
||||
this.isShowFontModal = true
|
||||
}
|
||||
},
|
||||
openDescriptionModal () {
|
||||
if (this.nativePlatform && window.webkit?.messageHandlers?.modal) {
|
||||
window.webkit.messageHandlers.modal.postMessage({ action: 'sheet', name: 'description', detent: 'medium', custom_field_uuid: this.field.uuid })
|
||||
} else {
|
||||
this.isShowDescriptionModal = true
|
||||
}
|
||||
},
|
||||
buildDefaultName () {
|
||||
return this.t('custom')
|
||||
},
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
<template>
|
||||
<div
|
||||
class="modal modal-open items-start !animate-none overflow-y-auto"
|
||||
:class="{ 'modal modal-open items-start !animate-none overflow-y-auto': !inline }"
|
||||
>
|
||||
<div
|
||||
v-if="!inline"
|
||||
class="absolute top-0 bottom-0 right-0 left-0"
|
||||
@click.prevent="$emit('close')"
|
||||
/>
|
||||
<div class="modal-box pt-4 pb-6 px-6 mt-20 max-h-none w-full max-w-xl">
|
||||
<div class="flex justify-between items-center border-b pb-2 mb-2 font-medium">
|
||||
<div :class="inline ? 'px-4 pt-3 pb-6' : 'modal-box pt-4 pb-6 px-6 mt-20 max-h-none w-full max-w-xl'">
|
||||
<div
|
||||
v-if="!inline"
|
||||
class="flex justify-between items-center border-b pb-2 mb-2 font-medium"
|
||||
>
|
||||
<span class="modal-title">
|
||||
{{ (defaultField ? (defaultField.title || field.title || field.name) : field.name) || buildDefaultName(field) }}
|
||||
</span>
|
||||
@@ -86,6 +90,11 @@ export default {
|
||||
buildDefaultName: {
|
||||
type: Function,
|
||||
required: true
|
||||
},
|
||||
inline: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
}
|
||||
},
|
||||
emits: ['close', 'save'],
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="flex flex-1 min-h-0">
|
||||
<div class="flex flex-col md:flex-row flex-1 min-h-0">
|
||||
<div
|
||||
class="flex-1 min-h-0 flex items-center justify-center px-6 py-4"
|
||||
class="flex-1 min-h-0 flex items-center justify-center px-4 md:px-6 py-4"
|
||||
style="container-type: size"
|
||||
>
|
||||
<div
|
||||
@@ -46,73 +46,73 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-56 flex-none border-l px-4 py-4 space-y-2 flex flex-col">
|
||||
<button
|
||||
class="btn btn-sm w-full justify-start normal-case font-normal rounded disabled:bg-base-300"
|
||||
:disabled="!!isProcessing"
|
||||
@click.prevent="submit(true)"
|
||||
>
|
||||
<IconInnerShadowTop
|
||||
v-if="isProcessing === 'scan'"
|
||||
class="w-4 h-4 animate-spin"
|
||||
/>
|
||||
<IconScan
|
||||
v-else
|
||||
class="w-4 h-4"
|
||||
/>
|
||||
{{ t('crop_and_scan') }}
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-sm w-full justify-start normal-case font-normal rounded disabled:bg-base-300"
|
||||
:disabled="!!isProcessing"
|
||||
@click.prevent="submit(false)"
|
||||
>
|
||||
<IconInnerShadowTop
|
||||
v-if="isProcessing === 'crop'"
|
||||
class="w-4 h-4 animate-spin"
|
||||
/>
|
||||
<IconCrop
|
||||
v-else
|
||||
width="22"
|
||||
height="22"
|
||||
style="margin-left: -3px"
|
||||
:stroke-width="1.5"
|
||||
/>
|
||||
<span :style="{ 'margin-left': isProcessing === 'crop' ? '0px' : '-3px' }">
|
||||
{{ t('crop') }}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-sm w-full justify-start normal-case font-normal rounded"
|
||||
@click.prevent="$emit('cancel')"
|
||||
>
|
||||
<IconX class="w-4 h-4" />
|
||||
{{ t('cancel') }}
|
||||
</button>
|
||||
<div class="border-t !mt-3 !mb-1" />
|
||||
<button
|
||||
class="btn btn-sm w-full justify-start normal-case font-normal rounded"
|
||||
@click.prevent="rotateCw"
|
||||
>
|
||||
<IconRotateClockwise class="w-4 h-4" />
|
||||
{{ t('rotate') }}
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-sm w-full justify-start normal-case font-normal rounded"
|
||||
:class="{ 'btn-active': flipH }"
|
||||
@click.prevent="toggleFlip('flipH')"
|
||||
>
|
||||
<IconFlipVertical class="w-4 h-4" />
|
||||
{{ t('flip_horizontal') }}
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-sm w-full justify-start normal-case font-normal rounded"
|
||||
:class="{ 'btn-active': flipV }"
|
||||
@click.prevent="toggleFlip('flipV')"
|
||||
>
|
||||
<IconFlipHorizontal class="w-4 h-4" />
|
||||
{{ t('flip_vertical') }}
|
||||
</button>
|
||||
<div class="flex-none px-4 border-t py-3 md:w-56 md:border-t-0 md:border-l md:py-4 md:space-y-2 md:flex md:flex-col">
|
||||
<div class="grid grid-cols-3 gap-2 md:block md:space-y-2">
|
||||
<button
|
||||
class="btn btn-sm normal-case font-normal rounded order-last disabled:bg-base-300 flex-col gap-1 h-auto min-h-0 py-2 px-1 text-xs leading-tight whitespace-normal md:flex-row md:gap-2 md:h-8 md:min-h-[2rem] md:py-0 md:px-3 md:text-sm md:whitespace-nowrap md:w-full md:justify-start"
|
||||
:disabled="!!isProcessing"
|
||||
@click.prevent="submit(true)"
|
||||
>
|
||||
<IconInnerShadowTop
|
||||
v-if="isProcessing === 'scan'"
|
||||
class="w-4 h-4 animate-spin"
|
||||
/>
|
||||
<IconScan
|
||||
v-else
|
||||
class="w-4 h-4"
|
||||
/>
|
||||
{{ t('crop_and_scan') }}
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-sm normal-case font-normal rounded order-last disabled:bg-base-300 flex-col gap-1 h-auto min-h-0 py-2 px-1 text-xs leading-tight whitespace-normal md:flex-row md:gap-2 md:h-8 md:min-h-[2rem] md:py-0 md:px-3 md:text-sm md:whitespace-nowrap md:w-full md:justify-start"
|
||||
:disabled="!!isProcessing"
|
||||
@click.prevent="submit(false)"
|
||||
>
|
||||
<IconInnerShadowTop
|
||||
v-if="isProcessing === 'crop'"
|
||||
class="w-4 h-4 animate-spin"
|
||||
/>
|
||||
<IconCrop
|
||||
v-else
|
||||
class="w-4 h-4 md:w-[22px] md:h-[22px] md:-ml-[3px]"
|
||||
:stroke-width="1.5"
|
||||
/>
|
||||
<span :class="{ 'md:-ml-[3px]': isProcessing !== 'crop' }">
|
||||
{{ t('crop') }}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-sm normal-case font-normal rounded order-last flex-col gap-1 h-auto min-h-0 py-2 px-1 text-xs leading-tight whitespace-normal md:flex-row md:gap-2 md:h-8 md:min-h-[2rem] md:py-0 md:px-3 md:text-sm md:whitespace-nowrap md:w-full md:justify-start"
|
||||
@click.prevent="$emit('cancel')"
|
||||
>
|
||||
<IconX class="w-4 h-4" />
|
||||
{{ t('cancel') }}
|
||||
</button>
|
||||
<div class="hidden md:block border-t !mt-3 !mb-1" />
|
||||
<button
|
||||
class="btn btn-sm normal-case font-normal rounded flex-col gap-1 h-auto min-h-0 py-2 px-1 text-xs leading-tight whitespace-normal md:flex-row md:gap-2 md:h-8 md:min-h-[2rem] md:py-0 md:px-3 md:text-sm md:whitespace-nowrap md:w-full md:justify-start"
|
||||
@click.prevent="rotateCw"
|
||||
>
|
||||
<IconRotateClockwise class="w-4 h-4" />
|
||||
{{ t('rotate') }}
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-sm normal-case font-normal rounded flex-col gap-1 h-auto min-h-0 py-2 px-1 text-xs leading-tight whitespace-normal md:flex-row md:gap-2 md:h-8 md:min-h-[2rem] md:py-0 md:px-3 md:text-sm md:whitespace-nowrap md:w-full md:justify-start"
|
||||
:class="{ 'btn-active': flipH }"
|
||||
@click.prevent="toggleFlip('flipH')"
|
||||
>
|
||||
<IconFlipVertical class="w-4 h-4" />
|
||||
{{ t('flip_horizontal') }}
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-sm normal-case font-normal rounded flex-col gap-1 h-auto min-h-0 py-2 px-1 text-xs leading-tight whitespace-normal md:flex-row md:gap-2 md:h-8 md:min-h-[2rem] md:py-0 md:px-3 md:text-sm md:whitespace-nowrap md:w-full md:justify-start"
|
||||
:class="{ 'btn-active': flipV }"
|
||||
@click.prevent="toggleFlip('flipV')"
|
||||
>
|
||||
<IconFlipHorizontal class="w-4 h-4" />
|
||||
{{ t('flip_vertical') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,19 +1,23 @@
|
||||
<template>
|
||||
<div
|
||||
class="modal modal-open items-center !animate-none"
|
||||
:class="inline ? 'flex flex-col h-full' : 'modal modal-open items-center !animate-none'"
|
||||
@dragover.prevent="onModalDragover"
|
||||
@dragleave="onModalDragleave"
|
||||
@drop.prevent="onModalDrop"
|
||||
>
|
||||
<div
|
||||
v-if="!inline"
|
||||
class="absolute top-0 bottom-0 right-0 left-0"
|
||||
@click.prevent="$emit('close')"
|
||||
/>
|
||||
<div
|
||||
class="modal-box relative flex flex-col p-0 w-full h-full rounded-2xl"
|
||||
style="max-width: 1240px; max-height: 92vh"
|
||||
:class="inline ? 'relative flex flex-col flex-1 min-h-0' : 'modal-box relative flex flex-col p-0 w-full h-full rounded-none md:rounded-2xl overflow-x-hidden !max-h-full md:!max-h-[92vh]'"
|
||||
:style="inline ? null : { maxWidth: '1240px' }"
|
||||
>
|
||||
<div class="flex justify-between items-center border-b py-2 px-6 font-medium">
|
||||
<div
|
||||
v-if="!inline"
|
||||
class="flex justify-between items-center border-b py-2 px-6 font-medium"
|
||||
>
|
||||
<span
|
||||
class="modal-title"
|
||||
style="padding-top: 2px"
|
||||
@@ -26,10 +30,13 @@
|
||||
@click.prevent="$emit('close')"
|
||||
>×</a>
|
||||
</div>
|
||||
<div class="flex flex-1 min-h-0 relative">
|
||||
<div
|
||||
class="flex flex-col md:flex-row flex-1 min-h-0 relative"
|
||||
>
|
||||
<CropView
|
||||
v-if="cropPageItem"
|
||||
class="absolute inset-0 z-10 bg-base-100"
|
||||
:style="inline ? { paddingTop: '70px' } : null"
|
||||
:template-id="template.id"
|
||||
:page="cropPageItem"
|
||||
:image-url="thumbUrl(cropPageItem)"
|
||||
@@ -40,6 +47,7 @@
|
||||
<RedactView
|
||||
v-else-if="redactPageItem"
|
||||
class="absolute inset-0 z-10 bg-base-100"
|
||||
:style="inline ? { paddingTop: '70px' } : null"
|
||||
:template-id="template.id"
|
||||
:page="redactPageItem"
|
||||
:image-url="thumbUrl(redactPageItem)"
|
||||
@@ -53,12 +61,16 @@
|
||||
class="contents"
|
||||
:class="{ invisible: cropPageItem || redactPageItem }"
|
||||
>
|
||||
<div class="flex-1 overflow-y-auto px-6 py-4 space-y-4">
|
||||
<div
|
||||
class="flex-1 overflow-y-auto overflow-x-hidden px-4 md:px-6 py-4 space-y-4"
|
||||
:style="inline ? { paddingTop: '86px' } : null"
|
||||
>
|
||||
<div
|
||||
v-for="(doc, docIndex) in layout"
|
||||
:key="doc.attachmentUuid"
|
||||
:data-uuid="doc.attachmentUuid"
|
||||
class="relative"
|
||||
:style="inline ? { scrollMarginTop: '86px' } : null"
|
||||
>
|
||||
<div
|
||||
class="flex items-center pb-2"
|
||||
@@ -76,7 +88,7 @@
|
||||
:class="{ 'rotate-90': !collapsedDocumentsIndex[doc.attachmentUuid] }"
|
||||
/>
|
||||
<span
|
||||
class="truncate"
|
||||
class="truncate min-w-0"
|
||||
:class="{ 'line-through': !doc.pages.length }"
|
||||
>{{ doc.name }}</span>
|
||||
</button>
|
||||
@@ -170,7 +182,7 @@
|
||||
</div>
|
||||
<div
|
||||
v-if="(!collapsedDocumentsIndex[doc.attachmentUuid] || layout.length === 1) && doc.pages.length"
|
||||
class="grid grid-cols-4 gap-3"
|
||||
class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3"
|
||||
@dragover.prevent="onGridDragover(docIndex, $event)"
|
||||
@drop.prevent="onPageDrop"
|
||||
>
|
||||
@@ -178,7 +190,8 @@
|
||||
v-for="(page, pageIndex) in doc.pages"
|
||||
:key="page.id"
|
||||
:data-page-id="page.id"
|
||||
class="relative cursor-pointer group"
|
||||
class="relative min-w-0 cursor-pointer group"
|
||||
:style="inline ? { scrollMarginTop: '86px' } : null"
|
||||
draggable="true"
|
||||
@click="selectedPageId = page.id"
|
||||
@mousedown="onPageMousedown(page, $event)"
|
||||
@@ -245,26 +258,31 @@
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-56 flex-none border-l px-4 py-4 flex flex-col">
|
||||
<div class="space-y-2">
|
||||
<div
|
||||
class="flex-none px-4 border-t pt-3 flex flex-col gap-3 md:w-56 md:border-t-0 md:border-l md:py-4 md:gap-0"
|
||||
:class="{ 'pb-3': !inline }"
|
||||
>
|
||||
<div class="grid grid-cols-4 gap-2 md:block md:space-y-2">
|
||||
<button
|
||||
class="btn btn-sm w-full justify-start normal-case font-normal rounded disabled:bg-base-300"
|
||||
class="btn btn-sm normal-case font-normal rounded disabled:bg-base-300 flex-col gap-1 h-auto min-h-0 py-2 px-1 text-xs leading-tight whitespace-normal md:flex-row md:gap-2 md:h-8 md:min-h-[2rem] md:py-0 md:px-3 md:text-sm md:whitespace-nowrap md:w-full md:justify-start"
|
||||
:disabled="!selectedPageId"
|
||||
@click.prevent="movePage(-1)"
|
||||
>
|
||||
<IconArrowBackUp class="w-4 h-4" />
|
||||
{{ t('move_backward') }}
|
||||
<span class="md:hidden">{{ t('backward') }}</span>
|
||||
<span class="hidden md:inline">{{ t('move_backward') }}</span>
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-sm w-full justify-start normal-case font-normal rounded disabled:bg-base-300"
|
||||
class="btn btn-sm normal-case font-normal rounded disabled:bg-base-300 flex-col gap-1 h-auto min-h-0 py-2 px-1 text-xs leading-tight whitespace-normal md:flex-row md:gap-2 md:h-8 md:min-h-[2rem] md:py-0 md:px-3 md:text-sm md:whitespace-nowrap md:w-full md:justify-start"
|
||||
:disabled="!selectedPageId"
|
||||
@click.prevent="movePage(1)"
|
||||
>
|
||||
<IconArrowForwardUp class="w-4 h-4" />
|
||||
{{ t('move_forward') }}
|
||||
<span class="md:hidden">{{ t('forward') }}</span>
|
||||
<span class="hidden md:inline">{{ t('move_forward') }}</span>
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-sm w-full justify-start normal-case font-normal rounded disabled:bg-base-300"
|
||||
class="btn btn-sm normal-case font-normal rounded disabled:bg-base-300 flex-col gap-1 h-auto min-h-0 py-2 px-1 text-xs leading-tight whitespace-normal md:flex-row md:gap-2 md:h-8 md:min-h-[2rem] md:py-0 md:px-3 md:text-sm md:whitespace-nowrap md:w-full md:justify-start"
|
||||
:disabled="!selectedPageId"
|
||||
@click.prevent="rotatePage"
|
||||
>
|
||||
@@ -272,7 +290,7 @@
|
||||
{{ t('rotate') }}
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-sm w-full justify-start normal-case font-normal rounded disabled:bg-base-300"
|
||||
class="btn btn-sm normal-case font-normal rounded disabled:bg-base-300 flex-col gap-1 h-auto min-h-0 py-2 px-1 text-xs leading-tight whitespace-normal md:flex-row md:gap-2 md:h-8 md:min-h-[2rem] md:py-0 md:px-3 md:text-sm md:whitespace-nowrap md:w-full md:justify-start"
|
||||
:disabled="!selectedPageId || isReplacing"
|
||||
@click.prevent="replaceSelected"
|
||||
>
|
||||
@@ -303,7 +321,7 @@
|
||||
@change="onReplaceDocFilesPicked"
|
||||
>
|
||||
<button
|
||||
class="btn btn-sm w-full justify-start normal-case font-normal rounded disabled:bg-base-300"
|
||||
class="btn btn-sm normal-case font-normal rounded disabled:bg-base-300 flex-col gap-1 h-auto min-h-0 py-2 px-1 text-xs leading-tight whitespace-normal md:flex-row md:gap-2 md:h-8 md:min-h-[2rem] md:py-0 md:px-3 md:text-sm md:whitespace-nowrap md:w-full md:justify-start"
|
||||
:disabled="!selectedPageId"
|
||||
@click.prevent="openRedactView"
|
||||
>
|
||||
@@ -312,29 +330,28 @@
|
||||
</button>
|
||||
<button
|
||||
v-if="isSelectedPageCroppable"
|
||||
class="btn btn-sm w-full justify-start normal-case font-normal rounded disabled:bg-base-300"
|
||||
class="btn btn-sm normal-case font-normal rounded disabled:bg-base-300 flex-col gap-1 h-auto min-h-0 py-2 px-1 text-xs leading-tight whitespace-normal md:flex-row md:gap-2 md:h-8 md:min-h-[2rem] md:py-0 md:px-3 md:text-sm md:whitespace-nowrap md:w-full md:justify-start"
|
||||
@click.prevent="openCropView"
|
||||
>
|
||||
<IconCrop
|
||||
width="22"
|
||||
height="22"
|
||||
style="margin-left: -3px"
|
||||
class="w-4 h-4 md:w-[22px] md:h-[22px] md:-ml-[3px]"
|
||||
:stroke-width="1.5"
|
||||
/>
|
||||
<span style="margin-left: -3px">
|
||||
<span class="md:-ml-[3px]">
|
||||
{{ t('crop') }}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-sm w-full justify-start normal-case font-normal rounded disabled:bg-base-300 text-red-600"
|
||||
class="btn btn-sm normal-case font-normal rounded disabled:bg-base-300 text-red-600 flex-col gap-1 h-auto min-h-0 py-2 px-1 text-xs leading-tight whitespace-normal md:flex-row md:gap-2 md:h-8 md:min-h-[2rem] md:py-0 md:px-3 md:text-sm md:whitespace-nowrap md:w-full md:justify-start"
|
||||
:disabled="!selectedPageId"
|
||||
@click.prevent="removePage"
|
||||
>
|
||||
<IconTrashX class="w-4 h-4" />
|
||||
{{ t('remove_page') }}
|
||||
<span class="md:hidden">{{ t('remove') }}</span>
|
||||
<span class="hidden md:inline">{{ t('remove_page') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="mt-auto space-y-2">
|
||||
<div class="grid grid-cols-2 gap-2 md:block md:mt-auto md:space-y-2">
|
||||
<Upload
|
||||
:template-id="template.id"
|
||||
:accept-file-types="acceptFileTypes"
|
||||
@@ -432,6 +449,11 @@ export default {
|
||||
type: String,
|
||||
required: false,
|
||||
default: 'image/*, application/pdf'
|
||||
},
|
||||
inline: {
|
||||
type: Boolean,
|
||||
required: false,
|
||||
default: false
|
||||
}
|
||||
},
|
||||
emits: ['close', 'saved'],
|
||||
@@ -689,12 +711,14 @@ export default {
|
||||
if (targetIndex < 0) {
|
||||
if (docIndex > 0) {
|
||||
this.layout[docIndex - 1].pages.push(page)
|
||||
this.collapsedDocumentsIndex[this.layout[docIndex - 1].attachmentUuid] = false
|
||||
} else {
|
||||
this.layout[docIndex].pages.unshift(page)
|
||||
}
|
||||
} else if (targetIndex > this.layout[docIndex].pages.length) {
|
||||
if (docIndex < this.layout.length - 1) {
|
||||
this.layout[docIndex + 1].pages.unshift(page)
|
||||
this.collapsedDocumentsIndex[this.layout[docIndex + 1].attachmentUuid] = false
|
||||
} else {
|
||||
this.layout[docIndex].pages.push(page)
|
||||
}
|
||||
@@ -703,6 +727,12 @@ export default {
|
||||
}
|
||||
|
||||
this.pushUndo()
|
||||
this.scrollIntoList(`[data-page-id="${page.id}"]`)
|
||||
},
|
||||
scrollIntoList (selector) {
|
||||
this.$nextTick(() => {
|
||||
this.$el.querySelector(selector)?.scrollIntoView({ block: 'nearest', behavior: 'smooth' })
|
||||
})
|
||||
},
|
||||
removePage () {
|
||||
const position = this.selectedPosition
|
||||
@@ -728,6 +758,7 @@ export default {
|
||||
this.layout.splice(docIndex + direction, 0, doc)
|
||||
|
||||
this.pushUndo()
|
||||
this.scrollIntoList(`[data-uuid="${doc.attachmentUuid}"]`)
|
||||
},
|
||||
mergeDocument (docIndex, direction) {
|
||||
const doc = this.layout[docIndex]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="flex flex-1 min-h-0">
|
||||
<div class="flex-1 overflow-y-auto px-6 py-4">
|
||||
<div class="flex flex-col md:flex-row flex-1 min-h-0">
|
||||
<div class="flex-1 overflow-y-auto overflow-x-hidden px-4 md:px-6 py-4">
|
||||
<div
|
||||
ref="pageEl"
|
||||
class="relative mx-auto select-none cursor-crosshair"
|
||||
@@ -48,8 +48,8 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-56 flex-none border-l px-4 py-4 space-y-2">
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<div class="flex-none px-4 border-t py-3 md:w-56 md:border-t-0 md:border-l md:py-4 md:space-y-2">
|
||||
<div class="flex items-center justify-between mb-3 md:mb-1">
|
||||
<span class="text-sm pl-1">{{ t('color') }}</span>
|
||||
<div
|
||||
class="join rounded"
|
||||
@@ -69,30 +69,32 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="btn btn-sm w-full justify-start normal-case font-normal rounded disabled:bg-base-300"
|
||||
:disabled="!hasRedactions && !wasReset"
|
||||
@click.prevent="apply"
|
||||
>
|
||||
<IconCheck class="w-4 h-4" />
|
||||
{{ t('apply') }}
|
||||
</button>
|
||||
<div class="border-t !mt-3 !mb-1" />
|
||||
<button
|
||||
class="btn btn-sm w-full justify-start normal-case font-normal rounded disabled:bg-base-300"
|
||||
:disabled="!hasRedactions"
|
||||
@click.prevent="reset"
|
||||
>
|
||||
<IconRotate class="w-4 h-4" />
|
||||
{{ t('reset') }}
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-sm w-full justify-start normal-case font-normal rounded"
|
||||
@click.prevent="$emit('cancel')"
|
||||
>
|
||||
<IconX class="w-4 h-4" />
|
||||
{{ t('cancel') }}
|
||||
</button>
|
||||
<div class="grid grid-cols-3 gap-2 md:block md:space-y-2">
|
||||
<button
|
||||
class="btn btn-sm normal-case font-normal rounded disabled:bg-base-300 flex-col gap-1 h-auto min-h-0 py-2 px-1 text-xs leading-tight whitespace-normal md:flex-row md:gap-2 md:h-8 md:min-h-[2rem] md:py-0 md:px-3 md:text-sm md:whitespace-nowrap md:w-full md:justify-start"
|
||||
:disabled="!hasRedactions && !wasReset"
|
||||
@click.prevent="apply"
|
||||
>
|
||||
<IconCheck class="w-4 h-4" />
|
||||
{{ t('apply') }}
|
||||
</button>
|
||||
<div class="hidden md:block border-t !mt-3 !mb-1" />
|
||||
<button
|
||||
class="btn btn-sm normal-case font-normal rounded disabled:bg-base-300 flex-col gap-1 h-auto min-h-0 py-2 px-1 text-xs leading-tight whitespace-normal md:flex-row md:gap-2 md:h-8 md:min-h-[2rem] md:py-0 md:px-3 md:text-sm md:whitespace-nowrap md:w-full md:justify-start"
|
||||
:disabled="!hasRedactions"
|
||||
@click.prevent="reset"
|
||||
>
|
||||
<IconRotate class="w-4 h-4" />
|
||||
{{ t('reset') }}
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-sm normal-case font-normal rounded flex-col gap-1 h-auto min-h-0 py-2 px-1 text-xs leading-tight whitespace-normal md:flex-row md:gap-2 md:h-8 md:min-h-[2rem] md:py-0 md:px-3 md:text-sm md:whitespace-nowrap md:w-full md:justify-start"
|
||||
@click.prevent="$emit('cancel')"
|
||||
>
|
||||
<IconX class="w-4 h-4" />
|
||||
{{ t('cancel') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
:style="dragPlaceholderStyle"
|
||||
:field="field"
|
||||
:with-options="false"
|
||||
class="fixed z-20 pointer-events-none"
|
||||
class="fixed z-50 pointer-events-none"
|
||||
:editable="false"
|
||||
/>
|
||||
<CustomField
|
||||
@@ -13,13 +13,13 @@
|
||||
ref="dragPlaceholder"
|
||||
:style="dragPlaceholderStyle"
|
||||
:field="field"
|
||||
class="fixed z-20 pointer-events-none opacity-90"
|
||||
class="fixed z-50 pointer-events-none opacity-90"
|
||||
/>
|
||||
<div
|
||||
v-else-if="dragPlaceholder && (isDefault || isRequired) && !isMask && field"
|
||||
ref="dragPlaceholder"
|
||||
:style="[dragPlaceholderStyle, { backgroundColor: backgroundColor }]"
|
||||
class="fixed z-20 border border-base-300 rounded group default-field fields-list-item pointer-events-none"
|
||||
class="fixed z-50 border border-base-300 rounded group default-field fields-list-item pointer-events-none"
|
||||
>
|
||||
<div class="flex items-center justify-between relative cursor-grab">
|
||||
<div class="flex items-center p-1 space-x-1">
|
||||
@@ -45,7 +45,7 @@
|
||||
<button
|
||||
v-else-if="dragPlaceholder && !isMask && field"
|
||||
ref="dragPlaceholder"
|
||||
class="fixed field-type-button z-20 flex items-center justify-center border border-dashed w-full rounded border-base-content/20 opacity-90 pointer-events-none"
|
||||
class="fixed field-type-button z-50 flex items-center justify-center border border-dashed w-full rounded border-base-content/20 opacity-90 pointer-events-none"
|
||||
:style="[dragPlaceholderStyle, { backgroundColor }]"
|
||||
>
|
||||
<div
|
||||
|
||||
@@ -38,7 +38,12 @@
|
||||
v-if="withDescription"
|
||||
class="text-sm"
|
||||
>
|
||||
<span class="font-medium">{{ t('click_to_upload') }}</span> {{ t('or_drag_and_drop_files') }}
|
||||
<template v-if="isMobile">
|
||||
{{ t('click_to_upload') }}
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="font-medium">{{ t('click_to_upload') }}</span> {{ t('or_drag_and_drop_files') }}
|
||||
</template>
|
||||
</div>
|
||||
<button
|
||||
v-if="withGoogleDrive"
|
||||
@@ -83,7 +88,7 @@ export default {
|
||||
IconFiles,
|
||||
IconBrandGoogleDrive
|
||||
},
|
||||
inject: ['baseFetch', 't'],
|
||||
inject: ['baseFetch', 't', 'isMobile'],
|
||||
props: {
|
||||
templateId: {
|
||||
type: [Number, String],
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
<template>
|
||||
<div
|
||||
class="absolute z-[5] pointer-events-none select-none"
|
||||
:style="{ top: coords.top + 'px', left: coords.left + 'px', width: coords.width + 'px', height: coords.height + 'px' }"
|
||||
>
|
||||
<div
|
||||
class="absolute left-0 bottom-0"
|
||||
:style="{ top: Math.max(coords.top - toolbarHeight - 2, 0) - coords.top + 'px' }"
|
||||
>
|
||||
<div
|
||||
ref="toolbar"
|
||||
class="sticky top-1.5 flex flex-col sm:flex-row gap-0.5 p-1 bg-white border border-neutral-200 rounded-lg shadow-lg pointer-events-auto"
|
||||
@mousedown.prevent
|
||||
>
|
||||
<div
|
||||
v-for="(row, rowIndex) in actionRows"
|
||||
:key="rowIndex"
|
||||
class="flex items-center gap-0.5"
|
||||
>
|
||||
<template
|
||||
v-for="(group, groupIndex) in row"
|
||||
:key="groupIndex"
|
||||
>
|
||||
<div
|
||||
v-if="rowIndex > 0 || groupIndex > 0"
|
||||
class="w-px h-5 bg-neutral-200 mx-1"
|
||||
:class="{ 'hidden sm:block': rowIndex > 0 && groupIndex === 0 }"
|
||||
/>
|
||||
<button
|
||||
v-for="action in group"
|
||||
:key="action.title"
|
||||
class="inline-flex items-center justify-center w-7 h-7 rounded-md"
|
||||
:class="{ 'text-red-600': action.danger, 'bg-neutral-200': action.active, 'hover:bg-neutral-100': !action.active && !isDisabled(action), 'opacity-50 cursor-not-allowed': isDisabled(action) }"
|
||||
:title="action.structural && !coords.isRegular ? t('irregular_table_structure') : t(action.title)"
|
||||
:disabled="isDisabled(action)"
|
||||
@click="$emit('command', action.command, action.arg)"
|
||||
>
|
||||
<component
|
||||
:is="action.icon"
|
||||
:width="16"
|
||||
:height="16"
|
||||
:stroke-width="1.6"
|
||||
/>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="size"
|
||||
class="absolute top-0 left-0 border border-dashed border-blue-500"
|
||||
:style="{ width: size.width + 'px', height: size.height + 'px' }"
|
||||
/>
|
||||
<div
|
||||
v-if="coords.type === 'table'"
|
||||
class="absolute w-3 h-3 bg-white border-2 border-blue-500 rounded-sm pointer-events-auto cursor-nwse-resize touch-none before:absolute before:-inset-3"
|
||||
:style="{ left: (size?.width ?? coords.width) - 6 + 'px', top: (size?.height ?? coords.height) - 6 + 'px' }"
|
||||
:title="t('resize')"
|
||||
@pointerdown.prevent.stop="startResize"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { h } from 'vue'
|
||||
import { IconAlignLeft, IconAlignCenter, IconAlignRight, IconRowInsertTop, IconRowInsertBottom, IconColumnInsertLeft, IconColumnInsertRight, IconRowRemove, IconColumnRemove, IconTrash } from '@tabler/icons-vue'
|
||||
|
||||
const IconMergeCells = (props) => h('svg', { xmlns: 'http://www.w3.org/2000/svg', viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', 'stroke-linecap': 'round', 'stroke-linejoin': 'round', ...props }, [
|
||||
h('path', { d: 'M5 4h14a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2v-12a2 2 0 0 1 2 -2z' }),
|
||||
h('path', { d: 'M6 12h4m-2 -2l2 2l-2 2' }),
|
||||
h('path', { d: 'M18 12h-4m2 -2l-2 2l2 2' })
|
||||
])
|
||||
|
||||
const IconSplitCell = (props) => h('svg', { xmlns: 'http://www.w3.org/2000/svg', viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', 'stroke-linecap': 'round', 'stroke-linejoin': 'round', ...props }, [
|
||||
h('path', { d: 'M5 4h14a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2v-12a2 2 0 0 1 2 -2z' }),
|
||||
h('path', { d: 'M12 4v16' }),
|
||||
h('path', { d: 'M10 12h-4m2 -2l-2 2l2 2' }),
|
||||
h('path', { d: 'M14 12h4m-2 -2l2 2l-2 2' })
|
||||
])
|
||||
|
||||
export default {
|
||||
name: 'DynamicBlockMenu',
|
||||
inject: ['t'],
|
||||
props: {
|
||||
coords: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
getResizeLimits: {
|
||||
type: Function,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
emits: ['command', 'resize'],
|
||||
data () {
|
||||
return {
|
||||
size: null,
|
||||
toolbarHeight: 0
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
actionRows () {
|
||||
const alignActions = [
|
||||
{ command: 'setBlockAlign', arg: 'left', icon: IconAlignLeft, title: 'align_left', active: this.coords.align === 'left' },
|
||||
{ command: 'setBlockAlign', arg: 'center', icon: IconAlignCenter, title: 'align_center', active: this.coords.align === 'center' },
|
||||
{ command: 'setBlockAlign', arg: 'right', icon: IconAlignRight, title: 'align_right', active: this.coords.align === 'right' }
|
||||
]
|
||||
|
||||
if (this.coords.type !== 'table') {
|
||||
return [[alignActions, [{ command: 'deleteSelection', icon: IconTrash, title: 'delete_image', danger: true }]]]
|
||||
}
|
||||
|
||||
return [
|
||||
[
|
||||
alignActions,
|
||||
[
|
||||
{ command: 'addTableRow', arg: false, icon: IconRowInsertTop, title: 'insert_row_above', structural: true },
|
||||
{ command: 'addTableRow', arg: true, icon: IconRowInsertBottom, title: 'insert_row_below', structural: true },
|
||||
{ command: 'addTableColumn', arg: false, icon: IconColumnInsertLeft, title: 'insert_column_left', structural: true },
|
||||
{ command: 'addTableColumn', arg: true, icon: IconColumnInsertRight, title: 'insert_column_right', structural: true }
|
||||
]
|
||||
],
|
||||
[
|
||||
[
|
||||
{ command: 'mergeTableCells', icon: IconMergeCells, title: 'merge_cells', structural: true, disabled: !this.coords.canMerge },
|
||||
{ command: 'splitTableCell', icon: IconSplitCell, title: 'split_cell', structural: true, disabled: !this.coords.canSplit }
|
||||
],
|
||||
[
|
||||
{ command: 'deleteTableRow', icon: IconRowRemove, title: 'delete_row', danger: true, structural: true },
|
||||
{ command: 'deleteTableColumn', icon: IconColumnRemove, title: 'delete_column', danger: true, structural: true },
|
||||
{ command: 'deleteTable', icon: IconTrash, title: 'delete_table', danger: true }
|
||||
]
|
||||
]
|
||||
]
|
||||
}
|
||||
},
|
||||
mounted () {
|
||||
this.toolbarHeight = this.$refs.toolbar.offsetHeight
|
||||
},
|
||||
updated () {
|
||||
this.toolbarHeight = this.$refs.toolbar.offsetHeight
|
||||
},
|
||||
beforeUnmount () {
|
||||
window.removeEventListener('pointermove', this.onResizeMove)
|
||||
window.removeEventListener('pointerup', this.onResizeEnd)
|
||||
},
|
||||
methods: {
|
||||
isDisabled (action) {
|
||||
return (action.structural && !this.coords.isRegular) || action.disabled
|
||||
},
|
||||
startResize (event) {
|
||||
this.resizeStart = { x: event.clientX, y: event.clientY, width: this.coords.width, height: this.coords.height, ...this.getResizeLimits() }
|
||||
this.size = { width: this.coords.width, height: this.coords.height }
|
||||
|
||||
window.addEventListener('pointermove', this.onResizeMove)
|
||||
window.addEventListener('pointerup', this.onResizeEnd)
|
||||
},
|
||||
onResizeMove (event) {
|
||||
const { x, y, width, height, minHeight, maxWidth } = this.resizeStart
|
||||
this.size = {
|
||||
width: Math.min(Math.max(width + event.clientX - x, 20), maxWidth),
|
||||
height: Math.max(height + event.clientY - y, minHeight)
|
||||
}
|
||||
},
|
||||
onResizeEnd () {
|
||||
window.removeEventListener('pointermove', this.onResizeMove)
|
||||
window.removeEventListener('pointerup', this.onResizeEnd)
|
||||
|
||||
if (this.size.width !== this.coords.width || this.size.height !== this.coords.height) {
|
||||
this.$emit('resize', this.size)
|
||||
}
|
||||
|
||||
this.size = null
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
Loaded 100 of 251 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user